mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-06-25 21:56:08 +00:00
feat(shadcn): add view command (#7994)
* feat(shadcn): add view command * test(shadcn): add tests for view command * feat(shadcn): allow shadow config in view command * chore: changeset * test(shadcn): skip view * test(shadcn): update view port number * feat(shadcn): add list command * fix * feat(shadcn): implement search command * fix: tests * fix * chore: update changesets
This commit is contained in:
5
.changeset/dirty-teachers-raise.md
Normal file
5
.changeset/dirty-teachers-raise.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"shadcn": major
|
||||
---
|
||||
|
||||
add view and search commands
|
||||
@@ -38,7 +38,7 @@
|
||||
"lint": "turbo run lint",
|
||||
"lint:fix": "turbo run lint:fix",
|
||||
"preview": "turbo run preview",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"typecheck": "turbo run typecheck --filter=!www",
|
||||
"format:write": "turbo run format:write",
|
||||
"format:check": "turbo run format:check",
|
||||
"sync:templates": "./scripts/sync-templates.sh \"templates/*\"",
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"execa": "^7.0.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.1.0",
|
||||
"fuzzysort": "^3.1.0",
|
||||
"https-proxy-agent": "^6.2.0",
|
||||
"kleur": "^4.1.5",
|
||||
"msw": "^2.7.1",
|
||||
|
||||
104
packages/shadcn/src/commands/search.ts
Normal file
104
packages/shadcn/src/commands/search.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import path from "path"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { searchRegistries } from "@/src/registry/search"
|
||||
import { validateRegistryConfigForItems } from "@/src/registry/validator"
|
||||
import { rawConfigSchema } from "@/src/schema"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import { createConfig, getConfig } from "@/src/utils/get-config"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { Command } from "commander"
|
||||
import fsExtra from "fs-extra"
|
||||
import { z } from "zod"
|
||||
|
||||
const searchOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
query: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
offset: z.number().optional(),
|
||||
})
|
||||
|
||||
// TODO: We're duplicating logic for shadowConfig here.
|
||||
// Revisit and properly abstract this.
|
||||
|
||||
export const search = new Command()
|
||||
.name("search")
|
||||
.description("search items from registries")
|
||||
.argument("<registries...>", "the registry names to search items from")
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.option("-q, --query <query>", "query string")
|
||||
.option(
|
||||
"-l, --limit <number>",
|
||||
"maximum number of items to display per registry",
|
||||
"100"
|
||||
)
|
||||
.option("-o, --offset <number>", "number of items to skip", "0")
|
||||
.action(async (registries: string[], opts) => {
|
||||
try {
|
||||
const options = searchOptionsSchema.parse({
|
||||
cwd: path.resolve(opts.cwd),
|
||||
query: opts.query,
|
||||
limit: opts.limit ? parseInt(opts.limit, 10) : undefined,
|
||||
offset: opts.offset ? parseInt(opts.offset, 10) : undefined,
|
||||
})
|
||||
|
||||
await loadEnvFiles(options.cwd)
|
||||
|
||||
// Start with a shadow config to support partial components.json.
|
||||
// Use createConfig to get proper default paths
|
||||
const defaultConfig = createConfig({
|
||||
style: "new-york",
|
||||
resolvedPaths: {
|
||||
cwd: options.cwd,
|
||||
},
|
||||
})
|
||||
let shadowConfig = configWithDefaults(defaultConfig)
|
||||
|
||||
// Check if there's a components.json file (partial or complete).
|
||||
const componentsJsonPath = path.resolve(options.cwd, "components.json")
|
||||
if (fsExtra.existsSync(componentsJsonPath)) {
|
||||
const existingConfig = await fsExtra.readJson(componentsJsonPath)
|
||||
const partialConfig = rawConfigSchema.partial().parse(existingConfig)
|
||||
shadowConfig = configWithDefaults({
|
||||
...defaultConfig,
|
||||
...partialConfig,
|
||||
})
|
||||
}
|
||||
|
||||
// Try to get the full config, but fall back to shadow config if it fails.
|
||||
let config = shadowConfig
|
||||
try {
|
||||
const fullConfig = await getConfig(options.cwd)
|
||||
if (fullConfig) {
|
||||
config = configWithDefaults(fullConfig)
|
||||
}
|
||||
} catch {
|
||||
// Use shadow config if getConfig fails (partial components.json).
|
||||
}
|
||||
|
||||
// Validate registries early for better error messages.
|
||||
validateRegistryConfigForItems(registries, config)
|
||||
|
||||
// Use searchRegistries for both search and non-search cases
|
||||
const results = await searchRegistries(
|
||||
registries as `@${string}`[],
|
||||
{
|
||||
query: options.query,
|
||||
limit: options.limit,
|
||||
offset: options.offset,
|
||||
},
|
||||
config
|
||||
)
|
||||
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
clearRegistryContext()
|
||||
}
|
||||
})
|
||||
68
packages/shadcn/src/commands/view.ts
Normal file
68
packages/shadcn/src/commands/view.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import path from "path"
|
||||
import { getRegistryItems } from "@/src/registry/api"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { validateRegistryConfigForItems } from "@/src/registry/validator"
|
||||
import { rawConfigSchema } from "@/src/schema"
|
||||
import { loadEnvFiles } from "@/src/utils/env-loader"
|
||||
import { getConfig } from "@/src/utils/get-config"
|
||||
import { handleError } from "@/src/utils/handle-error"
|
||||
import { Command } from "commander"
|
||||
import fsExtra from "fs-extra"
|
||||
import { z } from "zod"
|
||||
|
||||
const viewOptionsSchema = z.object({
|
||||
cwd: z.string(),
|
||||
})
|
||||
|
||||
export const view = new Command()
|
||||
.name("view")
|
||||
.description("view items from the registry")
|
||||
.argument("<items...>", "the item names or URLs to view")
|
||||
.option(
|
||||
"-c, --cwd <cwd>",
|
||||
"the working directory. defaults to the current directory.",
|
||||
process.cwd()
|
||||
)
|
||||
.action(async (items: string[], opts) => {
|
||||
try {
|
||||
const options = viewOptionsSchema.parse({
|
||||
cwd: path.resolve(opts.cwd),
|
||||
})
|
||||
|
||||
await loadEnvFiles(options.cwd)
|
||||
|
||||
// Start with a shadow config to support partial components.json.
|
||||
let shadowConfig = configWithDefaults({})
|
||||
|
||||
// Check if there's a components.json file (partial or complete).
|
||||
const componentsJsonPath = path.resolve(options.cwd, "components.json")
|
||||
if (fsExtra.existsSync(componentsJsonPath)) {
|
||||
const existingConfig = await fsExtra.readJson(componentsJsonPath)
|
||||
const partialConfig = rawConfigSchema.partial().parse(existingConfig)
|
||||
shadowConfig = configWithDefaults(partialConfig)
|
||||
}
|
||||
|
||||
// Try to get the full config, but fall back to shadow config if it fails.
|
||||
let config = shadowConfig
|
||||
try {
|
||||
const fullConfig = await getConfig(options.cwd)
|
||||
if (fullConfig) {
|
||||
config = configWithDefaults(fullConfig)
|
||||
}
|
||||
} catch {
|
||||
// Use shadow config if getConfig fails (partial components.json).
|
||||
}
|
||||
|
||||
// Validate registries early for better error messages.
|
||||
validateRegistryConfigForItems(items, config)
|
||||
|
||||
const payload = await getRegistryItems(items, config)
|
||||
console.log(JSON.stringify(payload, null, 2))
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
clearRegistryContext()
|
||||
}
|
||||
})
|
||||
@@ -7,6 +7,8 @@ import { init } from "@/src/commands/init"
|
||||
import { migrate } from "@/src/commands/migrate"
|
||||
import { build as registryBuild } from "@/src/commands/registry/build"
|
||||
import { mcp as registryMcp } from "@/src/commands/registry/mcp"
|
||||
import { search } from "@/src/commands/search"
|
||||
import { view } from "@/src/commands/view"
|
||||
import { Command } from "commander"
|
||||
|
||||
import packageJson from "../package.json"
|
||||
@@ -28,6 +30,8 @@ async function main() {
|
||||
.addCommand(init)
|
||||
.addCommand(add)
|
||||
.addCommand(diff)
|
||||
.addCommand(view)
|
||||
.addCommand(search)
|
||||
.addCommand(migrate)
|
||||
.addCommand(info)
|
||||
.addCommand(build)
|
||||
|
||||
@@ -1002,4 +1002,149 @@ describe("getRegistry", () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw RegistryParseError for registry name without @ prefix", async () => {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@acme": {
|
||||
url: "https://acme.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
try {
|
||||
// Cast to bypass TypeScript checking since we're testing runtime validation
|
||||
await getRegistry("invalid-name" as `@${string}`, mockConfig)
|
||||
expect.fail("Should have thrown RegistryParseError")
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryParseError)
|
||||
if (error instanceof RegistryParseError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR)
|
||||
expect(error.message).toContain("Failed to parse registry item")
|
||||
expect(error.message).toContain("invalid-name")
|
||||
expect(error.parseError).toBeDefined()
|
||||
if (error.parseError instanceof z.ZodError) {
|
||||
const zodError = error.parseError as z.ZodError
|
||||
expect(zodError.errors[0].message).toContain(
|
||||
"Registry name must start with @"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw RegistryParseError for empty registry name (@)", async () => {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@acme": {
|
||||
url: "https://acme.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
try {
|
||||
await getRegistry("@" as `@${string}`, mockConfig)
|
||||
expect.fail("Should have thrown RegistryParseError")
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryParseError)
|
||||
if (error instanceof RegistryParseError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR)
|
||||
expect(error.message).toContain("Failed to parse registry item")
|
||||
expect(error.parseError).toBeDefined()
|
||||
if (error.parseError instanceof z.ZodError) {
|
||||
const zodError = error.parseError as z.ZodError
|
||||
expect(zodError.errors[0].message).toContain(
|
||||
"Registry name must start with @ followed by alphanumeric characters"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should accept valid registry names with hyphens and underscores", async () => {
|
||||
const registryData = {
|
||||
name: "@test-123_abc/registry",
|
||||
homepage: "https://test.com",
|
||||
items: [],
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get("https://test.com/registry.json", () => {
|
||||
return HttpResponse.json(registryData)
|
||||
})
|
||||
)
|
||||
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@test-123_abc": {
|
||||
url: "https://test.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
const result = await getRegistry("@test-123_abc", mockConfig)
|
||||
expect(result).toMatchObject(registryData)
|
||||
})
|
||||
|
||||
it("should throw RegistryParseError for registry name with invalid characters", async () => {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {
|
||||
"@test$invalid": {
|
||||
url: "https://test.com/{name}.json",
|
||||
},
|
||||
},
|
||||
} as any
|
||||
|
||||
try {
|
||||
await getRegistry("@test$invalid" as `@${string}`, mockConfig)
|
||||
expect.fail("Should have thrown RegistryParseError")
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryParseError)
|
||||
if (error instanceof RegistryParseError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR)
|
||||
expect(error.message).toContain("Failed to parse registry item")
|
||||
expect(error.parseError).toBeDefined()
|
||||
if (error.parseError instanceof z.ZodError) {
|
||||
const zodError = error.parseError as z.ZodError
|
||||
expect(zodError.errors[0].message).toContain(
|
||||
"Registry name must start with @ followed by alphanumeric characters, hyphens, or underscores"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw RegistryParseError for registry name starting with @-", async () => {
|
||||
const mockConfig = {
|
||||
style: "new-york",
|
||||
tailwind: { baseColor: "neutral", cssVariables: true },
|
||||
registries: {},
|
||||
} as any
|
||||
|
||||
try {
|
||||
await getRegistry("@-invalid" as `@${string}`, mockConfig)
|
||||
expect.fail("Should have thrown RegistryParseError")
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(RegistryParseError)
|
||||
if (error instanceof RegistryParseError) {
|
||||
expect(error.code).toBe(RegistryErrorCode.PARSE_ERROR)
|
||||
expect(error.message).toContain("Failed to parse registry item")
|
||||
expect(error.parseError).toBeDefined()
|
||||
if (error.parseError instanceof z.ZodError) {
|
||||
const zodError = error.parseError as z.ZodError
|
||||
expect(zodError.errors[0].message).toContain(
|
||||
"Registry name must start with @ followed by alphanumeric characters"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,10 +28,25 @@ import { handleError } from "@/src/utils/handle-error"
|
||||
import { logger } from "@/src/utils/logger"
|
||||
import { z } from "zod"
|
||||
|
||||
// Schema for validating registry names
|
||||
const registryNameSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
/^@[a-zA-Z0-9][a-zA-Z0-9-_]*$/,
|
||||
"Registry name must start with @ followed by alphanumeric characters, hyphens, or underscores"
|
||||
)
|
||||
|
||||
export async function getRegistry(
|
||||
name: `@${string}`,
|
||||
config?: Partial<Config>
|
||||
) {
|
||||
// Validate the registry name using Zod schema
|
||||
try {
|
||||
registryNameSchema.parse(name.split("/")[0])
|
||||
} catch (error) {
|
||||
throw new RegistryParseError(name, error)
|
||||
}
|
||||
|
||||
if (!name.endsWith("/registry")) {
|
||||
name = `${name}/registry`
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export { getRegistryItems, resolveRegistryItems, getRegistry } from "./api"
|
||||
|
||||
export { searchRegistries } from "./search"
|
||||
|
||||
export {
|
||||
RegistryError,
|
||||
RegistryNotFoundError,
|
||||
|
||||
401
packages/shadcn/src/registry/search.test.ts
Normal file
401
packages/shadcn/src/registry/search.test.ts
Normal file
@@ -0,0 +1,401 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { getRegistry } from "./api"
|
||||
import { searchRegistries } from "./search"
|
||||
|
||||
describe("searchRegistries", () => {
|
||||
it("should fetch and return registries in flat format", async () => {
|
||||
// Mock getRegistry
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async (name: string) => {
|
||||
if (name === "@shadcn" || name === "@shadcn/registry") {
|
||||
return {
|
||||
name: "shadcn/ui",
|
||||
homepage: "https://ui.shadcn.com",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
description: "A card component",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if (name === "@custom" || name === "@custom/registry") {
|
||||
return {
|
||||
name: "custom/components",
|
||||
homepage: "https://custom.com",
|
||||
items: [
|
||||
{
|
||||
name: "header",
|
||||
type: "registry:component",
|
||||
description: "A header component",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown registry: ${name}`)
|
||||
})
|
||||
|
||||
const results = await searchRegistries(["@shadcn", "@custom"])
|
||||
|
||||
expect(results).toEqual({
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
registry: "@shadcn",
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
description: "A card component",
|
||||
registry: "@shadcn",
|
||||
},
|
||||
{
|
||||
name: "header",
|
||||
type: "registry:component",
|
||||
description: "A header component",
|
||||
registry: "@custom",
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
total: 3,
|
||||
offset: 0,
|
||||
limit: 3,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should apply search filter when query is provided", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async (name: string) => {
|
||||
if (name === "@shadcn" || name === "@shadcn/registry") {
|
||||
return {
|
||||
name: "shadcn/ui",
|
||||
homepage: "https://ui.shadcn.com",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
description: "A card component",
|
||||
},
|
||||
{
|
||||
name: "dialog",
|
||||
type: "registry:ui",
|
||||
description: "A dialog component",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown registry: ${name}`)
|
||||
})
|
||||
|
||||
const results = await searchRegistries(["@shadcn"], { query: "button" })
|
||||
|
||||
expect(results.items).toHaveLength(1)
|
||||
expect(results.items[0].name).toBe("button")
|
||||
expect(results.items[0].registry).toBe("@shadcn")
|
||||
expect(results.pagination).toEqual({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
hasMore: false,
|
||||
})
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should fail fast on registry error", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async (name: string) => {
|
||||
throw new Error(`Registry not found: ${name}`)
|
||||
})
|
||||
|
||||
await expect(searchRegistries(["@unknown"])).rejects.toThrow(
|
||||
"Registry not found"
|
||||
)
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should return empty items when search has no matches", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async () => ({
|
||||
name: "test/registry",
|
||||
homepage: "https://test.com",
|
||||
items: [{ name: "button", type: "registry:ui", description: "A button" }],
|
||||
}))
|
||||
|
||||
const results = await searchRegistries(["@test"], { query: "nonexistent" })
|
||||
|
||||
expect(results.items).toHaveLength(0)
|
||||
expect(results.pagination).toEqual({
|
||||
total: 0,
|
||||
offset: 0,
|
||||
limit: 0,
|
||||
hasMore: false,
|
||||
})
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle fuzzy search", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async () => ({
|
||||
name: "test/registry",
|
||||
homepage: "https://test.com",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
},
|
||||
{
|
||||
name: "dialog",
|
||||
type: "registry:ui",
|
||||
description: "A dialog overlay",
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const results = await searchRegistries(["@test"], { query: "butto" })
|
||||
|
||||
expect(results.items).toHaveLength(1)
|
||||
expect(results.items[0].name).toBe("button")
|
||||
expect(results.pagination).toEqual({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
hasMore: false,
|
||||
})
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should search in descriptions", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async () => ({
|
||||
name: "test/registry",
|
||||
homepage: "https://test.com",
|
||||
items: [
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A clickable element",
|
||||
},
|
||||
{ name: "dialog", type: "registry:ui", description: "A modal overlay" },
|
||||
],
|
||||
}))
|
||||
|
||||
const results = await searchRegistries(["@test"], { query: "modal" })
|
||||
|
||||
expect(results.items).toHaveLength(1)
|
||||
expect(results.items[0].name).toBe("dialog")
|
||||
expect(results.pagination).toEqual({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
hasMore: false,
|
||||
})
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should respect limit option", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async () => ({
|
||||
name: "test/registry",
|
||||
homepage: "https://test.com",
|
||||
items: [
|
||||
{ name: "alert", type: "registry:ui", description: "Alert component" },
|
||||
{
|
||||
name: "avatar",
|
||||
type: "registry:ui",
|
||||
description: "Avatar component",
|
||||
},
|
||||
{
|
||||
name: "accordion",
|
||||
type: "registry:ui",
|
||||
description: "Accordion component",
|
||||
},
|
||||
{
|
||||
name: "aspect-ratio",
|
||||
type: "registry:ui",
|
||||
description: "Aspect ratio component",
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const results = await searchRegistries(["@test"], { query: "a", limit: 2 })
|
||||
|
||||
expect(results.items.length).toBeLessThanOrEqual(2)
|
||||
expect(results.pagination.limit).toBe(2)
|
||||
expect(results.pagination.offset).toBe(0)
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle offset and limit for pagination", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async () => ({
|
||||
name: "test/registry",
|
||||
homepage: "https://test.com",
|
||||
items: [
|
||||
{ name: "item1", type: "registry:ui", description: "Item 1" },
|
||||
{ name: "item2", type: "registry:ui", description: "Item 2" },
|
||||
{ name: "item3", type: "registry:ui", description: "Item 3" },
|
||||
{ name: "item4", type: "registry:ui", description: "Item 4" },
|
||||
{ name: "item5", type: "registry:ui", description: "Item 5" },
|
||||
],
|
||||
}))
|
||||
|
||||
const results = await searchRegistries(["@test"], { offset: 2, limit: 2 })
|
||||
|
||||
expect(results.items).toHaveLength(2)
|
||||
expect(results.items[0].name).toBe("item3")
|
||||
expect(results.items[1].name).toBe("item4")
|
||||
expect(results.pagination).toEqual({
|
||||
total: 5,
|
||||
offset: 2,
|
||||
limit: 2,
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should set hasMore to false when no more items", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async () => ({
|
||||
name: "test/registry",
|
||||
homepage: "https://test.com",
|
||||
items: [
|
||||
{ name: "item1", type: "registry:ui", description: "Item 1" },
|
||||
{ name: "item2", type: "registry:ui", description: "Item 2" },
|
||||
{ name: "item3", type: "registry:ui", description: "Item 3" },
|
||||
],
|
||||
}))
|
||||
|
||||
const results = await searchRegistries(["@test"], { offset: 2, limit: 2 })
|
||||
|
||||
expect(results.items).toHaveLength(1)
|
||||
expect(results.items[0].name).toBe("item3")
|
||||
expect(results.pagination.hasMore).toBe(false)
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
|
||||
it("should handle pagination across multiple registries", async () => {
|
||||
vi.mock("./api", () => ({
|
||||
getRegistry: vi.fn(),
|
||||
}))
|
||||
|
||||
const mockGetRegistry = vi.mocked(getRegistry)
|
||||
|
||||
mockGetRegistry.mockImplementation(async (name: string) => {
|
||||
if (name === "@one") {
|
||||
return {
|
||||
name: "one",
|
||||
homepage: "https://one.com",
|
||||
items: [
|
||||
{ name: "item1", type: "registry:ui", description: "Item 1" },
|
||||
{ name: "item2", type: "registry:ui", description: "Item 2" },
|
||||
{ name: "item3", type: "registry:ui", description: "Item 3" },
|
||||
],
|
||||
}
|
||||
}
|
||||
if (name === "@two") {
|
||||
return {
|
||||
name: "two",
|
||||
homepage: "https://two.com",
|
||||
items: [
|
||||
{ name: "item4", type: "registry:ui", description: "Item 4" },
|
||||
{ name: "item5", type: "registry:ui", description: "Item 5" },
|
||||
],
|
||||
}
|
||||
}
|
||||
throw new Error("Unknown registry")
|
||||
})
|
||||
|
||||
const results = await searchRegistries(["@one", "@two"], {
|
||||
offset: 1,
|
||||
limit: 3,
|
||||
})
|
||||
|
||||
expect(results.items).toHaveLength(3)
|
||||
expect(results.items[0].name).toBe("item2")
|
||||
expect(results.items[0].registry).toBe("@one")
|
||||
expect(results.items[1].name).toBe("item3")
|
||||
expect(results.items[1].registry).toBe("@one")
|
||||
expect(results.items[2].name).toBe("item4")
|
||||
expect(results.items[2].registry).toBe("@two")
|
||||
expect(results.pagination).toEqual({
|
||||
total: 5,
|
||||
offset: 1,
|
||||
limit: 3,
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
mockGetRegistry.mockRestore()
|
||||
})
|
||||
})
|
||||
116
packages/shadcn/src/registry/search.ts
Normal file
116
packages/shadcn/src/registry/search.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { z } from "zod"
|
||||
|
||||
import { getRegistry } from "./api"
|
||||
|
||||
const searchResultItemSchema = z.object({
|
||||
name: z.string(),
|
||||
type: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
registry: z.string(),
|
||||
})
|
||||
|
||||
const searchResultsSchema = z.object({
|
||||
pagination: z.object({
|
||||
total: z.number(),
|
||||
offset: z.number(),
|
||||
limit: z.number(),
|
||||
hasMore: z.boolean(),
|
||||
}),
|
||||
items: z.array(searchResultItemSchema),
|
||||
})
|
||||
|
||||
export async function searchRegistries(
|
||||
registries: Array<Parameters<typeof getRegistry>[0]>,
|
||||
options?: {
|
||||
query?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
},
|
||||
config?: Partial<Config>
|
||||
) {
|
||||
let allItems: z.infer<typeof searchResultItemSchema>[] = []
|
||||
|
||||
for (const registry of registries) {
|
||||
const registryData = await getRegistry(registry, config)
|
||||
|
||||
const itemsWithRegistry = (registryData.items || []).map((item: any) => ({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
description: item.description,
|
||||
registry: registry,
|
||||
}))
|
||||
|
||||
allItems = allItems.concat(itemsWithRegistry)
|
||||
}
|
||||
|
||||
// Apply search if query is provided
|
||||
if (options?.query) {
|
||||
allItems = searchItems(allItems, {
|
||||
query: options.query,
|
||||
// No limit here - we want to search all items then paginate
|
||||
limit: allItems.length,
|
||||
keys: ["name", "description"],
|
||||
}) as z.infer<typeof searchResultItemSchema>[]
|
||||
}
|
||||
|
||||
// Apply offset and limit pagination
|
||||
const offset = options?.offset || 0
|
||||
const limit = options?.limit || allItems.length
|
||||
const totalItems = allItems.length
|
||||
|
||||
// Build result with pagination
|
||||
const result: z.infer<typeof searchResultsSchema> = {
|
||||
pagination: {
|
||||
total: totalItems,
|
||||
offset,
|
||||
limit,
|
||||
hasMore: offset + limit < totalItems,
|
||||
},
|
||||
items: allItems.slice(offset, offset + limit),
|
||||
}
|
||||
|
||||
return searchResultsSchema.parse(result)
|
||||
}
|
||||
|
||||
const searchableItemSchema = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
type: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
registry: z.string().optional(), // Optional for backward compatibility
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
type SearchableItem = z.infer<typeof searchableItemSchema>
|
||||
|
||||
function searchItems<
|
||||
T extends {
|
||||
name: string
|
||||
type?: string
|
||||
description?: string
|
||||
[key: string]: any
|
||||
} = SearchableItem
|
||||
>(
|
||||
items: T[],
|
||||
options: {
|
||||
query: string
|
||||
} & Pick<Parameters<typeof fuzzysort.go>[2], "keys" | "threshold" | "limit">
|
||||
) {
|
||||
options = {
|
||||
limit: 100,
|
||||
threshold: -10000,
|
||||
...options,
|
||||
}
|
||||
|
||||
const searchResults = fuzzysort.go(options.query, items, {
|
||||
keys: options.keys,
|
||||
threshold: options.threshold,
|
||||
limit: options.limit,
|
||||
})
|
||||
|
||||
const results = searchResults.map((result) => result.obj)
|
||||
|
||||
return z.array(searchableItemSchema).parse(results)
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { buildUrlAndHeadersForRegistryItem } from "@/src/registry/builder"
|
||||
import { configWithDefaults } from "@/src/registry/config"
|
||||
import { clearRegistryContext } from "@/src/registry/context"
|
||||
import { extractEnvVars } from "@/src/registry/env"
|
||||
import { RegistryMissingEnvironmentVariablesError } from "@/src/registry/errors"
|
||||
import { registryConfigItemSchema } from "@/src/schema"
|
||||
import { Config } from "@/src/utils/get-config"
|
||||
import { z } from "zod"
|
||||
|
||||
export function extractEnvVarsFromRegistryConfig(
|
||||
@@ -40,3 +44,15 @@ export function validateRegistryConfig(
|
||||
throw new RegistryMissingEnvironmentVariablesError(registryName, missing)
|
||||
}
|
||||
}
|
||||
|
||||
export function validateRegistryConfigForItems(
|
||||
items: string[],
|
||||
config?: Config
|
||||
): void {
|
||||
for (const item of items) {
|
||||
buildUrlAndHeadersForRegistryItem(item, configWithDefaults(config))
|
||||
}
|
||||
|
||||
// Clear the registry context after validation.
|
||||
clearRegistryContext()
|
||||
}
|
||||
|
||||
985
packages/tests/src/tests/search.test.ts
Normal file
985
packages/tests/src/tests/search.test.ts
Normal file
@@ -0,0 +1,985 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
|
||||
import { configureRegistries, createRegistryServer } from "../utils/registry"
|
||||
|
||||
const registryShadcn = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
dependencies: ["@radix-ui/react-slot"],
|
||||
devDependencies: ["@types/react"],
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/button.tsx",
|
||||
content:
|
||||
"export function Button() {\n return <button>Click me</button>\n}",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
description: "A card component",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/card.tsx",
|
||||
content:
|
||||
"export function Card() {\n return <div>Card Component</div>\n}",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "alert-dialog",
|
||||
type: "registry:ui",
|
||||
registryDependencies: ["button"],
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/alert-dialog.tsx",
|
||||
content:
|
||||
"export function AlertDialog() {\n return <div>AlertDialog Component</div>\n}",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
port: 9180,
|
||||
path: "/r",
|
||||
}
|
||||
)
|
||||
|
||||
const registryOne = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
name: "foo",
|
||||
type: "registry:component",
|
||||
description: "Foo component from registry one",
|
||||
dependencies: ["clsx", "tailwind-merge"],
|
||||
files: [
|
||||
{
|
||||
path: "components/foo.tsx",
|
||||
content:
|
||||
"export function Foo() {\n return <div>Foo Component from Registry 1</div>\n}",
|
||||
type: "registry:component",
|
||||
},
|
||||
],
|
||||
tailwind: {
|
||||
config: {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
foo: "#ff0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cssVars: {
|
||||
light: {
|
||||
"foo-color": "#ff0000",
|
||||
},
|
||||
dark: {
|
||||
"foo-color": "#00ff00",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
type: "registry:component",
|
||||
registryDependencies: ["@one/foo"],
|
||||
files: [
|
||||
{
|
||||
path: "components/bar.tsx",
|
||||
content:
|
||||
"export function Bar() {\n return <div>Bar Component from Registry 1</div>\n}",
|
||||
type: "registry:component",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
port: 9181,
|
||||
path: "/r",
|
||||
}
|
||||
)
|
||||
|
||||
// Create a registry with many items for pagination testing
|
||||
const registryLarge = await createRegistryServer(
|
||||
Array.from({ length: 20 }, (_, i) => ({
|
||||
name: `component-${i + 1}`,
|
||||
type: "registry:ui",
|
||||
description: `Component number ${i + 1}`,
|
||||
files: [
|
||||
{
|
||||
path: `components/ui/component-${i + 1}.tsx`,
|
||||
content: `export function Component${i + 1}() { return <div>Component ${
|
||||
i + 1
|
||||
}</div> }`,
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
})),
|
||||
{
|
||||
port: 9184,
|
||||
path: "/large",
|
||||
}
|
||||
)
|
||||
|
||||
const registryTwo = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
name: "item",
|
||||
type: "registry:ui",
|
||||
description: "Item component from registry two",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/item.tsx",
|
||||
content:
|
||||
"export function Item() {\n return <div>Item Component from Registry 2</div>\n}",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "secure-item",
|
||||
type: "registry:component",
|
||||
description: "Secure item requiring authentication",
|
||||
registryDependencies: ["@one/foo"],
|
||||
files: [
|
||||
{
|
||||
path: "components/secure-item.tsx",
|
||||
content:
|
||||
"export function SecureItem() {\n return <div>Secure Item</div>\n}",
|
||||
type: "registry:component",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
port: 9182,
|
||||
path: "/registry",
|
||||
}
|
||||
)
|
||||
|
||||
beforeAll(async () => {
|
||||
await registryShadcn.start()
|
||||
await registryOne.start()
|
||||
await registryTwo.start()
|
||||
await registryLarge.start()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await registryShadcn.stop()
|
||||
await registryOne.stop()
|
||||
await registryTwo.stop()
|
||||
await registryLarge.stop()
|
||||
})
|
||||
|
||||
describe("shadcn search", () => {
|
||||
it("should search items from shadcn registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
const output = await npxShadcn(fixturePath, ["search", "@shadcn"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
registry: "@shadcn",
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
description: "A card component",
|
||||
registry: "@shadcn",
|
||||
},
|
||||
{
|
||||
name: "alert-dialog",
|
||||
type: "registry:ui",
|
||||
description: undefined,
|
||||
registry: "@shadcn",
|
||||
},
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should list items from multiple registries", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
"@two": "http://localhost:9182/registry/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"@one",
|
||||
"@two",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items).toEqual(expect.any(Array))
|
||||
|
||||
// Check that items from all three registries are present
|
||||
const registries = parsed.items.map((item: any) => item.registry)
|
||||
expect(registries).toContain("@shadcn")
|
||||
expect(registries).toContain("@one")
|
||||
expect(registries).toContain("@two")
|
||||
})
|
||||
|
||||
it("should list from configured registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
name: "foo",
|
||||
type: "registry:component",
|
||||
description: "Foo component from registry one",
|
||||
registry: "@one",
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
type: "registry:component",
|
||||
registry: "@one",
|
||||
},
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle non-existent registry gracefully", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["search", "@unknown"])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@unknown"')
|
||||
})
|
||||
|
||||
it("should handle authentication for secured registries", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@two": {
|
||||
url: "http://localhost:9182/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer EXAMPLE_BEARER_TOKEN",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@two"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "item",
|
||||
type: "registry:ui",
|
||||
description: "Item component from registry two",
|
||||
registry: "@two",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "secure-item",
|
||||
type: "registry:component",
|
||||
description: "Secure item requiring authentication",
|
||||
registry: "@two",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should fail when listing secured registry without authentication", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@two": "http://localhost:9182/registry/bearer/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@two"])
|
||||
expect(output.stdout).toContain("Unauthorized")
|
||||
})
|
||||
|
||||
it("should handle authentication with environment variables", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@two": {
|
||||
url: "http://localhost:9182/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer ${BEARER_TOKEN}",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const originalBearerToken = process.env.BEARER_TOKEN
|
||||
try {
|
||||
process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN"
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@two"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "item",
|
||||
registry: "@two",
|
||||
}),
|
||||
])
|
||||
)
|
||||
} finally {
|
||||
if (originalBearerToken !== undefined) {
|
||||
process.env.BEARER_TOKEN = originalBearerToken
|
||||
} else {
|
||||
delete process.env.BEARER_TOKEN
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle missing environment variables for authenticated registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@auth": {
|
||||
url: "http://localhost:9182/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer ${MISSING_ENV_VAR}",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@auth"])
|
||||
|
||||
expect(output.stdout).toContain("MISSING_ENV_VAR")
|
||||
})
|
||||
|
||||
it("should work with @shadcn namespace", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
const output = await npxShadcn(fixturePath, ["search", "@shadcn"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "button",
|
||||
registry: "@shadcn",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "card",
|
||||
registry: "@shadcn",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle namespace with special characters", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["search", "@test-123"])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@test-123"')
|
||||
})
|
||||
|
||||
it("should handle empty registry name", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["search", "@"])
|
||||
|
||||
expect(output.stdout).toContain("Failed to parse registry item: @")
|
||||
expect(output.stdout).toContain(
|
||||
"Registry name must start with @ followed by alphanumeric characters"
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle namespace without @ prefix", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["search", "one"])
|
||||
|
||||
// Without @ prefix, it should show an error
|
||||
expect(output.stdout).toContain("Failed to parse registry item: one")
|
||||
expect(output.stdout).toContain("Registry name must start with @")
|
||||
})
|
||||
|
||||
it("should list from components.json with registries config only (shadow config)", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items.length).toBeGreaterThan(0)
|
||||
|
||||
// Verify items are from @one registry
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
registry: "@one",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should error when listing from non-existent registry with configured registries", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
|
||||
// Configure @one registry, but try to list from @two
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@two"])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@two"')
|
||||
})
|
||||
|
||||
it("should handle validation errors", async () => {
|
||||
// Create a server that returns invalid schema
|
||||
const badServer = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
name: "invalid-schema",
|
||||
type: "invalid-type", // Invalid type
|
||||
files: [
|
||||
{
|
||||
path: "components/bad.tsx",
|
||||
content: "export function Bad() { return null }",
|
||||
// Missing required 'type' field
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
],
|
||||
{
|
||||
port: 9183,
|
||||
path: "/bad",
|
||||
}
|
||||
)
|
||||
|
||||
await badServer.start()
|
||||
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@bad": "http://localhost:9183/bad/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@bad"])
|
||||
|
||||
// Should handle validation error
|
||||
expect(output.stdout.toLowerCase()).toContain("failed to parse")
|
||||
|
||||
await badServer.stop()
|
||||
})
|
||||
|
||||
it("should handle network timeouts gracefully", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@timeout": "http://localhost:9999/timeout/{name}", // Non-existent server
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@timeout"])
|
||||
|
||||
// Check for connection error in the output
|
||||
expect(output.stdout.toLowerCase()).toContain("failed, reason:")
|
||||
})
|
||||
|
||||
it("should list multiple registries with mixed success and failure", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@one",
|
||||
"@non-existent",
|
||||
])
|
||||
|
||||
// Should fail fast on first error
|
||||
expect(output.stdout).toContain('Unknown registry "@non-existent"')
|
||||
})
|
||||
|
||||
it("should handle partial components.json without other required fields", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
|
||||
// Create a partial components.json with only registries
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
"@two": "http://localhost:9182/registry/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one", "@two"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items).toEqual(expect.any(Array))
|
||||
|
||||
// Check that items from both registries are present
|
||||
const registries = parsed.items.map((item: any) => item.registry)
|
||||
expect(registries).toContain("@one")
|
||||
expect(registries).toContain("@two")
|
||||
})
|
||||
|
||||
it("should handle dependencies that require authentication", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
|
||||
// Configure both registries - @two requires auth, @one doesn't
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
"@two": {
|
||||
url: "http://localhost:9182/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer EXAMPLE_BEARER_TOKEN",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// List from both registries
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one", "@two"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveProperty("items")
|
||||
expect(parsed.items).toEqual(expect.any(Array))
|
||||
|
||||
// Check that items from both registries are present
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "foo",
|
||||
registry: "@one",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "item",
|
||||
registry: "@two",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle search with pagination", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
// Search for "button" with pagination
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"button",
|
||||
"--limit",
|
||||
"1",
|
||||
"--offset",
|
||||
"0",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed.items).toHaveLength(1)
|
||||
expect(parsed.items[0].name).toBe("button")
|
||||
expect(parsed.pagination).toEqual({
|
||||
total: 1,
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle empty search results with pagination", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"nonexistent",
|
||||
"--limit",
|
||||
"10",
|
||||
"--offset",
|
||||
"5",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed.items).toHaveLength(0)
|
||||
expect(parsed.pagination).toEqual({
|
||||
total: 0,
|
||||
offset: 5,
|
||||
limit: 10,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle typos in search (fuzzy matching)", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
// Test various typos that should still match "button"
|
||||
const typos = [
|
||||
"buton", // missing 't'
|
||||
"buttn", // missing 'o'
|
||||
"buttno", // 'no' instead of 'on'
|
||||
"btton", // missing 'u'
|
||||
"dialg", // typo in 'dialog'
|
||||
"alrt", // typo in 'alert'
|
||||
]
|
||||
|
||||
// Test button typos
|
||||
for (const typo of typos.slice(0, 4)) {
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
typo,
|
||||
])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(
|
||||
parsed.items.some((item: any) => item.name === "button"),
|
||||
`Typo '${typo}' should match 'button'`
|
||||
).toBe(true)
|
||||
}
|
||||
|
||||
// Test dialog typo
|
||||
const dialogOutput = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"dialg",
|
||||
])
|
||||
const dialogParsed = JSON.parse(dialogOutput.stdout)
|
||||
expect(
|
||||
dialogParsed.items.some((item: any) => item.name === "alert-dialog"),
|
||||
"Typo 'dialg' should match 'alert-dialog'"
|
||||
).toBe(true)
|
||||
|
||||
// Test alert typo
|
||||
const alertOutput = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"alrt",
|
||||
])
|
||||
const alertParsed = JSON.parse(alertOutput.stdout)
|
||||
expect(
|
||||
alertParsed.items.some((item: any) => item.name === "alert-dialog"),
|
||||
"Typo 'alrt' should match 'alert-dialog'"
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle partial word matching", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
// Test partial matches
|
||||
const partialQueries = [
|
||||
{ query: "btn", expected: "button" },
|
||||
{ query: "crd", expected: "card" },
|
||||
{ query: "alert", expected: "alert-dialog" },
|
||||
{ query: "dial", expected: "alert-dialog" },
|
||||
]
|
||||
|
||||
for (const { query, expected } of partialQueries) {
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
query,
|
||||
])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(
|
||||
parsed.items.some((item: any) => item.name === expected),
|
||||
`Partial '${query}' should match '${expected}'`
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("should handle case-insensitive search", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
// Search with different cases
|
||||
const output1 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"BUTTON",
|
||||
])
|
||||
const output2 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"button",
|
||||
])
|
||||
const output3 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"BuTtOn",
|
||||
])
|
||||
|
||||
const parsed1 = JSON.parse(output1.stdout)
|
||||
const parsed2 = JSON.parse(output2.stdout)
|
||||
const parsed3 = JSON.parse(output3.stdout)
|
||||
|
||||
// All should find the button component
|
||||
expect(parsed1.items).toHaveLength(1)
|
||||
expect(parsed2.items).toHaveLength(1)
|
||||
expect(parsed3.items).toHaveLength(1)
|
||||
expect(parsed1.items[0].name).toBe("button")
|
||||
})
|
||||
|
||||
it("should handle special characters in search", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
// Test searching for components with hyphens
|
||||
const output1 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"alert-dialog",
|
||||
])
|
||||
const parsed1 = JSON.parse(output1.stdout)
|
||||
expect(
|
||||
parsed1.items.some((item: any) => item.name === "alert-dialog")
|
||||
).toBe(true)
|
||||
|
||||
// Test searching with just the hyphen part
|
||||
const output2 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"-dialog",
|
||||
])
|
||||
const parsed2 = JSON.parse(output2.stdout)
|
||||
expect(
|
||||
parsed2.items.some((item: any) => item.name === "alert-dialog")
|
||||
).toBe(true)
|
||||
|
||||
// Test with spaces (should still work)
|
||||
const output3 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"alert dialog",
|
||||
])
|
||||
const parsed3 = JSON.parse(output3.stdout)
|
||||
expect(
|
||||
parsed3.items.some((item: any) => item.name === "alert-dialog")
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("should rank exact matches higher than fuzzy matches", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
// Search for "bar" which should match "bar" exactly
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@one",
|
||||
"--query",
|
||||
"bar",
|
||||
])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
|
||||
// If we have results, "bar" should be first (exact match)
|
||||
if (parsed.items.length > 0) {
|
||||
expect(parsed.items[0].name).toBe("bar")
|
||||
}
|
||||
})
|
||||
|
||||
it("should work with 'search' command alias", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
// Use 'search' instead of 'list'
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--query",
|
||||
"button",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "button",
|
||||
registry: "@shadcn",
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle limit edge cases", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
})
|
||||
|
||||
// Test with limit 0 - should return all items
|
||||
const output1 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--limit",
|
||||
"0",
|
||||
])
|
||||
const parsed1 = JSON.parse(output1.stdout)
|
||||
expect(parsed1.items.length).toBeGreaterThan(0)
|
||||
|
||||
// Test with very large limit
|
||||
const output2 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"--limit",
|
||||
"9999",
|
||||
])
|
||||
const parsed2 = JSON.parse(output2.stdout)
|
||||
expect(parsed2.items.length).toBeLessThanOrEqual(9999)
|
||||
expect(parsed2.pagination.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle pagination across registries with search", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9180/r/{name}",
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
// Search across multiple registries with pagination
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@shadcn",
|
||||
"@one",
|
||||
"--query",
|
||||
"o", // Should match "button", "foo", etc.
|
||||
"--limit",
|
||||
"2",
|
||||
"--offset",
|
||||
"1",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed.items).toHaveLength(2)
|
||||
expect(parsed.pagination.limit).toBe(2)
|
||||
expect(parsed.pagination.offset).toBe(1)
|
||||
})
|
||||
|
||||
it("should handle large dataset pagination properly", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@large": "http://localhost:9184/large/{name}",
|
||||
})
|
||||
|
||||
// First page
|
||||
const output1 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@large",
|
||||
"--limit",
|
||||
"5",
|
||||
"--offset",
|
||||
"0",
|
||||
])
|
||||
const parsed1 = JSON.parse(output1.stdout)
|
||||
expect(parsed1.items).toHaveLength(5)
|
||||
expect(parsed1.items[0].name).toBe("component-1")
|
||||
expect(parsed1.items[4].name).toBe("component-5")
|
||||
expect(parsed1.pagination).toEqual({
|
||||
total: 20,
|
||||
offset: 0,
|
||||
limit: 5,
|
||||
hasMore: true,
|
||||
})
|
||||
|
||||
// Middle page
|
||||
const output2 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@large",
|
||||
"--limit",
|
||||
"5",
|
||||
"--offset",
|
||||
"10",
|
||||
])
|
||||
const parsed2 = JSON.parse(output2.stdout)
|
||||
expect(parsed2.items).toHaveLength(5)
|
||||
expect(parsed2.items[0].name).toBe("component-11")
|
||||
expect(parsed2.items[4].name).toBe("component-15")
|
||||
expect(parsed2.pagination.hasMore).toBe(true)
|
||||
|
||||
// Last page (partial)
|
||||
const output3 = await npxShadcn(fixturePath, [
|
||||
"search",
|
||||
"@large",
|
||||
"--limit",
|
||||
"5",
|
||||
"--offset",
|
||||
"18",
|
||||
])
|
||||
const parsed3 = JSON.parse(output3.stdout)
|
||||
expect(parsed3.items).toHaveLength(2) // Only 2 items left
|
||||
expect(parsed3.items[0].name).toBe("component-19")
|
||||
expect(parsed3.items[1].name).toBe("component-20")
|
||||
expect(parsed3.pagination.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it("should list with only name, type, and description fields", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9181/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["search", "@one"])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
|
||||
// Check that we only get name, type, description, and registry fields
|
||||
expect(parsed.items).toContainEqual({
|
||||
name: "foo",
|
||||
type: "registry:component",
|
||||
description: "Foo component from registry one",
|
||||
registry: "@one",
|
||||
})
|
||||
|
||||
// Verify that other fields are not included
|
||||
const fooItem = parsed.items.find((item: any) => item.name === "foo")
|
||||
expect(fooItem).toBeDefined()
|
||||
expect(fooItem.dependencies).toBeUndefined()
|
||||
expect(fooItem.files).toBeUndefined()
|
||||
expect(fooItem.tailwind).toBeUndefined()
|
||||
expect(fooItem.cssVars).toBeUndefined()
|
||||
})
|
||||
})
|
||||
835
packages/tests/src/tests/view.test.ts
Normal file
835
packages/tests/src/tests/view.test.ts
Normal file
@@ -0,0 +1,835 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
|
||||
import { createFixtureTestDirectory, npxShadcn } from "../utils/helpers"
|
||||
import { configureRegistries, createRegistryServer } from "../utils/registry"
|
||||
|
||||
const registryShadcn = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
description: "A button component",
|
||||
dependencies: ["@radix-ui/react-slot"],
|
||||
devDependencies: ["@types/react"],
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/button.tsx",
|
||||
content:
|
||||
"export function Button() {\n return <button>Click me</button>\n}",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
type: "registry:ui",
|
||||
description: "A card component",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/card.tsx",
|
||||
content:
|
||||
"export function Card() {\n return <div>Card Component</div>\n}",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "alert-dialog",
|
||||
type: "registry:ui",
|
||||
registryDependencies: ["button"],
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/alert-dialog.tsx",
|
||||
content:
|
||||
"export function AlertDialog() {\n return <div>AlertDialog Component</div>\n}",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
port: 9080,
|
||||
path: "/r",
|
||||
}
|
||||
)
|
||||
|
||||
const registryOne = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
name: "foo",
|
||||
type: "registry:component",
|
||||
description: "Foo component from registry one",
|
||||
dependencies: ["clsx", "tailwind-merge"],
|
||||
files: [
|
||||
{
|
||||
path: "components/foo.tsx",
|
||||
content:
|
||||
"export function Foo() {\n return <div>Foo Component from Registry 1</div>\n}",
|
||||
type: "registry:component",
|
||||
},
|
||||
],
|
||||
tailwind: {
|
||||
config: {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
foo: "#ff0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cssVars: {
|
||||
light: {
|
||||
"foo-color": "#ff0000",
|
||||
},
|
||||
dark: {
|
||||
"foo-color": "#00ff00",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "bar",
|
||||
type: "registry:component",
|
||||
registryDependencies: ["@one/foo"],
|
||||
files: [
|
||||
{
|
||||
path: "components/bar.tsx",
|
||||
content:
|
||||
"export function Bar() {\n return <div>Bar Component from Registry 1</div>\n}",
|
||||
type: "registry:component",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "complex",
|
||||
type: "registry:component",
|
||||
registryDependencies: ["@two/item", "@one/foo"],
|
||||
files: [
|
||||
{
|
||||
path: "components/complex.tsx",
|
||||
content:
|
||||
"export function Complex() {\n return <div>Complex Component</div>\n}",
|
||||
type: "registry:component",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
port: 9081,
|
||||
path: "/r",
|
||||
}
|
||||
)
|
||||
|
||||
const registryTwo = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
name: "item",
|
||||
type: "registry:ui",
|
||||
description: "Item component from registry two",
|
||||
files: [
|
||||
{
|
||||
path: "components/ui/item.tsx",
|
||||
content:
|
||||
"export function Item() {\n return <div>Item Component from Registry 2</div>\n}",
|
||||
type: "registry:ui",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "secure-item",
|
||||
type: "registry:component",
|
||||
description: "Secure item requiring authentication",
|
||||
registryDependencies: ["@one/foo"],
|
||||
files: [
|
||||
{
|
||||
path: "components/secure-item.tsx",
|
||||
content:
|
||||
"export function SecureItem() {\n return <div>Secure Item</div>\n}",
|
||||
type: "registry:component",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
{
|
||||
port: 9082,
|
||||
path: "/registry",
|
||||
}
|
||||
)
|
||||
|
||||
beforeAll(async () => {
|
||||
await registryShadcn.start()
|
||||
await registryOne.start()
|
||||
await registryTwo.start()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await registryShadcn.stop()
|
||||
await registryOne.stop()
|
||||
await registryTwo.stop()
|
||||
})
|
||||
|
||||
describe("shadcn view", () => {
|
||||
it("should view a single component from shadcn registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@shadcn": "http://localhost:9080/r/{name}",
|
||||
})
|
||||
const output = await npxShadcn(fixturePath, ["view", "button"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
|
||||
expect(parsed[0]).toMatchObject({
|
||||
name: "button",
|
||||
type: "registry:ui",
|
||||
dependencies: ["@radix-ui/react-slot"],
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "registry/new-york-v4/ui/button.tsx",
|
||||
type: "registry:ui",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
})
|
||||
|
||||
it("should view multiple components from shadcn registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "button", "card"])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveLength(2)
|
||||
expect(parsed.map((p: any) => p.name)).toEqual(["button", "card"])
|
||||
})
|
||||
|
||||
it("should view component with registry dependencies", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "alert-dialog"])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "alert-dialog",
|
||||
type: "registry:ui",
|
||||
dependencies: ["@radix-ui/react-alert-dialog"],
|
||||
registryDependencies: ["button"],
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "registry/new-york-v4/ui/alert-dialog.tsx",
|
||||
type: "registry:ui",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should view component from URL without needing config", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"http://localhost:9081/r/foo.json",
|
||||
])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"cssVars": {
|
||||
"dark": {
|
||||
"foo-color": "#00ff00",
|
||||
},
|
||||
"light": {
|
||||
"foo-color": "#ff0000",
|
||||
},
|
||||
},
|
||||
"dependencies": [
|
||||
"clsx",
|
||||
"tailwind-merge",
|
||||
],
|
||||
"description": "Foo component from registry one",
|
||||
"files": [
|
||||
{
|
||||
"content": "export function Foo() {
|
||||
return <div>Foo Component from Registry 1</div>
|
||||
}",
|
||||
"path": "components/foo.tsx",
|
||||
"type": "registry:component",
|
||||
},
|
||||
],
|
||||
"name": "foo",
|
||||
"tailwind": {
|
||||
"config": {
|
||||
"theme": {
|
||||
"extend": {
|
||||
"colors": {
|
||||
"foo": "#ff0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"type": "registry:component",
|
||||
},
|
||||
]
|
||||
`)
|
||||
})
|
||||
|
||||
it("should view multiple URLs without needing config", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"http://localhost:9081/r/foo.json",
|
||||
"http://localhost:9082/registry/item.json",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveLength(2)
|
||||
expect(parsed.map((p: any) => p.name)).toEqual(["foo", "item"])
|
||||
})
|
||||
|
||||
it("should view component from configured registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@one/foo"])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "foo",
|
||||
type: "registry:component",
|
||||
description: "Foo component from registry one",
|
||||
dependencies: ["clsx", "tailwind-merge"],
|
||||
tailwind: {
|
||||
config: {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
foo: "#ff0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should view multiple components from different registries", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
"@two": "http://localhost:9082/registry/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"@one/foo",
|
||||
"@two/item",
|
||||
"button",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveLength(3)
|
||||
expect(parsed.map((p: any) => p.name)).toEqual(["foo", "item", "button"])
|
||||
})
|
||||
|
||||
it("should view component with nested registry dependencies", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@one/bar"])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "bar",
|
||||
type: "registry:component",
|
||||
registryDependencies: ["@one/foo"],
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "components/bar.tsx",
|
||||
type: "registry:component",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should view component with cross-registry dependencies", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
"@two": "http://localhost:9082/registry/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@one/complex"])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "complex",
|
||||
type: "registry:component",
|
||||
registryDependencies: expect.arrayContaining(["@two/item", "@one/foo"]),
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "components/complex.tsx",
|
||||
type: "registry:component",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle authentication for secured registries", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@two": {
|
||||
url: "http://localhost:9082/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer EXAMPLE_BEARER_TOKEN",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@two/secure-item"])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "secure-item",
|
||||
type: "registry:component",
|
||||
description: "Secure item requiring authentication",
|
||||
registryDependencies: ["@one/foo"],
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "components/secure-item.tsx",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should fail when viewing secured item without authentication", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@two": "http://localhost:9082/registry/bearer/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@two/secure-item"])
|
||||
expect(output.stdout).toContain("Unauthorized")
|
||||
})
|
||||
|
||||
it("should handle authentication with environment variables", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
"@two": {
|
||||
url: "http://localhost:9082/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer ${BEARER_TOKEN}",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
process.env.BEARER_TOKEN = "EXAMPLE_BEARER_TOKEN"
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"@two/secure-item",
|
||||
"@one/foo",
|
||||
])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "secure-item",
|
||||
type: "registry:component",
|
||||
registryDependencies: ["@one/foo"],
|
||||
},
|
||||
{
|
||||
name: "foo",
|
||||
type: "registry:component",
|
||||
dependencies: ["clsx", "tailwind-merge"],
|
||||
},
|
||||
])
|
||||
|
||||
delete process.env.BEARER_TOKEN
|
||||
})
|
||||
|
||||
it("should mix URLs and named components", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"http://localhost:9082/registry/item.json",
|
||||
"@one/foo",
|
||||
"button",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveLength(3)
|
||||
expect(parsed.map((p: any) => p.name)).toEqual(["item", "foo", "button"])
|
||||
})
|
||||
|
||||
it("should handle non-existent component gracefully", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "non-existent"])
|
||||
|
||||
expect(output.stdout).toContain("not found")
|
||||
})
|
||||
|
||||
it("should handle non-existent registry gracefully", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "@unknown/component"])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@unknown"')
|
||||
})
|
||||
|
||||
it("should work with @shadcn namespace", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"@shadcn/button",
|
||||
"@shadcn/card",
|
||||
])
|
||||
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
expect(parsed).toHaveLength(2)
|
||||
expect(parsed.map((p: any) => p.name)).toEqual(["button", "card"])
|
||||
})
|
||||
|
||||
it("should handle 404 for non-existent URL", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"http://localhost:9081/r/does-not-exist.json",
|
||||
])
|
||||
|
||||
expect(output.stdout).toContain("not found")
|
||||
})
|
||||
|
||||
it("should handle multiple errors in batch", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"non-existent",
|
||||
"@one/does-not-exist",
|
||||
])
|
||||
|
||||
// Should fail on first error - non-existent component
|
||||
expect(output.stdout.toLowerCase()).toContain("not found")
|
||||
})
|
||||
|
||||
it("should handle invalid URL format", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
const output = await npxShadcn(fixturePath, ["view", "not-a-valid-url"])
|
||||
|
||||
// With defaults in place, it will try to fetch as a component and fail
|
||||
expect(output.stdout.toLowerCase()).toContain("not found")
|
||||
})
|
||||
|
||||
it("should handle network timeouts gracefully", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"http://localhost:9999/timeout.json", // Non-existent server
|
||||
])
|
||||
|
||||
// Check for connection error in the output
|
||||
expect(output.stdout.toLowerCase()).toContain("failed, reason:")
|
||||
})
|
||||
|
||||
it("should handle mixed success and failure", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"button",
|
||||
"non-existent-component",
|
||||
])
|
||||
|
||||
// Should fail fast on first error
|
||||
expect(output.stdout).not.toContain('"name": "button"')
|
||||
expect(output.stdout).toContain("not found")
|
||||
})
|
||||
|
||||
it("should handle missing environment variables for authenticated registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
await configureRegistries(fixturePath, {
|
||||
"@auth": {
|
||||
url: "http://localhost:9082/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer ${MISSING_ENV_VAR}",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@auth/secure-item"])
|
||||
|
||||
expect(output.stdout).toContain("MISSING_ENV_VAR")
|
||||
})
|
||||
|
||||
it("should handle validation errors", async () => {
|
||||
// Create a server that returns invalid schema
|
||||
const badServer = await createRegistryServer(
|
||||
[
|
||||
{
|
||||
name: "invalid-schema",
|
||||
type: "invalid-type", // Invalid type
|
||||
files: [
|
||||
{
|
||||
path: "components/bad.tsx",
|
||||
content: "export function Bad() { return null }",
|
||||
// Missing required 'type' field
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
],
|
||||
{
|
||||
port: 9083,
|
||||
path: "/bad",
|
||||
}
|
||||
)
|
||||
|
||||
await badServer.start()
|
||||
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"http://localhost:9083/bad/invalid-schema.json",
|
||||
])
|
||||
|
||||
// Should handle validation error
|
||||
expect(output.stdout.toLowerCase()).toContain("failed to parse")
|
||||
|
||||
await badServer.stop()
|
||||
})
|
||||
|
||||
it("should handle dependencies that require authentication", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
|
||||
// Configure both registries - @two requires auth, @one doesn't
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
"@two": {
|
||||
url: "http://localhost:9082/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer EXAMPLE_BEARER_TOKEN",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// complex depends on @two/item which requires auth
|
||||
const output = await npxShadcn(fixturePath, ["view", "@one/complex"])
|
||||
|
||||
// Should just show the component metadata, not fail on auth
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "complex",
|
||||
type: "registry:component",
|
||||
registryDependencies: expect.arrayContaining(["@two/item", "@one/foo"]),
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "components/complex.tsx",
|
||||
type: "registry:component",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should fail when viewing component with unauthenticated dependencies", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
|
||||
// Configure registries - @two requires auth but no token provided
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
"@two": "http://localhost:9082/registry/bearer/{name}",
|
||||
})
|
||||
|
||||
// Try to view complex which depends on @two/item (requires auth)
|
||||
// Note: This should succeed for view command as it just shows metadata
|
||||
const output = await npxShadcn(fixturePath, ["view", "@one/complex"])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "complex",
|
||||
type: "registry:component",
|
||||
registryDependencies: expect.arrayContaining(["@two/item"]),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should view authenticated dependencies when directly requested", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
|
||||
// Configure with proper auth
|
||||
await configureRegistries(fixturePath, {
|
||||
"@two": {
|
||||
url: "http://localhost:9082/registry/bearer/{name}",
|
||||
headers: {
|
||||
Authorization: "Bearer EXAMPLE_BEARER_TOKEN",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Directly view the authenticated item
|
||||
const output = await npxShadcn(fixturePath, ["view", "@two/item"])
|
||||
|
||||
expect(JSON.parse(output.stdout)).toMatchObject([
|
||||
{
|
||||
name: "item",
|
||||
type: "registry:ui",
|
||||
description: "Item component from registry two",
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "components/ui/item.tsx",
|
||||
type: "registry:ui",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should view component with all metadata fields", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@one/foo"])
|
||||
const parsed = JSON.parse(output.stdout)
|
||||
|
||||
expect(parsed[0]).toMatchObject({
|
||||
name: "foo",
|
||||
type: "registry:component",
|
||||
description: "Foo component from registry one",
|
||||
dependencies: ["clsx", "tailwind-merge"],
|
||||
files: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "components/foo.tsx",
|
||||
type: "registry:component",
|
||||
}),
|
||||
]),
|
||||
tailwind: expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
theme: expect.objectContaining({
|
||||
extend: expect.objectContaining({
|
||||
colors: expect.objectContaining({
|
||||
foo: "#ff0000",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
cssVars: expect.objectContaining({
|
||||
light: expect.objectContaining({
|
||||
"foo-color": "#ff0000",
|
||||
}),
|
||||
dark: expect.objectContaining({
|
||||
"foo-color": "#00ff00",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle namespace with special characters", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "@test-123/component"])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@test-123"')
|
||||
})
|
||||
|
||||
it("should handle empty component name in namespace", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "@shadcn/"])
|
||||
|
||||
expect(output.stdout).toContain("not found")
|
||||
})
|
||||
|
||||
it("should handle namespace without @ prefix", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "one/foo"])
|
||||
|
||||
// Without @ prefix, it's treated as a regular component name
|
||||
expect(output.stdout).toContain("not found")
|
||||
})
|
||||
|
||||
it("should handle double namespace", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "@@test/component"])
|
||||
|
||||
expect(output.stdout).toContain("not found")
|
||||
})
|
||||
|
||||
it("should two error for unknown registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, ["view", "@test/component"])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@test"')
|
||||
})
|
||||
|
||||
it("should two error for unknown registry not in first position", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"@shadcn/component",
|
||||
"@does-not-exist/component",
|
||||
])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@does-not-exist"')
|
||||
})
|
||||
|
||||
it("should handle namespace with multiple slashes", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app-init")
|
||||
const output = await npxShadcn(fixturePath, [
|
||||
"view",
|
||||
"@test/path/to/component",
|
||||
])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@test"')
|
||||
})
|
||||
|
||||
it("should view from components.json with registries config only", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@one/foo"])
|
||||
|
||||
expect(output.stdout).toContain("Foo component from registry one")
|
||||
})
|
||||
|
||||
it("should error when viewing from non-existent registry with configured registries", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
|
||||
// Configure @one registry, but try to view from @two
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
})
|
||||
|
||||
const output = await npxShadcn(fixturePath, ["view", "@two/item"])
|
||||
|
||||
expect(output.stdout).toContain('Unknown registry "@two"')
|
||||
})
|
||||
|
||||
it("should error when viewing non-existent item from configured registry", async () => {
|
||||
const fixturePath = await createFixtureTestDirectory("next-app")
|
||||
|
||||
// Configure @one registry
|
||||
await configureRegistries(fixturePath, {
|
||||
"@one": "http://localhost:9081/r/{name}",
|
||||
})
|
||||
|
||||
// Try to view an item that doesn't exist in @one registry
|
||||
const output = await npxShadcn(fixturePath, ["view", "@one/does-not-exist"])
|
||||
|
||||
expect(output.stdout).toContain("not found")
|
||||
})
|
||||
})
|
||||
@@ -125,6 +125,30 @@ export async function createRegistryServer(
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a registry.json request
|
||||
if (urlWithoutQuery?.endsWith("/registry")) {
|
||||
// Check if this requires bearer authentication
|
||||
if (request.url?.includes("/bearer/")) {
|
||||
// Validate bearer token
|
||||
const token = request.headers.authorization?.split(" ")[1]
|
||||
if (token !== "EXAMPLE_BEARER_TOKEN") {
|
||||
response.writeHead(401, { "Content-Type": "application/json" })
|
||||
response.end(JSON.stringify({ error: "Unauthorized" }))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.writeHead(200, { "Content-Type": "application/json" })
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
name: "Test Registry",
|
||||
homepage: "https://example.com",
|
||||
items: items,
|
||||
})
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const match = urlWithoutQuery?.match(
|
||||
new RegExp(`^${path}/(?:.*/)?([^/]+)$`)
|
||||
)
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -740,6 +740,9 @@ importers:
|
||||
fs-extra:
|
||||
specifier: ^11.1.0
|
||||
version: 11.3.0
|
||||
fuzzysort:
|
||||
specifier: ^3.1.0
|
||||
version: 3.1.0
|
||||
https-proxy-agent:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.1
|
||||
@@ -6084,6 +6087,9 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
fuzzysort@3.1.0:
|
||||
resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==}
|
||||
|
||||
geist@1.3.1:
|
||||
resolution: {integrity: sha512-Q4gC1pBVPN+D579pBaz0TRRnGA4p9UK6elDY/xizXdFk/g4EKR5g0I+4p/Kj6gM0SajDBZ/0FvDV9ey9ud7BWw==}
|
||||
peerDependencies:
|
||||
@@ -16091,6 +16097,8 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
fuzzysort@3.1.0: {}
|
||||
|
||||
geist@1.3.1(next@14.3.0-canary.43(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)):
|
||||
dependencies:
|
||||
next: 14.3.0-canary.43(@babel/core@7.26.7)(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
|
||||
@@ -4,3 +4,4 @@ packages:
|
||||
- "!**/test/**"
|
||||
- "!**/fixtures/**"
|
||||
- "!**/temp/**"
|
||||
- "!packages/tests/temp/**"
|
||||
|
||||
Reference in New Issue
Block a user