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
492 lines
16 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|