## CHANGES

- Playbook Exchange now highlights local playbooks with blue “Local” badge 🟦
- Added new screenshot documenting the Local badge UI state 🖼️
- Marketplace manifest results now include per-playbook `source` metadata 🧾
- Manifest handling now merges official and local sources consistently 🔀
- Network failures now return empty merged manifest instead of error 📡
- HTTP fetch failures now degrade gracefully to empty manifest result 🧯
- Marketplace tests updated for dual-read cache + local manifest flow 🧪
- InputArea now expects pre-filtered `thinkingSessions` for better performance 
- ThinkingStatusPill mock now matches real conditional rendering behavior 🎭
- Added `onManifestChanged` stub to Maestro test setup for new hook 🪝
This commit is contained in:
Pedram Amini
2026-01-18 10:52:56 -06:00
parent a699332797
commit 0bd8a4ffc7
5 changed files with 54 additions and 18 deletions

View File

@@ -169,7 +169,9 @@ This enables rapid iteration during playbook development.
## UI Indicators
Local playbooks are visually distinguished in the Playbook Exchange:
Local playbooks are visually distinguished in the Playbook Exchange with a blue "Local" badge:
![Playbook Exchange with Local Badge](screenshots/playbook-exchange-list-with-local.png)
- **Badge:** Blue "Local" badge next to category
- **Tooltip:** "Custom local playbook" on hover

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -29,6 +29,7 @@ vi.mock('electron', () => ({
},
app: {
getPath: vi.fn(),
on: vi.fn(),
},
}));
@@ -160,6 +161,7 @@ describe('marketplace IPC handlers', () => {
// Setup mock app
mockApp = {
getPath: vi.fn().mockReturnValue('/mock/userData'),
on: vi.fn(),
} as unknown as App;
// Setup mock settings store for SSH remote lookup
@@ -213,7 +215,7 @@ describe('marketplace IPC handlers', () => {
describe('marketplace:getManifest', () => {
it('should create cache file in userData after first fetch', async () => {
// No existing cache
// No existing cache, no local manifest
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
@@ -242,7 +244,9 @@ describe('marketplace IPC handlers', () => {
// Verify response indicates not from cache
expect(result.fromCache).toBe(false);
expect(result.manifest).toEqual(sampleManifest);
// Merged manifest includes source field for each playbook
expect(result.manifest.playbooks.length).toBe(sampleManifest.playbooks.length);
expect(result.manifest.playbooks.every((p: any) => p.source === 'official')).toBe(true);
});
it('should use cache when within TTL', async () => {
@@ -252,7 +256,10 @@ describe('marketplace IPC handlers', () => {
manifest: sampleManifest,
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(cachedData));
// First read returns cache, second read (local manifest) returns ENOENT
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(cachedData))
.mockRejectedValueOnce({ code: 'ENOENT' });
const handler = handlers.get('marketplace:getManifest');
const result = await handler!({} as any);
@@ -264,7 +271,9 @@ describe('marketplace IPC handlers', () => {
expect(result.fromCache).toBe(true);
expect(result.cacheAge).toBeDefined();
expect(result.cacheAge).toBeGreaterThanOrEqual(cacheAge);
expect(result.manifest).toEqual(sampleManifest);
// Merged manifest includes source field for each playbook
expect(result.manifest.playbooks.length).toBe(sampleManifest.playbooks.length);
expect(result.manifest.playbooks.every((p: any) => p.source === 'official')).toBe(true);
});
it('should fetch fresh data when cache is expired', async () => {
@@ -277,7 +286,10 @@ describe('marketplace IPC handlers', () => {
},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(expiredCache));
// First read returns expired cache, second read (local manifest) returns ENOENT
vi.mocked(fs.readFile)
.mockResolvedValueOnce(JSON.stringify(expiredCache))
.mockRejectedValueOnce({ code: 'ENOENT' });
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
mockFetch.mockResolvedValue({
@@ -293,7 +305,9 @@ describe('marketplace IPC handlers', () => {
// Should return fresh data
expect(result.fromCache).toBe(false);
expect(result.manifest).toEqual(sampleManifest);
// Merged manifest includes source field for each playbook
expect(result.manifest.playbooks.length).toBe(sampleManifest.playbooks.length);
expect(result.manifest.playbooks.every((p: any) => p.source === 'official')).toBe(true);
});
it('should handle invalid cache structure gracefully', async () => {
@@ -316,7 +330,8 @@ describe('marketplace IPC handlers', () => {
expect(result.fromCache).toBe(false);
});
it('should handle network errors gracefully', async () => {
it('should handle network errors gracefully by returning empty merged manifest', async () => {
// No cache, no local manifest
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
mockFetch.mockRejectedValue(new Error('Network error'));
@@ -324,11 +339,15 @@ describe('marketplace IPC handlers', () => {
const handler = handlers.get('marketplace:getManifest');
const result = await handler!({} as any);
expect(result.success).toBe(false);
expect(result.error).toContain('Network error');
// With local manifest support, network errors are now handled gracefully
// Returns empty manifest (merged result of null official + null local)
expect(result.manifest).toBeDefined();
expect(result.manifest.playbooks).toEqual([]);
expect(result.fromCache).toBe(false);
});
it('should handle HTTP error responses', async () => {
it('should handle HTTP error responses gracefully by returning empty merged manifest', async () => {
// No cache, no local manifest
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
mockFetch.mockResolvedValue({
@@ -340,8 +359,11 @@ describe('marketplace IPC handlers', () => {
const handler = handlers.get('marketplace:getManifest');
const result = await handler!({} as any);
expect(result.success).toBe(false);
expect(result.error).toContain('Failed to fetch manifest');
// With local manifest support, HTTP errors are now handled gracefully
// Returns empty manifest (merged result of null official + null local)
expect(result.manifest).toBeDefined();
expect(result.manifest.playbooks).toEqual([]);
expect(result.fromCache).toBe(false);
});
});
@@ -356,7 +378,8 @@ describe('marketplace IPC handlers', () => {
},
};
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(validCache));
// First read is for local manifest (returns ENOENT = no local manifest)
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
mockFetch.mockResolvedValue({
@@ -372,7 +395,13 @@ describe('marketplace IPC handlers', () => {
// Should return fresh data
expect(result.fromCache).toBe(false);
expect(result.manifest).toEqual(sampleManifest);
// Manifest now includes source field from mergeManifests
expect(result.manifest.playbooks.length).toBe(sampleManifest.playbooks.length);
expect(result.manifest.playbooks.every((p: any) => p.source === 'official')).toBe(true);
expect(result.manifest.playbooks.map((p: any) => p.id)).toEqual(
sampleManifest.playbooks.map((p) => p.id)
);
// Should have updated cache
expect(fs.writeFile).toHaveBeenCalled();

View File

@@ -58,8 +58,11 @@ vi.mock('../../../renderer/hooks/agent/useAgentCapabilities', () => ({
// Mock child components to isolate InputArea testing
vi.mock('../../../renderer/components/ThinkingStatusPill', () => ({
ThinkingStatusPill: vi.fn(({ sessions, onSessionClick }) => (
<div data-testid="thinking-status-pill">ThinkingStatusPill</div>
ThinkingStatusPill: vi.fn(({ thinkingSessions, onSessionClick }) => (
// Only render when there are thinking sessions (matches real component behavior)
thinkingSessions && thinkingSessions.length > 0 ? (
<div data-testid="thinking-status-pill">ThinkingStatusPill</div>
) : null
)),
}));
@@ -377,6 +380,7 @@ describe('InputArea', () => {
it('renders ThinkingStatusPill when sessions are thinking', () => {
// ThinkingStatusPill only renders when there are thinking sessions (state: 'busy', busySource: 'ai')
// PERF: InputArea now expects pre-filtered thinkingSessions prop
const thinkingSession = createMockSession({
inputMode: 'ai',
state: 'busy',
@@ -384,7 +388,7 @@ describe('InputArea', () => {
});
const props = createDefaultProps({
session: thinkingSession,
sessions: [thinkingSession],
thinkingSessions: [thinkingSession],
});
render(<InputArea {...props} />);

View File

@@ -310,6 +310,7 @@ const mockMaestro = {
getDocument: vi.fn().mockResolvedValue({ success: true, content: '' }),
getReadme: vi.fn().mockResolvedValue({ success: true, content: null }),
importPlaybook: vi.fn().mockResolvedValue({ success: true, playbook: {}, importedDocs: [] }),
onManifestChanged: vi.fn().mockReturnValue(() => {}),
},
web: {
broadcastAutoRunState: vi.fn(),