1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import * as HostProcess from "@t3tools/shared/HostProcess";
4import * as Deferred from "effect/Deferred";
5import * as Crypto from "effect/Crypto";
6import type * as Duration from "effect/Duration";
7import * as DateTime from "effect/DateTime";
8import * as Effect from "effect/Effect";
9import * as Hex from "effect/encoding/Hex";
10import * as Fiber from "effect/Fiber";
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 * as TestClock from "effect/testing/TestClock";
16import { HttpClient, HttpClientResponse } from "effect/http";
17import * as NodeZlib from "node:zlib";
18
19import * as PreviewBrowser from "./PreviewBrowser.ts";
20
21// POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite.
22const hostPlatform: NodeJS.Platform =
23 HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux";
24const executableName =
25 hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
26const ROOT = "chrome-headless-shell-fixture/";
27
28/** A stored ZIP whose entries carry Unix modes, like Chrome for Testing's archives. */
29const makeZip = (
30 entries: ReadonlyArray<{ readonly name: string; readonly data?: string; readonly mode: number }>,
31) => {
32 const records: Array<Buffer> = [];
33 const directory: Array<Buffer> = [];
34 let offset = 0;
35 for (const entry of entries) {
36 const name = Buffer.from(entry.name);
37 const data = Buffer.from(entry.data ?? "");
38 const crc = NodeZlib.crc32(data);
39 const local = Buffer.alloc(30);
40 local.writeUInt32LE(0x04034b50, 0);
41 local.writeUInt16LE(20, 4);
42 local.writeUInt32LE(crc, 14);
43 local.writeUInt32LE(data.length, 18);
44 local.writeUInt32LE(data.length, 22);
45 local.writeUInt16LE(name.length, 26);
46 const central = Buffer.alloc(46);
47 central.writeUInt32LE(0x02014b50, 0);
48 central.writeUInt16LE((3 << 8) | 20, 4);
49 central.writeUInt16LE(20, 6);
50 central.writeUInt32LE(crc, 16);
51 central.writeUInt32LE(data.length, 20);
52 central.writeUInt32LE(data.length, 24);
53 central.writeUInt16LE(name.length, 28);
54 central.writeUInt32LE((entry.mode << 16) >>> 0, 38);
55 central.writeUInt32LE(offset, 42);
56 records.push(local, name, data);
57 directory.push(central, name);
58 offset += local.length + name.length + data.length;
59 }
60 const centralDirectory = Buffer.concat(directory);
61 const end = Buffer.alloc(22);
62 end.writeUInt32LE(0x06054b50, 0);
63 end.writeUInt16LE(entries.length, 8);
64 end.writeUInt16LE(entries.length, 10);
65 end.writeUInt32LE(centralDirectory.length, 12);
66 end.writeUInt32LE(offset, 16);
67 return Buffer.concat([...records, centralDirectory, end]);
68};
69
70const browserArchive = makeZip([
71 { name: ROOT, mode: 0o40755 },
72 { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
73 { name: `${ROOT}libEGL.so`, data: "library", mode: 0o100755 },
74 { name: `${ROOT}locales/`, mode: 0o40755 },
75 { name: `${ROOT}locales/en-US.pak`, data: "strings", mode: 0o100644 },
76]);
77
78const makeHarness = Effect.fn("test.makePreviewBrowser")(function* (
79 options: {
80 readonly archive?: Buffer;
81 readonly sha256?: string;
82 readonly body?: Stream.Stream<Uint8Array>;
83 readonly wait?: Duration.Input;
84 readonly unsupported?: boolean;
85 } = {},
86) {
87 const fs = yield* FileSystem.FileSystem;
88 const path = yield* Path.Path;
89 const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-preview-browser-test-" });
90 const archive = options.archive ?? browserArchive;
91 const crypto = yield* Crypto.Crypto;
92 const sha256 =
93 options.sha256 ?? Hex.encode(yield* crypto.digest("SHA-256", archive).pipe(Effect.orDie));
94 const requests: Array<string> = [];
95 const browser = yield* PreviewBrowser.makePreviewBrowser({
96 baseDir,
97 release: options.unsupported
98 ? null
99 : {
100 version: "1.2.3",
101 platform: "fixture",
102 url: "https://storage.googleapis.com/chrome-headless-shell-fixture.zip",
103 bytes: archive.byteLength,
104 sha256,
105 },
106 ...(options.wait === undefined ? {} : { wait: options.wait }),
107 }).pipe(
108 Effect.provideService(HostProcess.Platform, hostPlatform),
109 Effect.provideService(HostProcess.Architecture, "x64"),
110 Effect.provideService(
111 HttpClient.HttpClient,
112 HttpClient.make((request) =>
113 Effect.sync(() => {
114 requests.push(request.url);
115 const response = HttpClientResponse.fromWeb(request, new Response(null));
116 return Object.defineProperty(response, "stream", {
117 value: options.body ?? Stream.make(archive.subarray(0, 40), archive.subarray(40)),
118 });
119 }),
120 ),
121 ),
122 );
123 const installRoot = path.join(baseDir, "tools", "chrome-headless-shell", "fixture");
124 return { browser, fs, path, baseDir, installRoot, requests };
125});
126
127it.layer(NodeServices.layer)("PreviewBrowser", (it) => {
128 it.effect("installs a verified download with its file modes and then reuses it", () =>
129 Effect.gen(function* () {
130 const { browser, fs, path, installRoot, requests } = yield* makeHarness();
131 yield* fs.makeDirectory(path.join(installRoot, "1.0.0"), { recursive: true });
132 // Another server's install in progress, and one abandoned two hours ago.
133 const active = path.join(installRoot, ".install-other");
134 const abandoned = path.join(installRoot, ".install-abandoned");
135 yield* fs.makeDirectory(active);
136 yield* fs.makeDirectory(abandoned);
137 const twoHoursAgo = DateTime.toDate(DateTime.subtract(yield* DateTime.now, { hours: 2 }));
138 yield* fs.utimes(abandoned, twoHoursAgo, twoHoursAgo);
139 expect(yield* browser.installed).toEqual(Option.none());
140
141 const executable = yield* browser.executable;
142
143 const version = path.join(installRoot, "1.2.3");
144 expect(executable).toBe(path.join(version, executableName));
145 expect(yield* fs.readFileString(path.join(version, "locales", "en-US.pak"))).toBe("strings");
146 if (hostPlatform !== "win32") {
147 const mode = (file: string) =>
148 fs.stat(path.join(version, file)).pipe(Effect.map((info) => info.mode & 0o777));
149 expect(yield* mode(executableName)).toBe(0o755);
150 expect(yield* mode("libEGL.so")).toBe(0o755);
151 expect(yield* mode(path.join("locales", "en-US.pak"))).toBe(0o644);
152 }
153 // The old build and stale staging directories are gone; a fresh one stays.
154 expect((yield* fs.readDirectory(installRoot)).toSorted()).toEqual([
155 ".install-other",
156 "1.2.3",
157 ]);
158 expect(yield* browser.executable).toBe(executable);
159 expect(yield* browser.installed).toEqual(Option.some(executable));
160 expect(requests).toHaveLength(1);
161 }),
162 );
163
164 it.effect.each([
165 { name: "a hash mismatch", sha256: "0".repeat(64) },
166 { name: "a short download", body: Stream.make(browserArchive.subarray(0, -1)) },
167 {
168 name: "an oversized download",
169 body: Stream.make(browserArchive, Buffer.from("extra")),
170 },
171 ])("rejects $name, installs nothing, and retries on the next call", (testCase) =>
172 Effect.gen(function* () {
173 const { browser, fs, installRoot, requests } = yield* makeHarness(testCase);
174
175 const error = yield* browser.executable.pipe(Effect.flip);
176
177 expect(error._tag).toBe("PreviewBrowserInstallError");
178 expect(error.message).toMatch(/^T3 could not install its headless browser: .+ Try again\.$/);
179 expect(yield* fs.readDirectory(installRoot)).toEqual([]);
180 yield* browser.executable.pipe(Effect.flip);
181 expect(requests).toHaveLength(2);
182 }),
183 );
184
185 it.effect.each([
186 { name: "a parent-directory entry", entry: `${ROOT}../escape` },
187 { name: "an absolute entry", entry: "/escape" },
188 { name: "an entry outside the top directory", entry: "escape" },
189 ])("rejects an archive with $name", (testCase) =>
190 Effect.gen(function* () {
191 const { browser, fs, path, baseDir, installRoot } = yield* makeHarness({
192 archive: makeZip([
193 { name: `${ROOT}${executableName}`, data: "#!/bin/sh\n", mode: 0o100755 },
194 { name: testCase.entry, data: "escaped", mode: 0o100644 },
195 ]),
196 });
197
198 const error = yield* browser.executable.pipe(Effect.flip);
199
200 expect(error._tag).toBe("PreviewBrowserInstallError");
201 expect(yield* fs.readDirectory(installRoot)).toEqual([]);
202 expect(yield* fs.exists(path.join(baseDir, "tools", "chrome-headless-shell", "escape"))).toBe(
203 false,
204 );
205 }),
206 );
207
208 it.effect("shares one install between callers and finishes it after they stop waiting", () =>
209 Effect.gen(function* () {
210 const firstChunkSent = yield* Deferred.make<void>();
211 const finishDownload = yield* Deferred.make<void>();
212 const { browser, requests } = yield* makeHarness({
213 wait: "45 seconds",
214 body: Stream.concat(
215 Stream.make(browserArchive.subarray(0, 100)),
216 Stream.fromEffect(
217 Deferred.succeed(firstChunkSent, undefined).pipe(
218 Effect.andThen(Deferred.await(finishDownload)),
219 Effect.as(browserArchive.subarray(100)),
220 ),
221 ),
222 ),
223 });
224
225 const callers = yield* Effect.forEach([1, 2], () =>
226 Effect.forkChild(browser.executable.pipe(Effect.flip)),
227 );
228 yield* Deferred.await(firstChunkSent);
229 yield* TestClock.adjust("45 seconds");
230 for (const caller of callers) {
231 expect(yield* Fiber.join(caller)).toMatchObject({
232 _tag: "PreviewBrowserInstallingError",
233 downloadedBytes: 100,
234 unpacking: false,
235 });
236 }
237
238 // The install belongs to the service, so it outlives the callers that gave up on it.
239 yield* Deferred.succeed(finishDownload, undefined);
240 expect(yield* browser.executable).toMatch(new RegExp(`${executableName}$`));
241 expect(requests).toHaveLength(1);
242 }),
243 );
244
245 it.effect("reports hosts Chrome for Testing does not build for without downloading", () =>
246 Effect.gen(function* () {
247 const { browser, requests } = yield* makeHarness({ unsupported: true });
248 expect((yield* browser.executable.pipe(Effect.flip))._tag).toBe(
249 "PreviewBrowserUnsupportedError",
250 );
251 expect(yield* browser.installed).toEqual(Option.none());
252 expect(requests).toEqual([]);
253 }),
254 );
255});
256
257it("pins one Chrome for Testing build per host", () => {
258 const hosts = [
259 ["linux", "x64"],
260 ["linux", "arm64"],
261 ["darwin", "arm64"],
262 ["darwin", "x64"],
263 ["win32", "x64"],
264 ["win32", "arm64"],
265 ["win32", "ia32"],
266 ["linux", "ia32"],
267 ["freebsd", "x64"],
268 ] as const;
269 expect(
270 hosts.map(([platform, arch]) => PreviewBrowser.previewBrowserRelease(platform, arch)?.platform),
271 ).toEqual([
272 "linux64",
273 "linux-arm64",
274 "mac-arm64",
275 "mac-x64",
276 "win64",
277 "win64",
278 "win32",
279 undefined,
280 undefined,
281 ]);
282 expect(PreviewBrowser.previewBrowserRelease("linux", "x64")).toMatchObject({
283 url: "https://storage.googleapis.com/chrome-for-testing-public/154.0.8037.92/linux64/chrome-headless-shell-linux64.zip",
284 bytes: 120_477_194,
285 });
286});
287
288it("tells the agent how far the install has come", () => {
289 expect(
290 new PreviewBrowser.PreviewBrowserInstallingError({
291 downloadedBytes: 37_200_000,
292 totalBytes: 120_477_194,
293 unpacking: false,
294 }).message,
295 ).toBe("T3 is installing its headless browser (37 of 120 MB downloaded). Try again in a minute.");
296});