Files
wehub-resource-sync e30e75b5d4
Code Quality / Oxlint + Oxfmt (push) Waiting to run
Code Quality / Template Sync (push) Waiting to run
Code Quality / Build Changed Packages (push) Waiting to run
Code Quality / Test Changed Packages (push) Waiting to run
Deploy Expo Example / Deploy Production (push) Waiting to run
Deploy Ink Example / Deploy Production (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-stream, 3.12) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Waiting to run
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Waiting to run
Deploy Shadcn Registry / Deploy Production (push) Waiting to run
Template Metrics / LOC + Bundle Size (push) Waiting to run
Changesets / Create Version PR (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

492 lines
16 KiB
TypeScript

import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import { EventEmitter } from "node:events";
import {
resolveLatestReleaseRef,
downloadProject,
scaffoldProject,
transformProject,
} from "../../src/lib/create-project";
// Mock cross-spawn so no real child processes are spawned
vi.mock("cross-spawn", () => ({
spawn: vi.fn(() => {
const ee = new EventEmitter();
setTimeout(() => ee.emit("close", 0), 0);
return ee;
}),
}));
// Mock giget so no real downloads happen
vi.mock("giget", () => ({
downloadTemplate: vi.fn().mockResolvedValue({}),
}));
// Also mock detect-package-manager to avoid filesystem probing
vi.mock("detect-package-manager", () => ({
detect: vi.fn().mockResolvedValue("pnpm"),
}));
// Import the mocks after vi.mock so we can inspect calls
import { spawn } from "cross-spawn";
import { downloadTemplate } from "giget";
import {
dlxCommand,
type PackageManagerName,
} from "../../src/lib/create-project";
const TEST_PM: PackageManagerName = "pnpm";
const [TEST_DLX_CMD] = dlxCommand(TEST_PM);
const defaultOpts = {
packageManager: TEST_PM,
skipInstall: true,
} as const;
let testDir: string;
const GITHUB_AUTH_ENV_KEYS = [
"GIGET_AUTH",
"GITHUB_TOKEN",
"GH_TOKEN",
] as const;
let originalGitHubAuthEnv: Record<
(typeof GITHUB_AUTH_ENV_KEYS)[number],
string | undefined
>;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-create-project-"));
originalGitHubAuthEnv = Object.fromEntries(
GITHUB_AUTH_ENV_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof GITHUB_AUTH_ENV_KEYS)[number], string | undefined>;
for (const key of GITHUB_AUTH_ENV_KEYS) {
delete process.env[key];
}
});
afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
for (const key of GITHUB_AUTH_ENV_KEYS) {
const value = originalGitHubAuthEnv[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
vi.clearAllMocks();
vi.unstubAllGlobals();
});
function writeJSON(filePath: string, data: unknown) {
const full = path.join(testDir, filePath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, JSON.stringify(data, null, 2));
}
function writeFile(filePath: string, content: string) {
const full = path.join(testDir, filePath);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
function readJSON(filePath: string) {
return JSON.parse(fs.readFileSync(path.join(testDir, filePath), "utf-8"));
}
function readFile(filePath: string) {
return fs.readFileSync(path.join(testDir, filePath), "utf-8");
}
describe("resolveLatestReleaseRef", () => {
it("returns the tag name from the latest release", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ tag_name: "@assistant-ui/react@0.12.15" }),
}),
);
expect(await resolveLatestReleaseRef()).toBe("@assistant-ui/react@0.12.15");
});
it("authenticates the latest release request when a GitHub token is configured", async () => {
process.env.GITHUB_TOKEN = "ghs_test-token";
const fetchMock = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({ tag_name: "@assistant-ui/react@0.12.15" }),
});
vi.stubGlobal("fetch", fetchMock);
expect(await resolveLatestReleaseRef()).toBe("@assistant-ui/react@0.12.15");
expect(fetchMock).toHaveBeenCalledWith(
"https://api.github.com/repos/assistant-ui/assistant-ui/releases/latest",
{ headers: { Authorization: "Bearer ghs_test-token" } },
);
});
it("returns undefined when fetch fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("network error")),
);
expect(await resolveLatestReleaseRef()).toBeUndefined();
});
});
describe("downloadProject", () => {
it("passes ref in giget source when provided", async () => {
await downloadProject("templates/default", "/tmp/dest", "v1.0.0");
expect(downloadTemplate).toHaveBeenCalledWith(
"gh:assistant-ui/assistant-ui/templates/default#v1.0.0",
expect.objectContaining({ dir: "/tmp/dest", force: true, silent: true }),
);
});
it("omits ref from giget source when not provided", async () => {
await downloadProject("examples/with-tanstack", "/tmp/dest");
expect(downloadTemplate).toHaveBeenCalledWith(
"gh:assistant-ui/assistant-ui/examples/with-tanstack",
expect.objectContaining({ dir: "/tmp/dest", force: true, silent: true }),
);
});
it("passes auth to giget when a GitHub token is configured", async () => {
process.env.GH_TOKEN = "ghs_test-token";
await downloadProject("templates/default", "/tmp/dest", "v1.0.0");
expect(downloadTemplate).toHaveBeenCalledWith(
"gh:assistant-ui/assistant-ui/templates/default#v1.0.0",
expect.objectContaining({ auth: "ghs_test-token" }),
);
});
});
describe("scaffoldProject", () => {
it("downloads from GitHub sources", async () => {
await scaffoldProject("templates/default", "/tmp/dest", {
kind: "github",
ref: "v1.0.0",
});
expect(downloadTemplate).toHaveBeenCalledWith(
"gh:assistant-ui/assistant-ui/templates/default#v1.0.0",
expect.objectContaining({ dir: "/tmp/dest", force: true, silent: true }),
);
});
it("copies from a local assistant-ui repo root", async () => {
const repoRoot = path.join(testDir, "repo");
const destDir = path.join(testDir, "dest");
const templateDir = path.join(repoRoot, "templates", "default");
fs.mkdirSync(path.join(templateDir, "app"), { recursive: true });
fs.mkdirSync(path.join(templateDir, "node_modules", "pkg"), {
recursive: true,
});
fs.mkdirSync(path.join(templateDir, ".next", "server"), {
recursive: true,
});
fs.mkdirSync(path.join(templateDir, "dist"), { recursive: true });
fs.mkdirSync(path.join(templateDir, "build"), { recursive: true });
fs.writeFileSync(path.join(templateDir, "package.json"), "{}");
fs.writeFileSync(path.join(templateDir, "app", "page.tsx"), "export {};");
fs.writeFileSync(
path.join(templateDir, "node_modules", "pkg", "index.js"),
"module.exports = {};",
);
fs.writeFileSync(path.join(templateDir, ".next", "server", "page.js"), "");
fs.writeFileSync(path.join(templateDir, "dist", "index.js"), "");
fs.writeFileSync(path.join(templateDir, "build", "index.js"), "");
await scaffoldProject("templates/default", destDir, {
kind: "local",
rootDir: repoRoot,
});
expect(fs.existsSync(path.join(destDir, "package.json"))).toBe(true);
expect(fs.existsSync(path.join(destDir, "app", "page.tsx"))).toBe(true);
expect(fs.existsSync(path.join(destDir, "node_modules"))).toBe(false);
expect(fs.existsSync(path.join(destDir, ".next"))).toBe(false);
expect(fs.existsSync(path.join(destDir, "dist"))).toBe(false);
expect(fs.existsSync(path.join(destDir, "build"))).toBe(false);
expect(downloadTemplate).not.toHaveBeenCalled();
});
it("rejects missing local project sources", async () => {
await expect(
scaffoldProject("templates/missing", path.join(testDir, "dest"), {
kind: "local",
rootDir: testDir,
}),
).rejects.toThrow("Local project source does not exist:");
});
});
describe("transformProject — hasLocalComponents: true", () => {
it("transforms package.json correctly", async () => {
writeJSON("package.json", {
name: "old-name",
dependencies: {
"@assistant-ui/react": "workspace:*",
"@assistant-ui/ui": "workspace:*",
next: "^15.0.0",
},
devDependencies: {
"@assistant-ui/x-buildutils": "workspace:*",
typescript: "^5.0.0",
},
});
await transformProject(testDir, {
...defaultOpts,
hasLocalComponents: true,
});
const pkg = readJSON("package.json");
expect(pkg.dependencies["@assistant-ui/react"]).toBe("latest");
expect(pkg.dependencies.next).toBe("^15.0.0");
expect(pkg.dependencies["@assistant-ui/ui"]).toBeUndefined();
expect(pkg.devDependencies["@assistant-ui/x-buildutils"]).toBeUndefined();
expect(pkg.devDependencies.typescript).toBe("^5.0.0");
expect(pkg.name).toBe(path.basename(testDir));
});
});
describe("transformProject — hasLocalComponents: false", () => {
beforeEach(() => {
writeJSON("package.json", { name: "test", dependencies: {} });
});
async function run() {
return transformProject(testDir, {
...defaultOpts,
hasLocalComponents: false,
});
}
// tsconfig tests
describe("tsconfig transforms", () => {
it("removes workspace paths", async () => {
writeJSON("tsconfig.json", {
compilerOptions: {
paths: {
"@/components/assistant-ui/*": ["./components/assistant-ui/*"],
"@/components/icons/*": ["./components/icons/*"],
"@/components/ui/*": ["./components/ui/*"],
"@/components/ui/radix/*": [
"../../packages/ui/src/components/ui/radix/*",
],
"@/hooks/*": ["./hooks/*"],
"@/lib/utils": ["./lib/utils"],
"@assistant-ui/ui/*": ["../../packages/ui/src/*"],
"@/*": ["./*"],
},
},
});
await run();
const tsconfig = readJSON("tsconfig.json");
const paths = tsconfig.compilerOptions.paths;
expect(paths["@/components/assistant-ui/*"]).toBeUndefined();
expect(paths["@/components/icons/*"]).toBeUndefined();
expect(paths["@/components/ui/*"]).toBeUndefined();
expect(paths["@/components/ui/radix/*"]).toBeUndefined();
expect(paths["@/hooks/*"]).toBeUndefined();
expect(paths["@/lib/utils"]).toBeUndefined();
expect(paths["@assistant-ui/ui/*"]).toBeUndefined();
expect(paths["@/*"]).toEqual(["./*"]);
});
it("inlines x-buildutils/ts/next config with Next.js settings", async () => {
writeJSON("tsconfig.json", {
extends: "@assistant-ui/x-buildutils/ts/next",
compilerOptions: {
baseUrl: ".",
},
});
await run();
const tsconfig = readJSON("tsconfig.json");
expect(tsconfig.extends).toBeUndefined();
expect(tsconfig.compilerOptions.target).toBe("ESNext");
expect(tsconfig.compilerOptions.jsx).toBe("react-jsx");
expect(tsconfig.compilerOptions.plugins).toEqual([{ name: "next" }]);
// User's baseUrl should be preserved
expect(tsconfig.compilerOptions.baseUrl).toBe(".");
});
it("deletes empty paths object after removing workspace paths", async () => {
writeJSON("tsconfig.json", {
compilerOptions: {
paths: {
"@/components/assistant-ui/*": ["./components/assistant-ui/*"],
"@/components/icons/*": ["./components/icons/*"],
"@/components/ui/*": ["./components/ui/*"],
"@/hooks/*": ["./hooks/*"],
"@/lib/utils": ["./lib/utils"],
"@assistant-ui/ui/*": ["../../packages/ui/src/*"],
},
},
});
await run();
const tsconfig = readJSON("tsconfig.json");
expect(tsconfig.compilerOptions.paths).toBeUndefined();
});
it("inlines x-buildutils/ts/base config without Next.js settings", async () => {
writeJSON("tsconfig.json", {
extends: "@assistant-ui/x-buildutils/ts/base",
compilerOptions: {
baseUrl: ".",
},
});
await run();
const tsconfig = readJSON("tsconfig.json");
expect(tsconfig.extends).toBeUndefined();
expect(tsconfig.compilerOptions.target).toBe("ESNext");
expect(tsconfig.compilerOptions.jsx).toBe("react-jsx");
expect(tsconfig.compilerOptions.plugins).toBeUndefined();
expect(tsconfig.compilerOptions.baseUrl).toBe(".");
});
});
// CSS transform tests
describe("CSS transforms", () => {
it("removes @source lines pointing at packages/ui/src", async () => {
writeFile(
"app/globals.css",
'@source "../../packages/ui/src";\n@tailwind base;\nbody { margin: 0; }\n',
);
await run();
const css = readFile("app/globals.css");
expect(css).not.toContain("packages/ui/src");
expect(css).toContain("@tailwind base");
expect(css).toContain("body { margin: 0; }");
});
it("leaves other @source lines untouched", async () => {
writeFile(
"app/globals.css",
'@source "./components";\n@source "../../packages/ui/src";\nbody {}\n',
);
await run();
const css = readFile("app/globals.css");
expect(css).toContain('@source "./components"');
expect(css).not.toContain("packages/ui/src");
});
});
// Component scanning tests
describe("component scanning", () => {
it("installs shadcn and assistant-ui components in a single shadcn add call", async () => {
writeFile(
"app/page.tsx",
'import { Thread } from "@/components/assistant-ui/thread.tsx";\nimport { Button } from "@/components/ui/button.tsx";\nexport default function Page() { return <Thread />; }\n',
);
await transformProject(testDir, {
...defaultOpts,
skipInstall: false,
hasLocalComponents: false,
});
const addCalls = (spawn as Mock).mock.calls.filter(
([cmd, args]: [string, string[]]) =>
cmd === TEST_DLX_CMD &&
args.includes("shadcn@latest") &&
args.includes("add"),
);
expect(addCalls).toHaveLength(1);
const args = addCalls[0]![1] as string[];
expect(args).toContain("button");
expect(args).toContain("@assistant-ui/thread");
expect(args).not.toContain("button.tsx");
expect(args).not.toContain("@assistant-ui/thread.tsx");
});
it("skips shadcn when skipInstall is true even without local components", async () => {
writeFile(
"app/page.tsx",
'import { Thread } from "@/components/assistant-ui/thread.tsx";\nimport { Button } from "@/components/ui/button.tsx";\nexport default function Page() { return <Thread />; }\n',
);
await run();
const shadcnCalls = (spawn as Mock).mock.calls.filter(
([cmd, args]: [string, string[]]) =>
cmd === TEST_DLX_CMD && args.includes("shadcn@latest"),
);
expect(shadcnCalls).toHaveLength(0);
});
});
});
describe("transformProject — install behavior", () => {
it("spawns the correct package manager install command", async () => {
writeJSON("package.json", { name: "test", dependencies: {} });
await transformProject(testDir, {
...defaultOpts,
hasLocalComponents: true,
skipInstall: false,
});
expect(spawn).toHaveBeenCalledWith(
TEST_PM,
["install"],
expect.objectContaining({ cwd: testDir }),
);
});
});
describe("installShadcnRegistry behavior", () => {
it("resolves with a warning when shadcn exits non-zero", async () => {
// First spawn call is `pm install` (skipInstall: false); let it succeed.
(spawn as Mock).mockImplementationOnce(() => {
const ee = new EventEmitter();
setTimeout(() => ee.emit("close", 0), 0);
return ee;
});
// Second spawn call is `shadcn add`; emit non-zero exit.
(spawn as Mock).mockImplementationOnce(() => {
const ee = new EventEmitter();
setTimeout(() => ee.emit("close", 1), 0);
return ee;
});
writeJSON("package.json", { name: "test", dependencies: {} });
writeFile(
"app/page.tsx",
'import { Button } from "@/components/ui/button";\n',
);
// Should NOT throw — warn-and-continue behavior
await transformProject(testDir, {
...defaultOpts,
skipInstall: false,
hasLocalComponents: false,
});
});
});