mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
Validate skills catalog response entries
This commit is contained in:
parent
e9112c812f
commit
afa615a0bd
6 changed files with 162 additions and 5 deletions
|
|
@ -11,7 +11,7 @@ import { parseDesignSystemRenameArgs } from './design-system-rename-args.js';
|
|||
import { runLiveArtifactsToolCli } from './tools-live-artifacts-cli.js';
|
||||
import { splitResearchSubcommand } from './research/cli-args.js';
|
||||
import { resolveDaemonUrl } from './daemon-url.js';
|
||||
import { buildSkillCatalogTree } from '@open-design/contracts';
|
||||
import { buildSkillCatalogTree, parseSkillSummaries } from '@open-design/contracts';
|
||||
import { requestJsonIpc } from '@open-design/sidecar';
|
||||
import { SIDECAR_ENV, SIDECAR_MESSAGES } from '@open-design/sidecar-proto';
|
||||
|
||||
|
|
@ -5433,14 +5433,20 @@ async function runSkillsTree(args) {
|
|||
const resp = await fetch(`${base}/api/skills`);
|
||||
if (!resp.ok) return structuredHttpFailure(resp);
|
||||
const data = await resp.json().catch(() => null);
|
||||
if (!data || !Array.isArray(data.skills)) {
|
||||
let skills;
|
||||
try {
|
||||
skills = data && Array.isArray(data.skills) ? parseSkillSummaries(data.skills) : null;
|
||||
} catch {
|
||||
skills = null;
|
||||
}
|
||||
if (!skills) {
|
||||
return exitWithStructuredError({
|
||||
code: 'daemon-protocol-error',
|
||||
message: 'Malformed /api/skills response: expected { skills: SkillSummary[] }',
|
||||
data: { endpoint: '/api/skills' },
|
||||
});
|
||||
}
|
||||
const tree = buildSkillCatalogTree(data.skills);
|
||||
const tree = buildSkillCatalogTree(skills);
|
||||
if (flags.json) return process.stdout.write(JSON.stringify(tree, null, 2) + '\n');
|
||||
console.log(`Skills tree (${tree.total})`);
|
||||
for (const mode of tree.modes) {
|
||||
|
|
|
|||
|
|
@ -39,11 +39,18 @@ describe('od skills CLI', () => {
|
|||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
description: 'A dashboard skill.',
|
||||
triggers: [],
|
||||
mode: 'prototype',
|
||||
scenario: 'operation',
|
||||
platform: 'desktop',
|
||||
previewType: 'html',
|
||||
designSystemRequired: true,
|
||||
defaultFor: [],
|
||||
upstream: null,
|
||||
hasBody: true,
|
||||
examplePrompt: 'Build a dashboard.',
|
||||
aggregatesExamples: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
|
@ -139,4 +146,65 @@ describe('od skills CLI', () => {
|
|||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed skill elements in skills tree responses', async () => {
|
||||
await withSkillsServer(
|
||||
(_req, res) => {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
skills: [
|
||||
{
|
||||
id: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
description: 'A dashboard skill.',
|
||||
triggers: [],
|
||||
mode: 'prototype',
|
||||
scenario: 'operation',
|
||||
platform: 'desktop',
|
||||
previewType: 'html',
|
||||
designSystemRequired: true,
|
||||
defaultFor: [],
|
||||
upstream: null,
|
||||
hasBody: true,
|
||||
examplePrompt: 'Build a dashboard.',
|
||||
aggregatesExamples: false,
|
||||
},
|
||||
{ id: 'broken', mode: 'prototype' },
|
||||
],
|
||||
}));
|
||||
},
|
||||
async (baseUrl) => {
|
||||
try {
|
||||
await execFileAsync(
|
||||
process.execPath,
|
||||
[
|
||||
'--import',
|
||||
'tsx',
|
||||
cliEntry,
|
||||
'skills',
|
||||
'tree',
|
||||
'--json',
|
||||
'--daemon-url',
|
||||
baseUrl,
|
||||
],
|
||||
{
|
||||
cwd: daemonRoot,
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
throw new Error('skills tree command unexpectedly succeeded');
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { code?: number; stderr?: string; stdout?: string };
|
||||
expect(failed.code).toBe(74);
|
||||
expect(failed.stdout ?? '').toBe('');
|
||||
const envelope = JSON.parse(failed.stderr ?? '{}') as {
|
||||
error?: { code?: string; message?: string; data?: { endpoint?: string } };
|
||||
};
|
||||
expect(envelope.error?.code).toBe('daemon-protocol-error');
|
||||
expect(envelope.error?.message).toContain('Malformed /api/skills response');
|
||||
expect(envelope.error?.data?.endpoint).toBe('/api/skills');
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
ImportLocalDesignSystemResponse,
|
||||
ReplaceProjectWorkingDirResponse,
|
||||
} from '@open-design/contracts';
|
||||
import { parseSkillSummaries } from '@open-design/contracts';
|
||||
import type {
|
||||
AgentInfo,
|
||||
AppVersionInfo,
|
||||
|
|
@ -107,12 +108,12 @@ export async function fetchSkills(options?: { throwOnError?: boolean }): Promise
|
|||
if (options?.throwOnError) throw new Error(`skills ${resp.status}`);
|
||||
return [];
|
||||
}
|
||||
const json = (await resp.json()) as { skills?: SkillSummary[] };
|
||||
const json = await resp.json() as { skills?: unknown };
|
||||
if (!Array.isArray(json.skills)) {
|
||||
if (options?.throwOnError) throw new Error('skills response malformed');
|
||||
return [];
|
||||
}
|
||||
return json.skills;
|
||||
return parseSkillSummaries(json.skills);
|
||||
} catch (err) {
|
||||
if (options?.throwOnError) throw err;
|
||||
return [];
|
||||
|
|
|
|||
|
|
@ -252,6 +252,21 @@ describe('IntegrationsView skills tree', () => {
|
|||
expect(screen.queryByText('No skills match these filters.')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows a load failure when skills payload contains malformed entries', async () => {
|
||||
renderSkillsIntegration([], {
|
||||
skillsBody: {
|
||||
skills: [
|
||||
skill({ id: 'dashboard', name: 'Dashboard' }),
|
||||
{ id: 'broken', mode: 'prototype' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('Could not load skills. Make sure the local daemon is running, then try again.')).toBeTruthy();
|
||||
expect(screen.queryByText('No skills match these filters.')).toBeNull();
|
||||
expect(screen.queryByTestId('integrations-skill-list-row-dashboard')).toBeNull();
|
||||
});
|
||||
|
||||
it('localizes tree guide and legend labels', async () => {
|
||||
renderSkillsIntegration([
|
||||
skill({ id: 'dashboard', name: 'Dashboard', scenario: 'operation', platform: 'desktop' }),
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
export interface AgentModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
@ -140,6 +142,52 @@ export interface SkillsResponse {
|
|||
skills: SkillSummary[];
|
||||
}
|
||||
|
||||
export const SkillSummarySchema: z.ZodType<SkillSummary> = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
displayName: z.record(z.string()).optional(),
|
||||
description: z.string(),
|
||||
descriptionI18n: z.record(z.string()).optional(),
|
||||
triggers: z.array(z.string()),
|
||||
mode: z.enum([
|
||||
'prototype',
|
||||
'deck',
|
||||
'template',
|
||||
'design-system',
|
||||
'image',
|
||||
'video',
|
||||
'audio',
|
||||
]),
|
||||
surface: z.enum(['web', 'image', 'video', 'audio']).optional(),
|
||||
platform: z.enum(['desktop', 'mobile']).nullable().optional(),
|
||||
scenario: z.string().nullable().optional(),
|
||||
category: z.string().nullable().optional(),
|
||||
source: z.enum(['built-in', 'user']).optional(),
|
||||
previewType: z.string(),
|
||||
designSystemRequired: z.boolean(),
|
||||
defaultFor: z.array(z.string()),
|
||||
upstream: z.string().nullable(),
|
||||
featured: z.number().nullable().optional(),
|
||||
fidelity: z.enum(['wireframe', 'high-fidelity']).nullable().optional(),
|
||||
speakerNotes: z.boolean().nullable().optional(),
|
||||
animations: z.boolean().nullable().optional(),
|
||||
craftRequires: z.array(z.string()).optional(),
|
||||
hasBody: z.boolean(),
|
||||
examplePrompt: z.string(),
|
||||
examplePromptI18n: z.record(z.string()).optional(),
|
||||
aggregatesExamples: z.boolean(),
|
||||
}).passthrough() as z.ZodType<SkillSummary>;
|
||||
|
||||
export const SkillSummaryArraySchema = z.array(SkillSummarySchema);
|
||||
|
||||
export function parseSkillSummaries(value: unknown): SkillSummary[] {
|
||||
return SkillSummaryArraySchema.parse(value);
|
||||
}
|
||||
|
||||
export function isSkillSummaries(value: unknown): value is SkillSummary[] {
|
||||
return SkillSummaryArraySchema.safeParse(value).success;
|
||||
}
|
||||
|
||||
export interface SkillCatalogTreeSkill {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildSkillCatalogTree,
|
||||
parseSkillSummaries,
|
||||
type SkillSummary,
|
||||
} from '../src/api/registry.js';
|
||||
|
||||
|
|
@ -111,3 +112,21 @@ describe('buildSkillCatalogTree', () => {
|
|||
expect(leaf?.skill).toBe(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSkillSummaries', () => {
|
||||
it('accepts valid skill summary arrays', () => {
|
||||
const summaries = parseSkillSummaries([
|
||||
skill({ id: 'dashboard', name: 'Dashboard' }),
|
||||
]);
|
||||
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0]?.id).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('rejects malformed skill summary array elements', () => {
|
||||
expect(() => parseSkillSummaries([
|
||||
skill({ id: 'dashboard', name: 'Dashboard' }),
|
||||
{ id: 'broken', mode: 'prototype' },
|
||||
])).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue