From 2ae6a3b6e609509e0c19b87d3c4455d61ea88baa Mon Sep 17 00:00:00 2001 From: damyanpetev Date: Thu, 14 May 2026 20:27:02 +0300 Subject: [PATCH 1/5] feat(ai-config): add framework prompt & early detect so command can always run --- CHANGELOG.md | 5 + packages/cli/lib/PromptSession.ts | 4 +- packages/cli/lib/commands/ai-config.ts | 72 +++- packages/cli/lib/commands/new.ts | 2 +- packages/core/prompt/BasePromptSession.ts | 4 +- packages/core/util/ai-skills.ts | 44 +-- packages/core/util/detect-framework.ts | 24 +- .../ng-schematics/src/cli-config/index.ts | 4 +- .../src/cli-config/index_spec.ts | 20 +- .../ng-schematics/src/ng-new/index_spec.ts | 4 +- .../src/prompt/SchematicsPromptSession.ts | 2 +- spec/unit/PromptSession-spec.ts | 2 +- spec/unit/ai-config-spec.ts | 106 +++++- spec/unit/ai-skills-spec.ts | 320 ++---------------- spec/unit/detect-framework-spec.ts | 48 ++- spec/unit/new-spec.ts | 8 +- 16 files changed, 290 insertions(+), 379 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71368d75d..c7c0f8578 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 15.1.1 (2026-05-14) + +## What's Changed +* feat(ai-config): `ig ai-config` now accepts a `--framework` / `-f` option for explicit framework specification. When omitted, the command still attempts to auto-detect the framework, but if detection fails it now also prompts the user for selection (in TTY). + # 15.1.0 (2026-05-13) ## What's Changed diff --git a/packages/cli/lib/PromptSession.ts b/packages/cli/lib/PromptSession.ts index 5fa19293e..1b170ff25 100644 --- a/packages/cli/lib/PromptSession.ts +++ b/packages/cli/lib/PromptSession.ts @@ -38,8 +38,8 @@ export class PromptSession extends BasePromptSession { await upgrade.upgrade({ skipInstall: true, _: ["upgrade"], $0: "upgrade" }); } - protected override async configureAI(): Promise { - await aiConfigure(); + protected override async configureAI(frameworkId: string): Promise { + await aiConfigure(frameworkId); } protected override templateSelectedTask(type: "component" | "view" = "component"): Task { diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 496dd825a..6ecb1296c 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -1,4 +1,22 @@ -import { addMcpServers, AI_AGENT_LABELS, AI_AGENT_CHOICES, AIAgentTarget, copyAgentInstructionFiles, copyAISkillsToProject, GoogleAnalytics, InquirerWrapper, Util, AiCodingAssistant, AI_ASSISTANT_MCP_CONFIGS, AI_ASSISTANT_CHOICES, AI_ASSISTANT_LABELS } from "@igniteui/cli-core"; +import { + addMcpServers, + AI_AGENT_LABELS, + AI_AGENT_CHOICES, + type AIAgentTarget, + copyAgentInstructionFiles, + copyAISkillsToProject, + GoogleAnalytics, + InquirerWrapper, + Util, + type AiCodingAssistant, + AI_ASSISTANT_MCP_CONFIGS, + AI_ASSISTANT_CHOICES, + AI_ASSISTANT_LABELS, + detectFramework, + App, + type BaseTemplateManager, + TEMPLATE_MANAGER, +} from "@igniteui/cli-core"; import { ArgumentsCamelCase, CommandModule } from "yargs"; export function configureMCP(assistants: AiCodingAssistant[]): void { @@ -14,8 +32,8 @@ export function configureMCP(assistants: AiCodingAssistant[]): void { } } -export function configureSkills(agents: AIAgentTarget[]): void { - const result = copyAISkillsToProject(agents); +export function configureSkills(agents: AIAgentTarget[], framework: string): void { + const result = copyAISkillsToProject(agents, framework); if (result.found === 0) { Util.warn("No AI skill files found. Make sure packages are installed (npm install) " + "and your Ignite UI packages are up-to-date.", "yellow"); @@ -32,7 +50,7 @@ export function configureSkills(agents: AIAgentTarget[]): void { type AIAgentOption = AIAgentTarget | "none"; type AIAssistantOption = AiCodingAssistant | "none"; -export async function configure(agents: AIAgentOption[] = [], assistants: AIAssistantOption[] = [], skills = true): Promise<{ agents: AIAgentTarget[], assistants: AiCodingAssistant[] }> { +export async function configure(framework: string, agents: AIAgentOption[] = [], assistants: AIAssistantOption[] = [], skills = true): Promise<{ agents: AIAgentTarget[], assistants: AiCodingAssistant[] }> { if (!agents.length) { agents = await promptForAgents(); } @@ -53,9 +71,9 @@ export async function configure(agents: AIAgentOption[] = [], assistants: AIAssi Util.log("No AI configuration selected. Skipping."); } else { if (skills) { - configureSkills(resolvedAgents); + configureSkills(resolvedAgents, framework); } - copyAgentInstructionFiles(resolvedAgents); + copyAgentInstructionFiles(resolvedAgents, framework); } return { agents: resolvedAgents, assistants: resolvedAssistants }; @@ -82,7 +100,7 @@ const AI_ASSISTANT_CHECKBOX_CHOICES = [ })) ]; -export async function promptForAgents(): Promise { +async function promptForAgents(): Promise { let selected: AIAgentOption[] = AI_AGENT_CHECKBOX_DEFAULTS; if (Util.canPrompt()) { const result = await InquirerWrapper.checkbox({ @@ -95,7 +113,7 @@ export async function promptForAgents(): Promise { return selected; } -export async function promptForAssistant(): Promise { +async function promptForAssistant(): Promise { let selected: AIAssistantOption[] = AI_ASSISTANT_CHECKBOX_DEFAULTS; if (Util.canPrompt()) { const result = await InquirerWrapper.checkbox({ @@ -108,6 +126,23 @@ export async function promptForAssistant(): Promise { return selected; } +/** delayed call so it's not immediate on module import for testing purposes */ +function getTemplateManager(): BaseTemplateManager { + return App.container.get(TEMPLATE_MANAGER); +} + +/** Separate from the PromptSession prompt due to step by step config */ +async function promptForFrameworkId(): Promise { + const tm = getTemplateManager(); + const frameRes: string = await InquirerWrapper.select({ + name: "framework", + message: "Choose framework:", + choices: tm.getFrameworkNames(), + default: "Angular" + }); + return tm.getFrameworkByName(frameRes).id; +} + const command: CommandModule = { command: "ai-config", describe: "Configures Ignite UI AI tooling (MCP servers, AI coding skills and instructions)", @@ -122,6 +157,12 @@ const command: CommandModule = { describe: "Coding assistant(s) to configure MCP servers for", choices: [...AI_ASSISTANT_CHOICES, "none"] as string[], type: "array" + }) + .option("framework", { + alias: "f", + describe: "Manually set project framework to configure AI for.", + choices: getTemplateManager()?.getFrameworkIds(), + type: "string" }), async handler(argv: ArgumentsCamelCase) { const agents = (argv.agents ?? []) as AIAgentOption[]; @@ -131,7 +172,20 @@ const command: CommandModule = { cd: "Ai Config" }); - const result = await configure(agents, assistants); + let framework: string = argv.framework as string ?? detectFramework(); + if (!framework) { + Util.log("Framework not provided and couldn't detect project from config or structure."); + if (Util.canPrompt()) { + framework = await promptForFrameworkId(); + } else { + return Util.error("Please provide --framework argument.", "red"); + } + } + if (!getTemplateManager()?.getFrameworkById(framework)) { + return Util.error("Framework not supported", "red"); + } + + const result = await configure(framework, agents, assistants); GoogleAnalytics.post({ t: "event", diff --git a/packages/cli/lib/commands/new.ts b/packages/cli/lib/commands/new.ts index ff0f467e5..852ed0ce2 100644 --- a/packages/cli/lib/commands/new.ts +++ b/packages/cli/lib/commands/new.ts @@ -162,7 +162,7 @@ const command: NewCommandType = { } process.chdir(argv.name); - await configure(argv.agents as (AIAgentTarget | "none")[], argv.assistants as (AiCodingAssistant | "none")[]); + await configure(argv.framework, argv.agents as (AIAgentTarget | "none")[], argv.assistants as (AiCodingAssistant | "none")[]); process.chdir(".."); Util.log(Util.greenCheck() + " Project Created"); diff --git a/packages/core/prompt/BasePromptSession.ts b/packages/core/prompt/BasePromptSession.ts index 3021ed7f1..e1b949cb5 100644 --- a/packages/core/prompt/BasePromptSession.ts +++ b/packages/core/prompt/BasePromptSession.ts @@ -73,7 +73,7 @@ export abstract class BasePromptSession { } // move cwd to project folder process.chdir(projectName); - await this.configureAI(); + await this.configureAI(framework.id); } await this.chooseActionLoop(projLibrary); //TODO: restore cwd? @@ -102,7 +102,7 @@ export abstract class BasePromptSession { protected abstract upgradePackages(); /** Configure Ignite UI AI tooling (MCP servers and AI coding skills) for the project */ - protected abstract configureAI(): Promise; + protected abstract configureAI(frameworkId: string): Promise; /** * Get user name and set template's extra configurations if any diff --git a/packages/core/util/ai-skills.ts b/packages/core/util/ai-skills.ts index 76cfd4476..3f0d8615e 100644 --- a/packages/core/util/ai-skills.ts +++ b/packages/core/util/ai-skills.ts @@ -3,10 +3,8 @@ import type { BaseTemplateManager } from "../templates"; 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"; export const AI_AGENT_CHOICES = ["generic", "claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie"] as const; @@ -86,17 +84,10 @@ function resolveTemplateFilesDir(framework: string): string | null { * Ignite UI packages that are relevant to the project's detected framework. * Falls back to the bundled template skills when no npm package is installed. */ -function resolveSkillsRoots(): string[] { +function resolveSkillsRoots(framework: string): string[] { const fs = App.container.get(FS_TOKEN); const roots: string[] = []; - let framework: string | null = null; - try { - if (ProjectConfig.hasLocalConfig()) { - framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() ?? null; - } - } catch { /* config not readable – fall through to scan all */ } - const allPkgKeys = Object.keys(UPGRADEABLE_PACKAGES); let candidates = new Set(); if (framework === "angular") { @@ -120,12 +111,9 @@ function resolveSkillsRoots(): string[] { if (!roots.length) { // if no root discovered, take the root from the appropriate project template files: - framework ??= detectFrameworkFromPackageJson(); - if (framework) { - const filesDir = resolveTemplateFilesDir(framework); - if (filesDir) { - roots.push(path.join(filesDir, AI_SKILLS_DIR_NAME)); - } + const filesDir = resolveTemplateFilesDir(framework); + if (filesDir) { + roots.push(path.join(filesDir, AI_SKILLS_DIR_NAME)); } } @@ -137,14 +125,14 @@ function resolveSkillsRoots(): string[] { * skills directories for each of the given AI agents. * @param agents – list of AI agent targets to copy skills for */ -export function copyAISkillsToProject(agents: AIAgentTarget[]): AISkillsCopyResult { +export function copyAISkillsToProject(agents: AIAgentTarget[], framework: string): AISkillsCopyResult { const result: AISkillsCopyResult = { found: 0, skipped: 0, failed: 0 }; // 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(); + const skillsRoots = resolveSkillsRoots(framework); if (!skillsRoots.length) { return result; @@ -198,20 +186,8 @@ export function copyAISkillsToProject(agents: AIAgentTarget[]): AISkillsCopyResu * Resolves the AGENTS.md source file content from the bundled project template files. * AGENTS.md lives only in the template files/ directory, not in npm packages. */ -function resolveAgentsContent(): string | null { - let framework: string | null = null; - try { - if (ProjectConfig.hasLocalConfig()) { - framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() ?? null; - } - } catch { /* fall through */ } - framework ??= detectFrameworkFromPackageJson(); - - if (!framework) { - return null; - } - - const filesDir = resolveTemplateFilesDir(framework); +function resolveAgentsContent(framework: string): string | null { + const filesDir = resolveTemplateFilesDir(framework.toLowerCase()); if (!filesDir) { return null; } @@ -228,8 +204,8 @@ function resolveAgentsContent(): string | null { * each of the given agents. * @param agents – list of AI agent targets to create instruction files for */ -export function copyAgentInstructionFiles(agents: AIAgentTarget[]): void { - const content = resolveAgentsContent(); +export function copyAgentInstructionFiles(agents: AIAgentTarget[], framework: string): void { + const content = resolveAgentsContent(framework); if (!content) { return; } diff --git a/packages/core/util/detect-framework.ts b/packages/core/util/detect-framework.ts index 0badbb022..1263f73cc 100644 --- a/packages/core/util/detect-framework.ts +++ b/packages/core/util/detect-framework.ts @@ -1,5 +1,27 @@ import { App } from "./App"; import { IFileSystem, FS_TOKEN } from "../types/FileSystem"; +import { ProjectConfig } from "./ProjectConfig"; + +type Framework = "angular" | "react" | "webcomponents"; + +/** + * Attempt to detect project framework by first checking for local cli-config, + * then falling back to package.json analysis of `detectFrameworkFromPackageJson()`. + * @returns The detected framework Id or `null` if no framework could be detected. + */ +export function detectFramework(): Framework | null { + let framework: Framework | null = null; + try { + // try project config first: + if (ProjectConfig.hasLocalConfig()) { + framework = ProjectConfig.getConfig().project?.framework?.toLowerCase() as Framework ?? null; + } + } catch { /* fall through */ } + + framework ??= detectFrameworkFromPackageJson(); + + return framework; +} /** * Attempts to detect the front-end framework by inspecting for well-known, @@ -12,7 +34,7 @@ import { IFileSystem, FS_TOKEN } from "../types/FileSystem"; * - "webcomponents"→ fallback when neither of the above is found * - `null` if `package.json` is absent or cannot be parsed. */ -export function detectFrameworkFromPackageJson(): "angular" | "react" | "webcomponents" | null { +export function detectFrameworkFromPackageJson(): Framework | null { const fs = App.container.get(FS_TOKEN); if (!fs.fileExists("./package.json")) { return null; diff --git a/packages/ng-schematics/src/cli-config/index.ts b/packages/ng-schematics/src/cli-config/index.ts index 803d8db47..473e12853 100644 --- a/packages/ng-schematics/src/cli-config/index.ts +++ b/packages/ng-schematics/src/cli-config/index.ts @@ -132,8 +132,8 @@ function aiConfig({ init, agents, assistants }: { init: boolean; agents: AIAgent if (init) { appInit(tree); } - copyAISkillsToProject(agents); - copyAgentInstructionFiles(agents); + copyAISkillsToProject(agents, "angular"); + copyAgentInstructionFiles(agents, "angular"); const angularCliServer: Record = { "angular-cli": { diff --git a/packages/ng-schematics/src/cli-config/index_spec.ts b/packages/ng-schematics/src/cli-config/index_spec.ts index 0f9d2a202..a4cd2e8e5 100644 --- a/packages/ng-schematics/src/cli-config/index_spec.ts +++ b/packages/ng-schematics/src/cli-config/index_spec.ts @@ -332,8 +332,8 @@ export const appConfig: ApplicationConfig = { await runner.runSchematic("ai-config", {}, tree); expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1); - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "generic"]); - expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "generic"]); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "generic"], "angular"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "generic"], "angular"); }); it("should create .vscode/mcp.json with igniteui and angular-cli servers when file does not exist", async () => { @@ -407,30 +407,30 @@ export const appConfig: ApplicationConfig = { it("should pass agents when agents option is provided", async () => { await runner.runSchematic("ai-config", { agents: ["cursor"] }, tree); - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["cursor"]); - expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["cursor"]); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["cursor"], "angular"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["cursor"], "angular"); }); it("should pass agents for copilot agents", async () => { await runner.runSchematic("ai-config", { agents: ["copilot"] }, tree); - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["copilot"]); - expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["copilot"]); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["copilot"], "angular"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["copilot"], "angular"); }); it("should pass agents for generic agents", async () => { await runner.runSchematic("ai-config", { agents: ["generic"] }, tree); - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["generic"]); - expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["generic"]); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["generic"], "angular"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["generic"], "angular"); }); it("should configure multiple agents", async () => { await runner.runSchematic("ai-config", { agents: ["claude", "cursor"] }, tree); expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledTimes(1); - expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "cursor"]); - expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "cursor"]); + expect(aiSkillsModule.copyAISkillsToProject).toHaveBeenCalledWith(["claude", "cursor"], "angular"); + expect(aiSkillsModule.copyAgentInstructionFiles).toHaveBeenCalledWith(["claude", "cursor"], "angular"); }); it("should default MCP config to .vscode/mcp.json with servers key", async () => { diff --git a/packages/ng-schematics/src/ng-new/index_spec.ts b/packages/ng-schematics/src/ng-new/index_spec.ts index b0f2bcc90..ec3a60f43 100644 --- a/packages/ng-schematics/src/ng-new/index_spec.ts +++ b/packages/ng-schematics/src/ng-new/index_spec.ts @@ -131,7 +131,7 @@ describe("Schematics ng-new", () => { expect(mockFunc[1]).toHaveBeenCalled(); } expect(AppProjectSchematic.default).toHaveBeenCalled(); - expect(tree.files.length).toEqual(2); + expect(tree.files.length).toBeGreaterThanOrEqual(2); expect(tree.exists(`${workingDirectory}/.gitignore`)).toBeTruthy(); const taskOptions = runner.tasks.map(task => task.options); const expectedInstall: NodePackageTaskOptions = { @@ -199,7 +199,7 @@ describe("Schematics ng-new", () => { const tree = await runner.runSchematic("ng-new", { version: "8.0.3", name: workingDirectory }, myTree); expect(AppProjectSchematic.default).toHaveBeenCalled(); - expect(tree.files.length).toEqual(2); + expect(tree.files.length).toBeGreaterThanOrEqual(2); expect(tree.exists(`${workingDirectory}/.gitignore`)).toBeTruthy(); const taskOptions = runner.tasks.map(task => task.options); const expectedInstall: NodePackageTaskOptions = { diff --git a/packages/ng-schematics/src/prompt/SchematicsPromptSession.ts b/packages/ng-schematics/src/prompt/SchematicsPromptSession.ts index 6d0ef2221..7fc920ff5 100644 --- a/packages/ng-schematics/src/prompt/SchematicsPromptSession.ts +++ b/packages/ng-schematics/src/prompt/SchematicsPromptSession.ts @@ -60,7 +60,7 @@ export class SchematicsPromptSession extends BasePromptSession { // TODO? } - protected async configureAI(): Promise { + protected async configureAI(_frameworkId: string): Promise { // No-op in schematics context } diff --git a/spec/unit/PromptSession-spec.ts b/spec/unit/PromptSession-spec.ts index b739f902d..f6987d70f 100644 --- a/spec/unit/PromptSession-spec.ts +++ b/spec/unit/PromptSession-spec.ts @@ -315,7 +315,7 @@ describe("Unit - PromptSession", () => { expect(Util.log).toHaveBeenCalledWith(" Project structure generated."); expect(Util.gitInit).toHaveBeenCalled(); expect(mockSession.chooseActionLoop).toHaveBeenCalled(); - expect(aiConfig.configure).toHaveBeenCalledTimes(1); + expect(aiConfig.configure).toHaveBeenCalledOnceWith("angular"); }); it("start - New project - missing IFs", async () => { const mockProject = { diff --git a/spec/unit/ai-config-spec.ts b/spec/unit/ai-config-spec.ts index 915bd481c..06d159e4f 100644 --- a/spec/unit/ai-config-spec.ts +++ b/spec/unit/ai-config-spec.ts @@ -1,5 +1,6 @@ import * as path from "path"; import { App, Config, FS_TOKEN, FsFileSystem, GoogleAnalytics, IFileSystem, InquirerWrapper, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import * as coreDetect from "../../packages/core/util/detect-framework"; import { configureMCP, configureSkills } from "../../packages/cli/lib/commands/ai-config"; import * as aiConfig from "../../packages/cli/lib/commands/ai-config"; @@ -168,9 +169,8 @@ describe("Unit - ai-config command", () => { return { getFrameworkById: () => null } as any; } }) - setupAngularConfig(); - configureSkills(["claude"]); + configureSkills(["claude"], "angular"); expect(Util.warn).toHaveBeenCalledWith(jasmine.stringContaining("No AI skill files found"), "yellow"); expect(Util.log).not.toHaveBeenCalled(); @@ -198,9 +198,8 @@ describe("Unit - ai-config command", () => { d === angularSkillsDir ? [skillFile] : [] ); spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); - setupAngularConfig(); - configureSkills(["claude"]); + configureSkills(["claude"], "angular"); expect(Util.warn).toHaveBeenCalledWith(jasmine.stringContaining("Failed to write 1 skill file(s) out of 1"), "yellow"); expect(Util.log).not.toHaveBeenCalled(); @@ -230,9 +229,8 @@ describe("Unit - ai-config command", () => { d === angularSkillsDir ? [skillFile] : [] ); spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content); - setupAngularConfig(); - configureSkills(["claude"]); + configureSkills(["claude"], "angular"); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("already up-to-date")); expect(Util.warn).not.toHaveBeenCalled(); @@ -260,9 +258,8 @@ describe("Unit - ai-config command", () => { d === angularSkillsDir ? [skillFile] : [] ); spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content"); - setupAngularConfig(); - configureSkills(["claude"]); + configureSkills(["claude"], "angular"); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("1 AI skill file(s) created or updated")); expect(Util.warn).not.toHaveBeenCalled(); @@ -272,6 +269,13 @@ describe("Unit - ai-config command", () => { describe("handler", () => { beforeEach(() => { (GoogleAnalytics.post as jasmine.Spy).calls.reset(); + App.container.set(TEMPLATE_MANAGER, jasmine.createSpyObj("TemplateManager", { + getFrameworkById: { projectLibraries: [] }, + getFrameworkIds: ["angular"], + getFrameworkNames: ["Angular"], + getFrameworkByName: { id: "angular" } + })); + spyOn(Util, "warn"); }); it("prompts for agents when --agent is not provided", async () => { @@ -282,7 +286,7 @@ describe("Unit - ai-config command", () => { Promise.resolve(["vscode"]) ); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ message: "Which AI agents do you want to generate skills and instructions for?", @@ -297,7 +301,7 @@ describe("Unit - ai-config command", () => { spyOn(Util, "canPrompt").and.returnValue(false); spyOn(InquirerWrapper, "checkbox"); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); expect(InquirerWrapper.checkbox).not.toHaveBeenCalled(); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event", ea: "agent: generic, claude; assistant: generic" })); @@ -308,7 +312,7 @@ describe("Unit - ai-config command", () => { spyOn(Util, "canPrompt").and.returnValue(true); spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["none"])); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("Skipping")); expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "screenview", cd: "Ai Config" })); @@ -324,7 +328,7 @@ describe("Unit - ai-config command", () => { Promise.resolve(["vscode"]) ); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); expect(mockFs.writeFile).toHaveBeenCalled(); const config = writtenConfig(mockFs); @@ -347,7 +351,7 @@ describe("Unit - ai-config command", () => { Promise.resolve(["vscode"]) ); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ message: "Which AI agents do you want to generate skills and instructions for?" @@ -360,7 +364,7 @@ describe("Unit - ai-config command", () => { spyOn(Util, "canPrompt").and.returnValue(true); spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["vscode"])); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agents: ["cursor"] }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", agents: ["cursor"], framework: "angular" }); expect(InquirerWrapper.checkbox).not.toHaveBeenCalledWith(jasmine.objectContaining({ message: "Which AI agents do you want to generate skills and instructions for?" @@ -373,7 +377,7 @@ describe("Unit - ai-config command", () => { spyOn(Util, "canPrompt").and.returnValue(true); spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["claude"])); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", assistants: ["cursor"] }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", assistants: ["cursor"], framework: "angular" }); expect(InquirerWrapper.checkbox).toHaveBeenCalledTimes(1); }); @@ -386,7 +390,7 @@ describe("Unit - ai-config command", () => { Promise.resolve(["vscode"]) ); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); expect(InquirerWrapper.checkbox).toHaveBeenCalledWith(jasmine.objectContaining({ message: "Which coding assistants should MCP servers be configured for?" @@ -402,11 +406,79 @@ describe("Unit - ai-config command", () => { Promise.resolve(["generic"]) ); - await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "angular" }); expect(mockFs.writeFile).toHaveBeenCalledWith(".mcp.json", jasmine.any(String)); const config = writtenConfig(mockFs); expect((config.mcpServers as any)[IGNITEUI_SERVER_KEY]).toEqual(igniteuiServer); }); + + describe("framework resolution", () => { + const NO_DETECT_MESSAGE = "Framework not provided and couldn't detect project from config or structure."; + beforeEach(() => { + App.container.set(FS_TOKEN, createMockFs()); + spyOn(Util, "canPrompt").and.returnValue(true); + spyOn(InquirerWrapper, "checkbox").and.returnValue(Promise.resolve(["none"])); + }); + + it("uses detected framework when --framework is not provided", async () => { + spyOn(coreDetect, "detectFramework").and.returnValue("react"); + spyOn(InquirerWrapper, "select"); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(coreDetect.detectFramework).toHaveBeenCalled(); + expect(InquirerWrapper.select).not.toHaveBeenCalled(); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event" })); + }); + + it("prompts for framework when --framework is absent and detection returns null", async () => { + spyOn(coreDetect, "detectFramework").and.returnValue(null); + spyOn(InquirerWrapper, "select").and.returnValue(Promise.resolve("Angular")); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(Util.log).toHaveBeenCalledWith(NO_DETECT_MESSAGE); + expect(InquirerWrapper.select).toHaveBeenCalledWith(jasmine.objectContaining({ + message: "Choose framework:" + })); + expect(GoogleAnalytics.post).toHaveBeenCalledWith(jasmine.objectContaining({ t: "event" })); + }); + + it("shows framework choices from template manager when prompting", async () => { + spyOn(coreDetect, "detectFramework").and.returnValue(null); + spyOn(InquirerWrapper, "select").and.returnValue(Promise.resolve("Angular")); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(InquirerWrapper.select).toHaveBeenCalledWith(jasmine.objectContaining({ + choices: ["Angular"] + })); + }); + + it("errors without prompting when framework is absent and canPrompt is false", async () => { + spyOn(coreDetect, "detectFramework").and.returnValue(null); + spyOn(InquirerWrapper, "select"); + spyOn(Util, "error"); + (Util.canPrompt as jasmine.Spy).and.returnValue(false); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig" }); + + expect(InquirerWrapper.select).not.toHaveBeenCalled(); + expect(Util.log).toHaveBeenCalledWith(NO_DETECT_MESSAGE); + expect(Util.error).toHaveBeenCalledWith("Please provide --framework argument.", "red"); + }); + + it("errors when provided framework is not supported", async () => { + spyOn(Util, "error"); + // override the spy to return undefined (unknown framework) + (App.container.get(TEMPLATE_MANAGER) as jasmine.SpyObj) + .getFrameworkById.and.returnValue(undefined); + + await aiConfig.default.handler({ _: ["ai-config"], $0: "ig", framework: "unknown" }); + + expect(Util.error).toHaveBeenCalledWith("Framework not supported", "red"); + }); + }); }); }); diff --git a/spec/unit/ai-skills-spec.ts b/spec/unit/ai-skills-spec.ts index e2cfa8423..e2b428a64 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, copyAgentInstructionFiles, copyAISkillsToProject, FS_TOKEN, FsFileSystem, getInstructionFilePath, IFileSystem, ProjectConfig, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; +import { App, copyAgentInstructionFiles, copyAISkillsToProject, FS_TOKEN, FsFileSystem, getInstructionFilePath, IFileSystem, TEMPLATE_MANAGER, Util } from "@igniteui/cli-core"; function skillsDir(pkgName: string) { return `node_modules/${pkgName}/skills`; @@ -87,12 +87,8 @@ describe("Unit - copyAISkillsToProject", () => { ) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "angular" } - } as unknown as Config); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", mockSkillContent); }); @@ -123,12 +119,8 @@ describe("Unit - copyAISkillsToProject", () => { }) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "angular" } - } as unknown as Config); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); }); @@ -153,12 +145,8 @@ describe("Unit - copyAISkillsToProject", () => { readFile: jasmine.createSpy("destFs.readFile").and.returnValue("") // older content }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "angular" } - } as unknown as Config); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", newContent); expect(Util.log).toHaveBeenCalledWith(jasmine.stringContaining("Updated .claude/skills/angular.md")); @@ -186,12 +174,8 @@ describe("Unit - copyAISkillsToProject", () => { 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 = copyAISkillsToProject(["claude"]); + const result = copyAISkillsToProject(["claude"], "angular"); expect(destFs.writeFile).not.toHaveBeenCalled(); expect(result.found).toBe(1); @@ -224,12 +208,8 @@ describe("Unit - copyAISkillsToProject", () => { ) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "react" } - } as unknown as Config); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "react"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", content); }); @@ -258,94 +238,13 @@ describe("Unit - copyAISkillsToProject", () => { ) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "webcomponents" } - } as unknown as Config); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "webcomponents"); 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", () => { - const angularPkg = "igniteui-angular"; - const dir = skillsDir(angularPkg); - const file = skillFile(angularPkg, "angular.md"); - - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === dir ? [file] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("skill content") - }); - - 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); - - copyAISkillsToProject(["claude"]); - - expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", "skill content"); - }); - - it("should also scan igniteui-react in the fallback", () => { - const reactPkg = "igniteui-react"; - const dir = skillsDir(reactPkg); - const file = skillFile(reactPkg, "overview.md"); - - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === dir ? [file] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("react skill content") - }); - - 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); - - copyAISkillsToProject(["claude"]); - - expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/overview.md", "react skill content"); - }); - - it("should also scan igniteui-webcomponents in the fallback", () => { - const wcPkg = "igniteui-webcomponents"; - const dir = skillsDir(wcPkg); - const file = skillFile(wcPkg, "webcomponents.md"); - - spySrcFs({ - glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((d: string) => - d === dir ? [file] : [] - ), - readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue("wc skill content") - }); - - 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); - - copyAISkillsToProject(["claude"]); - - 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"; @@ -356,13 +255,9 @@ describe("Unit - copyAISkillsToProject", () => { 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); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - const result = copyAISkillsToProject(["claude"]); + const result = copyAISkillsToProject(["claude"], "angular"); expect(result.found).toBe(0); expect(destFs.writeFile).not.toHaveBeenCalled(); @@ -382,12 +277,8 @@ describe("Unit - copyAISkillsToProject", () => { ) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "angular" } - } as unknown as Config); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "angular"); expect(destFs.writeFile).not.toHaveBeenCalled(); }); @@ -413,12 +304,8 @@ describe("Unit - copyAISkillsToProject", () => { 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 = copyAISkillsToProject(["claude"]); + const result = copyAISkillsToProject(["claude"], "angular"); expect(result.found).toBe(1); expect(result.skipped).toBe(0); @@ -449,12 +336,8 @@ describe("Unit - copyAISkillsToProject", () => { 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 = copyAISkillsToProject(["claude"]); + const result = copyAISkillsToProject(["claude"], "angular"); expect(result.found).toBe(1); expect(result.skipped).toBe(0); @@ -487,12 +370,8 @@ describe("Unit - copyAISkillsToProject", () => { }) }); 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 = copyAISkillsToProject(["claude"]); + const result = copyAISkillsToProject(["claude"], "angular"); expect(result.found).toBe(2); expect(result.skipped).toBe(0); @@ -505,7 +384,7 @@ describe("Unit - copyAISkillsToProject", () => { const FAKE_TEMPLATE_PATH = "/fake/template"; const FAKE_SKILLS_ROOT = path.join(FAKE_TEMPLATE_PATH, "skills"); - it("should use angular template paths when framework is in config and no npm skills are found", () => { + it("should use template skill files when no npm package is installed", () => { const skillFilePath = path.join(FAKE_SKILLS_ROOT, "angular.md"); const content = "# Angular skills from template"; @@ -513,105 +392,19 @@ describe("Unit - copyAISkillsToProject", () => { glob: spyOn(FsFileSystem.prototype, "glob").and.callFake((dir: string) => dir === FAKE_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" - ) + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) }); 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); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "angular"); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("angular"); 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 skillFilePath = path.join(FAKE_SKILLS_ROOT, "react.md"); - const content = "# React skills from template"; - - 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("destFs.readFile").and.callFake((p: string) => { - if (p === "./package.json") return JSON.stringify({ dependencies: { "react": "^19.0.0" } }); - return ""; - }) - }); - App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - - copyAISkillsToProject(["claude"]); - - expect(mockTm.getFrameworkById).toHaveBeenCalledWith("react"); - 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 skillFilePath = path.join(FAKE_SKILLS_ROOT, "webcomponents.md"); - const content = "# WC skills from template"; - - 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("destFs.readFile").and.callFake((p: string) => { - if (p === "./package.json") return JSON.stringify({ dependencies: { "lit": "^3.0.0" } }); - return ""; - }) - }); - App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - - copyAISkillsToProject(["claude"]); - - expect(mockTm.getFrameworkById).toHaveBeenCalledWith("webcomponents"); - 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", () => { - spySrcFs({ - fileExists: spyOn(FsFileSystem.prototype, "fileExists").and.returnValue(false) - }); - - const destFs = makeDestFs(); - App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); - - const result = copyAISkillsToProject(["claude"]); - - expect(result.found).toBe(0); - expect(result.skipped).toBe(0); - expect(result.failed).toBe(0); - 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"; @@ -628,18 +421,14 @@ describe("Unit - copyAISkillsToProject", () => { 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([FAKE_TEMPLATE_PATH]); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "angular"); 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", () => { + it("should use the provided framework param when selecting template skill files", () => { const skillFilePath = path.join(FAKE_SKILLS_ROOT, "react.md"); const content = "# React skills from template"; @@ -647,26 +436,14 @@ describe("Unit - copyAISkillsToProject", () => { 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; - }), - 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; - }) + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) }); const destFs = makeDestFs(); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "react" } - } as unknown as Config); const mockTm = mockTemplateManager([FAKE_TEMPLATE_PATH]); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "react"); expect(mockTm.getFrameworkById).toHaveBeenCalledWith("react"); expect(mockTm.getFrameworkById).not.toHaveBeenCalledWith("angular"); @@ -684,21 +461,14 @@ describe("Unit - copyAISkillsToProject", () => { 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" - ) + readFile: spyOn(FsFileSystem.prototype, "readFile").and.returnValue(content) }); 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(["claude"]); + copyAISkillsToProject(["claude"], "angular"); // Source reads go to real FsFileSystem (srcFs) expect(srcSpies.glob).toHaveBeenCalledWith(SKILLS_ROOT, "**/*"); @@ -730,12 +500,8 @@ describe("Unit - copyAISkillsToProject", () => { ) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "angular" } - } as unknown as Config); - copyAISkillsToProject(["claude"]); + copyAISkillsToProject(["claude"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/grids/grid.md", content); }); @@ -760,12 +526,8 @@ describe("Unit - copyAISkillsToProject", () => { ) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "angular" } - } as unknown as Config); - copyAISkillsToProject(["cursor"]); + copyAISkillsToProject(["cursor"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/skills/angular.md", content); }); @@ -788,12 +550,8 @@ describe("Unit - copyAISkillsToProject", () => { ) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "angular" } - } as unknown as Config); - copyAISkillsToProject(["generic"]); + copyAISkillsToProject(["generic"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".agents/skills/angular.md", content); }); @@ -817,12 +575,8 @@ describe("Unit - copyAISkillsToProject", () => { ) }); App.container.set(FS_TOKEN, destFs); - spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); - spyOn(ProjectConfig, "getConfig").and.returnValue({ - project: { framework: "react" } - } as unknown as Config); - copyAISkillsToProject(["copilot"]); + copyAISkillsToProject(["copilot"], "react"); expect(destFs.writeFile).toHaveBeenCalledWith(".github/skills/overview.md", content); }); @@ -845,12 +599,8 @@ describe("Unit - copyAISkillsToProject", () => { ) }); 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 = copyAISkillsToProject(["claude", "cursor", "generic"]); + const result = copyAISkillsToProject(["claude", "cursor", "generic"], "angular"); expect(destFs.writeFile).toHaveBeenCalledWith(".claude/skills/angular.md", content); expect(destFs.writeFile).toHaveBeenCalledWith(".cursor/skills/angular.md", content); @@ -911,13 +661,9 @@ describe("Unit - copyAgentInstructionFiles", () => { 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([FAKE_FILES_DIR]); - copyAgentInstructionFiles(["claude", "cursor"]); + copyAgentInstructionFiles(["claude", "cursor"], "angular"); const cursorFrontmatter = "---\ncontext: true\npriority: high\nscope: project\n---\n"; expect(destFs.writeFile).toHaveBeenCalledWith(".claude/CLAUDE.md", agentsContent); @@ -943,13 +689,9 @@ describe("Unit - copyAgentInstructionFiles", () => { }) }); 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([FAKE_FILES_DIR]); - copyAgentInstructionFiles(["claude"]); + copyAgentInstructionFiles(["claude"], "angular"); expect(destFs.writeFile).not.toHaveBeenCalled(); }); @@ -961,13 +703,9 @@ describe("Unit - copyAgentInstructionFiles", () => { 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(["/fake/files"]); - copyAgentInstructionFiles(["claude", "generic"]); + copyAgentInstructionFiles(["claude", "generic"], "angular"); expect(destFs.writeFile).not.toHaveBeenCalled(); }); diff --git a/spec/unit/detect-framework-spec.ts b/spec/unit/detect-framework-spec.ts index 9e71cf9e1..56b376bcf 100644 --- a/spec/unit/detect-framework-spec.ts +++ b/spec/unit/detect-framework-spec.ts @@ -1,5 +1,5 @@ -import { App, IFileSystem } from "@igniteui/cli-core"; -import { detectFrameworkFromPackageJson } from "../../packages/core/util/detect-framework"; +import { App, IFileSystem, ProjectConfig } from "@igniteui/cli-core"; +import { detectFramework, detectFrameworkFromPackageJson } from "../../packages/core/util/detect-framework"; function makeFs(pkgJson?: object): IFileSystem { const present = pkgJson !== undefined; @@ -97,3 +97,47 @@ describe("Unit - detectFrameworkFromPackageJson", () => { }); }); }); + +describe("Unit - detectFramework", () => { + it("returns framework from ProjectConfig when local config is present", () => { + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "angular" } + } as any); + expect(detectFramework()).toBe("angular"); + }); + + it("falls back to package.json detection when local config is absent", () => { + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + spyOn(App.container, "get").and.returnValue( + makeFs({ dependencies: { "react": "^19.0.0" } }) + ); + expect(detectFramework()).toBe("react"); + }); + + it("prefers ProjectConfig framework over package.json when both are present", () => { + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(true); + spyOn(ProjectConfig, "getConfig").and.returnValue({ + project: { framework: "react" } + } as any); + // package.json has angular — should be ignored + spyOn(App.container, "get").and.returnValue( + makeFs({ dependencies: { "@angular/core": "^17.0.0" } }) + ); + expect(detectFramework()).toBe("react"); + }); + + it("falls back to package.json detection when ProjectConfig throws", () => { + spyOn(ProjectConfig, "hasLocalConfig").and.throwError("config read error"); + spyOn(App.container, "get").and.returnValue( + makeFs({ dependencies: { "@angular/core": "^17.0.0" } }) + ); + expect(detectFramework()).toBe("angular"); + }); + + it("returns null when neither ProjectConfig nor package.json provides a framework", () => { + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + spyOn(App.container, "get").and.returnValue(makeFs()); // no package.json present + expect(detectFramework()).toBeNull(); + }); +}); diff --git a/spec/unit/new-spec.ts b/spec/unit/new-spec.ts index 5c7e62c33..22c3eb037 100644 --- a/spec/unit/new-spec.ts +++ b/spec/unit/new-spec.ts @@ -414,7 +414,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", agents: ["claude", "cursor"], _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith(["claude", "cursor"], undefined); + expect(configureSpy).toHaveBeenCalledWith("jq", ["claude", "cursor"], undefined); }); it("calls configure with undefined when --agents is not provided", async () => { @@ -422,7 +422,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith(undefined, undefined); + expect(configureSpy).toHaveBeenCalledWith("jq", undefined, undefined); }); it("calls configure with single agent", async () => { @@ -430,7 +430,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", agents: ["generic"], _: ["new"], $0: "new" }); - expect(configureSpy).toHaveBeenCalledWith(["generic"], undefined); + expect(configureSpy).toHaveBeenCalledWith("jq", ["generic"], undefined); }); it("calls configure before package install", async () => { @@ -475,7 +475,7 @@ describe("Unit - New command", () => { await newCmd.handler({ name: "Test", framework: "jq", skipInstall: true, agents: ["claude"], _: ["new"], $0: "new" }); expect(PackageManager.installPackages).not.toHaveBeenCalled(); - expect(configureSpy).toHaveBeenCalledWith(["claude"], undefined); + expect(configureSpy).toHaveBeenCalledWith("jq", ["claude"], undefined); }); it("does not call configure when project creation fails (bad name)", async () => { From 475a92d9bb6e0ebe57078cae70a860b7242ca4cf Mon Sep 17 00:00:00 2001 From: damyanpetev Date: Fri, 15 May 2026 15:46:16 +0300 Subject: [PATCH 2/5] refactor(ai-config): include blazor as framework param --- packages/cli/lib/commands/ai-config.ts | 4 ++-- spec/acceptance/help-spec.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/cli/lib/commands/ai-config.ts b/packages/cli/lib/commands/ai-config.ts index 6ecb1296c..f16f65087 100644 --- a/packages/cli/lib/commands/ai-config.ts +++ b/packages/cli/lib/commands/ai-config.ts @@ -137,7 +137,7 @@ async function promptForFrameworkId(): Promise { const frameRes: string = await InquirerWrapper.select({ name: "framework", message: "Choose framework:", - choices: tm.getFrameworkNames(), + choices: tm.getFrameworkNames(true), default: "Angular" }); return tm.getFrameworkByName(frameRes).id; @@ -161,7 +161,7 @@ const command: CommandModule = { .option("framework", { alias: "f", describe: "Manually set project framework to configure AI for.", - choices: getTemplateManager()?.getFrameworkIds(), + choices: getTemplateManager()?.getFrameworkIds(true), type: "string" }), async handler(argv: ArgumentsCamelCase) { diff --git a/spec/acceptance/help-spec.ts b/spec/acceptance/help-spec.ts index b0551b64d..c0a546294 100644 --- a/spec/acceptance/help-spec.ts +++ b/spec/acceptance/help-spec.ts @@ -249,4 +249,26 @@ describe("Help command", () => { expect(actualNewText).toContain(replacedNewHelpText); }); + + it("should show help for the ai-config command", async () => { + const child = Util.spawnSync("node", [execLocation, "ai-config", "-h"], { + encoding: "utf-8" + }); + + const originalHelpText: string = ` + Options: + -v, --version Show current Ignite UI CLI version [boolean] + -h, --help Show help [boolean] + --agents AI agents/tools to generate configuration files for [array] + [choices: "generic", "claude", "copilot", "cursor", "codex", "windsurf", "gemini", "junie", "none"] + --assistants Coding assistant(s) to configure MCP servers for [array] + [choices: "generic", "vscode", "cursor", "gemini", "junie", "none"] + -f, --framework Manually set project framework to configure AI for. [string] + [choices: "angular", "blazor", "jquery", "react", "webcomponents"]`; + + const replacedHelpText: string = originalHelpText.replace(/\s/g, ""); + const actualNewText: string = (child.stdout.toString()).replace(/\s/g, ""); + + expect(actualNewText).toContain(replacedHelpText); + }); }); From a67798e1583ea955c6b4b957d44323bdb7520018 Mon Sep 17 00:00:00 2001 From: damyanpetev Date: Fri, 15 May 2026 15:50:20 +0300 Subject: [PATCH 3/5] refactor: integrate blazor into detectFramework --- packages/core/util/detect-framework.ts | 3 ++- spec/unit/detect-framework-spec.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/util/detect-framework.ts b/packages/core/util/detect-framework.ts index a24932e17..8583a3598 100644 --- a/packages/core/util/detect-framework.ts +++ b/packages/core/util/detect-framework.ts @@ -2,7 +2,7 @@ import { App } from "./App"; import { IFileSystem, FS_TOKEN } from "../types/FileSystem"; import { ProjectConfig } from "./ProjectConfig"; -type Framework = "angular" | "react" | "webcomponents"; +type Framework = "angular" | "react" | "webcomponents" | "blazor"; /** * Attempt to detect project framework by first checking for local cli-config, @@ -19,6 +19,7 @@ export function detectFramework(): Framework | null { } catch { /* fall through */ } framework ??= detectFrameworkFromPackageJson(); + framework ??= detectBlazorFromCsproj() ? "blazor" : null; return framework; } diff --git a/spec/unit/detect-framework-spec.ts b/spec/unit/detect-framework-spec.ts index 15503c4e5..462b6c2af 100644 --- a/spec/unit/detect-framework-spec.ts +++ b/spec/unit/detect-framework-spec.ts @@ -140,6 +140,14 @@ describe("Unit - detectFramework", () => { spyOn(App.container, "get").and.returnValue(makeFs()); // no package.json present expect(detectFramework()).toBeNull(); }); + + it("returns blazor when no config and no package.json but a .csproj file exists", () => { + spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); + const fs = makeFs(); // no package.json + (fs.glob as jasmine.Spy).and.returnValue(["MyApp.csproj"]); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectFramework()).toBe("blazor"); + }); }); describe("Unit - detectBlazorFromCsproj", () => { From 9927400b4626cdfef931f6e4f1a5bcca0d1e436c Mon Sep 17 00:00:00 2001 From: damyanpetev Date: Fri, 15 May 2026 17:06:05 +0300 Subject: [PATCH 4/5] test: spy object typing --- spec/unit/detect-framework-spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/unit/detect-framework-spec.ts b/spec/unit/detect-framework-spec.ts index 462b6c2af..eb6128a07 100644 --- a/spec/unit/detect-framework-spec.ts +++ b/spec/unit/detect-framework-spec.ts @@ -1,7 +1,7 @@ import { App, IFileSystem, ProjectConfig } from "@igniteui/cli-core"; import { detectBlazorFromCsproj, detectFramework, detectFrameworkFromPackageJson } from "../../packages/core/util/detect-framework"; -function makeFs(pkgJson?: object): IFileSystem { +function makeFs(pkgJson?: object): jasmine.SpyObj { const present = pkgJson !== undefined; return { fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => @@ -11,7 +11,7 @@ function makeFs(pkgJson?: object): IFileSystem { writeFile: jasmine.createSpy("writeFile"), directoryExists: jasmine.createSpy("directoryExists").and.returnValue(false), glob: jasmine.createSpy("glob").and.returnValue([]), - } as unknown as IFileSystem; + } satisfies jasmine.SpyObj; } describe("Unit - detectFrameworkFromPackageJson", () => { @@ -22,8 +22,8 @@ describe("Unit - detectFrameworkFromPackageJson", () => { it("returns null when package.json is malformed JSON", () => { const fs = makeFs({}); - (fs.fileExists as jasmine.Spy).and.returnValue(true); - (fs.readFile as jasmine.Spy).and.returnValue("not-valid-json{{{"); + fs.fileExists.and.returnValue(true); + fs.readFile.and.returnValue("not-valid-json{{{"); spyOn(App.container, "get").and.returnValue(fs); expect(detectFrameworkFromPackageJson()).toBeNull(); }); From 62b3833f0b7434e12a81e3d19e015c564f1b9f59 Mon Sep 17 00:00:00 2001 From: damyanpetev Date: Fri, 15 May 2026 18:08:25 +0300 Subject: [PATCH 5/5] refactor: Blazor framework detection --- packages/core/util/detect-framework.ts | 72 +++++++++++- spec/unit/detect-framework-spec.ts | 153 +++++++++++++++++++++++-- 2 files changed, 212 insertions(+), 13 deletions(-) diff --git a/packages/core/util/detect-framework.ts b/packages/core/util/detect-framework.ts index 8583a3598..4a568818f 100644 --- a/packages/core/util/detect-framework.ts +++ b/packages/core/util/detect-framework.ts @@ -64,11 +64,77 @@ export function detectFrameworkFromPackageJson(): Framework | null { return "webcomponents"; } +//#region Blazor detection + +/** Strings present in a `.csproj` that identify a Blazor project. */ +const BLAZOR_SIGNALS = [ + // project SDKs: + "Microsoft.NET.Sdk.Web", + "Microsoft.NET.Sdk.BlazorWebAssembly", + "Microsoft.NET.Sdk.Razor", + // package references + "Microsoft.AspNetCore.Components", + "IgniteUI.Blazor", +]; + +/** Returns true if the `.csproj` at `csprojPath` is a Blazor project.*/ +function isBlazorProject(fs: IFileSystem, csprojPath: string): boolean { + try { + const content = fs.readFile(csprojPath); + return BLAZOR_SIGNALS.some(s => content.includes(s)); + } catch { + return false; + } +} + /** - * Returns true if the current directory (or any subdirectory) contains - * a `.csproj` file, indicating a Blazor / .NET project. + * Extracts `.csproj` paths from a solution file (`.sln` or `.slnx`). + * @returns array of paths (backslashes normalised to forward slashes), if any. + */ +function parseCsprojPathsFromSolution(content: string): string[] { + const paths: string[] = []; + + // .sln text format: Project("{...}") = "Name", "path.csproj", "{...}" + // .slnx XML format: + // Matches any quoted .csproj path — covers both formats: + const re = /"([^"]*\.csproj)"/gi; + let match: RegExpExecArray | null; + + while ((match = re.exec(content)) !== null) { + paths.push(match[1].replace(/\\/g, "/")); + } + + return paths; +} + +/** + * Attempt to detect whether the current directory contains a Blazor project. + * + * Strategy: + * 1. Look for `.csproj` files in the working directory. + * 2. If none found, look for solution files (`.sln` / `.slnx`) and + * extract referenced `.csproj` paths. + * 3. Verify at least one `.csproj` is a Blazor project via {@link isBlazorProject}. */ export function detectBlazorFromCsproj(): boolean { const fs = App.container.get(FS_TOKEN); - return fs.glob(".", "**/*.csproj").length > 0; + + const csprojFiles = fs.glob(".", "*.csproj"); + + if (csprojFiles.length === 0) { + const slnFiles = [ + ...fs.glob(".", "*.sln"), + ...fs.glob(".", "*.slnx"), + ]; + for (const sln of slnFiles) { + try { + const content = fs.readFile(sln); + const projects = parseCsprojPathsFromSolution(content); + csprojFiles.push(...projects.filter(p => fs.fileExists(p))); + } catch { /* skip unreadable solution files */ } + } + } + + return csprojFiles.some(csproj => isBlazorProject(fs, csproj)); } +//#endregion Blazor detection diff --git a/spec/unit/detect-framework-spec.ts b/spec/unit/detect-framework-spec.ts index eb6128a07..8c15bb3ac 100644 --- a/spec/unit/detect-framework-spec.ts +++ b/spec/unit/detect-framework-spec.ts @@ -1,5 +1,9 @@ import { App, IFileSystem, ProjectConfig } from "@igniteui/cli-core"; -import { detectBlazorFromCsproj, detectFramework, detectFrameworkFromPackageJson } from "../../packages/core/util/detect-framework"; +import { + detectBlazorFromCsproj, + detectFramework, + detectFrameworkFromPackageJson, +} from "../../packages/core/util/detect-framework"; function makeFs(pkgJson?: object): jasmine.SpyObj { const present = pkgJson !== undefined; @@ -141,27 +145,156 @@ describe("Unit - detectFramework", () => { expect(detectFramework()).toBeNull(); }); - it("returns blazor when no config and no package.json but a .csproj file exists", () => { + it("returns blazor when no config and no package.json but a Blazor .csproj file exists", () => { spyOn(ProjectConfig, "hasLocalConfig").and.returnValue(false); const fs = makeFs(); // no package.json - (fs.glob as jasmine.Spy).and.returnValue(["MyApp.csproj"]); + fs.glob.and.returnValue(["MyApp.csproj"]); + fs.readFile.and.callFake((p: string) => { + if (p === "MyApp.csproj") return ``; + return "{}"; + }); spyOn(App.container, "get").and.returnValue(fs); expect(detectFramework()).toBe("blazor"); }); }); describe("Unit - detectBlazorFromCsproj", () => { - it("returns true when a .csproj file exists", () => { - const fs = makeFs(); - (fs.glob as jasmine.Spy).and.returnValue(["MyApp.csproj"]); + const makeCsProj = (sdk: string, ...refs: string[]) => + ` + + net10.0 + + ${refs.map((dep) => + ` + + `, + )} + `; + + function makeDotnetFs( + files: Record, + globResults: Record = {} + ): jasmine.SpyObj { + return { + fileExists: jasmine.createSpy("fileExists").and.callFake((p: string) => p in files), + readFile: jasmine.createSpy("readFile").and.callFake((p: string) => { + if (p in files) return files[p]; + throw new Error(`File not found: ${p}`); + }), + writeFile: jasmine.createSpy("writeFile"), + directoryExists: jasmine.createSpy("directoryExists").and.returnValue(false), + glob: jasmine.createSpy("glob").and.callFake( + (dir: string, pattern: string) => globResults[`${dir}|${pattern}`] ?? [] + ), + } satisfies jasmine.SpyObj; + } + + it("returns true for a BlazorWebAssembly SDK project in CWD", () => { + const fs = makeDotnetFs( + { "MyApp.csproj": makeCsProj("Microsoft.NET.Sdk.BlazorWebAssembly") }, + { ".|*.csproj": ["MyApp.csproj"] } + ); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(true); + }); + + it("returns true for a Web SDK project in CWD", () => { + const fs = makeDotnetFs( + { "MyApp.csproj": makeCsProj("Microsoft.NET.Sdk.Web") }, + { ".|*.csproj": ["MyApp.csproj"] } + ); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(true); + }); + + it("returns true for a Razor SDK project in CWD", () => { + const fs = makeDotnetFs( + { "MyApp.csproj": makeCsProj("Microsoft.NET.Sdk.Razor") }, + { ".|*.csproj": ["MyApp.csproj"] } + ); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(true); + }); + + it("returns true for a .NET SDK project with Components package reference", () => { + const fs = makeDotnetFs( + { "MyApp.csproj": makeCsProj( + "Microsoft.NET.Sdk", + "Microsoft.AspNetCore.Components.WebAssembly") + }, + { ".|*.csproj": ["MyApp.csproj"] } + ); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(true); + }); + + it("returns true for a .NET SDK project with IgniteUI.Blazor package reference", () => { + const fs = makeDotnetFs( + { "MyApp.csproj": makeCsProj( + "Microsoft.NET.Sdk", + "IgniteUI.Blazor.GridLite") + }, + { ".|*.csproj": ["MyApp.csproj"] } + ); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(true); + }); + + it("returns false for a .NET SDK project", () => { + const fs = makeDotnetFs( + { "MyApp.csproj": makeCsProj("Microsoft.NET.Sdk") }, + { ".|*.csproj": ["MyApp.csproj"] } + ); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(false); + }); + + it("returns false when no .csproj or solution files exist", () => { + const fs = makeDotnetFs({}, {}); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(false); + }); + + it("finds Blazor project through .sln solution file", () => { + const slnContent = + `Project("{FAE04EC0-301F-11D3-BF4B-00805F9B2053}") = "BlazorApp", "src\\BlazorApp\\BlazorApp.csproj", "{00000000-0000-0000-0000-000000000001}"\nEndProject`; + const fs = makeDotnetFs( + { "MySolution.sln": slnContent, "src/BlazorApp/BlazorApp.csproj": makeCsProj("Microsoft.NET.Sdk.Web") }, + { ".|*.csproj": [], ".|*.sln": ["MySolution.sln"], ".|*.slnx": [] } + ); spyOn(App.container, "get").and.returnValue(fs); expect(detectBlazorFromCsproj()).toBe(true); }); - it("returns false when no .csproj files exist", () => { - const fs = makeFs(); - (fs.glob as jasmine.Spy).and.returnValue([]); + it("finds Blazor project through .slnx solution file", () => { + const slnxContent = `\n \n`; + const fs = makeDotnetFs( + { "MySolution.slnx": slnxContent, "src/BlazorApp/BlazorApp.csproj": makeCsProj("Microsoft.NET.Sdk.Web") }, + { ".|*.csproj": [], ".|*.sln": [], ".|*.slnx": ["MySolution.slnx"] } + ); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(true); + }); + + it("returns false when solution references non-Blazor project", () => { + const slnContent = + `Project("{FAE04EC0}") = "ConsoleApp", "src\\ConsoleApp\\ConsoleApp.csproj", "{00000001}"\nEndProject`; + const fs = makeDotnetFs( + { "MySolution.sln": slnContent, "src/ConsoleApp/ConsoleApp.csproj": makeCsProj("Microsoft.NET.Sdk") }, + { ".|*.csproj": [], ".|*.sln": ["MySolution.sln"], ".|*.slnx": [] } + ); spyOn(App.container, "get").and.returnValue(fs); expect(detectBlazorFromCsproj()).toBe(false); }); -}); \ No newline at end of file + + it("skips solution-referenced projects that do not exist on disk", () => { + const slnContent = + `Project("{FAE04EC0}") = "MissingApp", "missing\\App.csproj", "{00000002}"\nEndProject`; + const fs = makeDotnetFs( + { "MySolution.sln": slnContent }, + { ".|*.csproj": [], ".|*.sln": ["MySolution.sln"], ".|*.slnx": [] } + ); + spyOn(App.container, "get").and.returnValue(fs); + expect(detectBlazorFromCsproj()).toBe(false); + }); +});