| 1 | import { stageWebsiteArtifact } from "@/Neon/Website/Artifact.ts"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import { expect, it } from "alchemy-test"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as FileSystem from "effect/FileSystem"; |
| 6 | import * as Path from "effect/Path"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 9 | import { buildPortableExample } from "./Fixture.ts"; |
| 10 | |
| 11 | for (const slug of ["nextjs", "vocs"] as const) { |
| 12 | it.live.skipIf(!process.env.NEON_WEBSITE_LINUX)( |
| 13 | `${slug} production artifact runs on Linux ARM64 glibc without host dependencies`, |
| 14 | () => |
| 15 | Effect.gen(function* () { |
| 16 | const fs = yield* FileSystem.FileSystem; |
| 17 | const path = yield* Path.Path; |
| 18 | const next = slug === "nextjs"; |
| 19 | const artifact = yield* stageWebsiteArtifact( |
| 20 | yield* buildPortableExample(slug), |
| 21 | ); |
| 22 | const probe = [ |
| 23 | 'import assert from "node:assert/strict";', |
| 24 | 'import fs from "node:fs";', |
| 25 | 'import path from "node:path";', |
| 26 | 'import { createRequire } from "node:module";', |
| 27 | "try {", |
| 28 | 'assert.equal(process.platform, "linux"); assert.equal(process.arch, "arm64");', |
| 29 | "assert.ok(process.report.getReport().header.glibcVersionRuntime);", |
| 30 | ...(next |
| 31 | ? [ |
| 32 | 'const entry = [...fs.globSync("files/**/serve-neon.mjs")][0]; fs.writeFileSync(path.join(path.dirname(entry), "public/pixel.png"), Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64"));', |
| 33 | ] |
| 34 | : []), |
| 35 | 'const { default: handler } = await import("./index.mjs");', |
| 36 | ...(next |
| 37 | ? [ |
| 38 | 'const require = createRequire(path.join(process.cwd(), "package.json")); const sharp = createRequire(require.resolve("next/package.json"))("sharp"); assert.ok(sharp.versions.vips);', |
| 39 | ] |
| 40 | : []), |
| 41 | 'const request = (url, method = "GET", headers) => handler.fetch(new Request("http://localhost" + url, { method, headers }));', |
| 42 | 'const home = await request("/"); assert.equal(home.status, 200); const html = await home.text(); assert.match(html, /Neon/);', |
| 43 | 'for (const method of ["GET", "HEAD"]) { const res = await request("/example.json", method); assert.equal(res.status, 200); const text = await res.text(); if (method === "HEAD") assert.equal(text, ""); else assert.match(text, /framework/); }', |
| 44 | 'const assets = [...html.matchAll(/(?:src|href)="([^" ]+\\.(?:js|css)(?:\\?[^" ]*)?)"/g)].map(m => m[1]).filter(url => url.startsWith("/")); assert.ok(assets.length > 0);', |
| 45 | "for (const url of assets.slice(0, 3)) { const res = await request(url); assert.equal(res.status, 200, url); assert.ok((await res.arrayBuffer()).byteLength > 0); }", |
| 46 | ...(next |
| 47 | ? [ |
| 48 | 'const image = await request("/_next/image?url=%2Fpixel.png&w=64&q=75", "GET", { accept: "image/webp" }); assert.equal(image.status, 200, await (image.status === 200 ? Promise.resolve("") : image.text())); assert.match(image.headers.get("content-type"), /^image\\/webp/); assert.ok((await image.arrayBuffer()).byteLength > 0);', |
| 49 | 'const stream = await request("/api/stream"); assert.equal(stream.status, 200); assert.match(stream.headers.get("content-type"), /text\\/event-stream/); const reader = stream.body.getReader(); const first = await reader.read(); assert.match(new TextDecoder().decode(first.value), /data: first/); let rest = ""; for (;;) { const part = await reader.read(); if (part.done) break; rest += new TextDecoder().decode(part.value); } assert.match(rest, /data: second/);', |
| 50 | ] |
| 51 | : [ |
| 52 | 'const page = await request("/guide"); assert.equal(page.status, 200); assert.match(await page.text(), /Guide/);', |
| 53 | ]), |
| 54 | 'console.log("NEON_LINUX_ARTIFACT_OK"); process.exit(0);', |
| 55 | "} catch (error) { console.error(error); process.exit(1); }", |
| 56 | ].join("\n"); |
| 57 | yield* fs.writeFileString( |
| 58 | path.join(artifact.directory, "probe.mjs"), |
| 59 | probe, |
| 60 | ); |
| 61 | const proc = yield* ChildProcess.make("docker", [ |
| 62 | "run", |
| 63 | "--rm", |
| 64 | "--network=none", |
| 65 | "--memory=1g", |
| 66 | "--memory-swap=1g", |
| 67 | "--cpus=2", |
| 68 | "--platform=linux/arm64", |
| 69 | "--mount", |
| 70 | `type=bind,source=${artifact.directory},target=/app`, |
| 71 | "--workdir", |
| 72 | "/app", |
| 73 | "--env", |
| 74 | "NODE_ENV=production", |
| 75 | "node:24-bookworm-slim", |
| 76 | "node", |
| 77 | "probe.mjs", |
| 78 | ]); |
| 79 | const [code, stdout, stderr] = yield* Effect.all( |
| 80 | [ |
| 81 | proc.exitCode, |
| 82 | proc.stdout.pipe(Stream.decodeText, Stream.mkString), |
| 83 | proc.stderr.pipe(Stream.decodeText, Stream.mkString), |
| 84 | ], |
| 85 | { concurrency: "unbounded" }, |
| 86 | ).pipe(Effect.timeout("45 seconds")); |
| 87 | expect({ code, stderr: code === 0 ? "" : stderr }).toEqual({ |
| 88 | code: 0, |
| 89 | stderr: "", |
| 90 | }); |
| 91 | expect({ |
| 92 | completed: stdout.includes("NEON_LINUX_ARTIFACT_OK"), |
| 93 | stdout, |
| 94 | stderr, |
| 95 | }).toMatchObject({ completed: true }); |
| 96 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 97 | { |
| 98 | tags: ["provider:neon", "provider:neon:website", "local"], |
| 99 | timeout: 120_000, |
| 100 | }, |
| 101 | ); |
| 102 | } |