All checks were successful
Deploy site (production) / build-and-deploy (push) Successful in 21s
Un groupe lance désormais UNE seule session Claude couvrant tous ses
repos/worktrees (--add-dir) au lieu d'une session par repo, pour
travailler en simultané dans une conversation partagée. Le transport WS,
le flow control et l'attach sont inchangés (toujours 1 session, 1 channel).
- shared: SessionSummary.addedDirs/groupId (champs additifs optionnels) ;
CreateGroupSessionRequest / GroupSessionResponse.
- server: --add-dir dans claude-launcher ; spawn({addDirs,groupId}) +
persistance (migration #8 : sessions.added_dirs/group_id) ;
POST /api/v1/groups/:id/session (résolution des dirs cote serveur) ;
resume re-relie les dirs ; deleteGroup nullifie group_id.
- web: GroupSessionModal (modes feature / checkouts principaux) ;
createGroupFeature/createGroupSession ; badge « groupe · N depots » ;
sessionsInGroup dedupliquee.
- site: textes EN/FR « une seule session Claude » + diagramme hub.
- tests: claude-launcher, pty-manager (groupe), group-manager ;
acceptation P5 etendue (session de groupe multi-repo). 272 tests verts.
112 lines
5.2 KiB
TypeScript
112 lines
5.2 KiB
TypeScript
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||
import { GroupManager } from '../src/core/group-manager.js';
|
||
import { openDb, type Db } from '../src/db/index.js';
|
||
|
||
/** Insère un repo minimal directement (le GroupManager n'a besoin que de repos.id). */
|
||
function insertRepo(db: Db, id: string, path: string): void {
|
||
db.prepare(
|
||
"INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, NULL, '[]', 0, ?)",
|
||
).run(id, path, id, new Date().toISOString());
|
||
}
|
||
|
||
describe('GroupManager', () => {
|
||
let db: Db;
|
||
let gm: GroupManager;
|
||
|
||
beforeEach(() => {
|
||
db = openDb(':memory:');
|
||
insertRepo(db, 'repo-a', '/tmp/a');
|
||
insertRepo(db, 'repo-b', '/tmp/b');
|
||
gm = new GroupManager(db);
|
||
});
|
||
|
||
it('createGroup : groupe vide, persisté, événement group_update émis', () => {
|
||
const spy = vi.fn();
|
||
gm.on('group_update', spy);
|
||
const g = gm.createGroup({ label: 'Sprint 42' });
|
||
expect(g).toMatchObject({ label: 'Sprint 42', description: null, color: null, repoIds: [] });
|
||
expect(gm.listGroups()).toHaveLength(1);
|
||
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: g.id }));
|
||
});
|
||
|
||
it('createGroup : repoIds initiaux ordonnés et dédoublonnés', () => {
|
||
const g = gm.createGroup({ label: 'Eco', repoIds: ['repo-b', 'repo-a', 'repo-b'] });
|
||
expect(g.repoIds).toEqual(['repo-b', 'repo-a']);
|
||
});
|
||
|
||
it('createGroup : repoId inexistant → 404', () => {
|
||
expect(() => gm.createGroup({ label: 'X', repoIds: ['nope'] })).toThrow(
|
||
expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }),
|
||
);
|
||
expect(gm.listGroups()).toHaveLength(0); // rollback de la transaction
|
||
});
|
||
|
||
it('createGroup : validations (label vide / trop long, couleur invalide)', () => {
|
||
expect(() => gm.createGroup({ label: ' ' })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||
expect(() => gm.createGroup({ label: 'x'.repeat(101) })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||
expect(() => gm.createGroup({ label: 'X', color: 'red' })).toThrow(expect.objectContaining({ statusCode: 400 }));
|
||
expect(gm.createGroup({ label: 'X', color: '#4f46e5' }).color).toBe('#4f46e5');
|
||
});
|
||
|
||
it('getGroup : id inconnu → 404', () => {
|
||
expect(() => gm.getGroup('nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
|
||
});
|
||
|
||
it('updateGroup : modifie label/description/color et bump updatedAt', () => {
|
||
const g = gm.createGroup({ label: 'A' });
|
||
const updated = gm.updateGroup(g.id, { label: 'B', description: 'hello', color: '#000000' });
|
||
expect(updated).toMatchObject({ label: 'B', description: 'hello', color: '#000000' });
|
||
expect(gm.updateGroup(g.id, { description: '' }).description).toBeNull(); // '' → null
|
||
});
|
||
|
||
it('addRepo : idempotent (PRIMARY KEY), émet group_update', () => {
|
||
const g = gm.createGroup({ label: 'A' });
|
||
const spy = vi.fn();
|
||
gm.on('group_update', spy);
|
||
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']);
|
||
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']); // pas de doublon
|
||
expect(gm.addRepo(g.id, 'repo-b').repoIds).toEqual(['repo-a', 'repo-b']);
|
||
expect(spy).toHaveBeenCalledTimes(3);
|
||
});
|
||
|
||
it('addRepo : groupe ou repo inexistant → 404', () => {
|
||
const g = gm.createGroup({ label: 'A' });
|
||
expect(() => gm.addRepo('nope', 'repo-a')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
|
||
expect(() => gm.addRepo(g.id, 'nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }));
|
||
});
|
||
|
||
it('removeRepo : idempotent (retrait d’un repo absent = no-op)', () => {
|
||
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a'] });
|
||
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]);
|
||
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]); // déjà absent → succès
|
||
});
|
||
|
||
it('deleteGroup : true + group_removed ; id inconnu → false', () => {
|
||
const g = gm.createGroup({ label: 'A' });
|
||
const spy = vi.fn();
|
||
gm.on('group_removed', spy);
|
||
expect(gm.deleteGroup(g.id)).toBe(true);
|
||
expect(spy).toHaveBeenCalledWith(g.id);
|
||
expect(gm.deleteGroup(g.id)).toBe(false);
|
||
expect(gm.listGroups()).toHaveLength(0);
|
||
});
|
||
|
||
it('CASCADE : supprimer un repo purge la membership (PRAGMA foreign_keys = ON)', () => {
|
||
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a', 'repo-b'] });
|
||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-a', 'repo-b']);
|
||
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
|
||
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
|
||
});
|
||
|
||
it('deleteGroup : désorpheline le group_id des sessions de groupe (P6)', () => {
|
||
const g = gm.createGroup({ label: 'A' });
|
||
// session de groupe liée à g (insérée directement, comme le ferait PtyManager.spawn).
|
||
db.prepare(
|
||
'INSERT INTO sessions (id, cwd, command, created_at, group_id) VALUES (?, ?, ?, ?, ?)',
|
||
).run('sess-1', '/tmp/a', 'claude', new Date().toISOString(), g.id);
|
||
expect(gm.deleteGroup(g.id)).toBe(true);
|
||
const row = db.prepare('SELECT group_id FROM sessions WHERE id = ?').get('sess-1') as { group_id: string | null };
|
||
expect(row.group_id).toBeNull();
|
||
});
|
||
});
|