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:
Smartsheet-JB-Brown 2025-04-13 12:24:48 -07:00
parent 4891fb04b7
commit dff42d28c0
2 changed files with 420 additions and 0 deletions

View file

@ -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

View file

@ -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",
})
})
})
})