Files
bun.sh/test/cli/create/create-jsx.test.ts
Jarred Sumner 2e02d9de28 Use ReadableStream.prototype.* in tests instead of new Response(...).* (#20937)
Co-authored-by: Jarred-Sumner <709451+Jarred-Sumner@users.noreply.github.com>
Co-authored-by: Alistair Smith <hi@alistair.sh>
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-14 00:47:53 -07:00

357 lines
11 KiB
TypeScript

import type { Subprocess } from "bun";
import { beforeEach, describe, expect, test } from "bun:test";
import { cp, readdir } from "fs/promises";
import { bunEnv, bunExe, isCI, isWindows, tempDirWithFiles } from "harness";
import path from "path";
async function getServerUrl(process: Subprocess<any, "pipe", any>, all = { text: "" }) {
// Read the port number from stdout
const decoder = new TextDecoder();
let serverUrl = "";
all.text = "";
const reader = process.stdout.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const textChunk = decoder.decode(value, { stream: true });
all.text += textChunk;
console.log(textChunk);
if (all.text.includes("http://")) {
serverUrl = all.text.trim();
serverUrl = serverUrl.slice(serverUrl.indexOf("http://"));
serverUrl = serverUrl.slice(0, serverUrl.indexOf("\n"));
if (URL.canParse(serverUrl)) {
break;
}
serverUrl = serverUrl.slice(0, serverUrl.indexOf("/n"));
serverUrl = serverUrl.slice(0, serverUrl.lastIndexOf("/"));
serverUrl = serverUrl.trim();
if (URL.canParse(serverUrl)) {
break;
}
}
}
reader.releaseLock();
if (!serverUrl) {
throw new Error("Could not find server URL in stdout: " + all.text);
}
return serverUrl;
}
async function checkBuildOutput(dir: string) {
const distDir = path.join(dir, "dist");
const files = await readdir(distDir);
expect(files.some(f => f.endsWith(".js"))).toBe(true);
expect(files.some(f => f.endsWith(".html"))).toBe(true);
expect(files.some(f => f.endsWith(".css"))).toBe(true);
}
let dir_with_happy_dom = tempDirWithFiles("happy-dom", {
["package.json"]: JSON.stringify({
name: "happy-dom-tester",
version: "0.0.0",
dependencies: {
"@happy-dom/global-registrator": "17.1.1",
},
}),
});
async function fetchAndInjectHTML(url: string) {
var subprocess = Bun.spawn({
cmd: [
bunExe(),
"--eval",
`
const url = ${JSON.stringify(url)};
const initial = await fetch(url).then(r => r.text());
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register({
url,
});
globalThis.WebSocket = class {
constructor(url) {
}
};
location.href = url;
document.write(initial);
window.happyDOM.waitUntilComplete().then(() => {
const html = document.documentElement.outerHTML;
process.stdout.write(html, () => {
process.exit(0);
});
});
`,
],
cwd: dir_with_happy_dom,
env: bunEnv,
stdout: "pipe",
stdin: "ignore",
stderr: "inherit",
});
await subprocess.exited;
return await subprocess.stdout.text();
}
for (const development of [true, false]) {
describe(`development: ${development}`, () => {
const normalizeHTML = normalizeHTMLFn(development);
const env = {
...bunEnv,
BUN_PORT: "0",
NODE_ENV: development ? undefined : "production",
};
const devServerLabel = development ? " dev server" : "";
describe("react spa (no tailwind)", async () => {
let dir: string;
beforeEach(async () => {
dir = tempDirWithFiles("react-spa-no-tailwind", {
"README.md": "Hello, world!",
});
await cp(path.join(__dirname, "react-spa-no-tailwind"), dir, {
recursive: true,
force: true,
});
});
test.todoIf(isCI || isWindows)("dev server", async () => {
console.log({ dir });
await using process = Bun.spawn([bunExe(), "create", "./index.jsx"], {
cwd: dir,
env: env,
stdout: "pipe",
stdin: "ignore",
});
const all = { text: "" };
const serverUrl = await getServerUrl(process, all);
try {
console.log({ dir });
const content = await fetchAndInjectHTML(serverUrl);
expect(normalizeHTML(content)).toMatchSnapshot();
expect(
all.text
.replaceAll(Bun.version, "*.*.*")
.replaceAll(Bun.version_with_sha, "*.*.*")
.replace(/v\d+\.\d+\.\d+(?:\s*\([a-f0-9]+\))?(?:-(debug|canary.*))?/g, "v*.*.*") // Handle version with git hash
.replace(/\[\d+\.?\d*m?s\]/g, "[*ms]")
.replace(/@\d+\.\d+\.\d+/g, "@*.*.*")
.replace(/\d+\.\d+\s*ms/g, "*.** ms")
.replace(/^\s+/gm, "") // Remove leading spaces
.replace(/installed react(-dom)?@\d+\.\d+\.\d+/g, "installed react$1@*.*.*") // Handle react versions
.trim()
.replaceAll(serverUrl, "http://[SERVER_URL]"),
).toMatchSnapshot();
} finally {
process.kill();
}
});
test.todoIf(isWindows)("build", async () => {
{
const process = Bun.spawn([bunExe(), "create", "./index.jsx"], {
cwd: dir,
env: env,
stdout: "pipe",
stdin: "ignore",
});
const all = { text: "" };
const serverUrl = await getServerUrl(process, all);
process.kill();
}
const process = Bun.spawn([bunExe(), "run", "build"], {
cwd: dir,
env: env,
stdout: "pipe",
});
await process.exited;
await checkBuildOutput(dir);
});
});
describe("react spa (tailwind)", async () => {
let dir: string;
beforeEach(async () => {
dir = tempDirWithFiles("react-spa-tailwind", {
"index.tsx": await Bun.file(path.join(__dirname, "tailwind.tsx")).text(),
});
});
test.todoIf(isCI || isWindows)("dev server", async () => {
const process = Bun.spawn([bunExe(), "create", "./index.tsx"], {
cwd: dir,
env: env,
stdout: "pipe",
stdin: "ignore",
});
const all = { text: "" };
console.log({ dir });
const serverUrl = await getServerUrl(process, all);
console.log(serverUrl);
try {
const content = await fetchAndInjectHTML(serverUrl);
expect(normalizeHTML(content)).toMatchSnapshot();
expect(
all.text
.replaceAll(Bun.version_with_sha, "*.*.*")
.replace(/Bun (v\d+\.\d+\.\d+)/, "Bun *.*.*")
.replace(/\[\d+\.?\d*m?s\]/g, "[*ms]")
.replace(/@\d+\.\d+\.\d+/g, "@*.*.*")
.replace(/\d+\.\d+\s*ms/g, "*.** ms")
.replace(/^\s+/gm, "")
.replace(/installed (react(-dom)?|tailwindcss)@\d+\.\d+\.\d+/g, "installed $1@*.*.*")
.trim()
.replaceAll(serverUrl, "http://[SERVER_URL]"),
).toMatchSnapshot();
} finally {
process.kill();
}
});
test.todoIf(isWindows)("build", async () => {
{
const process = Bun.spawn([bunExe(), "create", "./index.tsx"], {
cwd: dir,
env: env,
stdout: "pipe",
stdin: "ignore",
});
const all = { text: "" };
const serverUrl = await getServerUrl(process, all);
process.kill();
}
const process = Bun.spawn([bunExe(), "run", "build"], {
cwd: dir,
env: env,
stdout: "pipe",
});
await process.exited;
await checkBuildOutput(dir);
});
});
describe("shadcn/ui", async () => {
let dir: string;
beforeEach(async () => {
dir = tempDirWithFiles("shadcn-ui", {
"index.tsx": await Bun.file(path.join(__dirname, "shadcn.tsx")).text(),
});
});
test.todoIf(isCI || isWindows)("dev server", async () => {
const process = Bun.spawn([bunExe(), "create", "./index.tsx"], {
cwd: dir,
env: env,
stdout: "pipe",
stdin: "ignore",
});
const all = { text: "" };
const serverUrl = await getServerUrl(process, all);
console.log(serverUrl);
console.log(dir);
try {
const content = await fetchAndInjectHTML(serverUrl);
// Check for components.json
const componentsJson = await Bun.file(path.join(dir, "components.json")).exists();
expect(componentsJson).toBe(true);
expect(
all.text
.replaceAll(Bun.version_with_sha, "*.*.*")
.replaceAll(Bun.version, "*.*.*")
.replace(/\[\d+\.?\d*m?s\]/g, "[*ms]")
.replace(/@\d+\.\d+\.\d+/g, "@*.*.*")
.replace(/\d+\.\d+\s*ms/g, "*.** ms")
.replace(/^\s+/gm, "")
.replace(
/installed (react(-dom)?|@radix-ui\/.*|tailwindcss|class-variance-authority|clsx|lucide-react|tailwind-merge)@\d+\.\d+\.\d+/g,
"installed $1@*.*.*",
)
.trim()
.replaceAll(serverUrl, "http://[SERVER_URL]"),
).toMatchSnapshot();
expect(normalizeHTML(content)).toMatchSnapshot();
} finally {
process.kill();
}
});
test.todoIf(isCI || isWindows)("build", async () => {
{
const process = Bun.spawn([bunExe(), "create", "./index.tsx"], {
cwd: dir,
env: env,
stdout: "pipe",
stdin: "ignore",
});
const all = { text: "" };
const serverUrl = await getServerUrl(process, all);
process.kill();
}
const process = Bun.spawn([bunExe(), "run", "build"], {
cwd: dir,
env: env,
stdout: "pipe",
});
await process.exited;
await checkBuildOutput(dir);
});
});
});
}
function normalizeHTMLFn(development: boolean = true) {
return (html: string) =>
html
.split("\n")
.map(line => {
// First trim the line
const trimmed = line.trim();
if (!trimmed) return "";
if (!development) {
// Replace chunk hashes in stylesheet and script tags
return trimmed.replace(
/<(link rel="stylesheet" crossorigin="" href|script type="module" crossorigin="" src)="\/chunk-[a-zA-Z0-9]+\.(css|js)("><\/script>|">)/g,
(_, tagStart, ext) => {
if (ext === "css") {
return `<${tagStart}="/chunk-[HASH].css">`;
}
return `<${tagStart}="/chunk-[HASH].js"></script>`;
},
);
}
console.log(trimmed);
// In development mode, replace non-deterministic generation IDs
return trimmed
.replace(/\/_bun\/client\/(.*?-[a-z0-9]{8})[a-z0-9]{8}\.js/gm, "/_bun/client/$1[NONDETERMINISTIC].js")
.replace(/\/_bun\/asset\/[a-z0-9]{16}\.[a-z]+/gm, "/_bun/asset/[ASSET_HASH].css");
})
.filter(Boolean)
.join("\n")
.trim();
}