@pennyfarthing/cyclist 11.0.0 → 11.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bikerack.js +1 -1
- package/dist/bikerack.js.map +1 -1
- package/dist/git-cache.d.ts +10 -0
- package/dist/git-cache.d.ts.map +1 -1
- package/dist/git-cache.js +40 -5
- package/dist/git-cache.js.map +1 -1
- package/dist/public/js/react/react.js +3 -3
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +53 -8
- package/dist/websocket.js.map +1 -1
- package/package.json +3 -87
- package/portraits/stephen-king/large/christine-25112.png +0 -0
- package/portraits/stephen-king/large/danny-53243.png +0 -0
- package/portraits/stephen-king/large/flagg-55311.png +0 -0
- package/portraits/stephen-king/large/jack-44224.png +0 -0
- package/portraits/stephen-king/large/johnny-44353.png +0 -0
- package/portraits/stephen-king/large/paul-45233.png +0 -0
- package/portraits/stephen-king/large/pennywise-54411.png +0 -0
- package/portraits/stephen-king/medium/christine-25112.png +0 -0
- package/portraits/stephen-king/medium/danny-53243.png +0 -0
- package/portraits/stephen-king/medium/flagg-55311.png +0 -0
- package/portraits/stephen-king/medium/jack-44224.png +0 -0
- package/portraits/stephen-king/medium/johnny-44353.png +0 -0
- package/portraits/stephen-king/medium/paul-45233.png +0 -0
- package/portraits/stephen-king/medium/pennywise-54411.png +0 -0
- package/dist/hooks/cyclist-pretooluse-hook.d.ts +0 -60
- package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +0 -1
- package/dist/hooks/cyclist-pretooluse-hook.js +0 -57
- package/dist/hooks/cyclist-pretooluse-hook.js.map +0 -1
- package/dist/hooks/pretooluse-hook.d.ts +0 -89
- package/dist/hooks/pretooluse-hook.d.ts.map +0 -1
- package/dist/hooks/pretooluse-hook.js +0 -235
- package/dist/hooks/pretooluse-hook.js.map +0 -1
- package/dist/notification-sound.d.ts +0 -59
- package/dist/notification-sound.d.ts.map +0 -1
- package/dist/notification-sound.js +0 -219
- package/dist/notification-sound.js.map +0 -1
- package/dist/plugin-loader.test.d.ts +0 -17
- package/dist/plugin-loader.test.d.ts.map +0 -1
- package/dist/plugin-loader.test.js +0 -407
- package/dist/plugin-loader.test.js.map +0 -1
|
@@ -1,219 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Notification Sound Service (Story MSSCI-14191)
|
|
3
|
-
*
|
|
4
|
-
* Provides audio notifications for workflow events using Web Audio API.
|
|
5
|
-
* Different tones for different event types:
|
|
6
|
-
* - HANDOFF: Low tone (agent transition)
|
|
7
|
-
* - QUESTION: Higher tone (user input needed)
|
|
8
|
-
* - COMPLETION: Chord (workflow complete)
|
|
9
|
-
*
|
|
10
|
-
* State is managed in-memory but can sync with settings via loadNotificationSoundSetting().
|
|
11
|
-
*/
|
|
12
|
-
// =============================================================================
|
|
13
|
-
// Types
|
|
14
|
-
// =============================================================================
|
|
15
|
-
/**
|
|
16
|
-
* Notification event types that trigger sounds
|
|
17
|
-
*/
|
|
18
|
-
export const NotificationEvent = {
|
|
19
|
-
HANDOFF: 'handoff', // Agent handoff / phase transition
|
|
20
|
-
QUESTION: 'question', // User input required
|
|
21
|
-
COMPLETION: 'completion', // Workflow completion
|
|
22
|
-
};
|
|
23
|
-
/**
|
|
24
|
-
* Frequency configurations for each event type (in Hz)
|
|
25
|
-
*/
|
|
26
|
-
const EVENT_FREQUENCIES = {
|
|
27
|
-
[NotificationEvent.HANDOFF]: [330], // E4 - single low tone
|
|
28
|
-
[NotificationEvent.QUESTION]: [523], // C5 - higher, attention-grabbing
|
|
29
|
-
[NotificationEvent.COMPLETION]: [523, 659, 784], // C5-E5-G5 major chord
|
|
30
|
-
};
|
|
31
|
-
// =============================================================================
|
|
32
|
-
// State
|
|
33
|
-
// =============================================================================
|
|
34
|
-
let soundEnabled = false;
|
|
35
|
-
let audioContext = null;
|
|
36
|
-
// For testing: injectable AudioContext constructor
|
|
37
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
38
|
-
let _audioContextClass = null;
|
|
39
|
-
// =============================================================================
|
|
40
|
-
// Public API
|
|
41
|
-
// =============================================================================
|
|
42
|
-
/**
|
|
43
|
-
* Service class for notification sounds (exported for type checking)
|
|
44
|
-
*/
|
|
45
|
-
export class NotificationSound {
|
|
46
|
-
static isEnabled() {
|
|
47
|
-
return isNotificationSoundEnabled();
|
|
48
|
-
}
|
|
49
|
-
static setEnabled(enabled) {
|
|
50
|
-
setNotificationSoundEnabled(enabled);
|
|
51
|
-
}
|
|
52
|
-
static async play(event) {
|
|
53
|
-
return playNotificationSound(event);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Check if notification sounds are enabled
|
|
58
|
-
*/
|
|
59
|
-
export function isNotificationSoundEnabled() {
|
|
60
|
-
return soundEnabled;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Enable or disable notification sounds
|
|
64
|
-
*/
|
|
65
|
-
export function setNotificationSoundEnabled(enabled) {
|
|
66
|
-
soundEnabled = enabled;
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Load notification sound setting from REST API
|
|
70
|
-
*/
|
|
71
|
-
export async function loadNotificationSoundSetting() {
|
|
72
|
-
try {
|
|
73
|
-
const response = await fetch('/api/settings');
|
|
74
|
-
if (response.ok) {
|
|
75
|
-
const settings = await response.json();
|
|
76
|
-
soundEnabled = settings?.notifications?.sound ?? false;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
// Silently fail - keep current state
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Check if audio playback is permitted (handles autoplay restrictions)
|
|
85
|
-
* Returns true if audio can play, false if user gesture is required
|
|
86
|
-
*/
|
|
87
|
-
export async function checkAudioPermission() {
|
|
88
|
-
try {
|
|
89
|
-
const ctx = getOrCreateAudioContext();
|
|
90
|
-
if (!ctx)
|
|
91
|
-
return false;
|
|
92
|
-
if (ctx.state === 'suspended') {
|
|
93
|
-
await ctx.resume();
|
|
94
|
-
}
|
|
95
|
-
return ctx.state === 'running';
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
/**
|
|
102
|
-
* Play a notification sound for the given event type
|
|
103
|
-
*/
|
|
104
|
-
export async function playNotificationSound(event) {
|
|
105
|
-
// Exit early if disabled
|
|
106
|
-
if (!soundEnabled) {
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
try {
|
|
110
|
-
const ctx = getOrCreateAudioContext();
|
|
111
|
-
if (!ctx) {
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
// Handle suspended state (autoplay restrictions)
|
|
115
|
-
if (ctx.state === 'suspended') {
|
|
116
|
-
await ctx.resume();
|
|
117
|
-
}
|
|
118
|
-
const frequencies = EVENT_FREQUENCIES[event] || EVENT_FREQUENCIES[NotificationEvent.HANDOFF];
|
|
119
|
-
// Play each frequency in the pattern
|
|
120
|
-
for (let i = 0; i < frequencies.length; i++) {
|
|
121
|
-
const freq = frequencies[i];
|
|
122
|
-
const startTime = ctx.currentTime + i * 0.1; // Slight offset for chord/arpeggio effect
|
|
123
|
-
playTone(ctx, freq, startTime, 0.2);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
catch {
|
|
127
|
-
// Silently fail - audio is optional
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
// =============================================================================
|
|
131
|
-
// Internal Helpers
|
|
132
|
-
// =============================================================================
|
|
133
|
-
/**
|
|
134
|
-
* Get the AudioContext constructor (allows for mocking in tests)
|
|
135
|
-
* Checks multiple locations to support different environments and test mocking
|
|
136
|
-
*/
|
|
137
|
-
function getAudioContextClass() {
|
|
138
|
-
// Check if a test-injected class is set
|
|
139
|
-
if (_audioContextClass !== null) {
|
|
140
|
-
return _audioContextClass;
|
|
141
|
-
}
|
|
142
|
-
// Check various locations where AudioContext might be defined
|
|
143
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
144
|
-
const g = globalThis;
|
|
145
|
-
if (g.AudioContext) {
|
|
146
|
-
return g.AudioContext;
|
|
147
|
-
}
|
|
148
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
149
|
-
if (typeof window !== 'undefined' && window.AudioContext) {
|
|
150
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
151
|
-
return window.AudioContext;
|
|
152
|
-
}
|
|
153
|
-
// webkitAudioContext for Safari
|
|
154
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
155
|
-
if (typeof window !== 'undefined' && window.webkitAudioContext) {
|
|
156
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
157
|
-
return window.webkitAudioContext;
|
|
158
|
-
}
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Get or create the shared AudioContext
|
|
163
|
-
*/
|
|
164
|
-
function getOrCreateAudioContext() {
|
|
165
|
-
const AudioContextClass = getAudioContextClass();
|
|
166
|
-
if (!AudioContextClass) {
|
|
167
|
-
return null;
|
|
168
|
-
}
|
|
169
|
-
if (!audioContext) {
|
|
170
|
-
try {
|
|
171
|
-
audioContext = new AudioContextClass();
|
|
172
|
-
}
|
|
173
|
-
catch {
|
|
174
|
-
return null;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
return audioContext;
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Play a single tone using Web Audio API
|
|
181
|
-
*/
|
|
182
|
-
function playTone(ctx, frequency, startTime, duration) {
|
|
183
|
-
const oscillator = ctx.createOscillator();
|
|
184
|
-
const gainNode = ctx.createGain();
|
|
185
|
-
oscillator.connect(gainNode);
|
|
186
|
-
gainNode.connect(ctx.destination);
|
|
187
|
-
oscillator.type = 'sine';
|
|
188
|
-
oscillator.frequency.setValueAtTime(frequency, startTime);
|
|
189
|
-
// Envelope: quick attack, gradual decay
|
|
190
|
-
gainNode.gain.setValueAtTime(0.3, startTime);
|
|
191
|
-
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
|
192
|
-
oscillator.start(startTime);
|
|
193
|
-
oscillator.stop(startTime + duration);
|
|
194
|
-
}
|
|
195
|
-
/**
|
|
196
|
-
* Reset state (for testing)
|
|
197
|
-
*/
|
|
198
|
-
export function resetNotificationSoundState() {
|
|
199
|
-
soundEnabled = false;
|
|
200
|
-
if (audioContext) {
|
|
201
|
-
try {
|
|
202
|
-
audioContext.close().catch(() => { });
|
|
203
|
-
}
|
|
204
|
-
catch {
|
|
205
|
-
// Ignore errors closing mock audio context
|
|
206
|
-
}
|
|
207
|
-
audioContext = null;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* Inject a custom AudioContext class (for testing)
|
|
212
|
-
* Pass null to use the default detection
|
|
213
|
-
*/
|
|
214
|
-
export function setAudioContextClass(cls) {
|
|
215
|
-
_audioContextClass = cls;
|
|
216
|
-
// Reset the existing context so the new class is used
|
|
217
|
-
audioContext = null;
|
|
218
|
-
}
|
|
219
|
-
//# sourceMappingURL=notification-sound.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"notification-sound.js","sourceRoot":"","sources":["../src/notification-sound.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for Story 93-6: Plugin Router Loader for Cyclist
|
|
3
|
-
*
|
|
4
|
-
* Tests the dynamic discovery and mounting of API routers from installed
|
|
5
|
-
* @pennyfarthing/* plugin packages. The loader uses the plugin discovery
|
|
6
|
-
* system (93-3) to find plugins and mount their Express routers.
|
|
7
|
-
*
|
|
8
|
-
* Test categories:
|
|
9
|
-
* 1. initPluginRouters() - Core loading behavior
|
|
10
|
-
* 2. Plugin with API router - Benchmark plugin integration
|
|
11
|
-
* 3. Graceful degradation - Missing packages, failed imports
|
|
12
|
-
* 4. Cyclist starts without plugins - Empty state
|
|
13
|
-
*
|
|
14
|
-
* Run with: cd packages/cyclist && pnpm test
|
|
15
|
-
*/
|
|
16
|
-
export {};
|
|
17
|
-
//# sourceMappingURL=plugin-loader.test.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"plugin-loader.test.d.ts","sourceRoot":"","sources":["../src/plugin-loader.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG"}
|
|
@@ -1,407 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for Story 93-6: Plugin Router Loader for Cyclist
|
|
3
|
-
*
|
|
4
|
-
* Tests the dynamic discovery and mounting of API routers from installed
|
|
5
|
-
* @pennyfarthing/* plugin packages. The loader uses the plugin discovery
|
|
6
|
-
* system (93-3) to find plugins and mount their Express routers.
|
|
7
|
-
*
|
|
8
|
-
* Test categories:
|
|
9
|
-
* 1. initPluginRouters() - Core loading behavior
|
|
10
|
-
* 2. Plugin with API router - Benchmark plugin integration
|
|
11
|
-
* 3. Graceful degradation - Missing packages, failed imports
|
|
12
|
-
* 4. Cyclist starts without plugins - Empty state
|
|
13
|
-
*
|
|
14
|
-
* Run with: cd packages/cyclist && pnpm test
|
|
15
|
-
*/
|
|
16
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
17
|
-
import express from 'express';
|
|
18
|
-
import { initPluginRouters } from './plugin-loader.js';
|
|
19
|
-
// Mock @pennyfarthing/core plugin discovery
|
|
20
|
-
vi.mock('@pennyfarthing/core', async () => {
|
|
21
|
-
const actual = await vi.importActual('@pennyfarthing/core');
|
|
22
|
-
return {
|
|
23
|
-
...actual,
|
|
24
|
-
discoverPlugins: vi.fn(),
|
|
25
|
-
getPluginRouters: vi.fn(),
|
|
26
|
-
};
|
|
27
|
-
});
|
|
28
|
-
// Get mocked functions for test control
|
|
29
|
-
import { discoverPlugins, getPluginRouters } from '@pennyfarthing/core';
|
|
30
|
-
const mockDiscoverPlugins = vi.mocked(discoverPlugins);
|
|
31
|
-
const mockGetPluginRouters = vi.mocked(getPluginRouters);
|
|
32
|
-
// Helper: create a fake Express Router
|
|
33
|
-
function createFakeRouter() {
|
|
34
|
-
const router = express.Router();
|
|
35
|
-
router.get('/test', (_req, res) => res.json({ ok: true }));
|
|
36
|
-
return router;
|
|
37
|
-
}
|
|
38
|
-
describe('initPluginRouters()', () => {
|
|
39
|
-
let app;
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
app = express();
|
|
42
|
-
vi.clearAllMocks();
|
|
43
|
-
});
|
|
44
|
-
it('should return PluginLoadResult with correct shape', async () => {
|
|
45
|
-
mockDiscoverPlugins.mockReturnValue([]);
|
|
46
|
-
mockGetPluginRouters.mockReturnValue([]);
|
|
47
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
48
|
-
expect(result).toHaveProperty('discovered');
|
|
49
|
-
expect(result).toHaveProperty('loaded');
|
|
50
|
-
expect(result).toHaveProperty('failed');
|
|
51
|
-
expect(result).toHaveProperty('routers');
|
|
52
|
-
expect(typeof result.discovered).toBe('number');
|
|
53
|
-
expect(typeof result.loaded).toBe('number');
|
|
54
|
-
expect(typeof result.failed).toBe('number');
|
|
55
|
-
expect(Array.isArray(result.routers)).toBe(true);
|
|
56
|
-
});
|
|
57
|
-
it('should report zero when no plugins are discovered', async () => {
|
|
58
|
-
mockDiscoverPlugins.mockReturnValue([]);
|
|
59
|
-
mockGetPluginRouters.mockReturnValue([]);
|
|
60
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
61
|
-
expect(result.discovered).toBe(0);
|
|
62
|
-
expect(result.loaded).toBe(0);
|
|
63
|
-
expect(result.failed).toBe(0);
|
|
64
|
-
expect(result.routers).toHaveLength(0);
|
|
65
|
-
});
|
|
66
|
-
it('should call discoverPlugins with the project root', async () => {
|
|
67
|
-
mockDiscoverPlugins.mockReturnValue([]);
|
|
68
|
-
mockGetPluginRouters.mockReturnValue([]);
|
|
69
|
-
await initPluginRouters(app, '/my/project');
|
|
70
|
-
expect(mockDiscoverPlugins).toHaveBeenCalledWith('/my/project');
|
|
71
|
-
});
|
|
72
|
-
it('should pass discovered plugins to getPluginRouters', async () => {
|
|
73
|
-
const fakePlugins = [
|
|
74
|
-
{
|
|
75
|
-
name: '@pennyfarthing/benchmark',
|
|
76
|
-
path: '/node_modules/@pennyfarthing/benchmark',
|
|
77
|
-
manifest: { commands: 'commands/', skills: 'skills/' },
|
|
78
|
-
},
|
|
79
|
-
];
|
|
80
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
81
|
-
mockGetPluginRouters.mockReturnValue([]);
|
|
82
|
-
await initPluginRouters(app, '/fake/project');
|
|
83
|
-
expect(mockGetPluginRouters).toHaveBeenCalledWith(fakePlugins);
|
|
84
|
-
});
|
|
85
|
-
it('should report discovered count matching number of plugins', async () => {
|
|
86
|
-
const fakePlugins = [
|
|
87
|
-
{
|
|
88
|
-
name: '@pennyfarthing/benchmark',
|
|
89
|
-
path: '/node_modules/@pennyfarthing/benchmark',
|
|
90
|
-
manifest: { commands: 'commands/' },
|
|
91
|
-
},
|
|
92
|
-
{
|
|
93
|
-
name: '@pennyfarthing/analytics',
|
|
94
|
-
path: '/node_modules/@pennyfarthing/analytics',
|
|
95
|
-
manifest: { commands: 'commands/' },
|
|
96
|
-
},
|
|
97
|
-
];
|
|
98
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
99
|
-
mockGetPluginRouters.mockReturnValue([]);
|
|
100
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
101
|
-
expect(result.discovered).toBe(2);
|
|
102
|
-
});
|
|
103
|
-
});
|
|
104
|
-
describe('Plugin with API router', () => {
|
|
105
|
-
let app;
|
|
106
|
-
beforeEach(() => {
|
|
107
|
-
app = express();
|
|
108
|
-
vi.clearAllMocks();
|
|
109
|
-
});
|
|
110
|
-
it('should dynamically import and mount a plugin router', async () => {
|
|
111
|
-
const fakePlugins = [
|
|
112
|
-
{
|
|
113
|
-
name: '@pennyfarthing/benchmark',
|
|
114
|
-
path: '/node_modules/@pennyfarthing/benchmark',
|
|
115
|
-
manifest: {
|
|
116
|
-
commands: 'commands/',
|
|
117
|
-
api: {
|
|
118
|
-
path: '/api/benchmark',
|
|
119
|
-
module: './dist/api/benchmark.js',
|
|
120
|
-
export: 'createBenchmarkRouter',
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
];
|
|
125
|
-
const fakeRouters = [
|
|
126
|
-
{
|
|
127
|
-
mountPath: '/api/benchmark',
|
|
128
|
-
modulePath: '/node_modules/@pennyfarthing/benchmark/dist/api/benchmark.js',
|
|
129
|
-
exportName: 'createBenchmarkRouter',
|
|
130
|
-
plugin: '@pennyfarthing/benchmark',
|
|
131
|
-
},
|
|
132
|
-
];
|
|
133
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
134
|
-
mockGetPluginRouters.mockReturnValue(fakeRouters);
|
|
135
|
-
// Mock the dynamic import to return a module with the router factory
|
|
136
|
-
const fakeRouter = createFakeRouter();
|
|
137
|
-
vi.stubGlobal('import', undefined); // Clear any previous stubs
|
|
138
|
-
// We need to mock the dynamic import that initPluginRouters will use
|
|
139
|
-
// The implementation should use: const mod = await import(modulePath)
|
|
140
|
-
// We mock it via vi.mock for the specific module path
|
|
141
|
-
const originalImport = globalThis.import;
|
|
142
|
-
// @ts-expect-error - mocking dynamic import
|
|
143
|
-
globalThis.__pluginImportMock = vi.fn().mockResolvedValue({
|
|
144
|
-
createBenchmarkRouter: () => fakeRouter,
|
|
145
|
-
});
|
|
146
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
147
|
-
expect(result.loaded).toBe(1);
|
|
148
|
-
expect(result.failed).toBe(0);
|
|
149
|
-
expect(result.routers).toHaveLength(1);
|
|
150
|
-
expect(result.routers[0]).toEqual(expect.objectContaining({
|
|
151
|
-
mountPath: '/api/benchmark',
|
|
152
|
-
plugin: '@pennyfarthing/benchmark',
|
|
153
|
-
success: true,
|
|
154
|
-
}));
|
|
155
|
-
});
|
|
156
|
-
it('should mount router at the declared mount path', async () => {
|
|
157
|
-
const fakePlugins = [
|
|
158
|
-
{
|
|
159
|
-
name: '@pennyfarthing/benchmark',
|
|
160
|
-
path: '/node_modules/@pennyfarthing/benchmark',
|
|
161
|
-
manifest: {
|
|
162
|
-
api: {
|
|
163
|
-
path: '/api/benchmark',
|
|
164
|
-
module: './dist/api/benchmark.js',
|
|
165
|
-
export: 'createBenchmarkRouter',
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
},
|
|
169
|
-
];
|
|
170
|
-
const fakeRouters = [
|
|
171
|
-
{
|
|
172
|
-
mountPath: '/api/benchmark',
|
|
173
|
-
modulePath: '/node_modules/@pennyfarthing/benchmark/dist/api/benchmark.js',
|
|
174
|
-
exportName: 'createBenchmarkRouter',
|
|
175
|
-
plugin: '@pennyfarthing/benchmark',
|
|
176
|
-
},
|
|
177
|
-
];
|
|
178
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
179
|
-
mockGetPluginRouters.mockReturnValue(fakeRouters);
|
|
180
|
-
// Spy on app.use to verify the mount path
|
|
181
|
-
const useSpy = vi.spyOn(app, 'use');
|
|
182
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
183
|
-
// Verify app.use was called with the correct mount path
|
|
184
|
-
if (result.loaded > 0) {
|
|
185
|
-
const mountCalls = useSpy.mock.calls.filter((call) => call[0] === '/api/benchmark');
|
|
186
|
-
expect(mountCalls.length).toBeGreaterThan(0);
|
|
187
|
-
}
|
|
188
|
-
});
|
|
189
|
-
it('should pass getProjectDir to router factory if it accepts arguments', async () => {
|
|
190
|
-
// Some routers take a getProjectDir function as argument
|
|
191
|
-
// The plugin loader should detect this and pass it
|
|
192
|
-
const fakePlugins = [
|
|
193
|
-
{
|
|
194
|
-
name: '@pennyfarthing/benchmark',
|
|
195
|
-
path: '/node_modules/@pennyfarthing/benchmark',
|
|
196
|
-
manifest: {
|
|
197
|
-
api: {
|
|
198
|
-
path: '/api/benchmark',
|
|
199
|
-
module: './dist/api/benchmark.js',
|
|
200
|
-
export: 'createBenchmarkRouter',
|
|
201
|
-
},
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
];
|
|
205
|
-
const fakeRouters = [
|
|
206
|
-
{
|
|
207
|
-
mountPath: '/api/benchmark',
|
|
208
|
-
modulePath: '/node_modules/@pennyfarthing/benchmark/dist/api/benchmark.js',
|
|
209
|
-
exportName: 'createBenchmarkRouter',
|
|
210
|
-
plugin: '@pennyfarthing/benchmark',
|
|
211
|
-
},
|
|
212
|
-
];
|
|
213
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
214
|
-
mockGetPluginRouters.mockReturnValue(fakeRouters);
|
|
215
|
-
// The result should include the mounted router
|
|
216
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
217
|
-
// If loaded successfully, the factory was called
|
|
218
|
-
expect(result).toBeDefined();
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
describe('Graceful degradation', () => {
|
|
222
|
-
let app;
|
|
223
|
-
beforeEach(() => {
|
|
224
|
-
app = express();
|
|
225
|
-
vi.clearAllMocks();
|
|
226
|
-
});
|
|
227
|
-
it('should handle failed dynamic import gracefully', async () => {
|
|
228
|
-
const fakePlugins = [
|
|
229
|
-
{
|
|
230
|
-
name: '@pennyfarthing/broken',
|
|
231
|
-
path: '/node_modules/@pennyfarthing/broken',
|
|
232
|
-
manifest: {
|
|
233
|
-
api: {
|
|
234
|
-
path: '/api/broken',
|
|
235
|
-
module: './dist/api/broken.js',
|
|
236
|
-
export: 'createBrokenRouter',
|
|
237
|
-
},
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
];
|
|
241
|
-
const fakeRouters = [
|
|
242
|
-
{
|
|
243
|
-
mountPath: '/api/broken',
|
|
244
|
-
modulePath: '/node_modules/@pennyfarthing/broken/dist/api/broken.js',
|
|
245
|
-
exportName: 'createBrokenRouter',
|
|
246
|
-
plugin: '@pennyfarthing/broken',
|
|
247
|
-
},
|
|
248
|
-
];
|
|
249
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
250
|
-
mockGetPluginRouters.mockReturnValue(fakeRouters);
|
|
251
|
-
// The module path doesn't exist, so dynamic import will fail
|
|
252
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
253
|
-
// Should NOT throw — graceful degradation
|
|
254
|
-
expect(result.loaded).toBe(0);
|
|
255
|
-
expect(result.failed).toBe(1);
|
|
256
|
-
expect(result.routers[0]).toEqual(expect.objectContaining({
|
|
257
|
-
mountPath: '/api/broken',
|
|
258
|
-
plugin: '@pennyfarthing/broken',
|
|
259
|
-
success: false,
|
|
260
|
-
}));
|
|
261
|
-
expect(result.routers[0].error).toBeDefined();
|
|
262
|
-
});
|
|
263
|
-
it('should handle missing export name in module gracefully', async () => {
|
|
264
|
-
const fakePlugins = [
|
|
265
|
-
{
|
|
266
|
-
name: '@pennyfarthing/noexport',
|
|
267
|
-
path: '/node_modules/@pennyfarthing/noexport',
|
|
268
|
-
manifest: {
|
|
269
|
-
api: {
|
|
270
|
-
path: '/api/noexport',
|
|
271
|
-
module: './dist/api/noexport.js',
|
|
272
|
-
export: 'createNonexistentRouter',
|
|
273
|
-
},
|
|
274
|
-
},
|
|
275
|
-
},
|
|
276
|
-
];
|
|
277
|
-
const fakeRouters = [
|
|
278
|
-
{
|
|
279
|
-
mountPath: '/api/noexport',
|
|
280
|
-
modulePath: '/node_modules/@pennyfarthing/noexport/dist/api/noexport.js',
|
|
281
|
-
exportName: 'createNonexistentRouter',
|
|
282
|
-
plugin: '@pennyfarthing/noexport',
|
|
283
|
-
},
|
|
284
|
-
];
|
|
285
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
286
|
-
mockGetPluginRouters.mockReturnValue(fakeRouters);
|
|
287
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
288
|
-
// Should fail gracefully when the export doesn't exist
|
|
289
|
-
expect(result.failed).toBeGreaterThanOrEqual(1);
|
|
290
|
-
const routerResult = result.routers.find((r) => r.plugin === '@pennyfarthing/noexport');
|
|
291
|
-
expect(routerResult?.success).toBe(false);
|
|
292
|
-
});
|
|
293
|
-
it('should continue loading other plugins when one fails', async () => {
|
|
294
|
-
const fakePlugins = [
|
|
295
|
-
{
|
|
296
|
-
name: '@pennyfarthing/broken',
|
|
297
|
-
path: '/node_modules/@pennyfarthing/broken',
|
|
298
|
-
manifest: {
|
|
299
|
-
api: {
|
|
300
|
-
path: '/api/broken',
|
|
301
|
-
module: './dist/api/broken.js',
|
|
302
|
-
export: 'createBrokenRouter',
|
|
303
|
-
},
|
|
304
|
-
},
|
|
305
|
-
},
|
|
306
|
-
{
|
|
307
|
-
name: '@pennyfarthing/good',
|
|
308
|
-
path: '/node_modules/@pennyfarthing/good',
|
|
309
|
-
manifest: {
|
|
310
|
-
api: {
|
|
311
|
-
path: '/api/good',
|
|
312
|
-
module: './dist/api/good.js',
|
|
313
|
-
export: 'createGoodRouter',
|
|
314
|
-
},
|
|
315
|
-
},
|
|
316
|
-
},
|
|
317
|
-
];
|
|
318
|
-
const fakeRouters = [
|
|
319
|
-
{
|
|
320
|
-
mountPath: '/api/broken',
|
|
321
|
-
modulePath: '/node_modules/@pennyfarthing/broken/dist/api/broken.js',
|
|
322
|
-
exportName: 'createBrokenRouter',
|
|
323
|
-
plugin: '@pennyfarthing/broken',
|
|
324
|
-
},
|
|
325
|
-
{
|
|
326
|
-
mountPath: '/api/good',
|
|
327
|
-
modulePath: '/node_modules/@pennyfarthing/good/dist/api/good.js',
|
|
328
|
-
exportName: 'createGoodRouter',
|
|
329
|
-
plugin: '@pennyfarthing/good',
|
|
330
|
-
},
|
|
331
|
-
];
|
|
332
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
333
|
-
mockGetPluginRouters.mockReturnValue(fakeRouters);
|
|
334
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
335
|
-
// Both routers should be attempted
|
|
336
|
-
expect(result.routers).toHaveLength(2);
|
|
337
|
-
// At least the broken one should fail
|
|
338
|
-
const brokenResult = result.routers.find((r) => r.plugin === '@pennyfarthing/broken');
|
|
339
|
-
expect(brokenResult?.success).toBe(false);
|
|
340
|
-
});
|
|
341
|
-
it('should never throw from initPluginRouters', async () => {
|
|
342
|
-
// Even if discoverPlugins itself throws, we should catch it
|
|
343
|
-
mockDiscoverPlugins.mockImplementation(() => {
|
|
344
|
-
throw new Error('Catastrophic failure');
|
|
345
|
-
});
|
|
346
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
347
|
-
expect(result.discovered).toBe(0);
|
|
348
|
-
expect(result.loaded).toBe(0);
|
|
349
|
-
expect(result.failed).toBe(0);
|
|
350
|
-
});
|
|
351
|
-
});
|
|
352
|
-
describe('Cyclist starts without plugins', () => {
|
|
353
|
-
let app;
|
|
354
|
-
beforeEach(() => {
|
|
355
|
-
app = express();
|
|
356
|
-
vi.clearAllMocks();
|
|
357
|
-
});
|
|
358
|
-
it('should return clean result when no @pennyfarthing packages exist', async () => {
|
|
359
|
-
mockDiscoverPlugins.mockReturnValue([]);
|
|
360
|
-
mockGetPluginRouters.mockReturnValue([]);
|
|
361
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
362
|
-
expect(result.discovered).toBe(0);
|
|
363
|
-
expect(result.loaded).toBe(0);
|
|
364
|
-
expect(result.failed).toBe(0);
|
|
365
|
-
expect(result.routers).toEqual([]);
|
|
366
|
-
});
|
|
367
|
-
it('should not modify the Express app when no plugins have routers', async () => {
|
|
368
|
-
const fakePlugins = [
|
|
369
|
-
{
|
|
370
|
-
name: '@pennyfarthing/benchmark',
|
|
371
|
-
path: '/node_modules/@pennyfarthing/benchmark',
|
|
372
|
-
manifest: { commands: 'commands/', skills: 'skills/' },
|
|
373
|
-
// Note: no api field — plugin has commands/skills but no router
|
|
374
|
-
},
|
|
375
|
-
];
|
|
376
|
-
mockDiscoverPlugins.mockReturnValue(fakePlugins);
|
|
377
|
-
mockGetPluginRouters.mockReturnValue([]); // No routers declared
|
|
378
|
-
const useSpy = vi.spyOn(app, 'use');
|
|
379
|
-
const routesBefore = app._router?.stack?.length ?? 0;
|
|
380
|
-
const result = await initPluginRouters(app, '/fake/project');
|
|
381
|
-
// Plugin was discovered but has no router
|
|
382
|
-
expect(result.discovered).toBe(1);
|
|
383
|
-
expect(result.loaded).toBe(0);
|
|
384
|
-
// app.use should not have been called for plugin routers
|
|
385
|
-
const pluginMountCalls = useSpy.mock.calls.filter((call) => typeof call[0] === 'string' && call[0].startsWith('/api/'));
|
|
386
|
-
expect(pluginMountCalls).toHaveLength(0);
|
|
387
|
-
});
|
|
388
|
-
it('should log discovered plugins for observability', async () => {
|
|
389
|
-
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
390
|
-
mockDiscoverPlugins.mockReturnValue([
|
|
391
|
-
{
|
|
392
|
-
name: '@pennyfarthing/benchmark',
|
|
393
|
-
path: '/node_modules/@pennyfarthing/benchmark',
|
|
394
|
-
manifest: { commands: 'commands/' },
|
|
395
|
-
},
|
|
396
|
-
]);
|
|
397
|
-
mockGetPluginRouters.mockReturnValue([]);
|
|
398
|
-
await initPluginRouters(app, '/fake/project');
|
|
399
|
-
// Should log something about plugin discovery
|
|
400
|
-
expect(consoleSpy).toHaveBeenCalled();
|
|
401
|
-
const logMessages = consoleSpy.mock.calls.map((call) => call.join(' '));
|
|
402
|
-
const hasPluginLog = logMessages.some((msg) => msg.includes('plugin') || msg.includes('Plugin'));
|
|
403
|
-
expect(hasPluginLog).toBe(true);
|
|
404
|
-
consoleSpy.mockRestore();
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
//# sourceMappingURL=plugin-loader.test.js.map
|