| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { expect, it } from "@effect/vitest"; |
| 3 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 4 | import * as Deferred from "effect/Deferred"; |
| 5 | import * as Crypto from "effect/Crypto"; |
| 6 | import type * as Duration from "effect/Duration"; |
| 7 | import * as DateTime from "effect/DateTime"; |
| 8 | import * as Effect from "effect/Effect"; |
| 9 | import * as Hex from "effect/encoding/Hex"; |
| 10 | import * as Fiber from "effect/Fiber"; |
| 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Option from "effect/Option"; |
| 13 | import * as Path from "effect/Path"; |
| 14 | import * as Stream from "effect/Stream"; |
| 15 | import * as TestClock from "effect/testing/TestClock"; |
| 16 | import { HttpClient, HttpClientResponse } from "effect/http"; |
| 17 | import * as NodeZlib from "node:zlib"; |
| 18 | |
| 19 | import * as PreviewBrowser from "./PreviewBrowser.ts"; |
| 20 | |
| 21 | // POSIX modes cannot be checked on NTFS, so follow the host like the Antigravity suite. |
| 22 | const hostPlatform: NodeJS.Platform = |
| 23 | HostProcess.Platform.defaultValue() === "win32" ? "win32" : "linux"; |
| 24 | const executableName = |
| 25 | hostPlatform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell"; |
| 26 | const ROOT = "chrome-headless-shell-fixture/"; |
| 27 | |
| 28 | /** A stored ZIP whose entries carry Unix modes, like Chrome for Testing's archives. */ |
| 29 | const 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 | |
| 70 | const 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 | |
| 78 | const 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 | |
| 127 | it.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 | |
| 257 | it("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 | |
| 288 | it("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 | }); |