1import {
2 createComputeArchive,
3 normalizeEntrypoint,
4} from "@/Prisma/ComputeArchive";
5import { closeDirectoryHandle } from "@/Prisma/Internal/ArchivePlatform";
6import { PlatformServices } from "@/Util/PlatformServices";
7import { describe, expect, it } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Path from "effect/Path";
11import * as ChildProcess from "effect/process/ChildProcess";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13import { gunzipSync } from "node:zlib";
14
15interface TarEntry {
16 name: string;
17 body: string;
18 mode: number;
19 type: string;
20 linkname: string;
21}
22
23const readString = (buffer: Uint8Array, start: number, length: number) => {
24 const bytes = buffer.slice(start, start + length);
25 const end = bytes.indexOf(0);
26 return new TextDecoder().decode(end >= 0 ? bytes.slice(0, end) : bytes);
27};
28
29const parseTar = (buffer: Uint8Array) => {
30 const entries: TarEntry[] = [];
31 let offset = 0;
32 while (offset + 512 <= buffer.length) {
33 const header = buffer.slice(offset, offset + 512);
34 if (header.every((byte) => byte === 0)) break;
35 const name = readString(header, 0, 100);
36 const prefix = readString(header, 345, 155);
37 const sizeText = readString(header, 124, 12).trim();
38 const size = Number.parseInt(sizeText || "0", 8);
39 const bodyStart = offset + 512;
40 const body = buffer.slice(bodyStart, bodyStart + size);
41 entries.push({
42 name: prefix ? `${prefix}/${name}` : name,
43 body: new TextDecoder().decode(body),
44 mode: Number.parseInt(readString(header, 100, 8).trim() || "0", 8),
45 type: readString(header, 156, 1) || "0",
46 linkname: readString(header, 157, 100),
47 });
48 offset = bodyStart + size + ((512 - (size % 512)) % 512);
49 }
50 return entries;
51};
52
53describe(
54 "createComputeArchive",
55 { tags: ["provider:prisma", "provider:prisma:compute", "local"] },
56 () => {
57 it(
58 "closes Node and Bun directory handles without masking traversal",
59 async () => {
60 await expect(
61 closeDirectoryHandle({ close: () => undefined }),
62 ).resolves.toBeUndefined();
63 await expect(
64 closeDirectoryHandle({ close: () => Promise.resolve() }),
65 ).resolves.toBeUndefined();
66 await expect(
67 closeDirectoryHandle({
68 close: () => Promise.reject(new Error("closed")),
69 }),
70 ).resolves.toBeUndefined();
71 await expect(
72 closeDirectoryHandle({
73 close: () => {
74 throw new Error("closed");
75 },
76 }),
77 ).resolves.toBeUndefined();
78 },
79 { tags: ["unit"] },
80 );
81
82 it.effect(
83 "rejects exclusions that remove a required static index",
84 () =>
85 Effect.gen(function* () {
86 const fs = yield* FileSystem.FileSystem;
87 const path = yield* Path.Path;
88 const root = yield* fs.makeTempDirectoryScoped({
89 prefix: "alchemy-prisma-required-index-",
90 });
91 yield* fs.makeDirectory(path.join(root, "public"));
92 yield* fs.writeFileString(
93 path.join(root, "server.mjs"),
94 "export {};",
95 );
96 yield* fs.writeFileString(
97 path.join(root, "public", "index.html"),
98 "site",
99 );
100
101 const error = yield* createComputeArchive({
102 directory: root,
103 entrypoint: "server.mjs",
104 ignorePrefix: "public",
105 ignore: ["*.html"],
106 requiredFiles: ["public/index.html"],
107 }).pipe(Effect.flip);
108
109 expect(error.message).toContain(
110 "Required file not found in compute artifact: public/index.html",
111 );
112 }).pipe(Effect.scoped, Effect.provide(PlatformServices)),
113 { tags: ["unit"] },
114 );
115
116 it.effect(
117 "creates the tar.gz format expected by Prisma Compute",
118 () =>
119 Effect.gen(function* () {
120 const fs = yield* FileSystem.FileSystem;
121 const path = yield* Path.Path;
122 const root = yield* fs.makeTempDirectory({
123 prefix: "alchemy-prisma-compute-",
124 });
125 yield* fs.makeDirectory(path.join(root, "src"));
126 yield* fs.writeFileString(
127 path.join(root, "src", "main.ts"),
128 "console.log('hello');",
129 );
130 yield* fs.writeFileString(path.join(root, "package.json"), "{}");
131
132 const archive = yield* createComputeArchive({
133 directory: root,
134 entrypoint: "src/main.ts",
135 });
136 const entries = parseTar(
137 yield* Effect.sync(() => gunzipSync(archive)),
138 );
139 const byName = new Map(
140 entries.map((entry) => [entry.name, entry.body]),
141 );
142
143 expect(byName.get("bundle/src/main.ts")).toBe(
144 "console.log('hello');",
145 );
146 expect(byName.get("bundle/package.json")).toBe("{}");
147 expect(JSON.parse(byName.get("compute.manifest.json")!)).toEqual({
148 manifestVersion: "1",
149 entrypoint: "bundle/src/main.ts",
150 });
151 }).pipe(Effect.provide(PlatformServices)),
152 { tags: ["unit"] },
153 );
154
155 it.effect(
156 "round-trips long framework chunk names and symlink targets through tar",
157 () =>
158 Effect.gen(function* () {
159 const fs = yield* FileSystem.FileSystem;
160 const path = yield* Path.Path;
161 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
162 const root = yield* fs.makeTempDirectoryScoped({
163 prefix: "alchemy-prisma-long-path-",
164 });
165 const output = yield* fs.makeTempDirectoryScoped({
166 prefix: "alchemy-prisma-extract-",
167 });
168 const name = `${"framework-".repeat(12)}é.js`;
169 yield* fs.writeFileString(
170 path.join(root, name),
171 "export const greeting = 'vinext';",
172 );
173 yield* fs.symlink(name, path.join(root, "server.js"));
174 const archive = yield* createComputeArchive({
175 directory: root,
176 entrypoint: "server.js",
177 });
178 const again = yield* createComputeArchive({
179 directory: root,
180 entrypoint: "server.js",
181 });
182 expect(again).toEqual(archive);
183 const archivePath = path.join(output, "site.tar.gz");
184 yield* fs.writeFile(archivePath, archive);
185 const extracted = yield* spawner.exitCode(
186 ChildProcess.make("tar", ["-xzf", archivePath, "-C", output]),
187 );
188 expect(Number(extracted)).toBe(0);
189 expect(
190 yield* fs.readFileString(path.join(output, "bundle", name)),
191 ).toBe("export const greeting = 'vinext';");
192 expect(
193 (yield* fs.readLink(
194 path.join(output, "bundle", "server.js"),
195 )).normalize("NFC"),
196 ).toBe(name);
197 }).pipe(Effect.scoped, Effect.provide(PlatformServices)),
198 );
199
200 it.effect(
201 "produces deterministic bytes for unchanged input",
202 () =>
203 Effect.gen(function* () {
204 const fs = yield* FileSystem.FileSystem;
205 const path = yield* Path.Path;
206 const root = yield* fs.makeTempDirectory({
207 prefix: "alchemy-prisma-compute-deterministic-",
208 });
209 yield* fs.writeFileString(path.join(root, "z.ts"), "z");
210 yield* fs.writeFileString(path.join(root, "server.ts"), "server");
211 yield* fs.writeFileString(path.join(root, "a.ts"), "a");
212
213 const first = yield* createComputeArchive({
214 directory: root,
215 entrypoint: "server.ts",
216 });
217 const second = yield* createComputeArchive({
218 directory: root,
219 entrypoint: "server.ts",
220 });
221
222 expect(second).toEqual(first);
223 }).pipe(Effect.provide(PlatformServices)),
224 { tags: ["unit"] },
225 );
226
227 it.effect(
228 "rejects unsafe entrypoints",
229 () =>
230 Effect.gen(function* () {
231 const parent = yield* Effect.exit(
232 normalizeEntrypoint("../server.ts"),
233 );
234 const absolute = yield* Effect.exit(
235 normalizeEntrypoint("/server.ts"),
236 );
237
238 expect(parent._tag).toBe("Failure");
239 expect(absolute._tag).toBe("Failure");
240 }),
241 { tags: ["unit"] },
242 );
243
244 it.effect(
245 "preserves executable file modes",
246 () =>
247 Effect.gen(function* () {
248 const fs = yield* FileSystem.FileSystem;
249 const path = yield* Path.Path;
250 const root = yield* fs.makeTempDirectory({
251 prefix: "alchemy-prisma-compute-",
252 });
253 const server = path.join(root, "server.sh");
254 yield* fs.writeFileString(server, "#!/bin/sh\n");
255 yield* fs.chmod(server, 0o755);
256
257 const archive = yield* createComputeArchive({
258 directory: root,
259 entrypoint: "server.sh",
260 });
261 const entries = parseTar(
262 yield* Effect.sync(() => gunzipSync(archive)),
263 );
264 const byName = new Map(entries.map((entry) => [entry.name, entry]));
265
266 expect(byName.get("bundle/server.sh")?.mode).toBe(0o755);
267 }).pipe(Effect.provide(PlatformServices)),
268 { tags: ["unit"] },
269 );
270
271 it.effect(
272 "rejects missing entrypoints before uploading",
273 () =>
274 Effect.gen(function* () {
275 const fs = yield* FileSystem.FileSystem;
276 const root = yield* fs.makeTempDirectory({
277 prefix: "alchemy-prisma-compute-",
278 });
279 const result = yield* Effect.exit(
280 createComputeArchive({
281 directory: root,
282 entrypoint: "server.ts",
283 }),
284 );
285
286 expect(result._tag).toBe("Failure");
287 }).pipe(Effect.provide(PlatformServices)),
288 { tags: ["unit"] },
289 );
290
291 it.effect(
292 "preserves symlinks that stay inside the artifact root",
293 () =>
294 Effect.gen(function* () {
295 const fs = yield* FileSystem.FileSystem;
296 const path = yield* Path.Path;
297 const root = yield* fs.makeTempDirectory({
298 prefix: "alchemy-prisma-compute-",
299 });
300 yield* fs.writeFileString(path.join(root, "server.ts"), "export {};");
301 yield* fs.writeFileString(path.join(root, "real.ts"), "real file");
302 yield* fs.symlink(
303 path.join(root, "real.ts"),
304 path.join(root, "link.ts"),
305 );
306
307 const archive = yield* createComputeArchive({
308 directory: root,
309 entrypoint: "server.ts",
310 });
311 const entries = parseTar(
312 yield* Effect.sync(() => gunzipSync(archive)),
313 );
314 const byName = new Map(entries.map((entry) => [entry.name, entry]));
315
316 expect(byName.get("bundle/link.ts")).toMatchObject({
317 type: "2",
318 linkname: "real.ts",
319 });
320 expect(byName.get("bundle/real.ts")?.body).toBe("real file");
321 }).pipe(Effect.provide(PlatformServices)),
322 { tags: ["unit"] },
323 );
324
325 it.effect(
326 "preserves symlinked directories that stay inside the artifact root",
327 () =>
328 Effect.gen(function* () {
329 const fs = yield* FileSystem.FileSystem;
330 const path = yield* Path.Path;
331 const root = yield* fs.makeTempDirectory({
332 prefix: "alchemy-prisma-compute-",
333 });
334 const realDir = path.join(root, "real");
335 yield* fs.makeDirectory(realDir);
336 yield* fs.writeFileString(path.join(root, "server.ts"), "export {};");
337 yield* fs.writeFileString(path.join(realDir, "nested.ts"), "nested");
338 yield* fs.symlink(realDir, path.join(root, "linked"));
339
340 const archive = yield* createComputeArchive({
341 directory: root,
342 entrypoint: "server.ts",
343 });
344 const entries = parseTar(
345 yield* Effect.sync(() => gunzipSync(archive)),
346 );
347 const byName = new Map(entries.map((entry) => [entry.name, entry]));
348
349 expect(byName.get("bundle/linked")).toMatchObject({
350 type: "2",
351 linkname: "real",
352 });
353 expect(byName.get("bundle/real/nested.ts")?.body).toBe("nested");
354 expect(byName.has("bundle/linked/nested.ts")).toBe(false);
355 }).pipe(Effect.provide(PlatformServices)),
356 { tags: ["unit"] },
357 );
358
359 it.effect(
360 "rejects symlinks that escape the artifact root",
361 () =>
362 Effect.gen(function* () {
363 const fs = yield* FileSystem.FileSystem;
364 const path = yield* Path.Path;
365 const root = yield* fs.makeTempDirectory({
366 prefix: "alchemy-prisma-compute-",
367 });
368 const outside = yield* fs.makeTempDirectory({
369 prefix: "alchemy-prisma-compute-outside-",
370 });
371 yield* fs.writeFileString(path.join(root, "server.ts"), "export {};");
372 yield* fs.writeFileString(path.join(outside, "secret.ts"), "secret");
373 yield* fs.symlink(
374 path.join(outside, "secret.ts"),
375 path.join(root, "secret.ts"),
376 );
377
378 const result = yield* Effect.exit(
379 createComputeArchive({
380 directory: root,
381 entrypoint: "server.ts",
382 }),
383 );
384
385 expect(result._tag).toBe("Failure");
386 }).pipe(Effect.provide(PlatformServices)),
387 { tags: ["unit"] },
388 );
389
390 it.effect(
391 "rejects symlinked directories that escape the artifact root",
392 () =>
393 Effect.gen(function* () {
394 const fs = yield* FileSystem.FileSystem;
395 const path = yield* Path.Path;
396 const root = yield* fs.makeTempDirectory({
397 prefix: "alchemy-prisma-compute-",
398 });
399 const outside = yield* fs.makeTempDirectory({
400 prefix: "alchemy-prisma-compute-outside-",
401 });
402 yield* fs.writeFileString(path.join(root, "server.ts"), "export {};");
403 yield* fs.writeFileString(path.join(outside, "secret.ts"), "secret");
404 yield* fs.symlink(outside, path.join(root, "outside"));
405
406 const result = yield* Effect.exit(
407 createComputeArchive({
408 directory: root,
409 entrypoint: "server.ts",
410 }),
411 );
412
413 expect(result._tag).toBe("Failure");
414 }).pipe(Effect.provide(PlatformServices)),
415 { tags: ["unit"] },
416 );
417
418 it.effect(
419 "excludes sensitive files at every workspace depth",
420 () =>
421 Effect.gen(function* () {
422 const fs = yield* FileSystem.FileSystem;
423 const path = yield* Path.Path;
424 const root = yield* fs.makeTempDirectory({
425 prefix: "alchemy-prisma-compute-sensitive-",
426 });
427 yield* fs.makeDirectory(path.join(root, "apps", "api", ".git"), {
428 recursive: true,
429 });
430 yield* fs.makeDirectory(path.join(root, "apps", "api", ".alchemy"), {
431 recursive: true,
432 });
433 yield* fs.writeFileString(path.join(root, "server.ts"), "safe");
434 yield* fs.writeFileString(path.join(root, ".env"), "ROOT_SECRET=x");
435 yield* fs.writeFileString(
436 path.join(root, "apps", "api", ".env.production"),
437 "NESTED_SECRET=x",
438 );
439 yield* fs.writeFileString(
440 path.join(root, "apps", "api", ".git", "config"),
441 "credential=x",
442 );
443 yield* fs.writeFileString(
444 path.join(root, "apps", "api", ".alchemy", "state"),
445 "secret=x",
446 );
447 yield* fs.writeFileString(path.join(root, "debug.log"), "ignored");
448
449 const archive = yield* createComputeArchive({
450 directory: root,
451 entrypoint: "server.ts",
452 ignore: ["*.log"],
453 });
454 const names = parseTar(
455 yield* Effect.sync(() => gunzipSync(archive)),
456 ).map((entry) => entry.name);
457
458 expect(names).toContain("bundle/server.ts");
459 expect(names.some((name) => name.includes(".env"))).toBe(false);
460 expect(names.some((name) => name.includes(".git"))).toBe(false);
461 expect(names.some((name) => name.includes(".alchemy"))).toBe(false);
462 expect(names).not.toContain("bundle/debug.log");
463 }).pipe(Effect.provide(PlatformServices)),
464 { tags: ["unit"] },
465 );
466
467 it.effect(
468 "rejects an entrypoint excluded by the safety policy",
469 () =>
470 Effect.gen(function* () {
471 const fs = yield* FileSystem.FileSystem;
472 const path = yield* Path.Path;
473 const root = yield* fs.makeTempDirectory({
474 prefix: "alchemy-prisma-compute-ignored-entry-",
475 });
476 yield* fs.writeFileString(path.join(root, ".env"), "secret");
477
478 const result = yield* Effect.exit(
479 createComputeArchive({ directory: root, entrypoint: ".env" }),
480 );
481
482 expect(result._tag).toBe("Failure");
483 }).pipe(Effect.provide(PlatformServices)),
484 { tags: ["unit"] },
485 );
486
487 it.effect(
488 "allows dots in ignore names but rejects parent-segment patterns",
489 () =>
490 Effect.gen(function* () {
491 const fs = yield* FileSystem.FileSystem;
492 const path = yield* Path.Path;
493 const root = yield* fs.makeTempDirectory({
494 prefix: "alchemy-prisma-compute-ignore-validation-",
495 });
496 yield* fs.writeFileString(path.join(root, "server.ts"), "safe");
497 yield* fs.writeFileString(path.join(root, "foo..bar"), "ignored");
498
499 const archive = yield* createComputeArchive({
500 directory: root,
501 entrypoint: "server.ts",
502 ignore: ["foo..bar"],
503 });
504 const names = parseTar(
505 yield* Effect.sync(() => gunzipSync(archive)),
506 ).map((entry) => entry.name);
507 const unsafe = yield* Effect.exit(
508 createComputeArchive({
509 directory: root,
510 entrypoint: "server.ts",
511 ignore: ["../outside"],
512 }),
513 );
514
515 expect(names).not.toContain("bundle/foo..bar");
516 expect(unsafe._tag).toBe("Failure");
517 }).pipe(Effect.provide(PlatformServices)),
518 { tags: ["unit"] },
519 );
520
521 it.effect(
522 "validates custom ignore patterns before applying a prefix",
523 () =>
524 Effect.gen(function* () {
525 const fs = yield* FileSystem.FileSystem;
526 const path = yield* Path.Path;
527 const root = yield* fs.makeTempDirectory({
528 prefix: "alchemy-prisma-compute-prefixed-ignore-validation-",
529 });
530 yield* fs.writeFileString(path.join(root, "server.ts"), "safe");
531
532 const unsafe = yield* Effect.exit(
533 createComputeArchive({
534 directory: root,
535 entrypoint: "server.ts",
536 ignore: ["../outside"],
537 ignorePrefix: "public",
538 }),
539 );
540
541 expect(unsafe._tag).toBe("Failure");
542 }).pipe(Effect.provide(PlatformServices)),
543 { tags: ["unit"] },
544 );
545
546 it.effect(
547 "reports invalid archive limits as typed errors",
548 () =>
549 Effect.gen(function* () {
550 const fs = yield* FileSystem.FileSystem;
551 const path = yield* Path.Path;
552 const root = yield* fs.makeTempDirectory({
553 prefix: "alchemy-prisma-compute-invalid-limit-",
554 });
555 yield* fs.writeFileString(path.join(root, "server.ts"), "safe");
556
557 const error = yield* createComputeArchive({
558 directory: root,
559 entrypoint: "server.ts",
560 maxEntries: 0,
561 }).pipe(Effect.flip);
562
563 expect(error).toBeInstanceOf(Error);
564 expect(error.message).toContain("maxEntries");
565 }).pipe(Effect.provide(PlatformServices)),
566 { tags: ["unit"] },
567 );
568
569 it.effect(
570 "does not allow callers to raise production safety ceilings",
571 () =>
572 Effect.gen(function* () {
573 const fs = yield* FileSystem.FileSystem;
574 const path = yield* Path.Path;
575 const root = yield* fs.makeTempDirectory({
576 prefix: "alchemy-prisma-compute-hard-limit-",
577 });
578 yield* fs.writeFileString(path.join(root, "server.ts"), "safe");
579
580 const entriesError = yield* createComputeArchive({
581 directory: root,
582 entrypoint: "server.ts",
583 maxEntries: 50_001,
584 }).pipe(Effect.flip);
585 const fileError = yield* createComputeArchive({
586 directory: root,
587 entrypoint: "server.ts",
588 maxFileBytes: 128 * 1024 * 1024 + 1,
589 }).pipe(Effect.flip);
590 const totalError = yield* createComputeArchive({
591 directory: root,
592 entrypoint: "server.ts",
593 maxUncompressedBytes: 256 * 1024 * 1024 + 1,
594 }).pipe(Effect.flip);
595
596 expect(entriesError.message).toContain("hard limit");
597 expect(fileError.message).toContain("hard limit");
598 expect(totalError.message).toContain("hard limit");
599 }).pipe(Effect.provide(PlatformServices)),
600 { tags: ["unit"] },
601 );
602
603 it.effect(
604 "creates a verified file-backed archive with explicit cleanup",
605 () =>
606 Effect.gen(function* () {
607 const fs = yield* FileSystem.FileSystem;
608 const path = yield* Path.Path;
609 const root = yield* fs.makeTempDirectory({
610 prefix: "alchemy-prisma-compute-file-archive-",
611 });
612 yield* fs.writeFileString(path.join(root, "server.ts"), "safe");
613
614 const archive = yield* createComputeArchive({
615 directory: root,
616 entrypoint: "server.ts",
617 output: "file",
618 });
619 const bytes = yield* fs.readFile(archive.path);
620 const names = parseTar(
621 yield* Effect.sync(() => gunzipSync(bytes)),
622 ).map((entry) => entry.name);
623
624 expect(archive.size).toBe(bytes.byteLength);
625 expect(archive.sha256).toMatch(/^[a-f0-9]{64}$/);
626 expect(names).toContain("bundle/server.ts");
627 expect(yield* fs.exists(archive.path)).toBe(true);
628 yield* archive.cleanup;
629 expect(yield* fs.exists(archive.path)).toBe(false);
630 }).pipe(Effect.provide(PlatformServices)),
631 { tags: ["unit"] },
632 );
633
634 it.effect(
635 "fails explicitly for oversized files and archives",
636 () =>
637 Effect.gen(function* () {
638 const fs = yield* FileSystem.FileSystem;
639 const path = yield* Path.Path;
640 const root = yield* fs.makeTempDirectory({
641 prefix: "alchemy-prisma-compute-limit-",
642 });
643 yield* fs.writeFileString(path.join(root, "server.ts"), "12345");
644 yield* fs.writeFileString(path.join(root, "other.ts"), "67890");
645
646 const fileResult = yield* Effect.exit(
647 createComputeArchive({
648 directory: root,
649 entrypoint: "server.ts",
650 maxFileBytes: 4,
651 }),
652 );
653 const totalResult = yield* Effect.exit(
654 createComputeArchive({
655 directory: root,
656 entrypoint: "server.ts",
657 maxFileBytes: 10,
658 maxUncompressedBytes: 9,
659 }),
660 );
661
662 expect(fileResult._tag).toBe("Failure");
663 expect(totalResult._tag).toBe("Failure");
664 }).pipe(Effect.provide(PlatformServices)),
665 { tags: ["unit"] },
666 );
667
668 it.effect(
669 "encodes long nested symlink targets with PAX records",
670 () =>
671 Effect.gen(function* () {
672 const fs = yield* FileSystem.FileSystem;
673 const path = yield* Path.Path;
674 const root = yield* fs.makeTempDirectory({
675 prefix: "alchemy-prisma-compute-long-link-",
676 });
677 const segments = Array.from(
678 { length: 12 },
679 (_, index) => `segment-${index.toString().padStart(2, "0")}`,
680 );
681 const target = path.join(...segments, "target.ts");
682 yield* fs.makeDirectory(path.join(root, ...segments), {
683 recursive: true,
684 });
685 yield* fs.writeFileString(path.join(root, "server.ts"), "safe");
686 yield* fs.writeFileString(path.join(root, target), "target");
687 yield* fs.symlink(target, path.join(root, "long-link.ts"));
688
689 const archive = yield* createComputeArchive({
690 directory: root,
691 entrypoint: "server.ts",
692 });
693 const entries = parseTar(
694 yield* Effect.sync(() => gunzipSync(archive)),
695 );
696 expect(
697 entries.some(
698 (entry) =>
699 entry.type === "x" &&
700 entry.body.includes(`linkpath=${target}\n`),
701 ),
702 ).toBe(true);
703 }).pipe(Effect.provide(PlatformServices)),
704 { tags: ["unit"] },
705 );
706 },
707);