From dff42d28c0444d3c223fc09d3ba8a964eec61e38 Mon Sep 17 00:00:00 2001 From: Smartsheet-JB-Brown Date: Sun, 13 Apr 2025 12:24:48 -0700 Subject: [PATCH] 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 --- .../package-manager/MetadataScanner.ts | 72 ++++ .../__tests__/PackageSubcomponents.test.ts | 348 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 src/services/package-manager/__tests__/PackageSubcomponents.test.ts diff --git a/src/services/package-manager/MetadataScanner.ts b/src/services/package-manager/MetadataScanner.ts index 4ff6f739fc..e3c0a359ae 100644 --- a/src/services/package-manager/MetadataScanner.ts +++ b/src/services/package-manager/MetadataScanner.ts @@ -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 => 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 { + 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 diff --git a/src/services/package-manager/__tests__/PackageSubcomponents.test.ts b/src/services/package-manager/__tests__/PackageSubcomponents.test.ts new file mode 100644 index 0000000000..6a13afe3b2 --- /dev/null +++ b/src/services/package-manager/__tests__/PackageSubcomponents.test.ts @@ -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 + + 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", + }) + }) + }) +})