| 1 | import { make as makeVite } from "@alchemy.run/frontend-frameworks/vite"; |
| 2 | import { make as makeNext } from "@alchemy.run/frontend-frameworks/nextjs/node"; |
| 3 | import { make as makeVinext } from "@alchemy.run/frontend-frameworks/vinext/node"; |
| 4 | import { createComputeArchive } from "@/Prisma/ComputeArchive"; |
| 5 | import { stageWebsiteArtifact } from "@/Prisma/Website/Artifact"; |
| 6 | import { findAvailablePort } from "@/Util/Node"; |
| 7 | import { PlatformServices } from "@/Util/PlatformServices"; |
| 8 | import { describe, expect, it } from "alchemy-test"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as FileSystem from "effect/FileSystem"; |
| 11 | import * as Layer from "effect/Layer"; |
| 12 | import * as Path from "effect/Path"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 15 | import * as HttpClient from "effect/http/HttpClient"; |
| 16 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 17 | import { ChildProcessSpawner } from "effect/process/ChildProcessSpawner"; |
| 18 | |
| 19 | const services = Layer.mergeAll(PlatformServices, FetchHttpClient.layer); |
| 20 | |
| 21 | const fixture = Effect.fn(function* (name: "vite" | "next") { |
| 22 | const fs = yield* FileSystem.FileSystem; |
| 23 | const path = yield* Path.Path; |
| 24 | const fixtures = yield* path.fromFileUrl( |
| 25 | new URL("./fixtures/website-artifact/", import.meta.url), |
| 26 | ); |
| 27 | const root = yield* Effect.acquireRelease( |
| 28 | fs.makeTempDirectory({ directory: fixtures, prefix: "build-" }), |
| 29 | (directory) => |
| 30 | fs.remove(directory, { recursive: true, force: true }).pipe(Effect.orDie), |
| 31 | ); |
| 32 | yield* fs.copy(path.join(fixtures, name), root); |
| 33 | yield* fs.writeFileString( |
| 34 | path.join(root, ".env.private"), |
| 35 | "MUST_NOT_SHIP=secret", |
| 36 | ); |
| 37 | yield* fs.writeFileString( |
| 38 | path.join(root, "unrelated-private-file.txt"), |
| 39 | "not a runtime dependency", |
| 40 | ); |
| 41 | return root; |
| 42 | }); |
| 43 | |
| 44 | const extract = Effect.fn(function* (archive: Uint8Array) { |
| 45 | const fs = yield* FileSystem.FileSystem; |
| 46 | const path = yield* Path.Path; |
| 47 | const root = yield* fs.makeTempDirectoryScoped({ |
| 48 | prefix: "prisma-website-extracted-", |
| 49 | }); |
| 50 | const archivePath = path.join(root, "artifact.tar.gz"); |
| 51 | yield* fs.writeFile(archivePath, archive); |
| 52 | const spawner = yield* ChildProcessSpawner; |
| 53 | const child = yield* spawner.spawn( |
| 54 | ChildProcess.make("tar", ["-xzf", archivePath, "-C", root], { |
| 55 | stdin: "ignore", |
| 56 | stdout: "ignore", |
| 57 | stderr: "inherit", |
| 58 | }), |
| 59 | ); |
| 60 | expect(yield* child.exitCode).toBe(0); |
| 61 | const manifest = yield* fs.readFileString( |
| 62 | path.join(root, "compute.manifest.json"), |
| 63 | ); |
| 64 | expect(JSON.parse(manifest).entrypoint).toBe("bundle/server.mjs"); |
| 65 | return path.join(root, "bundle"); |
| 66 | }); |
| 67 | |
| 68 | const serve = Effect.fn(function* (directory: string) { |
| 69 | const port = yield* findAvailablePort(); |
| 70 | const runtime = yield* Effect.sync(() => process.execPath); |
| 71 | const spawner = yield* ChildProcessSpawner; |
| 72 | yield* spawner.spawn( |
| 73 | ChildProcess.make(runtime, ["server.mjs"], { |
| 74 | cwd: directory, |
| 75 | env: { PORT: String(port), HOST: "127.0.0.1", NODE_ENV: "production" }, |
| 76 | stdin: "ignore", |
| 77 | stdout: "ignore", |
| 78 | stderr: "inherit", |
| 79 | killSignal: "SIGKILL", |
| 80 | }), |
| 81 | ); |
| 82 | const url = `http://127.0.0.1:${port}`; |
| 83 | yield* HttpClient.get(`${url}/health`).pipe( |
| 84 | Effect.flatMap((response) => |
| 85 | response.status === 200 |
| 86 | ? Effect.void |
| 87 | : Effect.fail(new Error(`HTTP ${response.status}`)), |
| 88 | ), |
| 89 | Effect.timeout("1 second"), |
| 90 | Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 10 }), |
| 91 | ); |
| 92 | return url; |
| 93 | }); |
| 94 | |
| 95 | const listFiles = Effect.fn(function* (root: string) { |
| 96 | const fs = yield* FileSystem.FileSystem; |
| 97 | return yield* fs.readDirectory(root, { recursive: true }); |
| 98 | }); |
| 99 | |
| 100 | describe.sequential( |
| 101 | "Prisma Website artifacts", |
| 102 | { tags: ["provider:prisma", "provider:prisma:website", "local"] }, |
| 103 | () => { |
| 104 | it.effect( |
| 105 | "ignores dangling optional-package resolution links while retaining the runtime fallback", |
| 106 | () => |
| 107 | Effect.scoped( |
| 108 | Effect.gen(function* () { |
| 109 | const fs = yield* FileSystem.FileSystem; |
| 110 | const path = yield* Path.Path; |
| 111 | const root = yield* fixture("vite"); |
| 112 | const dist = path.join(root, "dist"); |
| 113 | const modules = path.join(root, "node_modules"); |
| 114 | yield* fs.makeDirectory(dist); |
| 115 | yield* fs.makeDirectory(modules); |
| 116 | yield* fs.symlink( |
| 117 | path.join(root, "missing-optional-runtime"), |
| 118 | path.join(modules, "optional-runtime"), |
| 119 | ); |
| 120 | const entry = path.join(dist, "server.cjs"); |
| 121 | yield* fs.writeFileString( |
| 122 | entry, |
| 123 | 'try { module.exports = require("optional-runtime"); } catch { module.exports = require("./fallback.cjs"); }', |
| 124 | ); |
| 125 | yield* fs.writeFileString( |
| 126 | path.join(dist, "fallback.cjs"), |
| 127 | 'module.exports = "fallback";', |
| 128 | ); |
| 129 | const staged = yield* stageWebsiteArtifact({ |
| 130 | root, |
| 131 | distDir: dist, |
| 132 | serverEntry: entry, |
| 133 | }); |
| 134 | const files = yield* listFiles(staged.directory); |
| 135 | expect(files.some((file) => file.endsWith("fallback.cjs"))).toBe( |
| 136 | true, |
| 137 | ); |
| 138 | expect( |
| 139 | files.some((file) => file.includes("optional-runtime")), |
| 140 | ).toBe(false); |
| 141 | }), |
| 142 | ).pipe(Effect.provide(services)), |
| 143 | { tags: ["unit"] }, |
| 144 | ); |
| 145 | |
| 146 | it.effect( |
| 147 | "rejects a dangling symlink in the build output", |
| 148 | () => |
| 149 | Effect.scoped( |
| 150 | Effect.gen(function* () { |
| 151 | const fs = yield* FileSystem.FileSystem; |
| 152 | const path = yield* Path.Path; |
| 153 | const root = yield* fixture("vite"); |
| 154 | const dist = path.join(root, "dist"); |
| 155 | yield* fs.makeDirectory(dist); |
| 156 | yield* fs.symlink( |
| 157 | path.join(root, "missing-build-asset"), |
| 158 | path.join(dist, "required-asset"), |
| 159 | ); |
| 160 | const error = yield* stageWebsiteArtifact({ |
| 161 | root, |
| 162 | distDir: dist, |
| 163 | static: {}, |
| 164 | }).pipe(Effect.flip); |
| 165 | expect(String(error)).toContain("required-asset"); |
| 166 | }), |
| 167 | ).pipe(Effect.provide(services)), |
| 168 | { tags: ["unit"] }, |
| 169 | ); |
| 170 | |
| 171 | it.effect( |
| 172 | "rejects output symlinks that would package the source tree", |
| 173 | () => |
| 174 | Effect.scoped( |
| 175 | Effect.gen(function* () { |
| 176 | const fs = yield* FileSystem.FileSystem; |
| 177 | const path = yield* Path.Path; |
| 178 | const root = yield* fixture("vite"); |
| 179 | const dist = path.join(root, "dist"); |
| 180 | yield* fs.makeDirectory(dist); |
| 181 | yield* fs.symlink(root, path.join(dist, "source")); |
| 182 | const error = yield* stageWebsiteArtifact({ |
| 183 | root, |
| 184 | distDir: dist, |
| 185 | static: {}, |
| 186 | }).pipe(Effect.flip); |
| 187 | expect(String(error)).toContain( |
| 188 | "symlink escapes its selected directory", |
| 189 | ); |
| 190 | }), |
| 191 | ).pipe(Effect.provide(services)), |
| 192 | { tags: ["unit"] }, |
| 193 | ); |
| 194 | |
| 195 | it.live( |
| 196 | "builds Vite and serves the extracted artifact without the source tree", |
| 197 | () => |
| 198 | Effect.scoped( |
| 199 | Effect.gen(function* () { |
| 200 | const fs = yield* FileSystem.FileSystem; |
| 201 | const path = yield* Path.Path; |
| 202 | const root = yield* fixture("vite"); |
| 203 | const framework = yield* makeVite({ |
| 204 | root, |
| 205 | target: "@alchemy.run/frontend-frameworks/vite/node", |
| 206 | }); |
| 207 | const built = yield* framework.build({ root }); |
| 208 | const dist = built.distDirectory!; |
| 209 | yield* fs.writeFileString( |
| 210 | path.join(dist, ".env"), |
| 211 | "BUILD_SECRET=hidden", |
| 212 | ); |
| 213 | const staged = yield* stageWebsiteArtifact({ |
| 214 | root, |
| 215 | distDir: dist, |
| 216 | serverEntry: path.join(dist, built.serverModules![0]!.name), |
| 217 | }); |
| 218 | const archive = yield* createComputeArchive(staged); |
| 219 | const extracted = yield* extract(archive); |
| 220 | const files = yield* listFiles(extracted); |
| 221 | expect(files.some((file) => file.endsWith(".env"))).toBe(false); |
| 222 | expect( |
| 223 | files.some((file) => file.includes("unrelated-private-file")), |
| 224 | ).toBe(false); |
| 225 | yield* fs.remove(root, { recursive: true }); |
| 226 | const url = yield* serve(extracted); |
| 227 | const html = yield* HttpClient.get(url).pipe( |
| 228 | Effect.flatMap((response) => response.text), |
| 229 | ); |
| 230 | expect(html).toContain("Website artifact Vite build"); |
| 231 | const asset = html.match(/src="([^"]+\.js)"/)?.[1]; |
| 232 | expect(asset).toBeDefined(); |
| 233 | expect( |
| 234 | yield* HttpClient.get(`${url}${asset}`).pipe( |
| 235 | Effect.flatMap((response) => response.text), |
| 236 | ), |
| 237 | ).toContain("built-by-real-vite"); |
| 238 | expect((yield* HttpClient.get(`${url}/deep/link`)).status).toBe( |
| 239 | 200, |
| 240 | ); |
| 241 | }), |
| 242 | ).pipe(Effect.provide(services)), |
| 243 | { tags: ["provider:prisma:compute"], timeout: 90_000 }, |
| 244 | ); |
| 245 | |
| 246 | it.live( |
| 247 | "packages static output without copying source or installed dependencies", |
| 248 | () => |
| 249 | Effect.scoped( |
| 250 | Effect.gen(function* () { |
| 251 | const fs = yield* FileSystem.FileSystem; |
| 252 | const path = yield* Path.Path; |
| 253 | const root = yield* fixture("vite"); |
| 254 | const framework = yield* makeVite({ |
| 255 | root, |
| 256 | target: "@alchemy.run/frontend-frameworks/vite/node", |
| 257 | }); |
| 258 | const built = yield* framework.build({ root }); |
| 259 | yield* fs.writeFileString( |
| 260 | path.join(built.distDirectory!, "missing.html"), |
| 261 | "Artifact not found", |
| 262 | ); |
| 263 | const staged = yield* stageWebsiteArtifact({ |
| 264 | root, |
| 265 | distDir: built.distDirectory!, |
| 266 | static: { |
| 267 | notFoundHandling: "404-page", |
| 268 | errorPage: "missing.html", |
| 269 | }, |
| 270 | }); |
| 271 | const extracted = yield* extract( |
| 272 | yield* createComputeArchive(staged), |
| 273 | ); |
| 274 | expect( |
| 275 | (yield* listFiles(extracted)).some((file) => |
| 276 | file.includes("node_modules"), |
| 277 | ), |
| 278 | ).toBe(false); |
| 279 | yield* fs.remove(root, { recursive: true }); |
| 280 | const url = yield* serve(extracted); |
| 281 | const missing = yield* HttpClient.get(`${url}/missing`); |
| 282 | expect(missing.status).toBe(404); |
| 283 | expect(yield* missing.text).toBe("Artifact not found"); |
| 284 | }), |
| 285 | ).pipe(Effect.provide(services)), |
| 286 | { tags: ["provider:prisma:compute"], timeout: 90_000 }, |
| 287 | ); |
| 288 | |
| 289 | it.live( |
| 290 | "builds vinext and preserves vendored package paths in the extracted artifact", |
| 291 | () => |
| 292 | Effect.scoped( |
| 293 | Effect.gen(function* () { |
| 294 | const path = yield* Path.Path; |
| 295 | const root = yield* path.fromFileUrl( |
| 296 | new URL( |
| 297 | "../../../../examples/prisma-website-vinext/", |
| 298 | import.meta.url, |
| 299 | ), |
| 300 | ); |
| 301 | const framework = yield* makeVinext({ root }); |
| 302 | const built = yield* framework.build({ root }); |
| 303 | const staged = yield* stageWebsiteArtifact({ |
| 304 | root, |
| 305 | distDir: built.distDirectory, |
| 306 | serverEntry: path.join( |
| 307 | built.distDirectory, |
| 308 | built.serverModules[0]!.name, |
| 309 | ), |
| 310 | }); |
| 311 | const extracted = yield* extract( |
| 312 | yield* createComputeArchive(staged), |
| 313 | ); |
| 314 | const files = yield* listFiles(extracted); |
| 315 | expect( |
| 316 | files.some( |
| 317 | (file) => |
| 318 | file.includes("/deps/.pnpm/pathslash@") && |
| 319 | file.endsWith("/dist/index.js"), |
| 320 | ), |
| 321 | ).toBe(true); |
| 322 | const url = yield* serve(extracted); |
| 323 | const home = yield* HttpClient.get(url); |
| 324 | expect(home.status).toBe(200); |
| 325 | expect(yield* home.text).toContain("Hello from vinext on Prisma!"); |
| 326 | const asset = yield* HttpClient.get(`${url}/example.json`); |
| 327 | expect(yield* asset.json).toEqual({ |
| 328 | framework: "vinext", |
| 329 | greeting: "Hello from vinext on Prisma!", |
| 330 | }); |
| 331 | const api = yield* HttpClient.get(`${url}/api/hello?name=artifact`); |
| 332 | expect(yield* api.json).toEqual({ |
| 333 | name: "artifact", |
| 334 | greeting: "hello", |
| 335 | }); |
| 336 | }), |
| 337 | ).pipe(Effect.provide(services)), |
| 338 | { tags: ["provider:prisma:compute"], timeout: 120_000 }, |
| 339 | ); |
| 340 | |
| 341 | it.live( |
| 342 | "builds Next.js and traces config, runtime packages, SSR, and public assets", |
| 343 | () => |
| 344 | Effect.scoped( |
| 345 | Effect.gen(function* () { |
| 346 | const fs = yield* FileSystem.FileSystem; |
| 347 | const path = yield* Path.Path; |
| 348 | const root = yield* fixture("next"); |
| 349 | const framework = yield* makeNext({ root }); |
| 350 | const built = yield* framework.build({ root }); |
| 351 | const staged = yield* stageWebsiteArtifact({ |
| 352 | root, |
| 353 | distDir: built.distDirectory!, |
| 354 | serverEntry: path.join(root, built.serverModules![0]!.name), |
| 355 | layout: "next", |
| 356 | }); |
| 357 | const extracted = yield* extract( |
| 358 | yield* createComputeArchive(staged), |
| 359 | ); |
| 360 | const files = yield* listFiles(extracted); |
| 361 | expect(files.some((file) => file.endsWith("site-config.mjs"))).toBe( |
| 362 | true, |
| 363 | ); |
| 364 | expect(files.some((file) => file.endsWith(".next/BUILD_ID"))).toBe( |
| 365 | true, |
| 366 | ); |
| 367 | expect(files.some((file) => file.includes(".next/cache/"))).toBe( |
| 368 | false, |
| 369 | ); |
| 370 | expect( |
| 371 | files.some((file) => file.includes("unrelated-private-file")), |
| 372 | ).toBe(false); |
| 373 | expect(files.some((file) => file.endsWith(".env.private"))).toBe( |
| 374 | false, |
| 375 | ); |
| 376 | yield* fs.remove(root, { recursive: true }); |
| 377 | const url = yield* serve(extracted); |
| 378 | expect( |
| 379 | yield* HttpClient.get(url).pipe( |
| 380 | Effect.flatMap((response) => response.text), |
| 381 | ), |
| 382 | ).toContain("Next artifact config dependency"); |
| 383 | expect( |
| 384 | yield* HttpClient.get(`${url}/artifact.txt`).pipe( |
| 385 | Effect.flatMap((response) => response.text), |
| 386 | ), |
| 387 | ).toBe("Next public artifact\n"); |
| 388 | }), |
| 389 | ).pipe(Effect.provide(services)), |
| 390 | { tags: ["provider:prisma:compute"], timeout: 120_000 }, |
| 391 | ); |
| 392 | }, |
| 393 | ); |