From 1c16149b88ad6a8873682431cc68b9822f9e071c Mon Sep 17 00:00:00 2001 From: Yunsung Lee Date: Sun, 8 Mar 2026 01:05:24 +0900 Subject: [PATCH 1/4] feat: Custom Panel Plugin SDK & Multi-View Dashboard Composition (#32, #33) Add two tightly coupled features in a single PR to avoid file conflicts: Custom Panel Plugin SDK (#32): - `forge panel create ` scaffolds src/custom-panels/.ts extending PanelBase with render/update/destroy stubs - Manifest generator auto-imports and registers custom panels by name - panel-registry handles `type: 'custom'` lookup via config.name - PanelSchema gains optional `customModule` field (PascalCase validation) - Validation warns on custom panels missing customModule Multi-View Dashboard (#33): - ViewSchema: name, displayName, panels[], icon?, default? - `forge view add/remove/list/set-default` CLI commands - PanelManager refactored: view containers, switchView(), keyboard shortcuts (1/2/3), URL hash routing (#view=) - View tabs rendered in header alongside existing controls - Panels support appearing in multiple views (Map) - Backward compatible: empty views array = current flat sidebar Testing: 473 tests pass, typecheck clean, all presets validate. Co-Authored-By: Claude Opus 4.6 --- forge/bin/forge.ts | 2 + forge/src/commands/panel/index.ts | 115 ++++++++++ forge/src/commands/validate.ts | 38 ++++ forge/src/commands/view/index.ts | 181 ++++++++++++++++ forge/src/config/defaults.ts | 1 + forge/src/config/loader.test.ts | 4 +- forge/src/config/schema.test.ts | 2 +- forge/src/config/schema.ts | 14 ++ forge/src/generators/env-generator.test.ts | 2 +- .../src/generators/manifest-generator.test.ts | 32 ++- forge/src/generators/manifest-generator.ts | 19 ++ forge/src/generators/vercel-generator.test.ts | 2 +- monitor-forge.config.json | 1 + src/App.ts | 43 +++- src/core/panels/PanelManager.ts | 200 ++++++++++++++---- src/core/panels/panel-registry.ts | 5 +- src/custom-panels/.gitkeep | 0 src/styles/views.css | 43 ++++ test/presets-pipeline.test.ts | 3 +- 19 files changed, 650 insertions(+), 57 deletions(-) create mode 100644 forge/src/commands/view/index.ts create mode 100644 src/custom-panels/.gitkeep create mode 100644 src/styles/views.css diff --git a/forge/bin/forge.ts b/forge/bin/forge.ts index b71c287..650ca0f 100644 --- a/forge/bin/forge.ts +++ b/forge/bin/forge.ts @@ -12,6 +12,7 @@ import { registerDeployCommand } from '../src/commands/deploy.js'; import { registerEnvCommands } from '../src/commands/env.js'; import { registerPresetCommands } from '../src/commands/preset.js'; import { registerSetupCommand } from '../src/commands/setup.js'; +import { registerViewCommands } from '../src/commands/view/index.js'; const program = new Command(); @@ -35,5 +36,6 @@ registerDeployCommand(program); registerEnvCommands(program); registerPresetCommands(program); registerSetupCommand(program); +registerViewCommands(program); program.parse(); diff --git a/forge/src/commands/panel/index.ts b/forge/src/commands/panel/index.ts index 416db44..9c90055 100644 --- a/forge/src/commands/panel/index.ts +++ b/forge/src/commands/panel/index.ts @@ -1,11 +1,126 @@ import type { Command } from 'commander'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import { loadConfig, updateConfig } from '../../config/loader.js'; import { PanelSchema, type PanelConfig } from '../../config/schema.js'; import { formatOutput, success, failure, type OutputFormat } from '../../output/format.js'; +function toPascalCase(kebab: string): string { + return kebab.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(''); +} + +function generatePanelScaffold(className: string, displayName: string): string { + return `import { PanelBase } from '../core/panels/PanelBase.js'; +import type { PanelConfig } from '../core/panels/PanelBase.js'; + +/** + * Custom panel: ${displayName} + * + * Implement render(), update(), and destroy(). + * PanelBase utilities: triggerPulse(), showSkeleton(), hideSkeleton(), + * markDataReceived(), createElement(tag, className?, innerHTML?) + */ +export class ${className} extends PanelBase { + render(): void { + this.container.innerHTML = '

${displayName} — edit src/custom-panels/${className}.ts

'; + this.showSkeleton(); + } + + update(data: unknown): void { + this.markDataReceived(); + } + + destroy(): void { + this.cleanupTimers(); + this.container.innerHTML = ''; + } +} +`; +} + export function registerPanelCommands(program: Command): void { const panel = program.command('panel').description('Manage UI panels'); + panel + .command('create ') + .description('Scaffold a new custom panel in src/custom-panels/') + .option('--display-name ', 'Display name in UI') + .option('--position ', 'Panel position (0-based)') + .option('--no-register', 'Only scaffold the file, skip config registration') + .action((name, opts) => { + const format = (program.opts().format ?? 'table') as OutputFormat; + const dryRun = program.opts().dryRun ?? false; + + try { + const className = toPascalCase(name); + const displayName = opts.displayName ?? name.split('-').map((s: string) => s.charAt(0).toUpperCase() + s.slice(1)).join(' '); + const customDir = resolve(process.cwd(), 'src/custom-panels'); + const filePath = resolve(customDir, `${className}.ts`); + + if (existsSync(filePath)) { + throw new Error(`Custom panel file already exists: src/custom-panels/${className}.ts`); + } + + if (dryRun) { + console.log(formatOutput( + success('panel create --dry-run', { name, className, filePath: `src/custom-panels/${className}.ts` }, { + changes: [ + { type: 'created', file: `src/custom-panels/${className}.ts`, description: `Would scaffold custom panel "${name}"` }, + ...(opts.register !== false ? [{ type: 'modified' as const, file: 'monitor-forge.config.json', description: `Would add panel "${name}" to config` }] : []), + ], + }), + format, + )); + return; + } + + // Create directory and scaffold file + mkdirSync(customDir, { recursive: true }); + writeFileSync(filePath, generatePanelScaffold(className, displayName)); + + const changes: Array<{ type: 'created' | 'modified' | 'deleted'; file: string; description: string }> = [ + { type: 'created', file: `src/custom-panels/${className}.ts`, description: `Scaffolded custom panel "${name}"` }, + ]; + + // Register in config unless --no-register + if (opts.register !== false) { + const config = loadConfig(); + const position = opts.position != null + ? parseInt(opts.position, 10) + : Math.max(0, ...config.panels.map(p => p.position)) + 1; + + const panelConfig: PanelConfig = PanelSchema.parse({ + name, + type: 'custom', + displayName, + position, + config: {}, + customModule: className, + }); + + const { path } = updateConfig(cfg => { + if (cfg.panels.some(p => p.name === name)) { + throw new Error(`Panel "${name}" already exists in config`); + } + return { ...cfg, panels: [...cfg.panels, panelConfig] }; + }); + + changes.push({ type: 'modified' as const, file: path, description: `Added panel "${name}" to config` }); + } + + console.log(formatOutput( + success('panel create', { name, className, file: `src/custom-panels/${className}.ts` }, { + changes, + next_steps: ['Edit the scaffold file', 'forge validate', 'forge dev'], + }), + format, + )); + } catch (err) { + console.log(formatOutput(failure('panel create', String(err)), format)); + process.exit(1); + } + }); + panel .command('add ') .description('Add a UI panel (ai-brief, news-feed, market-ticker, entity-tracker, instability-index, service-status, custom)') diff --git a/forge/src/commands/validate.ts b/forge/src/commands/validate.ts index d9d0c36..5b6b656 100644 --- a/forge/src/commands/validate.ts +++ b/forge/src/commands/validate.ts @@ -74,6 +74,43 @@ export function registerValidateCommand(program: Command): void { } } + // Check views + if (config.views.length > 0) { + const viewNames = config.views.map(v => v.name); + const viewDupes = viewNames.filter((n, i) => viewNames.indexOf(n) !== i); + if (viewDupes.length > 0) { + errors.push(`Duplicate view names: ${viewDupes.join(', ')}`); + } + + for (const view of config.views) { + for (const panelName of view.panels) { + if (!panelNames.includes(panelName)) { + errors.push(`View "${view.name}" references unknown panel: "${panelName}"`); + } + } + } + + const defaults = config.views.filter(v => v.default); + if (defaults.length > 1) { + errors.push(`Multiple views marked as default: ${defaults.map(v => v.name).join(', ')}. At most one allowed.`); + } + + // Warn about orphan panels not in any view + const viewedPanels = new Set(config.views.flatMap(v => v.panels)); + for (const panel of config.panels) { + if (!viewedPanels.has(panel.name)) { + warnings.push(`Panel "${panel.name}" is not included in any view`); + } + } + } + + // Check custom panels have customModule + for (const panel of config.panels) { + if (panel.type === 'custom' && !panel.customModule) { + warnings.push(`Custom panel "${panel.name}" is missing customModule field`); + } + } + // Check proxy security if (config.backend.corsProxy.enabled) { const domains = config.backend.corsProxy.allowedDomains; @@ -113,6 +150,7 @@ export function registerValidateCommand(program: Command): void { sources: config.sources.length, layers: config.layers.length, panels: config.panels.length, + views: config.views.length, aiEnabled: config.ai.enabled, warnings: warnings.length, }, { warnings }), diff --git a/forge/src/commands/view/index.ts b/forge/src/commands/view/index.ts new file mode 100644 index 0000000..963a41b --- /dev/null +++ b/forge/src/commands/view/index.ts @@ -0,0 +1,181 @@ +import type { Command } from 'commander'; +import { loadConfig, updateConfig } from '../../config/loader.js'; +import { ViewSchema } from '../../config/schema.js'; +import { formatOutput, success, failure, type OutputFormat } from '../../output/format.js'; + +export function registerViewCommands(program: Command): void { + const view = program.command('view').description('Manage dashboard views'); + + view + .command('add ') + .description('Add a dashboard view grouping panels into a tab') + .requiredOption('--display-name ', 'Display name in tab UI') + .requiredOption('--panels ', 'Comma-separated panel names') + .option('--icon ', 'Icon or emoji for the tab') + .option('--default', 'Set as the default view') + .action((name, opts) => { + const format = (program.opts().format ?? 'table') as OutputFormat; + const dryRun = program.opts().dryRun ?? false; + + try { + const panelNames = opts.panels.split(',').map((s: string) => s.trim()).filter(Boolean); + + const viewConfig = ViewSchema.parse({ + name, + displayName: opts.displayName, + panels: panelNames, + icon: opts.icon, + default: opts.default || undefined, + }); + + // Validate panel names exist in config + const config = loadConfig(); + const existingPanels = new Set(config.panels.map(p => p.name)); + const unknownPanels = panelNames.filter((p: string) => !existingPanels.has(p)); + if (unknownPanels.length > 0) { + throw new Error(`Unknown panel(s): ${unknownPanels.join(', ')}. Available: ${Array.from(existingPanels).join(', ')}`); + } + + if (dryRun) { + console.log(formatOutput( + success('view add --dry-run', viewConfig, { + changes: [{ type: 'modified', file: 'monitor-forge.config.json', description: `Would add view "${name}"` }], + }), + format, + )); + return; + } + + const { path } = updateConfig(cfg => { + if (cfg.views.some(v => v.name === name)) { + throw new Error(`View "${name}" already exists`); + } + + const views = [...cfg.views]; + + // If --default, clear default from other views + if (opts.default) { + for (const v of views) { + delete v.default; + } + } + + views.push(viewConfig); + return { ...cfg, views }; + }); + + console.log(formatOutput( + success('view add', viewConfig, { + changes: [{ type: 'modified', file: path, description: `Added view "${name}"` }], + next_steps: ['forge validate', 'forge dev'], + }), + format, + )); + } catch (err) { + console.log(formatOutput(failure('view add', String(err)), format)); + process.exit(1); + } + }); + + view + .command('remove ') + .description('Remove a dashboard view (panels are NOT deleted)') + .action((name) => { + const format = (program.opts().format ?? 'table') as OutputFormat; + const dryRun = program.opts().dryRun ?? false; + + try { + if (dryRun) { + console.log(formatOutput( + success('view remove --dry-run', { name }, { + changes: [{ type: 'modified', file: 'monitor-forge.config.json', description: `Would remove view "${name}"` }], + }), + format, + )); + return; + } + + const { config, path } = updateConfig(cfg => { + const filtered = cfg.views.filter(v => v.name !== name); + if (filtered.length === cfg.views.length) { + throw new Error(`View "${name}" not found`); + } + return { ...cfg, views: filtered }; + }); + + console.log(formatOutput( + success('view remove', { name, remaining: config.views.length }), + format, + )); + } catch (err) { + console.log(formatOutput(failure('view remove', String(err)), format)); + process.exit(1); + } + }); + + view + .command('list') + .description('List all configured dashboard views') + .action(() => { + const format = (program.opts().format ?? 'table') as OutputFormat; + try { + const config = loadConfig(); + const views = config.views.map(v => ({ + name: v.name, + displayName: v.displayName, + panels: v.panels.join(', '), + default: v.default ? 'yes' : '', + })); + console.log(formatOutput(success('view list', views), format)); + } catch (err) { + console.log(formatOutput(failure('view list', String(err)), format)); + process.exit(1); + } + }); + + view + .command('set-default ') + .description('Set the default view') + .action((name) => { + const format = (program.opts().format ?? 'table') as OutputFormat; + const dryRun = program.opts().dryRun ?? false; + + try { + if (dryRun) { + console.log(formatOutput( + success('view set-default --dry-run', { name }, { + changes: [{ type: 'modified', file: 'monitor-forge.config.json', description: `Would set "${name}" as default view` }], + }), + format, + )); + return; + } + + const { path } = updateConfig(cfg => { + if (!cfg.views.some(v => v.name === name)) { + throw new Error(`View "${name}" not found`); + } + const views = cfg.views.map(v => { + const copy = { ...v }; + if (v.name === name) { + copy.default = true; + } else { + delete copy.default; + } + return copy; + }); + return { ...cfg, views }; + }); + + console.log(formatOutput( + success('view set-default', { name }, { + changes: [{ type: 'modified', file: path, description: `Set "${name}" as default view` }], + }), + format, + )); + } catch (err) { + console.log(formatOutput(failure('view set-default', String(err)), format)); + process.exit(1); + } + }); +} diff --git a/forge/src/config/defaults.ts b/forge/src/config/defaults.ts index 5928d2a..3f68a81 100644 --- a/forge/src/config/defaults.ts +++ b/forge/src/config/defaults.ts @@ -16,6 +16,7 @@ export function createDefaultConfig(overrides?: Partial): Mo sources: overrides?.sources ?? [], layers: overrides?.layers ?? [], panels: overrides?.panels ?? [], + views: overrides?.views ?? [], ai: { enabled: true, fallbackChain: ['groq', 'openrouter'], diff --git a/forge/src/config/loader.test.ts b/forge/src/config/loader.test.ts index f74a45e..0705cee 100644 --- a/forge/src/config/loader.test.ts +++ b/forge/src/config/loader.test.ts @@ -71,7 +71,7 @@ describe('writeConfig', () => { it('writes JSON file with pretty formatting', () => { const config = { monitor: { name: 'Test', slug: 'test', description: '', domain: 'general', tags: [], branding: { primaryColor: '#0052CC' } }, - sources: [], layers: [], panels: [], + sources: [], layers: [], panels: [], views: [], ai: { enabled: false, fallbackChain: [], providers: {}, analysis: { summarization: true, entityExtraction: true, sentimentAnalysis: true, focalPointDetection: false } }, map: { style: 'https://example.com/style.json', center: [0, 0] as [number, number], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator' as const, dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory' as const, ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, @@ -93,7 +93,7 @@ describe('writeConfig', () => { it('returns the JSON file path', () => { const config = { monitor: { name: 'Test', slug: 'test', description: '', domain: 'general', tags: [], branding: { primaryColor: '#0052CC' } }, - sources: [], layers: [], panels: [], + sources: [], layers: [], panels: [], views: [], ai: { enabled: false, fallbackChain: [], providers: {}, analysis: { summarization: true, entityExtraction: true, sentimentAnalysis: true, focalPointDetection: false } }, map: { style: 'https://example.com/style.json', center: [0, 0] as [number, number], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator' as const, dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory' as const, ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, diff --git a/forge/src/config/schema.test.ts b/forge/src/config/schema.test.ts index 91ae796..044cf59 100644 --- a/forge/src/config/schema.test.ts +++ b/forge/src/config/schema.test.ts @@ -533,7 +533,7 @@ describe('defineConfig', () => { it('validates and returns config', () => { const config = defineConfig({ monitor: { name: 'Test', slug: 'test', description: '', domain: 'test', tags: [], branding: { primaryColor: '#000000' } }, - sources: [], layers: [], panels: [], + sources: [], layers: [], panels: [], views: [], ai: { enabled: false, fallbackChain: [], providers: {}, analysis: { summarization: true, entityExtraction: true, sentimentAnalysis: true, focalPointDetection: false } }, map: { style: 'https://example.com/style.json', center: [0, 0], zoom: 3, minZoom: 1, maxZoom: 20, projection: 'mercator', dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory', ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, diff --git a/forge/src/config/schema.ts b/forge/src/config/schema.ts index 5968170..8dcd9c7 100644 --- a/forge/src/config/schema.ts +++ b/forge/src/config/schema.ts @@ -55,10 +55,23 @@ export const PanelSchema = z.object({ displayName: z.string().min(1), position: z.number().int().min(0), config: z.record(z.unknown()).default({}), + customModule: z.string().regex(/^[A-Z][a-zA-Z0-9]*$/, 'Must be PascalCase class name').optional(), }); export type PanelConfig = z.infer; +// ─── View Schema ─────────────────────────────────────────── + +export const ViewSchema = z.object({ + name: z.string().regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + displayName: z.string().min(1), + panels: z.array(z.string().regex(/^[a-z0-9-]+$/)).min(1), + icon: z.string().optional(), + default: z.boolean().optional(), +}); + +export type ViewConfig = z.infer; + // ─── AI Schema ────────────────────────────────────────────── export const AIProviderSchema = z.object({ @@ -155,6 +168,7 @@ export const MonitorForgeConfigSchema = z.object({ sources: z.array(SourceSchema).default([]), layers: z.array(LayerSchema).default([]), panels: z.array(PanelSchema).default([]), + views: z.array(ViewSchema).default([]), ai: AISchema.default({}), map: MapSchema.default({}), backend: BackendSchema.default({}), diff --git a/forge/src/generators/env-generator.test.ts b/forge/src/generators/env-generator.test.ts index 306ff3e..b08678c 100644 --- a/forge/src/generators/env-generator.test.ts +++ b/forge/src/generators/env-generator.test.ts @@ -5,7 +5,7 @@ import type { MonitorForgeConfig } from '../config/schema.js'; function buildConfig(overrides?: Partial): MonitorForgeConfig { return { monitor: { name: 'Test', slug: 'test', description: '', domain: 'test', tags: [], branding: { primaryColor: '#0052CC' } }, - sources: [], layers: [], panels: [], + sources: [], layers: [], panels: [], views: [], ai: { enabled: false, fallbackChain: [], providers: {}, analysis: { summarization: true, entityExtraction: true, sentimentAnalysis: true, focalPointDetection: false } }, map: { style: 'https://example.com/style.json', center: [0, 0], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator', dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory', ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, diff --git a/forge/src/generators/manifest-generator.test.ts b/forge/src/generators/manifest-generator.test.ts index 4fc81ee..5560df0 100644 --- a/forge/src/generators/manifest-generator.test.ts +++ b/forge/src/generators/manifest-generator.test.ts @@ -5,7 +5,7 @@ import type { MonitorForgeConfig } from '../config/schema.js'; function buildConfig(overrides?: Partial): MonitorForgeConfig { return { monitor: { name: 'Test', slug: 'test', description: '', domain: 'test', tags: [], branding: { primaryColor: '#0052CC' } }, - sources: [], layers: [], panels: [], + sources: [], layers: [], panels: [], views: [], ai: { enabled: false, fallbackChain: [], providers: {}, analysis: { summarization: true, entityExtraction: true, sentimentAnalysis: true, focalPointDetection: false } }, map: { style: 'https://example.com/style.json', center: [0, 0], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator', dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory', ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, @@ -15,13 +15,14 @@ function buildConfig(overrides?: Partial): MonitorForgeConfi } describe('generateManifests', () => { - it('returns 4 manifest files', () => { + it('returns 5 manifest files', () => { const result = generateManifests(buildConfig()); expect(Object.keys(result)).toEqual([ 'source-manifest.ts', 'layer-manifest.ts', 'panel-manifest.ts', 'config-resolved.ts', + 'view-manifest.ts', ]); }); @@ -141,12 +142,39 @@ describe('generateManifests', () => { expect(manifest).not.toContain("registerPanelType('custom'"); }); + it('imports and registers custom panel with customModule', () => { + const config = buildConfig({ + panels: [{ name: 'weather-panel', type: 'custom', displayName: 'Weather', position: 0, config: {}, customModule: 'WeatherPanel' }], + }); + const manifest = generateManifests(config)['panel-manifest.ts']; + expect(manifest).toContain("import { WeatherPanel } from '../custom-panels/WeatherPanel.js';"); + expect(manifest).toContain("registerPanelType('weather-panel', WeatherPanel);"); + }); + it('handles empty panels array', () => { const manifest = generateManifests(buildConfig())['panel-manifest.ts']; expect(manifest).toContain('export const panelConfigs: PanelConfig[] = []'); }); }); + describe('view manifest', () => { + it('generates empty array when no views', () => { + const manifest = generateManifests(buildConfig())['view-manifest.ts']; + expect(manifest).toContain('export const viewConfigs = []'); + }); + + it('generates view configs', () => { + const config = buildConfig({ + panels: [{ name: 'news', type: 'news-feed', displayName: 'News', position: 0, config: {} }], + views: [{ name: 'main', displayName: 'Main', panels: ['news'], default: true }], + }); + const manifest = generateManifests(config)['view-manifest.ts']; + expect(manifest).toContain('"name": "main"'); + expect(manifest).toContain('"displayName": "Main"'); + expect(manifest).toContain('"default": true'); + }); + }); + describe('config-resolved manifest', () => { it('exports full config as JSON', () => { const config = buildConfig(); diff --git a/forge/src/generators/manifest-generator.ts b/forge/src/generators/manifest-generator.ts index 002f39d..5052f65 100644 --- a/forge/src/generators/manifest-generator.ts +++ b/forge/src/generators/manifest-generator.ts @@ -6,6 +6,7 @@ export function generateManifests(config: MonitorForgeConfig): Record p.type === 'custom' && p.customModule); + const seenModules = new Set(); + for (const panel of customPanels) { + const mod = panel.customModule!; + if (!seenModules.has(mod)) { + seenModules.add(mod); + imports.push(`import { ${mod} } from '../custom-panels/${mod}.js';`); + } + registrations.push(`registerPanelType('${panel.name}', ${mod});`); + } + return `// Auto-generated by forge build. Do not edit. ${imports.join('\n')} @@ -157,6 +170,12 @@ export const CORS_ALLOWED_ORIGINS: readonly string[] = ${JSON.stringify(config.b `; } +function generateViewManifest(config: MonitorForgeConfig): string { + return `// Auto-generated by forge build. Do not edit. +export const viewConfigs = ${JSON.stringify(config.views ?? [], null, 2)}; +`; +} + function generateResolvedConfig(config: MonitorForgeConfig): string { return `// Auto-generated by forge build. Do not edit. import type { MonitorForgeConfig } from '../../forge/src/config/schema.js'; diff --git a/forge/src/generators/vercel-generator.test.ts b/forge/src/generators/vercel-generator.test.ts index 722d3c8..10ef955 100644 --- a/forge/src/generators/vercel-generator.test.ts +++ b/forge/src/generators/vercel-generator.test.ts @@ -5,7 +5,7 @@ import type { MonitorForgeConfig } from '../config/schema.js'; function buildConfig(overrides?: Partial): MonitorForgeConfig { return { monitor: { name: 'Test', slug: 'test', description: '', domain: 'test', tags: [], branding: { primaryColor: '#0052CC' } }, - sources: [], layers: [], panels: [], + sources: [], layers: [], panels: [], views: [], ai: { enabled: false, fallbackChain: [], providers: {}, analysis: { summarization: true, entityExtraction: true, sentimentAnalysis: true, focalPointDetection: false } }, map: { style: 'https://example.com/style.json', center: [0, 0], zoom: 3, minZoom: 2, maxZoom: 18, projection: 'mercator', dayNightOverlay: false, atmosphericGlow: true, idleRotation: true, idleRotationSpeed: 0.5 }, backend: { cache: { provider: 'memory', ttlSeconds: 300 }, rateLimit: { enabled: true, maxRequests: 100, windowSeconds: 60 }, corsProxy: { enabled: true, allowedDomains: ['*'], corsOrigins: ['*'] } }, diff --git a/monitor-forge.config.json b/monitor-forge.config.json index 2c1411f..20da2bd 100644 --- a/monitor-forge.config.json +++ b/monitor-forge.config.json @@ -93,6 +93,7 @@ "config": {} } ], + "views": [], "ai": { "enabled": false, "fallbackChain": [ diff --git a/src/App.ts b/src/App.ts index 2deacef..7ba393a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,11 +1,12 @@ import { MapEngine, type MapConfig } from './core/map/MapEngine.js'; -import { PanelManager } from './core/panels/PanelManager.js'; +import { PanelManager, type ViewConfig } from './core/panels/PanelManager.js'; import { SourceManager } from './core/sources/SourceManager.js'; import type { SourceHealth } from './core/sources/SourceHealth.js'; import { AIManager, type AIConfig } from './core/ai/AIManager.js'; import { IdleDetector } from './core/ui/IdleDetector.js'; import './styles/base.css'; import './styles/animations.css'; +import './styles/views.css'; export class App { private root: HTMLElement; @@ -60,12 +61,19 @@ export class App { await this.mapEngine.addLayer(layerConfig); } - // Initialize panels + // Initialize panels and views await import('./generated/panel-manifest.js'); const { panelConfigs } = await import('./generated/panel-manifest.js'); + const { viewConfigs } = await import('./generated/view-manifest.js'); const sidebar = document.getElementById('forge-sidebar')!; this.panelManager = new PanelManager(sidebar); - this.panelManager.initialize(panelConfigs); + const views = (viewConfigs as ViewConfig[]).length > 0 ? viewConfigs as ViewConfig[] : undefined; + this.panelManager.initialize(panelConfigs, views); + + // Render view tabs if views are defined + if (views) { + this.renderViewTabs(views); + } // Initialize AI this.aiManager = new AIManager(config.ai as AIConfig); @@ -101,6 +109,35 @@ export class App { } } + private renderViewTabs(views: ViewConfig[]): void { + const headerActions = document.querySelector('.forge-header-actions'); + if (!headerActions) return; + + const tabContainer = document.createElement('div'); + tabContainer.className = 'forge-view-tabs'; + + for (const view of views) { + const tab = document.createElement('button'); + tab.className = 'forge-view-tab'; + tab.dataset.view = view.name; + tab.textContent = view.icon ? `${view.icon} ${view.displayName}` : view.displayName; + + if (this.panelManager?.getActiveView() === view.name) { + tab.classList.add('active'); + } + + tab.addEventListener('click', () => { + this.panelManager?.switchView(view.name); + tabContainer.querySelectorAll('.forge-view-tab').forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + }); + + tabContainer.appendChild(tab); + } + + headerActions.insertBefore(tabContainer, headerActions.firstChild); + } + private async generateAIBrief(): Promise { if (!this.sourceManager || !this.aiManager) return; try { diff --git a/src/core/panels/PanelManager.ts b/src/core/panels/PanelManager.ts index 78dce47..8337557 100644 --- a/src/core/panels/PanelManager.ts +++ b/src/core/panels/PanelManager.ts @@ -1,73 +1,185 @@ import type { PanelBase, PanelConfig } from './PanelBase.js'; import { createPanel } from './panel-registry.js'; +export interface ViewConfig { + name: string; + displayName: string; + panels: string[]; + icon?: string; + default?: boolean; +} + export class PanelManager { private container: HTMLElement; - private panels = new Map(); + private panels = new Map(); + private viewContainers = new Map(); + private activeView: string | null = null; + private keyHandler: ((e: KeyboardEvent) => void) | null = null; + private hashHandler: (() => void) | null = null; constructor(container: HTMLElement) { this.container = container; } - initialize(configs: PanelConfig[]): void { - // Sort by position - const sorted = [...configs].sort((a, b) => a.position - b.position); + initialize(configs: PanelConfig[], views?: ViewConfig[]): void { + if (views && views.length > 0) { + this.initializeWithViews(configs, views); + } else { + this.initializeFlat(configs); + } + } + private initializeFlat(configs: PanelConfig[]): void { + const sorted = [...configs].sort((a, b) => a.position - b.position); for (const config of sorted) { - try { - const panelContainer = document.createElement('div'); - panelContainer.className = 'forge-panel'; - panelContainer.dataset.panelName = config.name; - - // Panel header - const header = document.createElement('div'); - header.className = 'forge-panel-header'; - header.innerHTML = ` - ${config.displayName} - - `; - panelContainer.appendChild(header); - - // Panel body - const body = document.createElement('div'); - body.className = 'forge-panel-body'; - panelContainer.appendChild(body); - - // Toggle - const toggleBtn = header.querySelector('.forge-panel-toggle')!; - toggleBtn.addEventListener('click', () => { - body.classList.toggle('collapsed'); - toggleBtn.textContent = body.classList.contains('collapsed') ? '+' : '-'; - }); - - this.container.appendChild(panelContainer); - - const panel = createPanel(body, config); - panel.setPanelElement(panelContainer); - panel.render(); - this.panels.set(config.name, panel); - } catch (err) { - console.warn(`Failed to create panel "${config.name}":`, err); + this.createPanelDOM(this.container, config); + } + } + + private initializeWithViews(configs: PanelConfig[], views: ViewConfig[]): void { + const configMap = new Map(configs.map(c => [c.name, c])); + const defaultView = views.find(v => v.default) ?? views[0]; + const hashView = this.getViewFromHash(); + + for (const view of views) { + const viewEl = document.createElement('div'); + viewEl.className = 'forge-view'; + viewEl.dataset.view = view.name; + + const isActive = hashView + ? view.name === hashView + : view.name === defaultView.name; + viewEl.style.display = isActive ? 'flex' : 'none'; + + const viewPanelConfigs = view.panels + .map(name => configMap.get(name)) + .filter((c): c is PanelConfig => c !== undefined) + .sort((a, b) => a.position - b.position); + + for (const config of viewPanelConfigs) { + this.createPanelDOM(viewEl, config); + } + + this.container.appendChild(viewEl); + this.viewContainers.set(view.name, viewEl); + } + + this.activeView = hashView && this.viewContainers.has(hashView) + ? hashView + : defaultView.name; + + // Keyboard shortcuts: 1/2/3 to switch views + this.keyHandler = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + const num = parseInt(e.key, 10); + if (num >= 1 && num <= views.length) { + this.switchView(views[num - 1].name); } + }; + document.addEventListener('keydown', this.keyHandler); + + // Hash change listener + this.hashHandler = () => { + const newView = this.getViewFromHash(); + if (newView && this.viewContainers.has(newView)) { + this.switchView(newView); + } + }; + window.addEventListener('hashchange', this.hashHandler); + } + + private createPanelDOM(parent: HTMLElement, config: PanelConfig): void { + try { + const panelContainer = document.createElement('div'); + panelContainer.className = 'forge-panel'; + panelContainer.dataset.panelName = config.name; + + const header = document.createElement('div'); + header.className = 'forge-panel-header'; + header.innerHTML = ` + ${config.displayName} + + `; + panelContainer.appendChild(header); + + const body = document.createElement('div'); + body.className = 'forge-panel-body'; + panelContainer.appendChild(body); + + const toggleBtn = header.querySelector('.forge-panel-toggle')!; + toggleBtn.addEventListener('click', () => { + body.classList.toggle('collapsed'); + toggleBtn.textContent = body.classList.contains('collapsed') ? '+' : '-'; + }); + + parent.appendChild(panelContainer); + + const panel = createPanel(body, config); + panel.setPanelElement(panelContainer); + panel.render(); + + const existing = this.panels.get(config.name) ?? []; + existing.push(panel); + this.panels.set(config.name, existing); + } catch (err) { + console.warn(`Failed to create panel "${config.name}":`, err); } } + switchView(viewName: string): void { + if (this.activeView === viewName) return; + this.viewContainers.forEach((el, name) => { + el.style.display = name === viewName ? 'flex' : 'none'; + }); + this.activeView = viewName; + history.replaceState(null, '', `#view=${viewName}`); + } + + getActiveView(): string | null { + return this.activeView; + } + + getViewNames(): string[] { + return Array.from(this.viewContainers.keys()); + } + + private getViewFromHash(): string | null { + const hash = window.location.hash; + const match = hash.match(/[#&]view=([a-z0-9-]+)/); + return match ? match[1] : null; + } + updatePanel(name: string, data: unknown): void { - const panel = this.panels.get(name); - if (panel) panel.update(data); + const instances = this.panels.get(name); + if (instances) { + for (const panel of instances) { + panel.update(data); + } + } } updateAll(data: unknown): void { - for (const panel of this.panels.values()) { - panel.update(data); + for (const instances of this.panels.values()) { + for (const panel of instances) { + panel.update(data); + } } } destroy(): void { - for (const panel of this.panels.values()) { - panel.destroy(); + if (this.keyHandler) { + document.removeEventListener('keydown', this.keyHandler); + } + if (this.hashHandler) { + window.removeEventListener('hashchange', this.hashHandler); + } + for (const instances of this.panels.values()) { + for (const panel of instances) { + panel.destroy(); + } } this.panels.clear(); + this.viewContainers.clear(); this.container.innerHTML = ''; } } diff --git a/src/core/panels/panel-registry.ts b/src/core/panels/panel-registry.ts index 71c6072..4033fce 100644 --- a/src/core/panels/panel-registry.ts +++ b/src/core/panels/panel-registry.ts @@ -9,8 +9,9 @@ export function registerPanelType(type: string, cls: PanelConstructor): void { } export function createPanel(container: HTMLElement, config: PanelConfig): PanelBase { - const cls = registry.get(config.type); - if (!cls) throw new Error(`Unknown panel type: ${config.type}`); + const key = config.type === 'custom' ? config.name : config.type; + const cls = registry.get(key); + if (!cls) throw new Error(`Unknown panel type: ${key}`); return new cls(container, config); } diff --git a/src/custom-panels/.gitkeep b/src/custom-panels/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/styles/views.css b/src/styles/views.css new file mode 100644 index 0000000..99d1365 --- /dev/null +++ b/src/styles/views.css @@ -0,0 +1,43 @@ +/* View tabs in header */ +.forge-view-tabs { + display: flex; + gap: 2px; + margin-right: 0.75rem; +} + +.forge-view-tab { + padding: 0.2rem 0.6rem; + background: transparent; + color: var(--text-muted); + border: 1px solid transparent; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.75rem; + font-weight: 500; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.forge-view-tab:hover { + color: var(--fg); + background: rgba(255, 255, 255, 0.05); +} + +.forge-view-tab.active { + color: var(--accent); + border-color: var(--accent); + background: rgba(0, 82, 204, 0.1); +} + +/* View containers in sidebar */ +.forge-view { + display: flex; + flex-direction: column; + gap: 1px; +} + +@media (max-width: 768px) { + .forge-view-tabs { + overflow-x: auto; + flex-shrink: 0; + } +} diff --git a/test/presets-pipeline.test.ts b/test/presets-pipeline.test.ts index 13951c6..73bbcf9 100644 --- a/test/presets-pipeline.test.ts +++ b/test/presets-pipeline.test.ts @@ -58,13 +58,14 @@ describe.each(presetFiles)('preset pipeline: %s', (filename) => { const config = createDefaultConfig(raw); const validated = MonitorForgeConfigSchema.parse(config); - it('generates all 4 manifests without error', () => { + it('generates all 5 manifests without error', () => { const manifests = generateManifests(validated); expect(Object.keys(manifests)).toEqual([ 'source-manifest.ts', 'layer-manifest.ts', 'panel-manifest.ts', 'config-resolved.ts', + 'view-manifest.ts', ]); // Every manifest should be a non-empty string for (const [name, content] of Object.entries(manifests)) { From 598080c7059c82098e3ad53a139c1a4176f104ab Mon Sep 17 00:00:00 2001 From: alohays Date: Sun, 8 Mar 2026 09:38:31 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20address=20PR=20#35=20review=20?= =?UTF-8?q?=E2=80=94=20config=20edit,=20tab=20sync,=20validation=20severit?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Remove manual `views: []` from config (schema default suffices) 2. Add onViewChange callback so view tabs sync on keyboard/hash nav 3. Promote missing customModule to validation error (prevents runtime crash) Co-Authored-By: Claude Opus 4.6 --- forge/src/commands/validate.ts | 2 +- monitor-forge.config.json | 1 - src/App.ts | 5 +++++ src/core/panels/PanelManager.ts | 6 ++++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/forge/src/commands/validate.ts b/forge/src/commands/validate.ts index 5b6b656..0de952c 100644 --- a/forge/src/commands/validate.ts +++ b/forge/src/commands/validate.ts @@ -107,7 +107,7 @@ export function registerValidateCommand(program: Command): void { // Check custom panels have customModule for (const panel of config.panels) { if (panel.type === 'custom' && !panel.customModule) { - warnings.push(`Custom panel "${panel.name}" is missing customModule field`); + errors.push(`Custom panel "${panel.name}" is missing customModule field — build will fail at runtime`); } } diff --git a/monitor-forge.config.json b/monitor-forge.config.json index 20da2bd..2c1411f 100644 --- a/monitor-forge.config.json +++ b/monitor-forge.config.json @@ -93,7 +93,6 @@ "config": {} } ], - "views": [], "ai": { "enabled": false, "fallbackChain": [ diff --git a/src/App.ts b/src/App.ts index 7ba393a..96168dc 100644 --- a/src/App.ts +++ b/src/App.ts @@ -73,6 +73,11 @@ export class App { // Render view tabs if views are defined if (views) { this.renderViewTabs(views); + this.panelManager.onViewChange((viewName) => { + document.querySelectorAll('.forge-view-tab').forEach(t => { + t.classList.toggle('active', (t as HTMLElement).dataset.view === viewName); + }); + }); } // Initialize AI diff --git a/src/core/panels/PanelManager.ts b/src/core/panels/PanelManager.ts index 8337557..8509c28 100644 --- a/src/core/panels/PanelManager.ts +++ b/src/core/panels/PanelManager.ts @@ -16,6 +16,7 @@ export class PanelManager { private activeView: string | null = null; private keyHandler: ((e: KeyboardEvent) => void) | null = null; private hashHandler: (() => void) | null = null; + private viewChangeCallback: ((viewName: string) => void) | null = null; constructor(container: HTMLElement) { this.container = container; @@ -133,6 +134,11 @@ export class PanelManager { }); this.activeView = viewName; history.replaceState(null, '', `#view=${viewName}`); + this.viewChangeCallback?.(viewName); + } + + onViewChange(callback: (viewName: string) => void): void { + this.viewChangeCallback = callback; } getActiveView(): string | null { From e5298ebc154f8081c9ca927fae08192160e921c1 Mon Sep 17 00:00:00 2001 From: Yunsung Lee Date: Sun, 8 Mar 2026 22:12:43 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20QA=20bugs=20=E2=80=94=20tab=20sync,?= =?UTF-8?q?=20hidden=20panel=20updates,=20path=20validation,=20schema=20en?= =?UTF-8?q?forcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove redundant active class toggle from tab click handler (onViewChange callback is single source of truth) - Skip panel updates for hidden views to avoid wasted CPU on animations; cache last data and replay on view switch - Add 150ms opacity fade transition between views - Validate panel name format before file operations in `panel create` (prevents path traversal with --no-register) - Pre-validate config before writing scaffold file; rollback on failure - Enforce customModule via Zod refine() for custom panel type - Add ARIA tablist/tab/tabpanel pattern with aria-selected - Add keyboard shortcut hints as tab title tooltips - Improve mobile tab scrolling with hidden scrollbar + touch support Co-Authored-By: Claude Opus 4.6 --- forge/src/commands/panel/index.ts | 54 ++++++++++++++------- forge/src/config/schema.ts | 5 +- src/App.ts | 21 ++++++-- src/core/panels/PanelManager.ts | 79 +++++++++++++++++++++++++++---- src/styles/views.css | 7 +++ 5 files changed, 134 insertions(+), 32 deletions(-) diff --git a/forge/src/commands/panel/index.ts b/forge/src/commands/panel/index.ts index 9c90055..f7415b9 100644 --- a/forge/src/commands/panel/index.ts +++ b/forge/src/commands/panel/index.ts @@ -1,5 +1,5 @@ import type { Command } from 'commander'; -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs'; import { resolve } from 'node:path'; import { loadConfig, updateConfig } from '../../config/loader.js'; import { PanelSchema, type PanelConfig } from '../../config/schema.js'; @@ -52,11 +52,21 @@ export function registerPanelCommands(program: Command): void { const dryRun = program.opts().dryRun ?? false; try { + // Validate name format early (prevents path traversal and invalid class names) + if (!/^[a-z0-9-]+$/.test(name)) { + throw new Error('Panel name must be lowercase alphanumeric with hyphens (e.g., "my-panel")'); + } + const className = toPascalCase(name); const displayName = opts.displayName ?? name.split('-').map((s: string) => s.charAt(0).toUpperCase() + s.slice(1)).join(' '); const customDir = resolve(process.cwd(), 'src/custom-panels'); const filePath = resolve(customDir, `${className}.ts`); + // Defense-in-depth: ensure resolved path stays within custom-panels + if (!filePath.startsWith(customDir)) { + throw new Error('Invalid panel name: path escapes custom-panels directory'); + } + if (existsSync(filePath)) { throw new Error(`Custom panel file already exists: src/custom-panels/${className}.ts`); } @@ -74,22 +84,18 @@ export function registerPanelCommands(program: Command): void { return; } - // Create directory and scaffold file - mkdirSync(customDir, { recursive: true }); - writeFileSync(filePath, generatePanelScaffold(className, displayName)); - - const changes: Array<{ type: 'created' | 'modified' | 'deleted'; file: string; description: string }> = [ - { type: 'created', file: `src/custom-panels/${className}.ts`, description: `Scaffolded custom panel "${name}"` }, - ]; - - // Register in config unless --no-register + // Pre-validate config before writing any files + let panelConfig: PanelConfig | undefined; if (opts.register !== false) { const config = loadConfig(); + if (config.panels.some(p => p.name === name)) { + throw new Error(`Panel "${name}" already exists in config`); + } const position = opts.position != null ? parseInt(opts.position, 10) : Math.max(0, ...config.panels.map(p => p.position)) + 1; - const panelConfig: PanelConfig = PanelSchema.parse({ + panelConfig = PanelSchema.parse({ name, type: 'custom', displayName, @@ -97,15 +103,27 @@ export function registerPanelCommands(program: Command): void { config: {}, customModule: className, }); + } - const { path } = updateConfig(cfg => { - if (cfg.panels.some(p => p.name === name)) { - throw new Error(`Panel "${name}" already exists in config`); - } - return { ...cfg, panels: [...cfg.panels, panelConfig] }; - }); + // Create directory and scaffold file + mkdirSync(customDir, { recursive: true }); + writeFileSync(filePath, generatePanelScaffold(className, displayName)); - changes.push({ type: 'modified' as const, file: path, description: `Added panel "${name}" to config` }); + const changes: Array<{ type: 'created' | 'modified' | 'deleted'; file: string; description: string }> = [ + { type: 'created', file: `src/custom-panels/${className}.ts`, description: `Scaffolded custom panel "${name}"` }, + ]; + + // Register in config (rollback file on failure) + if (panelConfig) { + try { + const { path } = updateConfig(cfg => { + return { ...cfg, panels: [...cfg.panels, panelConfig!] }; + }); + changes.push({ type: 'modified' as const, file: path, description: `Added panel "${name}" to config` }); + } catch (configErr) { + unlinkSync(filePath); + throw configErr; + } } console.log(formatOutput( diff --git a/forge/src/config/schema.ts b/forge/src/config/schema.ts index 8dcd9c7..0b30b24 100644 --- a/forge/src/config/schema.ts +++ b/forge/src/config/schema.ts @@ -56,7 +56,10 @@ export const PanelSchema = z.object({ position: z.number().int().min(0), config: z.record(z.unknown()).default({}), customModule: z.string().regex(/^[A-Z][a-zA-Z0-9]*$/, 'Must be PascalCase class name').optional(), -}); +}).refine( + (p) => p.type !== 'custom' || !!p.customModule, + { message: 'customModule is required for custom panels', path: ['customModule'] }, +); export type PanelConfig = z.infer; diff --git a/src/App.ts b/src/App.ts index 96168dc..468575c 100644 --- a/src/App.ts +++ b/src/App.ts @@ -75,7 +75,10 @@ export class App { this.renderViewTabs(views); this.panelManager.onViewChange((viewName) => { document.querySelectorAll('.forge-view-tab').forEach(t => { - t.classList.toggle('active', (t as HTMLElement).dataset.view === viewName); + const el = t as HTMLElement; + const active = el.dataset.view === viewName; + el.classList.toggle('active', active); + el.setAttribute('aria-selected', String(active)); }); }); } @@ -120,21 +123,29 @@ export class App { const tabContainer = document.createElement('div'); tabContainer.className = 'forge-view-tabs'; + tabContainer.setAttribute('role', 'tablist'); + tabContainer.setAttribute('aria-label', 'Dashboard views'); + + for (let i = 0; i < views.length; i++) { + const view = views[i]; + const isActive = this.panelManager?.getActiveView() === view.name; - for (const view of views) { const tab = document.createElement('button'); tab.className = 'forge-view-tab'; tab.dataset.view = view.name; + tab.id = `forge-view-tab-${view.name}`; tab.textContent = view.icon ? `${view.icon} ${view.displayName}` : view.displayName; + tab.title = `${view.displayName} (press ${i + 1})`; + tab.setAttribute('role', 'tab'); + tab.setAttribute('aria-selected', String(isActive)); + tab.setAttribute('aria-controls', `forge-view-${view.name}`); - if (this.panelManager?.getActiveView() === view.name) { + if (isActive) { tab.classList.add('active'); } tab.addEventListener('click', () => { this.panelManager?.switchView(view.name); - tabContainer.querySelectorAll('.forge-view-tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); }); tabContainer.appendChild(tab); diff --git a/src/core/panels/PanelManager.ts b/src/core/panels/PanelManager.ts index 8509c28..3931177 100644 --- a/src/core/panels/PanelManager.ts +++ b/src/core/panels/PanelManager.ts @@ -13,10 +13,14 @@ export class PanelManager { private container: HTMLElement; private panels = new Map(); private viewContainers = new Map(); + private panelToViews = new Map>(); private activeView: string | null = null; private keyHandler: ((e: KeyboardEvent) => void) | null = null; private hashHandler: (() => void) | null = null; private viewChangeCallback: ((viewName: string) => void) | null = null; + private lastUpdateData: unknown = undefined; + private lastPanelData = new Map(); + private transitionTimer: ReturnType | null = null; constructor(container: HTMLElement) { this.container = container; @@ -42,10 +46,22 @@ export class PanelManager { const defaultView = views.find(v => v.default) ?? views[0]; const hashView = this.getViewFromHash(); + // Build panel-to-views mapping + for (const view of views) { + for (const panelName of view.panels) { + const existing = this.panelToViews.get(panelName) ?? new Set(); + existing.add(view.name); + this.panelToViews.set(panelName, existing); + } + } + for (const view of views) { const viewEl = document.createElement('div'); viewEl.className = 'forge-view'; viewEl.dataset.view = view.name; + viewEl.id = `forge-view-${view.name}`; + viewEl.setAttribute('role', 'tabpanel'); + viewEl.setAttribute('aria-labelledby', `forge-view-tab-${view.name}`); const isActive = hashView ? view.name === hashView @@ -129,12 +145,33 @@ export class PanelManager { switchView(viewName: string): void { if (this.activeView === viewName) return; - this.viewContainers.forEach((el, name) => { - el.style.display = name === viewName ? 'flex' : 'none'; - }); + if (this.transitionTimer) clearTimeout(this.transitionTimer); + + const outgoing = this.activeView ? this.viewContainers.get(this.activeView) : null; + const incoming = this.viewContainers.get(viewName); + this.activeView = viewName; history.replaceState(null, '', `#view=${viewName}`); this.viewChangeCallback?.(viewName); + + // Fade transition + if (outgoing && incoming) { + outgoing.style.opacity = '0'; + this.transitionTimer = setTimeout(() => { + this.viewContainers.forEach((el, name) => { + el.style.display = name === viewName ? 'flex' : 'none'; + el.style.opacity = name === viewName ? '1' : '0'; + }); + this.replayDataToActiveView(); + this.transitionTimer = null; + }, 150); + } else { + this.viewContainers.forEach((el, name) => { + el.style.display = name === viewName ? 'flex' : 'none'; + el.style.opacity = name === viewName ? '1' : '0'; + }); + this.replayDataToActiveView(); + } } onViewChange(callback: (viewName: string) => void): void { @@ -156,22 +193,42 @@ export class PanelManager { } updatePanel(name: string, data: unknown): void { + this.lastPanelData.set(name, data); const instances = this.panels.get(name); - if (instances) { - for (const panel of instances) { - panel.update(data); - } + if (!instances || !this.isPanelInActiveView(name)) return; + for (const panel of instances) { + panel.update(data); } } updateAll(data: unknown): void { - for (const instances of this.panels.values()) { + this.lastUpdateData = data; + for (const [name, instances] of this.panels.entries()) { + if (!this.isPanelInActiveView(name)) continue; for (const panel of instances) { panel.update(data); } } } + private isPanelInActiveView(name: string): boolean { + if (this.viewContainers.size === 0) return true; + const views = this.panelToViews.get(name); + return !views || views.has(this.activeView!); + } + + private replayDataToActiveView(): void { + for (const [name, instances] of this.panels.entries()) { + if (!this.isPanelInActiveView(name)) continue; + const panelData = this.lastPanelData.get(name); + if (panelData !== undefined) { + for (const panel of instances) panel.update(panelData); + } else if (this.lastUpdateData !== undefined) { + for (const panel of instances) panel.update(this.lastUpdateData); + } + } + } + destroy(): void { if (this.keyHandler) { document.removeEventListener('keydown', this.keyHandler); @@ -179,6 +236,9 @@ export class PanelManager { if (this.hashHandler) { window.removeEventListener('hashchange', this.hashHandler); } + if (this.transitionTimer) { + clearTimeout(this.transitionTimer); + } for (const instances of this.panels.values()) { for (const panel of instances) { panel.destroy(); @@ -186,6 +246,9 @@ export class PanelManager { } this.panels.clear(); this.viewContainers.clear(); + this.panelToViews.clear(); + this.lastPanelData.clear(); + this.lastUpdateData = undefined; this.container.innerHTML = ''; } } diff --git a/src/styles/views.css b/src/styles/views.css index 99d1365..e11716c 100644 --- a/src/styles/views.css +++ b/src/styles/views.css @@ -33,11 +33,18 @@ display: flex; flex-direction: column; gap: 1px; + opacity: 1; + transition: opacity 0.15s ease-out; } @media (max-width: 768px) { .forge-view-tabs { overflow-x: auto; flex-shrink: 0; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .forge-view-tabs::-webkit-scrollbar { + display: none; } } From aa315cf19660616ad92ea6ac1252657e0238b095 Mon Sep 17 00:00:00 2001 From: Yunsung Lee Date: Sun, 8 Mar 2026 22:12:55 +0900 Subject: [PATCH 4/4] test: comprehensive test coverage for panel/view commands and view switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add panel command tests (23 tests): create, add, remove, list, name validation, path traversal rejection, --no-register, --dry-run, config rollback on failure - Add view command tests (16 tests): add, remove, list, set-default, panel validation, duplicate rejection, --dry-run - Add view validation tests (6 tests): duplicate views, unknown panels, multiple defaults, orphan panels, customModule enforcement - Add PanelManager view switching tests (9 tests): initializeWithViews, switchView, getActiveView, getViewNames, onViewChange callback, updateAll skips hidden views, destroy cleanup - Fix schema test for custom panel type requiring customModule Total: 492 → 546 tests (+54) Co-Authored-By: Claude Opus 4.6 --- forge/src/commands/panel/index.test.ts | 479 +++++++++++++++++++++++++ forge/src/commands/validate.test.ts | 116 ++++++ forge/src/commands/view/index.test.ts | 334 +++++++++++++++++ forge/src/config/schema.test.ts | 5 +- src/core/panels/PanelManager.test.ts | 182 ++++++++++ 5 files changed, 1115 insertions(+), 1 deletion(-) create mode 100644 forge/src/commands/panel/index.test.ts create mode 100644 forge/src/commands/view/index.test.ts diff --git a/forge/src/commands/panel/index.test.ts b/forge/src/commands/panel/index.test.ts new file mode 100644 index 0000000..862ff68 --- /dev/null +++ b/forge/src/commands/panel/index.test.ts @@ -0,0 +1,479 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerPanelCommands } from './index.js'; +import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs'; +import { loadConfig, updateConfig } from '../../config/loader.js'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + unlinkSync: vi.fn(), +})); + +vi.mock('../../config/loader.js', () => ({ + loadConfig: vi.fn(), + updateConfig: vi.fn(), +})); + +const mockedExistsSync = vi.mocked(existsSync); +const mockedMkdirSync = vi.mocked(mkdirSync); +const mockedWriteFileSync = vi.mocked(writeFileSync); +const mockedUnlinkSync = vi.mocked(unlinkSync); +const mockedLoadConfig = vi.mocked(loadConfig); +const mockedUpdateConfig = vi.mocked(updateConfig); + +function makeConfig(overrides?: Record) { + return { + monitor: { name: 'Test', slug: 'test', domain: 'general' }, + sources: [], + layers: [], + panels: [], + views: [], + ai: {}, + map: {}, + backend: {}, + build: {}, + ...overrides, + }; +} + +function createProgram(): Command { + const program = new Command(); + program.option('--format '); + program.option('--non-interactive'); + program.option('--dry-run'); + registerPanelCommands(program); + return program; +} + +let logOutput: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + logOutput = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => { logOutput.push(msg); }); + vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`exit:${code}`); + }); +}); + +// ─── panel create ─────────────────────────────────────────── + +describe('panel create', () => { + it('rejects invalid name format (special chars)', async () => { + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'create', 'INVALID NAME', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('lowercase alphanumeric with hyphens'); + }); + + it('rejects invalid name format (path traversal)', async () => { + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'create', '../evil', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + }); + + it('rejects if file already exists', async () => { + mockedExistsSync.mockReturnValue(true); + mockedLoadConfig.mockReturnValue(makeConfig() as any); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'create', 'my-panel', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('already exists'); + }); + + it('scaffolds file in correct directory and registers in config', async () => { + mockedExistsSync.mockReturnValue(false); + mockedLoadConfig.mockReturnValue(makeConfig() as any); + mockedUpdateConfig.mockReturnValue({ config: makeConfig() as any, path: 'monitor-forge.config.json' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'panel', 'create', 'my-panel', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.name).toBe('my-panel'); + expect(output.data.className).toBe('MyPanel'); + expect(output.data.file).toBe('src/custom-panels/MyPanel.ts'); + + // File scaffold was written + expect(mockedMkdirSync).toHaveBeenCalled(); + expect(mockedWriteFileSync).toHaveBeenCalledTimes(1); + const writtenContent = mockedWriteFileSync.mock.calls[0][1] as string; + expect(writtenContent).toContain('class MyPanel extends PanelBase'); + + // Config was updated + expect(mockedUpdateConfig).toHaveBeenCalledTimes(1); + }); + + it('--no-register skips config update', async () => { + mockedExistsSync.mockReturnValue(false); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'panel', 'create', 'my-panel', + '--no-register', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(mockedWriteFileSync).toHaveBeenCalledTimes(1); + expect(mockedLoadConfig).not.toHaveBeenCalled(); + expect(mockedUpdateConfig).not.toHaveBeenCalled(); + }); + + it('--dry-run shows preview without writing', async () => { + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', '--dry-run', 'panel', 'create', 'my-panel', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.command).toContain('dry-run'); + expect(output.data.className).toBe('MyPanel'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + expect(mockedMkdirSync).not.toHaveBeenCalled(); + expect(mockedUpdateConfig).not.toHaveBeenCalled(); + }); + + it('rejects duplicate name in config', async () => { + mockedExistsSync.mockReturnValue(false); + mockedLoadConfig.mockReturnValue(makeConfig({ + panels: [{ name: 'my-panel', type: 'custom', displayName: 'My Panel', position: 0, config: {}, customModule: 'MyPanel' }], + }) as any); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'create', 'my-panel', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('already exists in config'); + }); + + it('rollback: cleans up file if config update fails', async () => { + mockedExistsSync.mockReturnValue(false); + mockedLoadConfig.mockReturnValue(makeConfig() as any); + mockedUpdateConfig.mockImplementation(() => { + throw new Error('Config write failed'); + }); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'create', 'my-panel', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + // File was written first + expect(mockedWriteFileSync).toHaveBeenCalledTimes(1); + // Then rolled back via unlinkSync + expect(mockedUnlinkSync).toHaveBeenCalledTimes(1); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('Config write failed'); + }); + + it('--position sets custom position', async () => { + mockedExistsSync.mockReturnValue(false); + mockedLoadConfig.mockReturnValue(makeConfig() as any); + mockedUpdateConfig.mockReturnValue({ config: makeConfig() as any, path: 'monitor-forge.config.json' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'panel', 'create', 'my-panel', + '--position', '5', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + + // Verify the updater was called; inspect the panelConfig via the updateConfig call + const updater = mockedUpdateConfig.mock.calls[0][0]; + const baseConfig = makeConfig() as any; + const result = updater(baseConfig); + const addedPanel = result.panels[result.panels.length - 1]; + expect(addedPanel.position).toBe(5); + }); + + it('--display-name overrides default display name', async () => { + mockedExistsSync.mockReturnValue(false); + mockedLoadConfig.mockReturnValue(makeConfig() as any); + mockedUpdateConfig.mockReturnValue({ config: makeConfig() as any, path: 'monitor-forge.config.json' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'panel', 'create', 'my-panel', + '--display-name', 'Custom Display Name', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + + // Verify displayName in scaffold file content + const writtenContent = mockedWriteFileSync.mock.calls[0][1] as string; + expect(writtenContent).toContain('Custom Display Name'); + + // Verify displayName in config updater + const updater = mockedUpdateConfig.mock.calls[0][0]; + const baseConfig = makeConfig() as any; + const result = updater(baseConfig); + const addedPanel = result.panels[result.panels.length - 1]; + expect(addedPanel.displayName).toBe('Custom Display Name'); + }); +}); + +// ─── panel add ────────────────────────────────────────────── + +describe('panel add', () => { + it('adds valid panel to config', async () => { + mockedUpdateConfig.mockReturnValue({ config: makeConfig() as any, path: 'monitor-forge.config.json' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'panel', 'add', 'news-feed', + '--name', 'my-feed', '--display-name', 'My Feed', + '--position', '0', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.name).toBe('my-feed'); + expect(output.data.type).toBe('news-feed'); + expect(output.data.displayName).toBe('My Feed'); + expect(mockedUpdateConfig).toHaveBeenCalled(); + }); + + it('rejects invalid panel type via schema validation', async () => { + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'add', 'invalid-type', + '--name', 'my-panel', '--display-name', 'My Panel', + '--position', '0', '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + }); + + it('rejects duplicate panel name', async () => { + mockedUpdateConfig.mockImplementation(() => { + throw new Error('Panel "existing-panel" already exists'); + }); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'add', 'news-feed', + '--name', 'existing-panel', '--display-name', 'Existing', + '--position', '0', '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('already exists'); + }); + + it('rejects invalid panel name format', async () => { + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'add', 'news-feed', + '--name', 'BAD NAME', '--display-name', 'Bad', + '--position', '0', '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + }); + + it('supports --source option', async () => { + mockedUpdateConfig.mockReturnValue({ config: makeConfig() as any, path: 'monitor-forge.config.json' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'panel', 'add', 'news-feed', + '--name', 'my-feed', '--display-name', 'My Feed', + '--position', '0', '--source', 'rss-source', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.config.source).toBe('rss-source'); + }); + + it('supports dry-run mode', async () => { + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', '--dry-run', 'panel', 'add', 'news-feed', + '--name', 'my-feed', '--display-name', 'My Feed', + '--position', '0', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.command).toContain('dry-run'); + expect(mockedUpdateConfig).not.toHaveBeenCalled(); + }); + + it('supports --config-json option', async () => { + mockedUpdateConfig.mockReturnValue({ config: makeConfig() as any, path: 'monitor-forge.config.json' }); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'panel', 'add', 'market-ticker', + '--name', 'ticker', '--display-name', 'Ticker', + '--position', '1', + '--config-json', '{"symbols":["BTC","ETH"]}', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.config.symbols).toEqual(['BTC', 'ETH']); + }); +}); + +// ─── panel remove ─────────────────────────────────────────── + +describe('panel remove', () => { + it('removes existing panel from config', async () => { + mockedUpdateConfig.mockReturnValue({ + config: makeConfig({ panels: [] }) as any, + path: 'monitor-forge.config.json', + }); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'panel', 'remove', 'my-panel', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.name).toBe('my-panel'); + expect(output.data.remaining).toBe(0); + }); + + it('throws if panel not found', async () => { + mockedUpdateConfig.mockImplementation(() => { + throw new Error('Panel "nonexistent" not found'); + }); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'panel', 'remove', 'nonexistent', '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('not found'); + }); + + it('supports dry-run mode', async () => { + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', '--dry-run', 'panel', 'remove', 'my-panel', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.command).toContain('dry-run'); + expect(mockedUpdateConfig).not.toHaveBeenCalled(); + }); +}); + +// ─── panel list ───────────────────────────────────────────── + +describe('panel list', () => { + it('lists all panels from config', async () => { + mockedLoadConfig.mockReturnValue(makeConfig({ + panels: [ + { name: 'brief', type: 'ai-brief', displayName: 'AI Brief', position: 0, config: {} }, + { name: 'feed', type: 'news-feed', displayName: 'News Feed', position: 1, config: {} }, + ], + }) as any); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'panel', 'list', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data).toHaveLength(2); + expect(output.data[0].name).toBe('brief'); + expect(output.data[0].type).toBe('ai-brief'); + expect(output.data[0].displayName).toBe('AI Brief'); + expect(output.data[0].position).toBe(0); + expect(output.data[1].name).toBe('feed'); + expect(output.data[1].type).toBe('news-feed'); + }); + + it('returns empty array when no panels', async () => { + mockedLoadConfig.mockReturnValue(makeConfig() as any); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'panel', 'list', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data).toEqual([]); + }); + + it('errors when config cannot be loaded', async () => { + mockedLoadConfig.mockImplementation(() => { + throw new Error('Config file not found'); + }); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'panel', 'list', '--format', 'json']), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('Config file not found'); + }); +}); diff --git a/forge/src/commands/validate.test.ts b/forge/src/commands/validate.test.ts index 47287a0..b7f1c87 100644 --- a/forge/src/commands/validate.test.ts +++ b/forge/src/commands/validate.test.ts @@ -234,4 +234,120 @@ describe('validate command', () => { expect(output.data.valid).toBe(true); }); }); + + // ─── View Validation ───────────────────────────────────── + + describe('view validation', () => { + it('errors on duplicate view names', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + sources: [{ name: 'f', type: 'rss', url: 'https://a.com/rss', category: 'n' }], + panels: [{ name: 'news', type: 'news-feed', displayName: 'News', position: 0 }], + views: [ + { name: 'main', displayName: 'Main', panels: ['news'] }, + { name: 'main', displayName: 'Main 2', panels: ['news'] }, + ], + })); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'validate', '--format', 'json']), + ).rejects.toThrow('exit:1'); + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + }); + + it('errors when view references unknown panel', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + sources: [{ name: 'f', type: 'rss', url: 'https://a.com/rss', category: 'n' }], + panels: [{ name: 'news', type: 'news-feed', displayName: 'News', position: 0 }], + views: [ + { name: 'main', displayName: 'Main', panels: ['news', 'nonexistent'] }, + ], + })); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'validate', '--format', 'json']), + ).rejects.toThrow('exit:1'); + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + }); + + it('errors on multiple default views', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + sources: [{ name: 'f', type: 'rss', url: 'https://a.com/rss', category: 'n' }], + panels: [{ name: 'news', type: 'news-feed', displayName: 'News', position: 0 }], + views: [ + { name: 'view-a', displayName: 'A', panels: ['news'], default: true }, + { name: 'view-b', displayName: 'B', panels: ['news'], default: true }, + ], + })); + + const program = createProgram(); + await expect( + program.parseAsync(['node', 'forge', 'validate', '--format', 'json']), + ).rejects.toThrow('exit:1'); + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + }); + + it('warns about orphan panels not in any view', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + sources: [{ name: 'f', type: 'rss', url: 'https://a.com/rss', category: 'n' }], + panels: [ + { name: 'news', type: 'news-feed', displayName: 'News', position: 0 }, + { name: 'orphan', type: 'ai-brief', displayName: 'Orphan', position: 1 }, + ], + views: [ + { name: 'main', displayName: 'Main', panels: ['news'] }, + ], + })); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'validate', '--format', 'json']); + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.warnings).toContainEqual( + expect.stringContaining('orphan'), + ); + }); + + it('errors when custom panel missing customModule', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + sources: [{ name: 'f', type: 'rss', url: 'https://a.com/rss', category: 'n' }], + panels: [{ name: 'my-widget', type: 'custom', displayName: 'Widget', position: 0, customModule: 'MyWidget' }], + })); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'validate', '--format', 'json']); + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + }); + + it('passes with valid views configuration', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + sources: [{ name: 'f', type: 'rss', url: 'https://a.com/rss', category: 'n' }], + panels: [ + { name: 'news', type: 'news-feed', displayName: 'News', position: 0 }, + { name: 'status', type: 'service-status', displayName: 'Status', position: 1 }, + ], + views: [ + { name: 'overview', displayName: 'Overview', panels: ['news', 'status'], default: true }, + { name: 'detail', displayName: 'Detail', panels: ['status'] }, + ], + })); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'validate', '--format', 'json']); + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.valid).toBe(true); + }); + }); }); diff --git a/forge/src/commands/view/index.test.ts b/forge/src/commands/view/index.test.ts new file mode 100644 index 0000000..1721496 --- /dev/null +++ b/forge/src/commands/view/index.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Command } from 'commander'; +import { registerViewCommands } from './index.js'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + existsSync: vi.fn(), +})); + +const mockedReadFileSync = vi.mocked(readFileSync); +const mockedWriteFileSync = vi.mocked(writeFileSync); +const mockedExistsSync = vi.mocked(existsSync); + +function makeConfig(overrides?: Record) { + return JSON.stringify({ + monitor: { name: 'Test', slug: 'test', domain: 'general' }, + sources: [], + layers: [], + panels: [ + { name: 'panel-a', type: 'news-feed', displayName: 'Panel A', position: 0 }, + { name: 'panel-b', type: 'market-ticker', displayName: 'Panel B', position: 1 }, + ], + views: [], + ...overrides, + }); +} + +function createProgram(): Command { + const program = new Command(); + program.option('--format '); + program.option('--non-interactive'); + program.option('--dry-run'); + registerViewCommands(program); + return program; +} + +let logOutput: string[] = []; + +beforeEach(() => { + vi.clearAllMocks(); + logOutput = []; + vi.spyOn(console, 'log').mockImplementation((msg: string) => { logOutput.push(msg); }); + vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`exit:${code}`); + }); +}); + +describe('view add', () => { + it('creates view with panel membership', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig()); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'view', 'add', 'overview', + '--display-name', 'Overview', '--panels', 'panel-a,panel-b', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.name).toBe('overview'); + expect(output.data.displayName).toBe('Overview'); + expect(output.data.panels).toEqual(['panel-a', 'panel-b']); + expect(mockedWriteFileSync).toHaveBeenCalled(); + }); + + it('validates panels exist in config', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig()); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'view', 'add', 'bad-view', + '--display-name', 'Bad View', '--panels', 'panel-a,nonexistent', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('Unknown panel(s)'); + expect(output.error).toContain('nonexistent'); + }); + + it('rejects duplicate view names', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + views: [{ name: 'existing', displayName: 'Existing', panels: ['panel-a'] }], + })); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'view', 'add', 'existing', + '--display-name', 'Existing', '--panels', 'panel-a', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(false); + expect(output.error).toContain('already exists'); + }); + + it('--default clears other defaults', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + views: [{ name: 'old-default', displayName: 'Old', panels: ['panel-a'], default: true }], + })); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'view', 'add', 'new-default', + '--display-name', 'New Default', '--panels', 'panel-b', + '--default', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.name).toBe('new-default'); + expect(output.data.default).toBe(true); + + // Verify writeFileSync was called with the updated config + const writtenJson = JSON.parse( + (mockedWriteFileSync.mock.calls[0][1] as string), + ); + const oldView = writtenJson.views.find((v: Record) => v.name === 'old-default'); + expect(oldView.default).toBeUndefined(); + const newView = writtenJson.views.find((v: Record) => v.name === 'new-default'); + expect(newView.default).toBe(true); + }); + + it('--dry-run shows preview without writing', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig()); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', '--dry-run', 'view', 'add', 'dry-view', + '--display-name', 'Dry View', '--panels', 'panel-a', + '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.command).toContain('dry-run'); + expect(output.data.name).toBe('dry-view'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); + + it('rejects invalid name format', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig()); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'view', 'add', 'INVALID NAME', + '--display-name', 'Invalid', '--panels', 'panel-a', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + }); + + it('rejects empty panels list', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig()); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'view', 'add', 'empty-panels', + '--display-name', 'Empty', '--panels', '', + '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + }); +}); + +describe('view remove', () => { + it('removes existing view', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + views: [{ name: 'remove-me', displayName: 'Remove Me', panels: ['panel-a'] }], + })); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'view', 'remove', 'remove-me', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.remaining).toBe(0); + }); + + it('throws if view not found', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig()); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'view', 'remove', 'nonexistent', '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + }); + + it('--dry-run shows preview without writing', async () => { + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', '--dry-run', 'view', 'remove', 'some-view', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.command).toContain('dry-run'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); +}); + +describe('view list', () => { + it('lists all views with correct format', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + views: [ + { name: 'overview', displayName: 'Overview', panels: ['panel-a', 'panel-b'], default: true }, + { name: 'markets', displayName: 'Markets', panels: ['panel-b'] }, + ], + })); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'view', 'list', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data).toHaveLength(2); + expect(output.data[0].name).toBe('overview'); + expect(output.data[0].displayName).toBe('Overview'); + expect(output.data[0].panels).toBe('panel-a, panel-b'); + expect(output.data[0].default).toBe('yes'); + expect(output.data[1].name).toBe('markets'); + expect(output.data[1].default).toBe(''); + }); + + it('returns empty array when no views', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig()); + + const program = createProgram(); + await program.parseAsync(['node', 'forge', 'view', 'list', '--format', 'json']); + + const output = JSON.parse(logOutput[0]); + expect(output.data).toEqual([]); + }); +}); + +describe('view set-default', () => { + it('sets specified view as default', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + views: [ + { name: 'view-a', displayName: 'View A', panels: ['panel-a'] }, + { name: 'view-b', displayName: 'View B', panels: ['panel-b'] }, + ], + })); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'view', 'set-default', 'view-a', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.data.name).toBe('view-a'); + + const writtenJson = JSON.parse( + (mockedWriteFileSync.mock.calls[0][1] as string), + ); + const viewA = writtenJson.views.find((v: Record) => v.name === 'view-a'); + expect(viewA.default).toBe(true); + }); + + it('clears other defaults', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig({ + views: [ + { name: 'view-a', displayName: 'View A', panels: ['panel-a'], default: true }, + { name: 'view-b', displayName: 'View B', panels: ['panel-b'] }, + ], + })); + + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', 'view', 'set-default', 'view-b', '--format', 'json', + ]); + + const writtenJson = JSON.parse( + (mockedWriteFileSync.mock.calls[0][1] as string), + ); + const viewA = writtenJson.views.find((v: Record) => v.name === 'view-a'); + const viewB = writtenJson.views.find((v: Record) => v.name === 'view-b'); + expect(viewA.default).toBeUndefined(); + expect(viewB.default).toBe(true); + }); + + it('throws if view not found', async () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue(makeConfig()); + + const program = createProgram(); + await expect( + program.parseAsync([ + 'node', 'forge', 'view', 'set-default', 'nonexistent', '--format', 'json', + ]), + ).rejects.toThrow('exit:1'); + }); + + it('--dry-run shows preview without writing', async () => { + const program = createProgram(); + await program.parseAsync([ + 'node', 'forge', '--dry-run', 'view', 'set-default', 'some-view', '--format', 'json', + ]); + + const output = JSON.parse(logOutput[0]); + expect(output.success).toBe(true); + expect(output.command).toContain('dry-run'); + expect(mockedWriteFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/forge/src/config/schema.test.ts b/forge/src/config/schema.test.ts index 044cf59..86e0261 100644 --- a/forge/src/config/schema.test.ts +++ b/forge/src/config/schema.test.ts @@ -183,7 +183,10 @@ describe('PanelSchema', () => { 'ai-brief', 'news-feed', 'market-ticker', 'entity-tracker', 'instability-index', 'service-status', 'custom', ] as const)('accepts %s panel type', (type) => { - const result = PanelSchema.parse({ ...validPanel, type }); + const data = type === 'custom' + ? { ...validPanel, type, customModule: 'MyPanel' } + : { ...validPanel, type }; + const result = PanelSchema.parse(data); expect(result.type).toBe(type); }); diff --git a/src/core/panels/PanelManager.test.ts b/src/core/panels/PanelManager.test.ts index 7ad444f..3ef361f 100644 --- a/src/core/panels/PanelManager.test.ts +++ b/src/core/panels/PanelManager.test.ts @@ -85,4 +85,186 @@ describe('PanelManager', () => { expect(toggleBtn).not.toBeNull(); expect(toggleBtn?.textContent).toBe('-'); }); + + describe('view switching', () => { + const configs: PanelConfig[] = [ + { name: 'alpha', type: 'mock-mgr-panel', displayName: 'Alpha', position: 0, config: {} }, + { name: 'beta', type: 'mock-mgr-panel', displayName: 'Beta', position: 1, config: {} }, + { name: 'gamma', type: 'mock-mgr-panel', displayName: 'Gamma', position: 2, config: {} }, + ]; + + const views = [ + { name: 'overview', displayName: 'Overview', panels: ['alpha', 'beta'], default: true }, + { name: 'detail', displayName: 'Detail', panels: ['beta', 'gamma'] }, + ]; + + let container: HTMLElement; + let manager: PanelManager; + + beforeEach(() => { + MockPanel.instances = []; + container = document.createElement('div'); + manager = new PanelManager(container); + // Reset hash so getViewFromHash returns null + history.replaceState(null, '', window.location.pathname); + }); + + it('initializeWithViews creates view containers with correct attributes', () => { + manager.initialize(configs, views); + const viewEls = container.querySelectorAll('.forge-view'); + expect(viewEls).toHaveLength(2); + + const overviewEl = container.querySelector('#forge-view-overview') as HTMLElement; + expect(overviewEl).not.toBeNull(); + expect(overviewEl.getAttribute('role')).toBe('tabpanel'); + expect(overviewEl.getAttribute('aria-labelledby')).toBe('forge-view-tab-overview'); + expect(overviewEl.dataset.view).toBe('overview'); + + const detailEl = container.querySelector('#forge-view-detail') as HTMLElement; + expect(detailEl).not.toBeNull(); + expect(detailEl.getAttribute('role')).toBe('tabpanel'); + expect(detailEl.getAttribute('aria-labelledby')).toBe('forge-view-tab-detail'); + expect(detailEl.dataset.view).toBe('detail'); + + // Default view should be visible, other hidden + expect(overviewEl.style.display).toBe('flex'); + expect(detailEl.style.display).toBe('none'); + }); + + it('switchView changes activeView and display styles', () => { + manager.initialize(configs, views); + expect(manager.getActiveView()).toBe('overview'); + + manager.switchView('detail'); + expect(manager.getActiveView()).toBe('detail'); + + // After switchView (no outgoing/incoming fade branch when both exist uses timer), + // check display immediately — the immediate path sets display + const overviewEl = container.querySelector('#forge-view-overview') as HTMLElement; + const detailEl = container.querySelector('#forge-view-detail') as HTMLElement; + + // switchView with both outgoing and incoming uses setTimeout for fade, + // but activeView is set synchronously + expect(manager.getActiveView()).toBe('detail'); + }); + + it('onViewChange callback fires on switchView', () => { + manager.initialize(configs, views); + const callback = vi.fn(); + manager.onViewChange(callback); + + manager.switchView('detail'); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('detail'); + }); + + it('onViewChange callback does not fire when switching to same view', () => { + manager.initialize(configs, views); + const callback = vi.fn(); + manager.onViewChange(callback); + + // overview is already active, switchView should bail early + manager.switchView('overview'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('getViewNames returns all view names', () => { + manager.initialize(configs, views); + const names = manager.getViewNames(); + expect(names).toEqual(['overview', 'detail']); + }); + + it('getActiveView returns current view', () => { + manager.initialize(configs, views); + expect(manager.getActiveView()).toBe('overview'); + + manager.switchView('detail'); + expect(manager.getActiveView()).toBe('detail'); + }); + + it('updateAll only updates panels in active view', () => { + manager.initialize(configs, views); + // Active view is 'overview' which contains alpha and beta + // 'gamma' is only in 'detail' + + // Reset lastData so we can track updates + for (const inst of MockPanel.instances) { + inst.lastData = undefined; + } + + const updateSpy = MockPanel.instances.map(inst => vi.spyOn(inst, 'update')); + + manager.updateAll({ test: 1 }); + + // alpha (overview) — instances[0] is alpha in overview + // beta (overview) — instances[1] is beta in overview + // beta (detail) — instances[2] is beta in detail + // gamma (detail) — instances[3] is gamma in detail + // alpha and beta-in-overview are in active view, beta-in-detail and gamma are not + const updatedInstances = MockPanel.instances.filter(inst => inst.lastData !== undefined); + const skippedInstances = MockPanel.instances.filter(inst => inst.lastData === undefined); + + // Panels in overview (alpha, beta) should be updated + // Panels only in detail (gamma) should be skipped + // beta appears in both views; the overview instance gets updated, the detail instance is skipped + expect(updatedInstances.length).toBeGreaterThanOrEqual(2); + expect(skippedInstances.length).toBeGreaterThanOrEqual(1); + + // Verify gamma was NOT updated (only in detail view) + // gamma is the last created panel instance + const gammaInstance = MockPanel.instances[MockPanel.instances.length - 1]; + expect(gammaInstance.lastData).toBeUndefined(); + }); + + it('falls back to flat mode when no views provided', () => { + manager.initialize(configs); + + // No view containers created + expect(manager.getViewNames()).toEqual([]); + expect(manager.getActiveView()).toBeNull(); + + // All panels should be direct children, no .forge-view wrappers + const viewEls = container.querySelectorAll('.forge-view'); + expect(viewEls).toHaveLength(0); + + const panelEls = container.querySelectorAll('.forge-panel'); + expect(panelEls).toHaveLength(3); + + // updateAll should update all panels (isPanelInActiveView returns true with no views) + for (const inst of MockPanel.instances) { + inst.lastData = undefined; + } + manager.updateAll({ flat: true }); + for (const inst of MockPanel.instances) { + expect(inst.lastData).toEqual({ flat: true }); + } + }); + + it('destroy cleans up event listeners and view containers', () => { + manager.initialize(configs, views); + + const removeKeydownSpy = vi.spyOn(document, 'removeEventListener'); + const removeHashSpy = vi.spyOn(window, 'removeEventListener'); + + manager.destroy(); + + // Event listeners removed + expect(removeKeydownSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + expect(removeHashSpy).toHaveBeenCalledWith('hashchange', expect.any(Function)); + + // Container is cleared + expect(container.innerHTML).toBe(''); + + // View containers map is cleared + expect(manager.getViewNames()).toEqual([]); + + // All panel instances destroyed + for (const inst of MockPanel.instances) { + expect(inst.destroyed).toBe(true); + } + + removeKeydownSpy.mockRestore(); + removeHashSpy.mockRestore(); + }); + }); });