diff --git a/src/core/init.ts b/src/core/init.ts index aa38408f2..a2b380bf2 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -39,10 +39,13 @@ import { getSkillTemplates, getCommandContents, generateSkillContent, + removeAllSkillDirs, + removeAllCommandFiles, + printOnboardingFooter, type ToolSkillStatus, } from './shared/index.js'; import { getGlobalConfig, type Delivery, type Profile } from './global-config.js'; -import { getProfileWorkflows, CORE_WORKFLOWS, ALL_WORKFLOWS } from './profiles.js'; +import { getProfileWorkflows } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { migrateIfNeeded } from './migration.js'; @@ -60,20 +63,6 @@ const PROGRESS_SPINNER = { frames: ['░░░', '▒░░', '▒▒░', '▒▒▒', '▓▒▒', '▓▓▒', '▓▓▓', '▒▓▓', '░▒▓'], }; -const WORKFLOW_TO_SKILL_DIR: Record = { - 'explore': 'openspec-explore', - 'new': 'openspec-new-change', - 'continue': 'openspec-continue-change', - 'apply': 'openspec-apply-change', - 'ff': 'openspec-ff-change', - 'sync': 'openspec-sync-specs', - 'archive': 'openspec-archive-change', - 'bulk-archive': 'openspec-bulk-archive-change', - 'verify': 'openspec-verify-change', - 'onboard': 'openspec-onboard', - 'propose': 'openspec-propose', -}; - // ----------------------------------------------------------------------------- // Types // ----------------------------------------------------------------------------- @@ -547,7 +536,7 @@ export class InitCommand { } if (!shouldGenerateSkills) { const skillsDir = path.join(projectPath, tool.skillsDir, 'skills'); - removedSkillCount += await this.removeSkillDirs(skillsDir); + removedSkillCount += await removeAllSkillDirs(skillsDir); } // Generate commands if delivery includes commands @@ -565,7 +554,7 @@ export class InitCommand { } } if (!shouldGenerateCommands) { - removedCommandCount += await this.removeCommandFiles(projectPath, tool.value); + removedCommandCount += await removeAllCommandFiles(projectPath, tool.value); } spinner.succeed(`Setup complete for ${tool.name}`); @@ -696,33 +685,14 @@ export class InitCommand { console.log(chalk.dim(`Config: skipped (non-interactive mode)`)); } - // Getting started (task 7.6: show propose if in profile) + // Onboarding footer (getting started + links + IDE restart) const globalCfg = getGlobalConfig(); const activeProfile: Profile = (this.profileOverride as Profile) ?? globalCfg.profile ?? 'core'; - const activeWorkflows = [...getProfileWorkflows(activeProfile, globalCfg.workflows)]; - console.log(); - if (activeWorkflows.includes('propose')) { - console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:propose "your idea"'); - } else if (activeWorkflows.includes('new')) { - console.log(chalk.bold('Getting started:')); - console.log(' Start your first change: /opsx:new "your idea"'); - } else { - console.log("Done. Run 'openspec config profile' to configure your workflows."); - } - - // Links - console.log(); - console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); - console.log(`Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`); - - // Restart instruction if any tools were configured - if (results.createdTools.length > 0 || results.refreshedTools.length > 0) { - console.log(); - console.log(chalk.white('Restart your IDE for slash commands to take effect.')); - } - - console.log(); + printOnboardingFooter({ + profile: activeProfile, + customWorkflows: globalCfg.workflows, + hasConfiguredTools: results.createdTools.length > 0 || results.refreshedTools.length > 0, + }); } private startSpinner(text: string) { @@ -734,46 +704,4 @@ export class InitCommand { }).start(); } - private async removeSkillDirs(skillsDir: string): Promise { - let removed = 0; - - for (const workflow of ALL_WORKFLOWS) { - const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; - if (!dirName) continue; - - const skillDir = path.join(skillsDir, dirName); - try { - if (fs.existsSync(skillDir)) { - await fs.promises.rm(skillDir, { recursive: true, force: true }); - removed++; - } - } catch { - // Ignore errors - } - } - - return removed; - } - - private async removeCommandFiles(projectPath: string, toolId: string): Promise { - let removed = 0; - const adapter = CommandAdapterRegistry.get(toolId); - if (!adapter) return 0; - - for (const workflow of ALL_WORKFLOWS) { - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - - try { - if (fs.existsSync(fullPath)) { - await fs.promises.unlink(fullPath); - removed++; - } - } catch { - // Ignore errors - } - } - - return removed; - } } diff --git a/src/core/shared/artifact-removal.ts b/src/core/shared/artifact-removal.ts new file mode 100644 index 000000000..6dff87de0 --- /dev/null +++ b/src/core/shared/artifact-removal.ts @@ -0,0 +1,138 @@ +/** + * Artifact Removal Helpers + * + * Shared functions for removing skill directories and command files. + * Used by both init and update commands when delivery mode changes or + * workflows are deselected. + */ + +import path from 'path'; +import * as fs from 'fs'; +import { ALL_WORKFLOWS } from '../profiles.js'; +import { WORKFLOW_TO_SKILL_DIR } from '../profile-sync-drift.js'; +import { CommandAdapterRegistry } from '../command-generation/index.js'; + +// --------------------------------------------------------------------------- +// Skill directory removal +// --------------------------------------------------------------------------- + +/** + * Removes ALL workflow skill directories under the given skillsDir. + * Used when delivery changes to commands-only. + */ +export async function removeAllSkillDirs(skillsDir: string): Promise { + let removed = 0; + + for (const workflow of ALL_WORKFLOWS) { + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + if (!dirName) continue; + + const skillDir = path.join(skillsDir, dirName); + try { + if (fs.existsSync(skillDir)) { + await fs.promises.rm(skillDir, { recursive: true, force: true }); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; +} + +/** + * Removes skill directories for workflows that are NOT in the desired set. + * Used during profile-sync to clean up deselected workflows. + */ +export async function removeUnselectedSkillDirs( + skillsDir: string, + desiredWorkflows: readonly string[], +): Promise { + const desiredSet = new Set(desiredWorkflows); + let removed = 0; + + for (const workflow of ALL_WORKFLOWS) { + if (desiredSet.has(workflow)) continue; + const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; + if (!dirName) continue; + + const skillDir = path.join(skillsDir, dirName); + try { + if (fs.existsSync(skillDir)) { + await fs.promises.rm(skillDir, { recursive: true, force: true }); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; +} + +// --------------------------------------------------------------------------- +// Command file removal +// --------------------------------------------------------------------------- + +/** + * Removes ALL workflow command files for a given tool. + * Used when delivery changes to skills-only. + */ +export async function removeAllCommandFiles( + projectPath: string, + toolId: string, +): Promise { + let removed = 0; + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return 0; + + for (const workflow of ALL_WORKFLOWS) { + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; +} + +/** + * Removes command files for workflows that are NOT in the desired set. + * Used during profile-sync to clean up deselected workflows. + */ +export async function removeUnselectedCommandFiles( + projectPath: string, + toolId: string, + desiredWorkflows: readonly string[], +): Promise { + let removed = 0; + const adapter = CommandAdapterRegistry.get(toolId); + if (!adapter) return 0; + + const desiredSet = new Set(desiredWorkflows); + + for (const workflow of ALL_WORKFLOWS) { + if (desiredSet.has(workflow)) continue; + const cmdPath = adapter.getFilePath(workflow); + const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); + + try { + if (fs.existsSync(fullPath)) { + await fs.promises.unlink(fullPath); + removed++; + } + } catch { + // Ignore errors + } + } + + return removed; +} diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index 32b965696..4255e5e4a 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -28,3 +28,18 @@ export { getCommandContents, generateSkillContent, } from './skill-generation.js'; + +export { + removeAllSkillDirs, + removeUnselectedSkillDirs, + removeAllCommandFiles, + removeUnselectedCommandFiles, +} from './artifact-removal.js'; + +export { + type OnboardingContext, + formatGettingStarted, + formatLinks, + formatIdeRestart, + printOnboardingFooter, +} from './onboarding.js'; diff --git a/src/core/shared/onboarding.ts b/src/core/shared/onboarding.ts new file mode 100644 index 000000000..b0cfe859a --- /dev/null +++ b/src/core/shared/onboarding.ts @@ -0,0 +1,101 @@ +/** + * Onboarding Helpers + * + * Shared output formatting for "getting started" and IDE-restart messages + * used by both init and update commands. + */ + +import chalk from 'chalk'; +import type { Profile } from '../global-config.js'; +import { getProfileWorkflows } from '../profiles.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface OnboardingContext { + /** Effective profile ('core' | 'custom'). */ + profile: Profile; + /** Custom workflows list (only relevant when profile === 'custom'). */ + customWorkflows?: string[]; + /** Whether any tools were created or refreshed in this run. */ + hasConfiguredTools: boolean; +} + +// --------------------------------------------------------------------------- +// Getting-started message +// --------------------------------------------------------------------------- + +/** + * Returns the "Getting started" lines appropriate for the active profile. + * + * Both init and update previously had divergent implementations: + * - init showed `/opsx:propose` or `/opsx:new` based on profile + * - update showed a fixed `/opsx:new`, `/opsx:continue`, `/opsx:apply` set + * + * This function unifies the logic: show the primary entry-point command for the + * active profile, keeping the output concise and consistent. + */ +export function formatGettingStarted(ctx: OnboardingContext): string[] { + const activeWorkflows = [ + ...getProfileWorkflows(ctx.profile, ctx.customWorkflows), + ]; + + const lines: string[] = []; + + if (activeWorkflows.includes('propose')) { + lines.push(chalk.bold('Getting started:')); + lines.push(' Start your first change: /opsx:propose "your idea"'); + } else if (activeWorkflows.includes('new')) { + lines.push(chalk.bold('Getting started:')); + lines.push(' Start your first change: /opsx:new "your idea"'); + } else { + lines.push( + "Done. Run 'openspec config profile' to configure your workflows.", + ); + } + + return lines; +} + +// --------------------------------------------------------------------------- +// Links +// --------------------------------------------------------------------------- + +export function formatLinks(): string[] { + return [ + `Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`, + `Feedback: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec/issues')}`, + ]; +} + +// --------------------------------------------------------------------------- +// IDE-restart hint +// --------------------------------------------------------------------------- + +export function formatIdeRestart(): string { + return chalk.dim('Restart your IDE for changes to take effect.'); +} + +/** + * Convenience: print the full onboarding footer (getting-started + links + IDE restart). + */ +export function printOnboardingFooter(ctx: OnboardingContext): void { + const started = formatGettingStarted(ctx); + console.log(); + for (const line of started) { + console.log(line); + } + + console.log(); + for (const line of formatLinks()) { + console.log(line); + } + + if (ctx.hasConfiguredTools) { + console.log(); + console.log(formatIdeRestart()); + } + + console.log(); +} diff --git a/src/core/update.ts b/src/core/update.ts index de922a5ff..986db6e57 100644 --- a/src/core/update.ts +++ b/src/core/update.ts @@ -23,6 +23,12 @@ import { getCommandContents, generateSkillContent, getToolsWithSkillsDir, + removeAllSkillDirs, + removeUnselectedSkillDirs, + removeAllCommandFiles, + removeUnselectedCommandFiles, + printOnboardingFooter, + formatIdeRestart, type ToolVersionStatus, } from './shared/index.js'; import { @@ -38,7 +44,6 @@ import { getGlobalConfig, type Delivery } from './global-config.js'; import { getProfileWorkflows, ALL_WORKFLOWS } from './profiles.js'; import { getAvailableTools } from './available-tools.js'; import { - WORKFLOW_TO_SKILL_DIR, getCommandConfiguredTools, getConfiguredToolsForProfileSync, getToolsNeedingProfileSync, @@ -200,12 +205,12 @@ export class UpdateCommand { await FileSystemUtils.writeFile(skillFile, skillContent); } - removedDeselectedSkillCount += await this.removeUnselectedSkillDirs(skillsDir, desiredWorkflows); + removedDeselectedSkillCount += await removeUnselectedSkillDirs(skillsDir, desiredWorkflows); } // Delete skill directories if delivery is commands-only if (!shouldGenerateSkills) { - removedSkillCount += await this.removeSkillDirs(skillsDir); + removedSkillCount += await removeAllSkillDirs(skillsDir); } // Generate commands if delivery includes commands @@ -219,7 +224,7 @@ export class UpdateCommand { await FileSystemUtils.writeFile(commandFile, cmd.fileContent); } - removedDeselectedCommandCount += await this.removeUnselectedCommandFiles( + removedDeselectedCommandCount += await removeUnselectedCommandFiles( resolvedProjectPath, toolId, desiredWorkflows @@ -229,7 +234,7 @@ export class UpdateCommand { // Delete command files if delivery is skills-only if (!shouldGenerateCommands) { - removedCommandCount += await this.removeCommandFiles(resolvedProjectPath, toolId); + removedCommandCount += await removeAllCommandFiles(resolvedProjectPath, toolId); } spinner.succeed(`Updated ${tool.name}`); @@ -266,13 +271,11 @@ export class UpdateCommand { // 12. Show onboarding message for newly configured tools from legacy upgrade if (newlyConfiguredTools.length > 0) { - console.log(); - console.log(chalk.bold('Getting started:')); - console.log(' /opsx:new Start a new change'); - console.log(' /opsx:continue Create the next artifact'); - console.log(' /opsx:apply Implement tasks'); - console.log(); - console.log(`Learn more: ${chalk.cyan('https://github.com/Fission-AI/OpenSpec')}`); + printOnboardingFooter({ + profile, + customWorkflows: globalConfig.workflows, + hasConfiguredTools: false, // IDE restart is shown below for all cases + }); } const configuredAndNewTools = [...new Set([...configuredTools, ...newlyConfiguredTools])]; @@ -290,7 +293,7 @@ export class UpdateCommand { } console.log(); - console.log(chalk.dim('Restart your IDE for changes to take effect.')); + console.log(formatIdeRestart()); } /** @@ -369,125 +372,6 @@ export class UpdateCommand { } } - /** - * Removes skill directories for workflows when delivery changed to commands-only. - * Returns the number of directories removed. - */ - private async removeSkillDirs(skillsDir: string): Promise { - let removed = 0; - - for (const workflow of ALL_WORKFLOWS) { - const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; - if (!dirName) continue; - - const skillDir = path.join(skillsDir, dirName); - try { - if (fs.existsSync(skillDir)) { - await fs.promises.rm(skillDir, { recursive: true, force: true }); - removed++; - } - } catch { - // Ignore errors - } - } - - return removed; - } - - /** - * Removes skill directories for workflows that are no longer selected in the active profile. - * Returns the number of directories removed. - */ - private async removeUnselectedSkillDirs( - skillsDir: string, - desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][] - ): Promise { - const desiredSet = new Set(desiredWorkflows); - let removed = 0; - - for (const workflow of ALL_WORKFLOWS) { - if (desiredSet.has(workflow)) continue; - const dirName = WORKFLOW_TO_SKILL_DIR[workflow]; - if (!dirName) continue; - - const skillDir = path.join(skillsDir, dirName); - try { - if (fs.existsSync(skillDir)) { - await fs.promises.rm(skillDir, { recursive: true, force: true }); - removed++; - } - } catch { - // Ignore errors - } - } - - return removed; - } - - /** - * Removes command files for workflows when delivery changed to skills-only. - * Returns the number of files removed. - */ - private async removeCommandFiles( - projectPath: string, - toolId: string, - ): Promise { - let removed = 0; - - const adapter = CommandAdapterRegistry.get(toolId); - if (!adapter) return 0; - - for (const workflow of ALL_WORKFLOWS) { - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - - try { - if (fs.existsSync(fullPath)) { - await fs.promises.unlink(fullPath); - removed++; - } - } catch { - // Ignore errors - } - } - - return removed; - } - - /** - * Removes command files for workflows that are no longer selected in the active profile. - * Returns the number of files removed. - */ - private async removeUnselectedCommandFiles( - projectPath: string, - toolId: string, - desiredWorkflows: readonly (typeof ALL_WORKFLOWS)[number][] - ): Promise { - let removed = 0; - - const adapter = CommandAdapterRegistry.get(toolId); - if (!adapter) return 0; - - const desiredSet = new Set(desiredWorkflows); - - for (const workflow of ALL_WORKFLOWS) { - if (desiredSet.has(workflow)) continue; - const cmdPath = adapter.getFilePath(workflow); - const fullPath = path.isAbsolute(cmdPath) ? cmdPath : path.join(projectPath, cmdPath); - - try { - if (fs.existsSync(fullPath)) { - await fs.promises.unlink(fullPath); - removed++; - } - } catch { - // Ignore errors - } - } - - return removed; - } - /** * Detect and handle legacy OpenSpec artifacts. * Unlike init, update warns but continues if legacy files found in non-interactive mode. diff --git a/test/core/output-consistency.test.ts b/test/core/output-consistency.test.ts new file mode 100644 index 000000000..58ca8b169 --- /dev/null +++ b/test/core/output-consistency.test.ts @@ -0,0 +1,167 @@ +/** + * Output Consistency Tests + * + * Verifies that init, update, and migration produce consistent + * onboarding output using the shared helpers. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { promises as fs } from 'fs'; +import path from 'path'; +import os from 'os'; +import { InitCommand } from '../../src/core/init.js'; +import { UpdateCommand } from '../../src/core/update.js'; +import { saveGlobalConfig, getGlobalConfig } from '../../src/core/global-config.js'; + +const { confirmMock, showWelcomeScreenMock, searchableMultiSelectMock } = vi.hoisted(() => ({ + confirmMock: vi.fn(), + showWelcomeScreenMock: vi.fn().mockResolvedValue(undefined), + searchableMultiSelectMock: vi.fn(), +})); + +vi.mock('@inquirer/prompts', () => ({ + confirm: confirmMock, +})); + +vi.mock('../../src/ui/welcome-screen.js', () => ({ + showWelcomeScreen: showWelcomeScreenMock, +})); + +vi.mock('../../src/prompts/searchable-multi-select.js', () => ({ + searchableMultiSelect: searchableMultiSelectMock, +})); + +// Strip ANSI escape codes for content assertions +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\u001B\[[\d;]*m/g, ''); +} + +function collectOutput(spy: ReturnType): string { + return spy.mock.calls + .map((call) => call.map(String).join(' ')) + .join('\n'); +} + +describe('init and update output consistency', () => { + let testDir: string; + let configTempDir: string; + let originalEnv: NodeJS.ProcessEnv; + let consoleSpy: ReturnType; + + beforeEach(async () => { + testDir = path.join(os.tmpdir(), `openspec-output-test-${Date.now()}`); + await fs.mkdir(testDir, { recursive: true }); + originalEnv = { ...process.env }; + configTempDir = path.join(os.tmpdir(), `openspec-config-output-${Date.now()}`); + await fs.mkdir(configTempDir, { recursive: true }); + process.env.XDG_CONFIG_HOME = configTempDir; + + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + confirmMock.mockReset(); + confirmMock.mockResolvedValue(true); + showWelcomeScreenMock.mockClear(); + searchableMultiSelectMock.mockReset(); + }); + + afterEach(async () => { + process.env = originalEnv; + await fs.rm(testDir, { recursive: true, force: true }); + await fs.rm(configTempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('init should include getting-started with /opsx:propose for core profile', async () => { + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const output = stripAnsi(collectOutput(consoleSpy)); + expect(output).toContain('Getting started'); + expect(output).toContain('/opsx:propose'); + }); + + it('init should include links in output', async () => { + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const output = stripAnsi(collectOutput(consoleSpy)); + expect(output).toContain('https://github.com/Fission-AI/OpenSpec'); + expect(output).toContain('https://github.com/Fission-AI/OpenSpec/issues'); + }); + + it('init should include IDE restart message', async () => { + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const output = stripAnsi(collectOutput(consoleSpy)); + expect(output).toContain('Restart your IDE'); + }); + + it('update should include IDE restart message after updating tools', async () => { + // First init + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + consoleSpy.mockClear(); + + // Force update + const updateCommand = new UpdateCommand({ force: true }); + await updateCommand.execute(testDir); + + const output = stripAnsi(collectOutput(consoleSpy)); + expect(output).toContain('Restart your IDE'); + }); + + it('init and update should use the same IDE restart wording', async () => { + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const initOutput = stripAnsi(collectOutput(consoleSpy)); + const initRestartLine = initOutput.split('\n').find((l) => l.includes('Restart your IDE')); + + consoleSpy.mockClear(); + + const updateCommand = new UpdateCommand({ force: true }); + await updateCommand.execute(testDir); + + const updateOutput = stripAnsi(collectOutput(consoleSpy)); + const updateRestartLine = updateOutput.split('\n').find((l) => l.includes('Restart your IDE')); + + expect(initRestartLine).toBe(updateRestartLine); + }); + + it('init should show /opsx:new for custom profile without propose', async () => { + saveGlobalConfig({ + featureFlags: {}, + profile: 'custom', + delivery: 'both', + workflows: ['explore', 'new', 'apply'], + }); + + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const output = stripAnsi(collectOutput(consoleSpy)); + expect(output).toContain('Getting started'); + expect(output).toContain('/opsx:new'); + expect(output).not.toContain('/opsx:propose'); + }); + + it('migration output should include propose hint', async () => { + // Pre-create existing skills to trigger migration + const skillsDir = path.join(testDir, '.claude', 'skills', 'openspec-explore'); + await fs.mkdir(skillsDir, { recursive: true }); + await fs.writeFile(path.join(skillsDir, 'SKILL.md'), 'old content'); + + // Create openspec dir to trigger extend mode + await fs.mkdir(path.join(testDir, 'openspec'), { recursive: true }); + + // Run init (which triggers migrateIfNeeded in extend mode) + const initCommand = new InitCommand({ tools: 'claude', force: true }); + await initCommand.execute(testDir); + + const output = stripAnsi(collectOutput(consoleSpy)); + // Migration should mention propose + expect(output).toContain('/opsx:propose'); + }); +}); diff --git a/test/core/shared-onboarding.test.ts b/test/core/shared-onboarding.test.ts new file mode 100644 index 000000000..bb033fd7e --- /dev/null +++ b/test/core/shared-onboarding.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + formatGettingStarted, + formatLinks, + formatIdeRestart, + printOnboardingFooter, +} from '../../src/core/shared/onboarding.js'; + +// Strip ANSI escape codes for content assertions +function stripAnsi(str: string): string { + return str.replace(/\u001B\[\d+m/g, ''); +} + +describe('formatGettingStarted', () => { + it('should show /opsx:propose when propose is in core profile', () => { + const lines = formatGettingStarted({ + profile: 'core', + hasConfiguredTools: true, + }); + const plain = lines.map(stripAnsi); + expect(plain).toContain(' Start your first change: /opsx:propose "your idea"'); + expect(plain.some((l) => l.includes('Getting started'))).toBe(true); + }); + + it('should show /opsx:new when custom profile has new but not propose', () => { + const lines = formatGettingStarted({ + profile: 'custom', + customWorkflows: ['explore', 'new', 'apply'], + hasConfiguredTools: true, + }); + const plain = lines.map(stripAnsi); + expect(plain.some((l) => l.includes('/opsx:new'))).toBe(true); + expect(plain.some((l) => l.includes('/opsx:propose'))).toBe(false); + }); + + it('should show /opsx:propose when custom profile includes propose', () => { + const lines = formatGettingStarted({ + profile: 'custom', + customWorkflows: ['propose', 'explore', 'apply'], + hasConfiguredTools: true, + }); + const plain = lines.map(stripAnsi); + expect(plain.some((l) => l.includes('/opsx:propose'))).toBe(true); + }); + + it('should show fallback message when profile has neither propose nor new', () => { + const lines = formatGettingStarted({ + profile: 'custom', + customWorkflows: ['explore'], + hasConfiguredTools: true, + }); + const plain = lines.map(stripAnsi); + expect(plain.some((l) => l.includes('openspec config profile'))).toBe(true); + }); + + it('should show fallback when custom profile has empty workflows', () => { + const lines = formatGettingStarted({ + profile: 'custom', + customWorkflows: [], + hasConfiguredTools: false, + }); + const plain = lines.map(stripAnsi); + expect(plain.some((l) => l.includes('openspec config profile'))).toBe(true); + }); +}); + +describe('formatLinks', () => { + it('should include repo and issues URLs', () => { + const lines = formatLinks(); + const plain = lines.map(stripAnsi).join('\n'); + expect(plain).toContain('https://github.com/Fission-AI/OpenSpec'); + expect(plain).toContain('https://github.com/Fission-AI/OpenSpec/issues'); + }); +}); + +describe('formatIdeRestart', () => { + it('should mention IDE restart', () => { + const msg = stripAnsi(formatIdeRestart()); + expect(msg).toContain('Restart your IDE'); + }); +}); + +describe('printOnboardingFooter', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('should print getting-started, links, and IDE restart when tools were configured', () => { + printOnboardingFooter({ + profile: 'core', + hasConfiguredTools: true, + }); + + const output = consoleSpy.mock.calls + .map((call) => call.map(String).join(' ')) + .join('\n'); + const plain = stripAnsi(output); + + expect(plain).toContain('Getting started'); + expect(plain).toContain('/opsx:propose'); + expect(plain).toContain('https://github.com/Fission-AI/OpenSpec'); + expect(plain).toContain('Restart your IDE'); + }); + + it('should not print IDE restart when no tools were configured', () => { + printOnboardingFooter({ + profile: 'core', + hasConfiguredTools: false, + }); + + const output = consoleSpy.mock.calls + .map((call) => call.map(String).join(' ')) + .join('\n'); + const plain = stripAnsi(output); + + expect(plain).toContain('Getting started'); + expect(plain).not.toContain('Restart your IDE'); + }); + + it('should produce identical output for init and update contexts with same profile', () => { + // Simulate init context + printOnboardingFooter({ + profile: 'core', + hasConfiguredTools: true, + }); + const initOutput = consoleSpy.mock.calls + .map((call) => call.map(String).join(' ')) + .join('\n'); + + consoleSpy.mockClear(); + + // Simulate update context (same profile) + printOnboardingFooter({ + profile: 'core', + hasConfiguredTools: true, + }); + const updateOutput = consoleSpy.mock.calls + .map((call) => call.map(String).join(' ')) + .join('\n'); + + expect(initOutput).toBe(updateOutput); + }); +}); diff --git a/test/core/update.test.ts b/test/core/update.test.ts index 6eeae843f..bc61f3b43 100644 --- a/test/core/update.test.ts +++ b/test/core/update.test.ts @@ -1137,12 +1137,13 @@ More user content after markers. expect.stringContaining('Claude Code') ); - // Should show getting started message for newly configured tools + // Should show getting started message for newly configured tools. + // The shared onboarding helper shows /opsx:propose for the default core profile. expect(consoleSpy).toHaveBeenCalledWith( expect.stringContaining('Getting started') ); expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining('/opsx:new') + expect.stringContaining('/opsx:propose') ); // Skills should be created