__tests__/userRoles.test.js
Rama main · réplica de lectura del repositorio original.
/**
* Roles a person authored — the marketplace's foundation.
*
* The guarantees worth pinning here are the ones the whole feature rests on: a built-in can never be
* shadowed by a user role, a role naming a tool that does not exist is refused rather than silently
* crippled, a pack installs whole or not at all, and a model's reply becomes a SAFE draft no matter
* what it says. Everything the UI and the endpoints do sits on top of these.
*/
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// PROFILE_DIR must be set before the store is imported — it reads it at module load, exactly as the
// profiles store does. So we point it at a throwaway dir and import after.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gb-roles-'));
process.env.PROFILE_DIR = dir;
const store = await import('../src/userRoles.js');
const roles = await import('../src/roles.js');
// A small stand-in palette so these tests never depend on the exact set of tools the agent ships.
const PALETTE = ['look', 'read', 'open', 'click', 'type', 'scroll', 'back', 'note', 'finish',
'list_profiles', 'use_profile', 'sweep', 'act', 'save_lead'];
const good = () => ({
label: 'Reddit · Complaint scout',
site: 'reddit',
description: 'Reads Reddit for people complaining about a problem you solve.',
tools: ['look', 'read', 'scroll', 'note', 'finish', 'save_lead'],
prompt: 'Your job is to find people voicing a problem, and only that. Never post, comment or reply.',
});
beforeEach(() => {
// Wipe every stored role between tests so counts are predictable.
fs.rmSync(path.join(dir, 'roles'), { recursive: true, force: true });
});
afterAll(() => {
roles.useExternal(null);
fs.rmSync(dir, { recursive: true, force: true });
});
describe('storing an authored role', () => {
it('saves a valid role and mints a stable id from the label', () => {
const r = store.save(good(), PALETTE);
expect(r.id).toBe('reddit-complaint-scout');
expect(r.source).toBe('user');
expect(store.read(r.id).label).toBe('Reddit · Complaint scout');
});
it('refuses a role with too short a name or playbook', () => {
expect(() => store.save({ ...good(), label: 'x' }, PALETTE)).toThrow(/name of at least/);
expect(() => store.save({ ...good(), prompt: 'too short' }, PALETTE)).toThrow(/playbook of at least/);
});
it('refuses a role naming a tool that does not exist — a typo must not silently widen it', () => {
let err;
try { store.save({ ...good(), tools: ['look', 'teleport'] }, PALETTE); } catch (e) { err = e; }
expect(err).toBeTruthy();
expect(err.status).toBe(400);
expect(err.message).toMatch(/do not exist: teleport/);
});
it('allows null tools to mean "every tool", like a built-in', () => {
const r = store.save({ ...good(), tools: null }, PALETTE);
expect(store.getRole(r.id).tools).toBeNull();
});
it('editing keeps the id and createdAt, and can be forgotten', () => {
const r = store.save(good(), PALETTE);
const edited = store.save({ ...good(), id: r.id, label: 'Reddit · Renamed' }, PALETTE);
expect(edited.id).toBe(r.id);
expect(edited.createdAt).toBe(r.createdAt);
expect(store.remove(r.id)).toBe(true);
expect(store.read(r.id)).toBeNull();
expect(store.remove(r.id)).toBe(false);
});
it('lists roles tagged with where they came from, in the agent-ready shape', () => {
store.save(good(), PALETTE);
const rows = store.listRoles();
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({ name: 'reddit-complaint-scout', source: 'user', site: 'reddit' });
const asAgentSees = store.getRole('reddit-complaint-scout');
expect(asAgentSees).toMatchObject({ site: 'reddit', prompt: expect.stringContaining('find people') });
expect(Array.isArray(asAgentSees.tools)).toBe(true);
});
});
describe('packs', () => {
it('exports the named roles as a recipe, without ids or timestamps', () => {
const a = store.save(good(), PALETTE);
store.save({ ...good(), label: 'Reddit · Other' }, PALETTE);
const pack = store.exportPack([a.id], 'My set');
expect(pack.kind).toBe('ghost-roles-pack');
expect(pack.roles).toHaveLength(1);
expect(pack.roles[0]).not.toHaveProperty('id');
expect(pack.roles[0].label).toBe('Reddit · Complaint scout');
});
it('installs a pack whole, tagging each role with the pack it came from', () => {
const pack = { kind: 'ghost-roles-pack', name: 'Starter', roles: [good(), { ...good(), label: 'Reddit · Two' }] };
const installed = store.importPack(pack, PALETTE);
expect(installed).toHaveLength(2);
expect(installed.every((r) => r.source === 'pack:starter')).toBe(true);
expect(store.all()).toHaveLength(2);
});
it('refuses a pack whole if any role names a missing tool — no half installs', () => {
const pack = { kind: 'ghost-roles-pack', name: 'Bad', roles: [good(), { ...good(), tools: ['nope'] }] };
expect(() => store.importPack(pack, PALETTE)).toThrow(/cannot be installed/);
expect(store.all()).toHaveLength(0);
});
it('rejects something that is not a pack', () => {
expect(() => store.importPack({ roles: [] }, PALETTE)).toThrow(/not a roles pack/);
});
});
describe('coerceDraft — a model reply becomes a safe draft', () => {
it('pulls JSON out of a fenced, prose-wrapped reply', () => {
const reply = 'Sure! Here is the role:\n```json\n{"label":"X scout","site":"x","tools":["look","read"],"prompt":"do the thing well and carefully"}\n```\nHope that helps.';
const d = store.coerceDraft(reply, PALETTE);
expect(d.label).toBe('X scout');
expect(d.site).toBe('x');
expect(d.tools).toEqual(['look', 'read']);
});
it('drops tools that are not in the palette', () => {
const d = store.coerceDraft('{"tools":["look","teleport","read"]}', PALETTE);
expect(d.tools).toEqual(['look', 'read']);
});
it('defaults to the basics when the reply names nothing usable', () => {
const d = store.coerceDraft('{"tools":["teleport"]}', PALETTE);
expect(d.tools).toEqual(expect.arrayContaining(['look', 'read', 'list_profiles', 'use_profile']));
});
it('normalises an unknown site to null and never throws on garbage', () => {
expect(store.coerceDraft('{"site":"myspace"}', PALETTE).site).toBeNull();
expect(() => store.coerceDraft('not json at all', PALETTE)).not.toThrow();
expect(store.coerceDraft('not json at all', PALETTE).tools.length).toBeGreaterThan(0);
});
});
describe('roles.js merges the store without letting it shadow a built-in', () => {
it('resolves a user role by id, but a built-in always wins a name collision', () => {
roles.useExternal({
getRole: (id) => (id === 'reddit.mine' ? { site: 'reddit', label: 'Mine', description: '', tools: ['look'], prompt: 'p' }
: id === 'general' ? { site: null, label: 'HIJACK', description: '', tools: null, prompt: 'evil' } : null),
listRoles: () => [{ name: 'reddit.mine', label: 'Mine', description: '', site: 'reddit', group: 'Yours', source: 'user' }],
});
// A user role fills a gap...
expect(roles.get('reddit.mine').label).toBe('Mine');
expect(roles.canonical('reddit.mine')).toBe('reddit.mine');
// ...but 'general' is a built-in, so the provider's attempt to redefine it is ignored.
expect(roles.get('general')).toBe(roles.ROLES.general);
// The list carries both, tagged.
const l = roles.list();
expect(l.find((r) => r.name === 'general').source).toBe('builtin');
expect(l.find((r) => r.name === 'reddit.mine').source).toBe('user');
// toolsFor resolves a user role's tools against the real palette.
const fakePalette = [{ function: { name: 'look' } }, { function: { name: 'act' } }];
expect(roles.toolsFor('reddit.mine', fakePalette).map((t) => t.function.name)).toEqual(['look']);
});
it('with no provider registered, behaves exactly as before', () => {
roles.useExternal(null);
expect(roles.get('reddit.mine')).toBe(roles.ROLES.general);
expect(roles.list().every((r) => r.source === 'builtin')).toBe(true);
});
});