mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
feat(package-manager): enhance subcomponent metadata scanning
- Add recursive scanning for nested components - Fix path handling for nested directories - Improve test coverage for package subcomponents - Fix timestamp handling in git-based dates
This commit is contained in:
parent
4891fb04b7
commit
dff42d28c0
2 changed files with 420 additions and 0 deletions
|
|
@ -38,6 +38,31 @@ export class MetadataScanner {
|
|||
if (metadata?.["en"]) {
|
||||
const item = await this.createPackageManagerItem(metadata["en"], componentDir, repoUrl, sourceName)
|
||||
if (item) {
|
||||
// If this is a package, scan for subcomponents
|
||||
if (this.isPackageMetadata(metadata["en"])) {
|
||||
// Load metadata for items listed in package metadata
|
||||
if (metadata["en"].items) {
|
||||
const subcomponents = await Promise.all(
|
||||
metadata["en"].items.map(async (subItem) => {
|
||||
const subPath = path.join(componentDir, subItem.path)
|
||||
const subMetadata = await this.loadComponentMetadata(subPath)
|
||||
if (subMetadata?.["en"]) {
|
||||
return {
|
||||
type: subItem.type,
|
||||
path: subItem.path,
|
||||
metadata: subMetadata["en"],
|
||||
lastUpdated: await this.getLastModifiedDate(subPath),
|
||||
}
|
||||
}
|
||||
return null
|
||||
}),
|
||||
)
|
||||
item.items = subcomponents.filter((sub): sub is NonNullable<typeof sub> => sub !== null)
|
||||
}
|
||||
|
||||
// Also scan directory for unlisted subcomponents
|
||||
await this.scanPackageSubcomponents(componentDir, item)
|
||||
}
|
||||
items.push(item)
|
||||
// Skip recursion if this is a package directory
|
||||
if (this.isPackageMetadata(metadata["en"])) {
|
||||
|
|
@ -141,6 +166,7 @@ export class MetadataScanner {
|
|||
repoUrl,
|
||||
sourceName,
|
||||
lastUpdated: await this.getLastModifiedDate(componentDir),
|
||||
items: [], // Initialize empty items array for all components
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -178,6 +204,52 @@ export class MetadataScanner {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively scans a package directory for subcomponents
|
||||
* @param packageDir The package directory to scan
|
||||
* @param packageItem The package item to add subcomponents to
|
||||
*/
|
||||
private async scanPackageSubcomponents(
|
||||
packageDir: string,
|
||||
packageItem: PackageManagerItem,
|
||||
parentPath: string = "",
|
||||
): Promise<void> {
|
||||
console.log(`Scanning directory: ${packageDir}`)
|
||||
const entries = await fs.readdir(packageDir, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
const subPath = path.join(packageDir, entry.name)
|
||||
const relativePath = parentPath ? path.join(parentPath, entry.name) : entry.name
|
||||
console.log(`Found directory: ${entry.name}, relative path: ${relativePath}`)
|
||||
|
||||
// Try to load metadata directly
|
||||
const subMetadata = await this.loadComponentMetadata(subPath)
|
||||
console.log(`Metadata for ${entry.name}:`, subMetadata?.["en"])
|
||||
|
||||
if (subMetadata?.["en"]) {
|
||||
const isListed = packageItem.items?.some((i) => i.path === relativePath)
|
||||
console.log(`${entry.name} is ${isListed ? "already listed" : "not listed"}`)
|
||||
|
||||
if (!isListed) {
|
||||
const subItem = {
|
||||
type: subMetadata["en"].type,
|
||||
path: relativePath,
|
||||
metadata: subMetadata["en"],
|
||||
lastUpdated: await this.getLastModifiedDate(subPath),
|
||||
}
|
||||
packageItem.items = packageItem.items || []
|
||||
packageItem.items.push(subItem)
|
||||
console.log(`Added ${entry.name} to items`)
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively scan this directory
|
||||
await this.scanPackageSubcomponents(subPath, packageItem, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for component types
|
||||
* @param type The type to check
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
import * as fs from "fs/promises"
|
||||
import { MetadataScanner } from "../MetadataScanner"
|
||||
import { Dirent } from "fs"
|
||||
import { SimpleGit } from "simple-git"
|
||||
|
||||
// Mock fs/promises
|
||||
jest.mock("fs/promises", () => ({
|
||||
readdir: jest.fn(),
|
||||
readFile: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock only what we need from SimpleGit
|
||||
const mockGit = {
|
||||
raw: jest.fn(),
|
||||
} as unknown as SimpleGit & { raw: jest.Mock }
|
||||
|
||||
describe("Package Subcomponents", () => {
|
||||
let metadataScanner: MetadataScanner
|
||||
const mockFs = fs as jest.Mocked<typeof fs>
|
||||
|
||||
beforeEach(() => {
|
||||
metadataScanner = new MetadataScanner(mockGit)
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("scanDirectory with packages", () => {
|
||||
it("should load subcomponents listed in metadata.yml", async () => {
|
||||
// Mock directory structure
|
||||
mockFs.readdir.mockImplementation((path: any) => {
|
||||
const pathStr = path.toString()
|
||||
if (pathStr === "/test/repo") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "test-package",
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "metadata.en.yml",
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
} as Dirent,
|
||||
{
|
||||
name: "subcomponent1",
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package/subcomponent1") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "metadata.en.yml",
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
return Promise.resolve([])
|
||||
})
|
||||
|
||||
// Mock file contents
|
||||
mockFs.readFile.mockImplementation((path: any) => {
|
||||
const pathStr = path.toString()
|
||||
if (pathStr === "/test/repo/test-package/metadata.en.yml") {
|
||||
return Promise.resolve(`
|
||||
name: Test Package
|
||||
description: A test package
|
||||
type: package
|
||||
version: 1.0.0
|
||||
items:
|
||||
- type: mode
|
||||
path: subcomponent1
|
||||
`)
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package/subcomponent1/metadata.en.yml") {
|
||||
return Promise.resolve(`
|
||||
name: Test Mode
|
||||
description: A test mode
|
||||
type: mode
|
||||
version: 1.0.0
|
||||
`)
|
||||
}
|
||||
return Promise.resolve("")
|
||||
})
|
||||
|
||||
// Mock git dates
|
||||
mockGit.raw.mockImplementation((...args: any[]) => {
|
||||
const path = args[0][args[0].length - 1]
|
||||
if (path.includes("/test/repo/test-package/subcomponent1")) {
|
||||
return Promise.resolve("2025-04-13T09:00:00-07:00")
|
||||
}
|
||||
if (path.includes("/test/repo/test-package")) {
|
||||
return Promise.resolve("2025-04-13T10:00:00-07:00")
|
||||
}
|
||||
return Promise.resolve("")
|
||||
})
|
||||
|
||||
const items = await metadataScanner.scanDirectory("/test/repo", "https://example.com")
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].type).toBe("package")
|
||||
expect(items[0].items).toHaveLength(1)
|
||||
expect(items[0].items![0]).toMatchObject({
|
||||
type: "mode",
|
||||
path: "subcomponent1",
|
||||
metadata: {
|
||||
name: "Test Mode",
|
||||
description: "A test mode",
|
||||
type: "mode",
|
||||
version: "1.0.0",
|
||||
},
|
||||
lastUpdated: "2025-04-13T09:00:00-07:00",
|
||||
})
|
||||
})
|
||||
|
||||
it("should load subcomponents from directory structure", async () => {
|
||||
// Mock directory structure
|
||||
mockFs.readdir.mockImplementation((path: any) => {
|
||||
const pathStr = path.toString()
|
||||
if (pathStr === "/test/repo") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "test-package",
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "metadata.en.yml",
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
} as Dirent,
|
||||
{
|
||||
name: "modes",
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package/modes") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "test-mode",
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package/modes/test-mode") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "metadata.en.yml",
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
return Promise.resolve([])
|
||||
})
|
||||
|
||||
// Mock file contents
|
||||
mockFs.readFile.mockImplementation((path: any) => {
|
||||
const pathStr = path.toString()
|
||||
if (pathStr === "/test/repo/test-package/metadata.en.yml") {
|
||||
return Promise.resolve(`
|
||||
name: Test Package
|
||||
description: A test package
|
||||
type: package
|
||||
version: 1.0.0
|
||||
`)
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package/modes/test-mode/metadata.en.yml") {
|
||||
return Promise.resolve(`
|
||||
name: Directory Mode
|
||||
description: A mode from directory
|
||||
type: mode
|
||||
version: 1.0.0
|
||||
`)
|
||||
}
|
||||
return Promise.resolve("")
|
||||
})
|
||||
|
||||
// Mock git dates
|
||||
mockGit.raw.mockImplementation((...args: any[]) => {
|
||||
const path = args[0][args[0].length - 1]
|
||||
if (path.includes("/test/repo/test-package/modes/test-mode")) {
|
||||
return Promise.resolve("2025-04-13T09:00:00-07:00")
|
||||
}
|
||||
if (path.includes("/test/repo/test-package")) {
|
||||
return Promise.resolve("2025-04-13T10:00:00-07:00")
|
||||
}
|
||||
return Promise.resolve("")
|
||||
})
|
||||
|
||||
const items = await metadataScanner.scanDirectory("/test/repo", "https://example.com")
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].type).toBe("package")
|
||||
expect(items[0].items).toHaveLength(1)
|
||||
expect(items[0].items![0]).toMatchObject({
|
||||
type: "mode",
|
||||
path: "modes/test-mode",
|
||||
metadata: {
|
||||
name: "Directory Mode",
|
||||
description: "A mode from directory",
|
||||
type: "mode",
|
||||
version: "1.0.0",
|
||||
},
|
||||
lastUpdated: "2025-04-13T09:00:00-07:00",
|
||||
})
|
||||
})
|
||||
|
||||
it("should combine subcomponents from metadata and directory", async () => {
|
||||
// Mock directory structure
|
||||
mockFs.readdir.mockImplementation((path: any) => {
|
||||
const pathStr = path.toString()
|
||||
if (pathStr === "/test/repo") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "test-package",
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package") {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "metadata.en.yml",
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
} as Dirent,
|
||||
{
|
||||
name: "listed-mode",
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as Dirent,
|
||||
{
|
||||
name: "unlisted-mode",
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
if (pathStr.includes("listed-mode") || pathStr.includes("unlisted-mode")) {
|
||||
return Promise.resolve([
|
||||
{
|
||||
name: "metadata.en.yml",
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
} as Dirent,
|
||||
])
|
||||
}
|
||||
return Promise.resolve([])
|
||||
})
|
||||
|
||||
// Mock file contents
|
||||
mockFs.readFile.mockImplementation((path: any) => {
|
||||
const pathStr = path.toString()
|
||||
if (pathStr === "/test/repo/test-package/metadata.en.yml") {
|
||||
return Promise.resolve(`
|
||||
name: Test Package
|
||||
description: A test package
|
||||
type: package
|
||||
version: 1.0.0
|
||||
items:
|
||||
- type: mode
|
||||
path: listed-mode
|
||||
`)
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package/listed-mode/metadata.en.yml") {
|
||||
return Promise.resolve(`
|
||||
name: Listed Mode
|
||||
description: A mode listed in metadata
|
||||
type: mode
|
||||
version: 1.0.0
|
||||
`)
|
||||
}
|
||||
if (pathStr === "/test/repo/test-package/unlisted-mode/metadata.en.yml") {
|
||||
return Promise.resolve(`
|
||||
name: Unlisted Mode
|
||||
description: A mode from directory only
|
||||
type: mode
|
||||
version: 1.0.0
|
||||
`)
|
||||
}
|
||||
return Promise.resolve("")
|
||||
})
|
||||
|
||||
// Mock git dates
|
||||
mockGit.raw.mockImplementation((...args: any[]) => {
|
||||
const path = args[0][args[0].length - 1]
|
||||
if (path === "/test/repo/test-package/unlisted-mode") {
|
||||
return Promise.resolve("2025-04-13T08:00:00-07:00")
|
||||
}
|
||||
if (path === "/test/repo/test-package/listed-mode") {
|
||||
return Promise.resolve("2025-04-13T09:00:00-07:00")
|
||||
}
|
||||
return Promise.resolve("2025-04-13T10:00:00-07:00")
|
||||
})
|
||||
|
||||
const items = await metadataScanner.scanDirectory("/test/repo", "https://example.com")
|
||||
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0].type).toBe("package")
|
||||
expect(items[0].items).toHaveLength(2)
|
||||
|
||||
// Should include both listed and unlisted modes
|
||||
const listedMode = items[0].items!.find((item) => item.metadata?.name === "Listed Mode")
|
||||
const unlistedMode = items[0].items!.find((item) => item.metadata?.name === "Unlisted Mode")
|
||||
|
||||
expect(listedMode).toBeDefined()
|
||||
expect(unlistedMode).toBeDefined()
|
||||
|
||||
expect(listedMode).toMatchObject({
|
||||
type: "mode",
|
||||
path: "listed-mode",
|
||||
metadata: {
|
||||
name: "Listed Mode",
|
||||
description: "A mode listed in metadata",
|
||||
type: "mode",
|
||||
version: "1.0.0",
|
||||
},
|
||||
lastUpdated: "2025-04-13T09:00:00-07:00",
|
||||
})
|
||||
|
||||
expect(unlistedMode).toMatchObject({
|
||||
type: "mode",
|
||||
path: "unlisted-mode",
|
||||
metadata: {
|
||||
name: "Unlisted Mode",
|
||||
description: "A mode from directory only",
|
||||
type: "mode",
|
||||
version: "1.0.0",
|
||||
},
|
||||
lastUpdated: "2025-04-13T08:00:00-07:00",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue