diff --git a/.vscode/launch.json b/.vscode/launch.json index e14a2aa9d..e3596beae 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -355,22 +355,14 @@ "type": "node", // "cwd": "" - "cwd": "C:\\Users\\User\\Desktop\\ng_test\\test_proj", + "cwd": "${workspaceFolder}/output/test_proj", + "program": "${env:AppData}/npm/node_modules/@angular/cli/bin/ng", + "preLaunchTask": "build", "args": [ - "-r", - - // you need to install ts-node for the test project - "ts-node/register", - - // "", "g", - "${env:AppData}/npm/node_modules/@angular/cli/bin/ng", "g", - + "g", // "<../../relative/path/from/cwd/to>/igniteui-cli/packages/ng-schematics/src/collection.json:cli-config" - "../../../../../work/git/igniteui-cli/packages/ng-schematics/src/collection.json:cli-config" - ], - "env": { - "TS_NODE_PROJECT": "${workspaceFolder}/packages/ng-schematics/tsconfig.json" - } + "../../packages/ng-schematics/src/collection.json:cli-config" + ] }, { // in order to test schematics, you need to have ignitui-angular package already installed in the test project diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index 9bedbe07d..70625f4f8 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -4,6 +4,7 @@ import { FS_TOKEN, IFileSystem } from "../types/FileSystem"; import { NPM_ANGULAR, NPM_REACT, NPM_WEBCOMPONENTS, resolvePackage, UPGRADEABLE_PACKAGES } from "../update/package-resolve"; import { App } from "./App"; import { detectFrameworkFromPackageJson } from "./detect-framework"; +import { FsFileSystem } from "./FileSystem"; import { TEMPLATE_MANAGER } from "./GlobalConstants"; import { ProjectConfig } from "./ProjectConfig"; import { Util } from "./Util"; @@ -73,11 +74,14 @@ function resolveSkillsRoots(): string[] { /** * Copies skill files from the installed Ignite UI package(s) into .claude/skills/. - * Works with both real FS (CLI) and virtual Tree FS (schematics) through IFileSystem. */ export function copyAISkillsToProject(): AISkillsCopyResult { const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0 }; - const fs = App.container.get(FS_TOKEN); + // Source reads (glob + readFile) always use physical FS - skill files can + // come from sources outside the project virtual tree (external/global package): + const srcFs = new FsFileSystem(); + // Destination writes respect the App FS (which may be virtual): + const destFs = App.container.get(FS_TOKEN); const skillsRoots = resolveSkillsRoots(); if (!skillsRoots.length) { @@ -87,7 +91,7 @@ export function copyAISkillsToProject(): AISkillsCopyResult { const multiRoot = skillsRoots.length > 1; for (const skillsRoot of skillsRoots) { - const rawPaths = fs.glob(skillsRoot, "**/*"); + const rawPaths = srcFs.glob(skillsRoot, "**/*"); const pkgDirName = multiRoot ? path.basename(path.dirname(skillsRoot)) : ""; for (const p of rawPaths) { @@ -101,18 +105,18 @@ export function copyAISkillsToProject(): AISkillsCopyResult { ? `${CLAUDE_SKILLS_DIR}/${pkgDirName}/${rel}` : `${CLAUDE_SKILLS_DIR}/${rel}`; - const newContent = fs.readFile(p); + const newContent = srcFs.readFile(p); try { - if (fs.fileExists(dest)) { - const existingContent = fs.readFile(dest); + if (destFs.fileExists(dest)) { + const existingContent = destFs.readFile(dest); if (existingContent === newContent) { result.skipped++; continue; } - fs.writeFile(dest, newContent); + destFs.writeFile(dest, newContent); Util.log(`${Util.greenCheck()} Updated ${dest}`); } else { - fs.writeFile(dest, newContent); + destFs.writeFile(dest, newContent); Util.log(`${Util.greenCheck()} Created ${dest}`); } } catch { diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 71b17e87d..bb2eb84f0 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { App, Config, FS_TOKEN, GoogleAnalytics, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import { App, Config, FS_TOKEN, FsFileSystem, GoogleAnalytics, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; import { configureMCP, configureSkills } from "../../packages/cli/lib/commands/ai-config"; import * as aiConfig from "../../packages/cli/lib/commands/ai-config"; @@ -180,6 +180,11 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); + // srcFs reads (FsFileSystem.prototype) for source content + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); setupAngularConfig(); configureSkills(); @@ -207,6 +212,11 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); + // srcFs reads (FsFileSystem.prototype) for source content + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content); setupAngularConfig(); configureSkills(); @@ -232,6 +242,11 @@ describe("Unit - ai-config command", () => { } as unknown as IFileSystem; spyOn(App.container, "get").and.returnValue(mockFs); + // srcFs reads (FsFileSystem.prototype) for source content + spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === angularSkillsDir ? [skillFile] : [] + ); + spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); setupAngularConfig(); configureSkills(); diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index d5a17860d..2d8fb0a89 100644 --- a/spec/unit/ai-skills-spec.ts +++ b/spec/unit/ai-skills-spec.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { App, Config, copyAISkillsToProject, FS_TOKEN, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import { App, Config, copyAISkillsToProject, FS_TOKEN, FsFileSystem, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; function skillsDir(pkgName: string) { return `node_modules/${pkgName}/skills`; @@ -21,17 +21,41 @@ function mockTemplateManager(templatePaths: string[]) { return mockTm; } -function makeFs(overrides: Partial = {}): IFileSystem { +/** Creates a mock for the destination FS (injected via container, may be virtual Tree) */ +function makeDestFs(overrides: Partial = {}): IFileSystem { return { - fileExists: jasmine.createSpy("fileExists").and.returnValue(false), - readFile: jasmine.createSpy("readFile").and.returnValue(""), - writeFile: jasmine.createSpy("writeFile"), - directoryExists: jasmine.createSpy("directoryExists").and.returnValue(false), - glob: jasmine.createSpy("glob").and.returnValue([]), + fileExists: jasmine.createSpy("destFs.fileExists").and.returnValue(false), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue(""), + writeFile: jasmine.createSpy("destFs.writeFile"), + directoryExists: jasmine.createSpy("destFs.directoryExists").and.returnValue(false), + glob: jasmine.createSpy("destFs.glob").and.returnValue([]), ...overrides } as unknown as IFileSystem; } +/** + * Spies on FsFileSystem.prototype methods to mock the source FS (always real disk). + * Returns spies dict so tests can configure callFake / returnValue. + */ +function spySrcFs(overrides: { + directoryExists?: jasmine.Spy; + glob?: jasmine.Spy; + readFile?: jasmine.Spy; + fileExists?: jasmine.Spy; +} = {}) { + const spies = { + directoryExists: overrides.directoryExists ?? + spyOn(FsFileSystem.prototype, "directoryExists").and.returnValue(false), + glob: overrides.glob ?? + spyOn(FsFileSystem.prototype, "glob").and.returnValue([]), + readFile: overrides.readFile ?? + spyOn(FsFileSystem.prototype, "readFile").and.returnValue(""), + fileExists: overrides.fileExists ?? + spyOn(FsFileSystem.prototype, "fileExists").and.returnValue(false), + }; + return spies; +} + describe("Unit - copyAISkillsToProject", () => { beforeEach(() => { spyOn(Util, "log"); @@ -39,469 +63,438 @@ describe("Unit - copyAISkillsToProject", () => { }); describe("Angular framework", () => { - it("should copy skills from igniteui-angular into .claude/skills/", async () => { + it("should copy skills from igniteui-angular into .claude/skills/", () => { const angularSkillsDir = skillsDir("igniteui-angular"); const skillFilePath = skillFile("igniteui-angular", "angular.md"); const mockSkillContent = "# Ignite UI for Angular skills"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === "./package.json") return false; - return false; // dest file does not exist yet - }), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { - if (p === skillFilePath) return mockSkillContent; - return ""; - }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === angularSkillsDir - ), - glob: jasmine.createSpy("glob").and.callFake((dir: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => dir === angularSkillsDir ? [skillFilePath] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + if (p === skillFilePath) return mockSkillContent; + return ""; + }) }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", mockSkillContent); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", mockSkillContent); }); - it("should prefer the licensed @infragistics/igniteui-angular package if installed", async () => { + it("should prefer the licensed @infragistics/igniteui-angular package if installed", () => { const licensedPkg = "@infragistics/igniteui-angular"; const angularSkillsDir = skillsDir(licensedPkg); const skillFilePath = skillFile(licensedPkg, "angular.md"); - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === "./package.json") return true; - return false; - }), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { - if (p === "./package.json") { - return JSON.stringify({ dependencies: { [licensedPkg]: "^18.0.0" } }); - } - return "skill content"; - }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === angularSkillsDir - ), - glob: jasmine.createSpy("glob").and.callFake((dir: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => dir === angularSkillsDir ? [skillFilePath] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content") }); - spyOn(App.container, "get").and.returnValue(fs); + // resolvePackage + directoryExists use the container FS (destFs) + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => + p === "./package.json" + ), + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { + if (p === "./package.json") return JSON.stringify({ dependencies: { [licensedPkg]: "^18.0.0" } }); + return ""; + }) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); }); - it("should overwrite an existing skill file with newer content", async () => { + it("should overwrite an existing skill file with newer content", () => { const angularSkillsDir = skillsDir("igniteui-angular"); const skillFilePath = skillFile("igniteui-angular", "angular.md"); const newContent = "# Updated Angular skills"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === ".claude/skills/angular.md") return true; // already exists - return false; - }), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { - if (p === skillFilePath) return newContent; - return ""; // dest has different (older) content - }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.returnValue([skillFilePath]), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(newContent) + }); + + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === angularSkillsDir ), - glob: jasmine.createSpy("glob").and.returnValue([skillFilePath]), - writeFile: jasmine.createSpy("writeFile") + fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => + p === ".claude/skills/angular.md" + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue("") // older content }); - - spyOn(App.container, "get").and.returnValue(fs); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", newContent); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", newContent); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("Updated .claude/skills/angular.md")); }); - it("should not write when destination content is already up-to-date", async () => { + it("should not write when destination content is already up-to-date", () => { const angularSkillsDir = skillsDir("igniteui-angular"); const skillFilePath = skillFile("igniteui-angular", "angular.md"); const existingContent = "# Ignite UI for Angular skills"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === ".claude/skills/angular.md") return true; - return false; - }), - readFile: jasmine.createSpy("readFile").and.returnValue(existingContent), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === angularSkillsDir - ), - glob: jasmine.createSpy("glob").and.callFake((dir: string) => + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => dir === angularSkillsDir ? [skillFilePath] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(existingContent) }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === angularSkillsDir + ), + fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => + p === ".claude/skills/angular.md" + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue(existingContent) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - const result = await copyAISkillsToProject(); + const result = copyAISkillsToProject(); - expect(fs.writeFile).not.toHaveBeenCalled(); + expect(destFs.writeFile).not.toHaveBeenCalled(); expect(result.found).toBe(1); expect(result.skipped).toBe(1); expect(result.failed).toBe(0); - expect(Util.log).not.toHaveBeenCalled(); // no per-file Created/Updated logs emitted + expect(Util.log).not.toHaveBeenCalled(); }); }); describe("React framework", () => { - it("should copy skills from igniteui-react into .claude/skills/", async () => { + it("should copy skills from igniteui-react into .claude/skills/", () => { const reactPkg = "igniteui-react"; const dir = skillsDir(reactPkg); const file = skillFile(reactPkg, "overview.md"); const content = "# React overview"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === "./package.json") return false; - return false; - }), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { - if (p === file) return content; - return ""; - }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => d === dir ? [file] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + if (p === file) return content; + return ""; + }) }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "react" } } as unknown as Config); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", content); }); }); describe("WebComponents framework", () => { - it("should copy skills from igniteui-webcomponents into .claude/skills/", async () => { + it("should copy skills from igniteui-webcomponents into .claude/skills/", () => { const wcPkg = "igniteui-webcomponents"; const dir = skillsDir(wcPkg); const file = skillFile(wcPkg, "webcomponents.md"); const content = "# Ignite UI WebComponents skills"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === "./package.json") return false; - return false; - }), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { - if (p === file) return content; - return ""; - }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => d === dir ? [file] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { + if (p === file) return content; + return ""; + }) }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "webcomponents" } } as unknown as Config); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", content); }); }); describe("No local config (fallback)", () => { - it("should scan all known packages when no ignite-ui-cli.json is present", async () => { + it("should scan all known packages when no ignite-ui-cli.json is present", () => { const angularPkg = "igniteui-angular"; const dir = skillsDir(angularPkg); const file = skillFile(angularPkg, "angular.md"); - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - // no local config, no package.json, no dest file - return false; - }), - readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => d === dir ? [file] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content") }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - await copyAISkillsToProject(); + copyAISkillsToProject(); - // With multiple roots, the dest path is prefixed; angular is the only root found here - // but since we scan ALL packages and only one directory exists, roots.length === 1 → no prefix - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); }); - it("should also scan igniteui-react in the fallback", async () => { + it("should also scan igniteui-react in the fallback", () => { const reactPkg = "igniteui-react"; const dir = skillsDir(reactPkg); const file = skillFile(reactPkg, "overview.md"); - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.returnValue(false), - readFile: jasmine.createSpy("readFile").and.returnValue("react skill content"), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => d === dir ? [file] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("react skill content") }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", "react skill content"); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", "react skill content"); }); - it("should also scan igniteui-webcomponents in the fallback", async () => { + it("should also scan igniteui-webcomponents in the fallback", () => { const wcPkg = "igniteui-webcomponents"; const dir = skillsDir(wcPkg); const file = skillFile(wcPkg, "webcomponents.md"); - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.returnValue(false), - readFile: jasmine.createSpy("readFile").and.returnValue("wc skill content"), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => d === dir ? [file] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("wc skill content") }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", "wc skill content"); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", "wc skill content"); }); }); describe("No skills available", () => { it("should silently return when no npm skills exist and template paths also have no files", () => { const FAKE_TEMPLATE_PATH = "/no-skills/template"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => - p === "ignite-ui-cli.json" - ), - directoryExists: jasmine.createSpy("directoryExists").and.returnValue(false), - glob: jasmine.createSpy("glob").and.returnValue([]), - writeFile: jasmine.createSpy("writeFile") + + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.returnValue([]) }); - App.container.set(FS_TOKEN, fs); + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - // Explicitly control the template fallback — glob returns nothing for the fake path too const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); const result = copyAISkillsToProject(); expect(result.found).toBe(0); - expect(fs.writeFile).not.toHaveBeenCalled(); + expect(destFs.writeFile).not.toHaveBeenCalled(); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("angular"); }); - it("should silently return when skills directory exists but is empty", async () => { + it("should silently return when skills directory exists but is empty", () => { const dir = skillsDir("igniteui-angular"); - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => - p === "ignite-ui-cli.json" - ), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.returnValue([]), - writeFile: jasmine.createSpy("writeFile") + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.returnValue([]) }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).not.toHaveBeenCalled(); + expect(destFs.writeFile).not.toHaveBeenCalled(); }); }); describe("Error handling", () => { - it("should increment failed when writeFile throws creating a new file", async () => { + it("should increment failed when writeFile throws creating a new file", () => { const pkg = "igniteui-angular"; const dir = skillsDir(pkg); const file = skillFile(pkg, "angular.md"); - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - return false; // dest does not exist - }), - readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => d === dir ? [file] : [] ), - writeFile: jasmine.createSpy("writeFile").and.throwError("EACCES: permission denied") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content") }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ), + writeFile: jasmine.createSpy("destFs.writeFile").and.throwError("EACCES: permission denied") + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - const result = await copyAISkillsToProject(); + const result = copyAISkillsToProject(); expect(result.found).toBe(1); expect(result.skipped).toBe(0); expect(result.failed).toBe(1); }); - it("should increment failed when writeFile throws updating an existing file", async () => { + it("should increment failed when writeFile throws updating an existing file", () => { const pkg = "igniteui-angular"; const dir = skillsDir(pkg); const file = skillFile(pkg, "angular.md"); const destFile = ".claude/skills/angular.md"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === destFile) return true; // dest exists with different content - return false; - }), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { - if (p === file) return "new content"; - return "old content"; // dest has different content - }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => d === dir ? [file] : [] ), - writeFile: jasmine.createSpy("writeFile").and.throwError("EACCES: permission denied") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("new content") }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ), + fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => + p === destFile + ), + readFile: jasmine.createSpy("destFs.readFile").and.returnValue("old content"), + writeFile: jasmine.createSpy("destFs.writeFile").and.throwError("EACCES: permission denied") + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - const result = await copyAISkillsToProject(); + const result = copyAISkillsToProject(); expect(result.found).toBe(1); expect(result.skipped).toBe(0); expect(result.failed).toBe(1); }); - it("should report correct counts when some writes fail and some succeed", async () => { + it("should report correct counts when some writes fail and some succeed", () => { const pkg = "igniteui-angular"; const dir = skillsDir(pkg); const file1 = skillFile(pkg, "angular.md"); const file2 = skillFile(pkg, "components.md"); + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => + d === dir ? [file1, file2] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content") + }); + let writeCallCount = 0; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - return false; - }), - readFile: jasmine.createSpy("readFile").and.returnValue("skill content"), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => p === dir ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => - d === dir ? [file1, file2] : [] - ), - writeFile: jasmine.createSpy("writeFile").and.callFake(() => { + writeFile: jasmine.createSpy("destFs.writeFile").and.callFake(() => { writeCallCount++; if (writeCallCount === 2) { throw new Error("ENOSPC: no space left on device"); } }) }); - - spyOn(App.container, "get").and.returnValue(fs); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - const result = await copyAISkillsToProject(); + const result = copyAISkillsToProject(); expect(result.found).toBe(2); expect(result.skipped).toBe(0); expect(result.failed).toBe(1); - expect(fs.writeFile).toHaveBeenCalledTimes(2); + expect(destFs.writeFile).toHaveBeenCalledTimes(2); }); }); @@ -510,24 +503,21 @@ describe("Unit - copyAISkillsToProject", () => { const FAKE_SKILLS_ROOT = path.join(FAKE_TEMPLATE_PATH, "__dot__claude/skills"); it("should use angular template paths when framework is in config and no npm skills are found", () => { - const skillFile = path.join(FAKE_SKILLS_ROOT, "angular.md"); + const skillFilePath = path.join(FAKE_SKILLS_ROOT, "angular.md"); const content = "# Angular skills from template"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => - p === "ignite-ui-cli.json" + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === FAKE_SKILLS_ROOT ? [skillFilePath] : [] ), - readFile: jasmine.createSpy("readFile").and.returnValue(content), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === FAKE_SKILLS_ROOT - ), - glob: jasmine.createSpy("glob").and.callFake((dir: string) => - dir === FAKE_SKILLS_ROOT ? [skillFile] : [] - ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content), + fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" + ) }); - App.container.set(FS_TOKEN, fs); + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } @@ -537,109 +527,104 @@ describe("Unit - copyAISkillsToProject", () => { copyAISkillsToProject(); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("angular"); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); }); it("should detect react from package.json and use react template paths when no npm skills are found", () => { - const skillFile = path.join(FAKE_SKILLS_ROOT, "react.md"); + const skillFilePath = path.join(FAKE_SKILLS_ROOT, "react.md"); const content = "# React skills from template"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === FAKE_SKILLS_ROOT ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + // detectFrameworkFromPackageJson reads ./package.json via the container FS (destFs) + const destFs = makeDestFs({ + fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => p === "./package.json" ), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { if (p === "./package.json") return JSON.stringify({ dependencies: { "react": "^19.0.0" } }); - return content; - }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === FAKE_SKILLS_ROOT - ), - glob: jasmine.createSpy("glob").and.callFake((dir: string) => - dir === FAKE_SKILLS_ROOT ? [skillFile] : [] - ), - writeFile: jasmine.createSpy("writeFile") + return ""; + }) }); - - App.container.set(FS_TOKEN, fs); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); copyAISkillsToProject(); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("react"); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/react.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/react.md", content); }); it("should use webcomponents (catch-all) template paths when no angular or react detected in package.json", () => { - const skillFile = path.join(FAKE_SKILLS_ROOT, "webcomponents.md"); + const skillFilePath = path.join(FAKE_SKILLS_ROOT, "webcomponents.md"); const content = "# WC skills from template"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === FAKE_SKILLS_ROOT ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) + }); + + // detectFrameworkFromPackageJson reads ./package.json via the container FS (destFs) + const destFs = makeDestFs({ + fileExists: jasmine.createSpy("destFs.fileExists").and.callFake((p: string) => p === "./package.json" ), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + readFile: jasmine.createSpy("destFs.readFile").and.callFake((p: string) => { if (p === "./package.json") return JSON.stringify({ dependencies: { "lit": "^3.0.0" } }); - return content; - }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === FAKE_SKILLS_ROOT - ), - glob: jasmine.createSpy("glob").and.callFake((dir: string) => - dir === FAKE_SKILLS_ROOT ? [skillFile] : [] - ), - writeFile: jasmine.createSpy("writeFile") + return ""; + }) }); - - App.container.set(FS_TOKEN, fs); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); copyAISkillsToProject(); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("webcomponents"); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/webcomponents.md", content); }); it("should return empty result when no package.json exists and no npm skills are found", () => { - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.returnValue(false), - directoryExists: jasmine.createSpy("directoryExists").and.returnValue(false), - writeFile: jasmine.createSpy("writeFile") + spySrcFs({ + fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.returnValue(false) }); - App.container.set(FS_TOKEN, fs); + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - // no package.json → detectFrameworkFromPackageJson returns null → no template fallback const result = copyAISkillsToProject(); expect(result.found).toBe(0); expect(result.skipped).toBe(0); expect(result.failed).toBe(0); - expect(fs.writeFile).not.toHaveBeenCalled(); + expect(destFs.writeFile).not.toHaveBeenCalled(); }); it("should preserve nested directory structure from template skill paths", () => { const nestedFile = path.join(FAKE_SKILLS_ROOT, "grids", "grid.md"); const content = "# Grid skills from template"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => - p === "ignite-ui-cli.json" - ), - readFile: jasmine.createSpy("readFile").and.returnValue(content), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === FAKE_SKILLS_ROOT - ), - glob: jasmine.createSpy("glob").and.callFake((dir: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => dir === FAKE_SKILLS_ROOT ? [nestedFile] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content), + fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" + ) }); - App.container.set(FS_TOKEN, fs); + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } @@ -648,80 +633,108 @@ describe("Unit - copyAISkillsToProject", () => { copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); }); it("should use config framework (not detectFrameworkFromPackageJson) when config has framework but npm skills absent", () => { - // framework from config = "react"; package.json has @angular/core — config must win - const skillFile = path.join(FAKE_SKILLS_ROOT, "react.md"); + const skillFilePath = path.join(FAKE_SKILLS_ROOT, "react.md"); const content = "# React skills from template"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === "./package.json") return true; - return false; - }), - readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === FAKE_SKILLS_ROOT ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.callFake((p: string) => { if (p === "./package.json") return JSON.stringify({ dependencies: { "@angular/core": "^17.0.0" } }); return content; }), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === FAKE_SKILLS_ROOT - ), - glob: jasmine.createSpy("glob").and.callFake((dir: string) => - dir === FAKE_SKILLS_ROOT ? [skillFile] : [] - ), - writeFile: jasmine.createSpy("writeFile") + fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.callFake((p: string) => { + if (p === "ignite-ui-cli.json") return true; + if (p === "./package.json") return true; + return false; + }) }); - App.container.set(FS_TOKEN, fs); + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "react" } // config says react, even though package.json has angular + project: { framework: "react" } } as unknown as Config); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); copyAISkillsToProject(); - // ??= must NOT overwrite already-set framework value expect(mockTm.getFrameworkById).toHaveBeenCalledWith("react"); expect(mockTm.getFrameworkById).not.toHaveBeenCalledWith("angular"); }); + + it("should read skill sources from real disk FS even when destFs is virtual", () => { + // Simulates the schematics scenario: srcFs (FsFileSystem) reads from disk, + // destFs (NgTreeFileSystem) writes into the virtual Tree. + const ABS_TEMPLATE_PATH = path.resolve("/usr/lib/node_modules/fake-templates/base/files"); + const SKILLS_ROOT = path.join(ABS_TEMPLATE_PATH, "__dot__claude/skills"); + const skillFilePath = path.join(SKILLS_ROOT, "angular.md"); + const content = "# Angular skills from template"; + + const srcSpies = spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => + dir === SKILLS_ROOT ? [skillFilePath] : [] + ), + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content), + fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.callFake((p: string) => + p === "ignite-ui-cli.json" + ) + }); + + const destFs = makeDestFs(); + App.container.set(FS_TOKEN, destFs); + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as unknown as Config); + mockTemplateManager([ABS_TEMPLATE_PATH]); + + copyAISkillsToProject(); + + // Source reads go to real FsFileSystem (srcFs) + expect(srcSpies.glob).toHaveBeenCalledWith(SKILLS_ROOT, "**/*"); + expect(srcSpies.readFile).toHaveBeenCalledWith(skillFilePath); + // Dest writes go to the injected FS (virtual Tree) + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); + // destFs.glob should NOT have been called — source ops use srcFs + expect(destFs.glob).not.toHaveBeenCalled(); + }); }); describe("Nested skill files", () => { - it("should preserve directory structure when copying nested skill files", async () => { + it("should preserve directory structure when copying nested skill files", () => { const pkg = "igniteui-angular"; const dir = skillsDir(pkg); const nestedFile = skillFile(pkg, "grids/grid.md"); const content = "# Grid skills"; - const fs = makeFs({ - fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => { - if (p === "ignite-ui-cli.json") return true; - if (p === "./package.json") return false; - return false; - }), - readFile: jasmine.createSpy("readFile").and.returnValue(content), - directoryExists: jasmine.createSpy("directoryExists").and.callFake((p: string) => - p === dir - ), - glob: jasmine.createSpy("glob").and.callFake((d: string) => + spySrcFs({ + glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => d === dir ? [nestedFile] : [] ), - writeFile: jasmine.createSpy("writeFile") + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) }); - spyOn(App.container, "get").and.returnValue(fs); + const destFs = makeDestFs({ + directoryExists: jasmine.createSpy("destFs.directoryExists").and.callFake((p: string) => + p === dir + ) + }); + App.container.set(FS_TOKEN, destFs); spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); spyOn(ProjectConfig, "getConfig").and.returnValue({ project: { framework: "angular" } } as unknown as Config); - await copyAISkillsToProject(); + copyAISkillsToProject(); - expect(fs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); + expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); }); }); });