This commit is contained in:
113
packages/shared/tests/connector-recommendations.test.ts
Normal file
113
packages/shared/tests/connector-recommendations.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Persona-aware connector recommendations.
|
||||
*
|
||||
* Two layers of guards:
|
||||
* 1. SHAPE — primary lengths in the agreed 3–5 range, no duplicates,
|
||||
* fallback for unknown personas, every onboarding persona has a
|
||||
* matching map entry.
|
||||
* 2. CATALOG MEMBERSHIP — every connector ID referenced by the
|
||||
* recommendations actually exists in mcp-catalog.ts. This is the
|
||||
* bug class we fix here: typos / stale IDs / forward-references
|
||||
* that ship a recommendation pointing nowhere.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
recommendConnectors,
|
||||
flattenRecommendation,
|
||||
allReferencedConnectorIds,
|
||||
CONNECTOR_RECOMMENDATIONS,
|
||||
} from '../src/connector-recommendations.js';
|
||||
import { MCP_CATALOG } from '../src/mcp-catalog.js';
|
||||
|
||||
const CATALOG_IDS = new Set(MCP_CATALOG.map(s => s.id));
|
||||
|
||||
describe('recommendConnectors', () => {
|
||||
it('returns universal defaults for an unknown persona id', () => {
|
||||
const r = recommendConnectors('not-a-real-persona-id');
|
||||
expect(r.primary).toContain('gdrive-mcp');
|
||||
expect(r.primary).toContain('gmail-mcp');
|
||||
expect(r.primary).toContain('notion-mcp');
|
||||
});
|
||||
|
||||
it('returns the persona-specific recommendation when the id matches', () => {
|
||||
const sales = recommendConnectors('sales-rep');
|
||||
expect(sales.primary).toContain('hubspot-mcp');
|
||||
expect(sales.primary).toContain('salesforce-mcp');
|
||||
|
||||
const coder = recommendConnectors('coder');
|
||||
expect(coder.primary).toContain('github-mcp');
|
||||
expect(coder.primary).toContain('linear-mcp');
|
||||
|
||||
const consultant = recommendConnectors('consultant');
|
||||
expect(consultant.primary).toContain('notion-mcp');
|
||||
expect(consultant.primary).toContain('gdrive-mcp');
|
||||
});
|
||||
|
||||
it('always returns a non-empty primary list (never strands the UI on empty)', () => {
|
||||
const personaIds = [...Object.keys(CONNECTOR_RECOMMENDATIONS), 'unknown-persona', ''];
|
||||
for (const id of personaIds) {
|
||||
const r = recommendConnectors(id);
|
||||
expect(r.primary.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CONNECTOR_RECOMMENDATIONS shape', () => {
|
||||
it('every recommendation has 3-6 primary entries (Ljiljana-bar cognitive ceiling)', () => {
|
||||
for (const [personaId, rec] of Object.entries(CONNECTOR_RECOMMENDATIONS)) {
|
||||
expect(rec.primary.length, `persona "${personaId}" primary out of range`).toBeGreaterThanOrEqual(3);
|
||||
expect(rec.primary.length, `persona "${personaId}" primary out of range`).toBeLessThanOrEqual(6);
|
||||
}
|
||||
});
|
||||
|
||||
it('no recommendation has duplicate ids within primary or secondary', () => {
|
||||
for (const [personaId, rec] of Object.entries(CONNECTOR_RECOMMENDATIONS)) {
|
||||
const allIds = [...rec.primary, ...rec.secondary];
|
||||
const dupes = allIds.filter((id, i) => allIds.indexOf(id) !== i);
|
||||
expect(dupes, `persona "${personaId}" has duplicate connector ids: ${dupes.join(', ')}`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('every recommended id resolves to a real entry in mcp-catalog.ts', () => {
|
||||
const referenced = allReferencedConnectorIds();
|
||||
const missing = referenced.filter(id => !CATALOG_IDS.has(id));
|
||||
expect(missing, `connector recommendation references ids not in mcp-catalog.ts: ${missing.join(', ')}`).toEqual([]);
|
||||
});
|
||||
|
||||
it('covers every persona id from persona-data.ts (no recommendation drift after a persona is added)', () => {
|
||||
// The persona list is duplicated here intentionally — importing from
|
||||
// packages/agent would couple shared->agent, which we don't want for
|
||||
// a leaf data module. If a persona is added in persona-data.ts but
|
||||
// not here, this test fails and the fix is a one-line entry.
|
||||
const ALL_PERSONA_IDS = [
|
||||
'researcher', 'writer', 'analyst', 'coder',
|
||||
'project-manager', 'executive-assistant', 'sales-rep', 'marketer',
|
||||
'product-manager-senior', 'hr-manager', 'legal-professional',
|
||||
'finance-owner', 'consultant',
|
||||
'general-purpose', 'planner', 'verifier', 'coordinator',
|
||||
'support-agent', 'ops-manager', 'data-engineer', 'recruiter',
|
||||
'creative-director',
|
||||
];
|
||||
const recommendedIds = new Set(Object.keys(CONNECTOR_RECOMMENDATIONS));
|
||||
const missingFromMap = ALL_PERSONA_IDS.filter(id => !recommendedIds.has(id));
|
||||
expect(missingFromMap, `personas without a CONNECTOR_RECOMMENDATIONS entry: ${missingFromMap.join(', ')}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flattenRecommendation', () => {
|
||||
it('returns primary IDs in order, then secondary', () => {
|
||||
const flat = flattenRecommendation({
|
||||
primary: ['a', 'b', 'c'],
|
||||
secondary: ['d', 'e'],
|
||||
});
|
||||
expect(flat).toEqual(['a', 'b', 'c', 'd', 'e']);
|
||||
});
|
||||
|
||||
it('preserves primary-first ordering for a real persona', () => {
|
||||
const flat = flattenRecommendation(recommendConnectors('coder'));
|
||||
// First five must be the primary list (anchored to github-mcp on top
|
||||
// because that's the most-load-bearing connector for the coder persona).
|
||||
expect(flat[0]).toBe('github-mcp');
|
||||
});
|
||||
});
|
||||
41
packages/shared/tests/loop-templates.test.ts
Normal file
41
packages/shared/tests/loop-templates.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LOOP_TEMPLATES } from '../src/loop-templates.js';
|
||||
|
||||
describe('LOOP_TEMPLATES (knowledge-worker Loop catalog)', () => {
|
||||
it('ships a useful set of templates', () => {
|
||||
expect(LOOP_TEMPLATES.length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it('every template id is unique', () => {
|
||||
const ids = LOOP_TEMPLATES.map(t => t.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('every template has a non-empty name, description, role and prompt', () => {
|
||||
for (const t of LOOP_TEMPLATES) {
|
||||
expect(t.name.trim().length, t.id).toBeGreaterThan(0);
|
||||
expect(t.description.trim().length, t.id).toBeGreaterThan(0);
|
||||
expect(t.role.trim().length, t.id).toBeGreaterThan(0);
|
||||
expect(t.jobConfig.prompt.trim().length, t.id).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('every defaultCron is a valid 5-field cron expression', () => {
|
||||
for (const t of LOOP_TEMPLATES) {
|
||||
const fields = t.defaultCron.trim().split(/\s+/);
|
||||
expect(fields.length, `${t.id}: "${t.defaultCron}"`).toBe(5);
|
||||
for (const f of fields) {
|
||||
expect(/^[\d*,/-]+$/.test(f), `${t.id} field "${f}"`).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('cadences are daily-or-slower — never a minute/sub-hourly wildcard', () => {
|
||||
// A Loop is several LLM round-trips; a '*' or '*/n' minute field would burn
|
||||
// tokens every minute. Templates must use a concrete minute.
|
||||
for (const t of LOOP_TEMPLATES) {
|
||||
const minute = t.defaultCron.trim().split(/\s+/)[0];
|
||||
expect(minute === '*' || minute.includes('/'), `${t.id} fires too often (minute="${minute}")`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
54
packages/shared/tests/risk.test.ts
Normal file
54
packages/shared/tests/risk.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
RISK_LEVELS, APPROVAL_CLASSES, TRUST_SOURCES, ASSESSMENT_MODES,
|
||||
AUDIT_ACTIONS, AUDIT_CAPABILITY_TYPES, AUDIT_INITIATORS,
|
||||
riskRank, riskAtLeast, sqlInList,
|
||||
} from '../src/risk.js';
|
||||
|
||||
describe('canonical risk taxonomy (A1) — widest-set parity', () => {
|
||||
it('RiskLevel adopts the audit set (adds critical)', () => {
|
||||
expect([...RISK_LEVELS]).toEqual(['low', 'medium', 'high', 'critical']);
|
||||
});
|
||||
it('ApprovalClass adopts the audit set (adds blocked)', () => {
|
||||
expect([...APPROVAL_CLASSES]).toEqual(['standard', 'elevated', 'critical', 'blocked']);
|
||||
});
|
||||
it('TrustSource is the 7-value set incl. security-gate', () => {
|
||||
expect([...TRUST_SOURCES]).toEqual([
|
||||
'builtin', 'starter_pack', 'local_user', 'third_party_verified',
|
||||
'third_party_unverified', 'unknown', 'security-gate',
|
||||
]);
|
||||
});
|
||||
it('AssessmentMode is declared/heuristic/mixed', () => {
|
||||
expect([...ASSESSMENT_MODES]).toEqual(['declared', 'heuristic', 'mixed']);
|
||||
});
|
||||
it('AuditAction includes uninstalled (P5/D4)', () => {
|
||||
expect([...AUDIT_ACTIONS]).toEqual([
|
||||
'proposed', 'approved', 'installed', 'rejected', 'failed', 'blocked', 'uninstalled',
|
||||
]);
|
||||
});
|
||||
it('AuditCapabilityType + AuditInitiator match the store', () => {
|
||||
expect([...AUDIT_CAPABILITY_TYPES]).toEqual(['native', 'skill', 'plugin', 'mcp', 'connector', 'marketplace']);
|
||||
expect([...AUDIT_INITIATORS]).toEqual(['agent', 'user', 'system']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('risk ordering helpers', () => {
|
||||
it('riskRank orders ascending, critical highest', () => {
|
||||
expect(riskRank('low')).toBe(0);
|
||||
expect(riskRank('critical')).toBe(3);
|
||||
expect(riskRank('critical')).toBeGreaterThan(riskRank('low'));
|
||||
});
|
||||
it('riskAtLeast compares on the canonical scale (critical >= low, not below)', () => {
|
||||
expect(riskAtLeast('critical', 'low')).toBe(true);
|
||||
expect(riskAtLeast('low', 'high')).toBe(false);
|
||||
expect(riskAtLeast('high', 'high')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sqlInList — CHECK-constraint single source', () => {
|
||||
it('quotes and comma-joins for a SQLite IN clause', () => {
|
||||
expect(sqlInList(AUDIT_ACTIONS)).toBe(
|
||||
"'proposed', 'approved', 'installed', 'rejected', 'failed', 'blocked', 'uninstalled'",
|
||||
);
|
||||
});
|
||||
});
|
||||
89
packages/shared/tests/schemas.test.ts
Normal file
89
packages/shared/tests/schemas.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createTeamSchema,
|
||||
createTaskSchema,
|
||||
sendMessageSchema,
|
||||
createAgentSchema,
|
||||
createAgentGroupSchema,
|
||||
} from '../src/schemas.js';
|
||||
|
||||
describe('createTeamSchema', () => {
|
||||
it('accepts valid team', () => {
|
||||
const result = createTeamSchema.safeParse({ name: 'Marketing', slug: 'marketing' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty name', () => {
|
||||
const result = createTeamSchema.safeParse({ name: '', slug: 'ok' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid slug characters', () => {
|
||||
const result = createTeamSchema.safeParse({ name: 'Ok', slug: 'Has Spaces' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTaskSchema', () => {
|
||||
it('accepts valid task with defaults', () => {
|
||||
const result = createTaskSchema.safeParse({ title: 'Research competitors' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.priority).toBe('normal');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessageSchema', () => {
|
||||
it('accepts valid waggle dance message', () => {
|
||||
const result = sendMessageSchema.safeParse({
|
||||
type: 'request',
|
||||
subtype: 'knowledge_check',
|
||||
content: { topic: 'competitor pricing', scope: 'Product Line X' },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid subtype', () => {
|
||||
const result = sendMessageSchema.safeParse({
|
||||
type: 'broadcast',
|
||||
subtype: 'invalid_type',
|
||||
content: {},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAgentSchema', () => {
|
||||
it('accepts agent with defaults', () => {
|
||||
const result = createAgentSchema.safeParse({ name: 'web-searcher' });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.model).toBe('claude-haiku-4-5');
|
||||
expect(result.data.tools).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAgentGroupSchema', () => {
|
||||
it('accepts valid agent group', () => {
|
||||
const result = createAgentGroupSchema.safeParse({
|
||||
name: 'Research Team',
|
||||
strategy: 'parallel',
|
||||
members: [
|
||||
{ agentId: '00000000-0000-0000-0000-000000000001', roleInGroup: 'lead' },
|
||||
{ agentId: '00000000-0000-0000-0000-000000000002' },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid strategy', () => {
|
||||
const result = createAgentGroupSchema.safeParse({
|
||||
name: 'Bad',
|
||||
strategy: 'random',
|
||||
members: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
41
packages/shared/tests/tool-manifests.test.ts
Normal file
41
packages/shared/tests/tool-manifests.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
BUILTIN_TOOL_MANIFESTS, SUPPORTED_TOOLS, LAUNCH_COHORT, TOOL_DISPLAY_NAMES,
|
||||
applyPromptArgTemplate,
|
||||
} from '../src/tool-detection.js';
|
||||
|
||||
describe('BUILTIN_TOOL_MANIFESTS', () => {
|
||||
it('has one manifest per supported tool, ids matching SUPPORTED_TOOLS', () => {
|
||||
expect(BUILTIN_TOOL_MANIFESTS.map((m) => m.id).sort()).toEqual([...SUPPORTED_TOOLS].sort());
|
||||
});
|
||||
it('marks every built-in as builtin:true and launchable', () => {
|
||||
for (const m of BUILTIN_TOOL_MANIFESTS) {
|
||||
expect(m.builtin).toBe(true);
|
||||
expect(m.launchable).toBe(true);
|
||||
}
|
||||
});
|
||||
it('every built-in is hook-capable', () => {
|
||||
expect(BUILTIN_TOOL_MANIFESTS.filter((m) => !m.hookCapable)).toEqual([]);
|
||||
});
|
||||
it('derives TOOL_DISPLAY_NAMES + LAUNCH_COHORT from the manifests (unchanged values)', () => {
|
||||
expect(TOOL_DISPLAY_NAMES['claude-code']).toBe('Claude Code');
|
||||
expect(TOOL_DISPLAY_NAMES['codex']).toBe('Codex CLI');
|
||||
expect([...LAUNCH_COHORT].sort()).toEqual([...SUPPORTED_TOOLS].sort());
|
||||
});
|
||||
it('claude-code detects by PATH binary "claude" (not its id)', () => {
|
||||
const cc = BUILTIN_TOOL_MANIFESTS.find((m) => m.id === 'claude-code')!;
|
||||
expect(cc.detect).toEqual({ kind: 'path', binaryName: 'claude' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPromptArgTemplate (#5 fast-follow)', () => {
|
||||
it('substitutes {prompt} in each template entry', () => {
|
||||
expect(applyPromptArgTemplate(['--print', '{prompt}'], 'hello')).toEqual(['--print', 'hello']);
|
||||
});
|
||||
it('substitutes within an entry and across multiple entries', () => {
|
||||
expect(applyPromptArgTemplate(['-m', 'msg={prompt}', '{prompt}'], 'hi')).toEqual(['-m', 'msg=hi', 'hi']);
|
||||
});
|
||||
it('leaves entries without the placeholder untouched', () => {
|
||||
expect(applyPromptArgTemplate(['--yes', '--fast'], 'hi')).toEqual(['--yes', '--fast']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user