Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/openui-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"build:templates": "node scripts/build-templates.js",
"build": "pnpm run build:cli && pnpm run build:templates",
"build:exec": "node dist/index.js",
"test": "vitest run",
"lint:check": "eslint ./src --ignore-pattern 'src/templates/**'",
"lint:fix": "eslint ./src --fix --ignore-pattern 'src/templates/**'",
"format:fix": "prettier --write './src/**' '!./src/templates/**'",
Expand All @@ -22,7 +23,8 @@
"ci": "pnpm run lint:check && pnpm run format:check"
},
"devDependencies": {
"@types/node": "^22.15.32"
"@types/node": "^22.15.32",
"vitest": "^4.0.18"
},
"keywords": [
"openui",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { detectPackageManager } from "../detect-package-manager";

describe("detectPackageManager", () => {
const originalUserAgent = process.env["npm_config_user_agent"];

beforeEach(() => {
delete process.env["npm_config_user_agent"];
});

afterEach(() => {
if (originalUserAgent === undefined) {
delete process.env["npm_config_user_agent"];
} else {
process.env["npm_config_user_agent"] = originalUserAgent;
}
});

it("returns 'pnpm dlx' for a pnpm user agent", () => {
process.env["npm_config_user_agent"] = "pnpm/9.1.0 npm/? node/v20.0.0 linux x64";
expect(detectPackageManager()).toBe("pnpm dlx");
});

it("returns 'yarn dlx' for a yarn user agent", () => {
process.env["npm_config_user_agent"] = "yarn/4.1.0 npm/? node/v20.0.0 linux x64";
expect(detectPackageManager()).toBe("yarn dlx");
});

it("returns 'bunx' for a bun user agent", () => {
process.env["npm_config_user_agent"] = "bun/1.1.0 npm/? node/v20.0.0 linux x64";
expect(detectPackageManager()).toBe("bunx");
});

it("returns 'npx' for an npm user agent", () => {
process.env["npm_config_user_agent"] = "npm/10.5.0 node/v20.0.0 linux x64";
expect(detectPackageManager()).toBe("npx");
});

it("returns 'npx' when the user agent is unset", () => {
expect(detectPackageManager()).toBe("npx");
});

it("returns 'npx' for an unrecognized user agent", () => {
process.env["npm_config_user_agent"] = "deno/1.40.0";
expect(detectPackageManager()).toBe("npx");
});

it("does not match package managers that merely contain the name", () => {
// The check is prefix-based, so a stray substring must not trigger a match.
process.env["npm_config_user_agent"] = "custom-pnpm/1.0.0";
expect(detectPackageManager()).toBe("npx");
});
});
187 changes: 187 additions & 0 deletions packages/openui-cli/src/lib/__tests__/resolve-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { resolveArgs } from "../resolve-args";

const inputMock = vi.fn();
const selectMock = vi.fn();

// The prompt libraries are imported dynamically inside resolveArgs, so we stub
// them here to keep the tests deterministic and free of real interactive input.
vi.mock("@inquirer/prompts", () => ({
input: (...args: unknown[]) => inputMock(...args),
select: (...args: unknown[]) => selectMock(...args),
}));

class FakeExitPromptError extends Error {}

vi.mock("@inquirer/core", () => ({
ExitPromptError: FakeExitPromptError,
}));

// process.exit never returns in production; replicate that by throwing so the
// remaining logic does not run, then assert on the captured exit code.
class ProcessExit extends Error {
constructor(public code: number) {
super(`process.exit(${code})`);
}
}

describe("resolveArgs", () => {
let exitSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
inputMock.mockReset();
selectMock.mockReset();
exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new ProcessExit((code as number) ?? 0);
});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
exitSpy.mockRestore();
errorSpy.mockRestore();
});

it("returns provided values without prompting (interactive)", async () => {
const result = await resolveArgs(
{
name: { value: "my-app" },
count: { value: 3 },
},
true,
);

expect(result).toEqual({ name: "my-app", count: 3 });
expect(inputMock).not.toHaveBeenCalled();
expect(selectMock).not.toHaveBeenCalled();
});

it("returns provided values without prompting (non-interactive)", async () => {
const result = await resolveArgs({ name: { value: "my-app" } }, false);

expect(result).toEqual({ name: "my-app" });
expect(inputMock).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});

it("exits with code 1 on a missing required arg when non-interactive", async () => {
await expect(
resolveArgs(
{
entry: {
prompt: { type: "input", message: "Entry file path?" },
required: true,
},
},
false,
),
).rejects.toBeInstanceOf(ProcessExit);

expect(exitSpy).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("Missing required argument --entry"),
);
expect(inputMock).not.toHaveBeenCalled();
});

it("prompts for missing required args when interactive (input)", async () => {
inputMock.mockResolvedValue("from-input");

const result = await resolveArgs(
{
entry: {
prompt: { type: "input", message: "Entry file path?", default: "index.ts" },
required: true,
},
},
true,
);

expect(result).toEqual({ entry: "from-input" });
expect(inputMock).toHaveBeenCalledWith({
message: "Entry file path?",
default: "index.ts",
});
});

it("prompts using select for select-type configs", async () => {
selectMock.mockResolvedValue("react");

const result = await resolveArgs(
{
framework: {
prompt: {
type: "select",
message: "Pick a framework",
choices: [{ value: "react" }, { value: "vue" }],
},
required: true,
},
},
true,
);

expect(result).toEqual({ framework: "react" });
expect(selectMock).toHaveBeenCalledWith({
message: "Pick a framework",
choices: [{ value: "react" }, { value: "vue" }],
});
});

it("mixes provided values with prompted ones, preserving keys", async () => {
inputMock.mockResolvedValue("prompted");

const result = await resolveArgs(
{
name: { value: "given" },
entry: {
prompt: { type: "input", message: "Entry?" },
required: true,
},
},
true,
);

expect(result).toEqual({ name: "given", entry: "prompted" });
expect(inputMock).toHaveBeenCalledTimes(1);
});

it("exits cleanly (code 0) when the user cancels a prompt", async () => {
inputMock.mockRejectedValue(new FakeExitPromptError("cancelled"));

await expect(
resolveArgs(
{
entry: {
prompt: { type: "input", message: "Entry?" },
required: true,
},
},
true,
),
).rejects.toBeInstanceOf(ProcessExit);

expect(exitSpy).toHaveBeenCalledWith(0);
});

it("rethrows non-cancellation errors from a prompt", async () => {
const boom = new Error("unexpected");
inputMock.mockRejectedValue(boom);

await expect(
resolveArgs(
{
entry: {
prompt: { type: "input", message: "Entry?" },
required: true,
},
},
true,
),
).rejects.toBe(boom);

expect(exitSpy).not.toHaveBeenCalled();
});
});
2 changes: 1 addition & 1 deletion packages/openui-cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../tsconfig.json",
"include": ["src/**/*.ts"],
"exclude": ["src/playground", "src/templates"],
"exclude": ["src/playground", "src/templates", "**/__tests__/**", "**/*.test.ts"],
"compilerOptions": {
"target": "es2022",
"lib": ["es2022"],
Expand Down
9 changes: 9 additions & 0 deletions packages/openui-cli/tsconfig.test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["dist", "node_modules"]
}
18 changes: 12 additions & 6 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading