Open History to see the last commit
1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Layer from "effect/Layer";
4import { expect, it } from "@effect/vitest";
5import * as HostProcess from "@t3tools/shared/HostProcess";
6import * as Digest from "effect/crypto/Digest";
7import type * as Duration from "effect/Duration";
8import * as DateTime from "effect/DateTime";
9import * as Effect from "effect/Effect";
10import * as Hex from "effect/encoding/Hex";
11import * as FileSystem from "effect/FileSystem";
12import * as Option from "effect/Option";
13import * as Path from "effect/Path";
14import * as Stream from "effect/Stream";
15import { HttpClient, HttpClientResponse } from "effect/http";
16import * as NodeZlib from "node:zlib";
17
18import * as PreviewBrowser from "./PreviewBrowser.ts";
19
20// NTFS cannot enforce POSIX modes; choose the host platform.
21const hostPlatform: NodeJS.Platform =
22 HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux";
23const executableName =
24 hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
25const ROOT = "chrome-headless-shell-fixture/";
26
27const makeZip = (
28 entries: ReadonlyArray<{ readonly name: string; readonly data?: string; readonly mode: number }>,
29) => {
30 const records: Array<Buffer> = [];
31 const directory: Array<Buffer> = [];
32 let offset = 0;
33 for (const entry of entries) {
34 const name = Buffer.from(entry.name);
35 const data = Buffer.from(entry.data ?? "");
36 const crc = NodeZlib.crc32(data);
37 const local = Buffer.alloc(30);
38 local.writeUInt32LE(0x04034b50, 0);
39 local.writeUInt16LE(20, 4);
40 local.writeUInt32LE(crc, 14);
41 local.writeUInt32LE(data.length, 18);
42 local.writeUInt32LE(data.length, 22);
43 local.writeUInt16LE(name.length, 26);
44 const central = Buffer.alloc(46);
45 central.writeUInt32LE(0x02014b50, 0);
46 central.writeUInt16LE((3 << 8) | 20, 4);
47 central.writeUInt16LE(20, 6);
48 central.writeUInt32LE(crc, 16);
49 central.writeUInt32LE(data.length, 20);
50 central.writeUInt32LE(data.length, 24);
51 central.writeUInt16LE(name.length, 28);
52 central.writeUInt32LE((entry.mode << 16) >>> 0, 38);
53 central.writeUInt32LE(offset, 42);
54 records.push(local, name, data);
55 directory.push(central, name);
56 offset += local.length + name.length + data.length;
57 }
58 const centralDirectory = Buffer.concat(directory);
59 const end = Buffer.alloc(22);
60 end.writeUInt32LE(0x06054b50, 0);
61 end.writeUInt16LE(entries.length, 8);
62 end.writeUInt16LE(entries.length, 10);
63 end.writeUInt32LE(centralDirectory.length, 12);
64 end.writeUInt32LE(offset, 16);
65 return Buffer.concat([...records, centralDirectory, end]);
66};
67
68const browserArchive = makeZip([
69 { name: ROOT, mode: 0o40755 },
70 { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
71 { name: `${ROOT}libEGL.so`, data: "library", mode: 0o100755 },
72 { name: `${ROOT}locales/`, mode: 0o40755 },
73 { name: `${ROOT}locales/en-US.pak`, data: "strings", mode: 0o100644 },
74]);
75
76const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
77 options: {
78 readonly version?: string;
79 readonly archive?: Buffer;
80 readonly sha256?: string;
81 readonly body?: Stream.Stream<Uint8Array>;
82 readonly wait?: Duration.Input;
83 readonly unsupported?: boolean;
84 } = {},
85) {
86 const fs = yield* FileSystem.FileSystem;
87 const path = yield* Path.Path;
88 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-browser-test-" });
89 const archive = options.archive ?? browserArchive;
90 const digest = yield* Digest.Digest;
91 const sha256 =
92 options.sha256 ?? Hex.encode(yield* digest.digest(Digest.sha256, archive).pipe(Effect.orDie));
93 const requests: Array<string> = [];
94 const browser = yield* PreviewBrowser.makePreviewBrowser({
95 baseDir,
96 release: options.unsupported
97 ? null
98 : {
99 version: options.version ?? "1.2.3",
100 platform: "fixture",
101 url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip",
102 bytes: archive.byteLength,
103 sha256,
104 },
105 ...(options.wait === undefined ? {} : { wait: options.wait }),
106 }).pipe(
107 Effect.provideService(HostProcess.Platform, hostPlatform),
108 Effect.provideService(HostProcess.Architecture, "x64"),
109 Effect.provideService(
110 HttpClient.HttpClient,
111 HttpClient.make((request) =>
112 Effect.sync(() => {
113 requests.push(request.url);
114 const response = HttpClientResponse.fromWeb(request, new Response(null));
115 return Object.defineProperty(response, "stream", {
116 value: options.body ?? Stream.make(archive.subarray(0, 40), archive.subarray(40)),
117 });
118 }),
119 ),
120 ),
121 );
122 const installRoot = path.join(baseDir, "tools", "chrome-headless-shell", "fixture");
123 return { browser, fs, path, baseDir, installRoot, requests };
124});
125
126it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PreviewBrowser", (it) => {
127 it.effect.each(["0.8.0", "1.2.3"])("retains another version when installing %s", (current) =>
128 Effect.gen(function* () {
129 const { browser, fs, path, installRoot, requests } = yield* makeHarness({ version: current });
130 yield* fs.makeDirectory(path.join(installRoot, "0.9.0"), { recursive: true });
131 yield* fs.makeDirectory(path.join(installRoot, "0.10.0"));
132 const active = path.join(installRoot, ".install-other");
133 const abandoned = path.join(installRoot, ".install-abandoned");
134 yield* fs.makeDirectory(active);
135 yield* fs.makeDirectory(abandoned);
136 const twoHoursAgo = DateTime.toDate(DateTime.subtract(yield* DateTime.now, { hours: 2 }));
137 yield* fs.utimes(abandoned, twoHoursAgo, twoHoursAgo);
138 expect(yield* browser.installed).toEqual(Option.none());
139
140 const executable = yield* browser.executable;
141
142 const version = path.join(installRoot, current);
143 expect(executable).toBe(path.join(version, executableName));
144 expect(yield* fs.readFileString(path.join(version, "locales", "en-US.pak"))).toBe("strings");
145 if (hostPlatform !== "win32") {
146 const mode = (file: string) =>
147 fs.stat(path.join(version, file)).pipe(Effect.map((info) => info.mode & 0o777));
148 expect(yield* mode(executableName)).toBe(0o755);
149 expect(yield* mode("libEGL.so")).toBe(0o755);
150 expect(yield* mode(path.join("locales", "en-US.pak"))).toBe(0o644);
151 }
152 expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual(
153 [".install-other", "0.10.0", current].toSorted(),
154 );
155 expect(yield* browser.executable).toBe(executable);
156 expect(yield* browser.installed).toEqual(Option.some(executable));
157 expect(requests).toHaveLength(1);
158 }),
159 );
160
161 it.effect.each([
162 { name: "a hash mismatch", sha256: "0".repeat(64) },
163 { name: "a short download", body: Stream.make(browserArchive.subarray(0, -1)) },
164 {
165 name: "an oversized download",
166 body: Stream.make(browserArchive, Buffer.from("extra")),
167 },
168 ])("rejects $name, installs nothing, and retries on the next call", (testCase) =>
169 Effect.gen(function* () {
170 const { browser, fs, installRoot, requests } = yield* makeHarness(testCase);
171
172 const error = yield* browser.executable.pipe(Effect.flip);
173
174 expect(error._tag).toBe("PreviewBrowserInstallError");
175 expect(error.message).toMatch(/^T3 could not install its headless browser: .+ Try again\.$/);
176 expect(yield* fs.readDirectory(installRoot)).toEqual([]);
177 yield* browser.executable.pipe(Effect.flip);
178 expect(requests).toHaveLength(2);
179 }),
180 );
181
182 it.effect.each([
183 { name: "a parent-directory entry", entry: `${ROOT}../escape` },
184 { name: "an absolute entry", entry: "/escape" },
185 { name: "an entry outside the top directory", entry: "escape" },
186 ])("rejects an archive with $name", (testCase) =>
187 Effect.gen(function* () {
188 const { browser, fs, path, baseDir, installRoot } = yield* makeHarness({
189 archive: makeZip([
190 { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
191 { name: testCase.entry, data: "escaped", mode: 0o100644 },
192 ]),
193 });
194
195 const error = yield* browser.executable.pipe(Effect.flip);
196
197 expect(error._tag).toBe("PreviewBrowserInstallError");
198 expect(yield* fs.readDirectory(installRoot)).toEqual([]);
199 expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-headless-shell", "escape"))).toBe(
200 false,
201 );
202 }),
203 );
204});