tmux-team 2.2.0 → 3.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1462 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // PM Commands Tests
3
- // ─────────────────────────────────────────────────────────────
4
-
5
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6
- import fs from 'fs';
7
- import path from 'path';
8
- import os from 'os';
9
- import type { Context } from '../types.js';
10
- import type { UI } from '../types.js';
11
- // ExitCodes imported for reference but tested via ctx.exit mock
12
- import { cmdPm, cmdPmInit, cmdPmMilestone, cmdPmTask, cmdPmLog, cmdPmList } from './commands.js';
13
- import { findCurrentTeamId, linkTeam, getTeamsDir } from './manager.js';
14
-
15
- // ─────────────────────────────────────────────────────────────
16
- // Test helpers
17
- // ─────────────────────────────────────────────────────────────
18
-
19
- function createMockUI(): UI & { logs: string[]; errors: string[]; jsonData: unknown[] } {
20
- const logs: string[] = [];
21
- const errors: string[] = [];
22
- const jsonData: unknown[] = [];
23
-
24
- return {
25
- logs,
26
- errors,
27
- jsonData,
28
- info: vi.fn((msg: string) => logs.push(`[info] ${msg}`)),
29
- success: vi.fn((msg: string) => logs.push(`[success] ${msg}`)),
30
- warn: vi.fn((msg: string) => logs.push(`[warn] ${msg}`)),
31
- error: vi.fn((msg: string) => errors.push(msg)),
32
- table: vi.fn((_headers: string[], _rows: string[][]) => logs.push('[table]')),
33
- json: vi.fn((data: unknown) => jsonData.push(data)),
34
- };
35
- }
36
-
37
- function createMockContext(
38
- globalDir: string,
39
- options: {
40
- json?: boolean;
41
- cwd?: string;
42
- agents?: Record<string, { deny?: string[] }>;
43
- defaults?: Partial<{
44
- timeout: number;
45
- pollInterval: number;
46
- captureLines: number;
47
- preambleEvery: number;
48
- hideOrphanTasks: boolean;
49
- }>;
50
- } = {}
51
- ): Context & { ui: ReturnType<typeof createMockUI>; exitCode: number | null } {
52
- const ui = createMockUI();
53
- let exitCode: number | null = null;
54
-
55
- // Override cwd for tests
56
- const originalCwd = process.cwd;
57
- if (options.cwd) {
58
- vi.spyOn(process, 'cwd').mockReturnValue(options.cwd);
59
- }
60
-
61
- return {
62
- ui,
63
- exitCode,
64
- flags: { json: options.json ?? false },
65
- paths: { globalDir, configFile: path.join(globalDir, 'config.json') },
66
- config: {
67
- mode: 'polling',
68
- preambleMode: 'always',
69
- defaults: {
70
- timeout: 60,
71
- pollInterval: 1,
72
- captureLines: 100,
73
- preambleEvery: 3,
74
- hideOrphanTasks: false,
75
- ...options.defaults,
76
- },
77
- agents: options.agents ?? {},
78
- paneRegistry: {},
79
- },
80
- exit: vi.fn((code: number) => {
81
- exitCode = code;
82
- throw new Error(`Exit: ${code}`);
83
- }),
84
- restoreCwd: () => {
85
- if (options.cwd) {
86
- vi.spyOn(process, 'cwd').mockImplementation(originalCwd);
87
- }
88
- },
89
- } as unknown as Context & { ui: ReturnType<typeof createMockUI>; exitCode: number | null };
90
- }
91
-
92
- // ─────────────────────────────────────────────────────────────
93
- // requireTeam tests
94
- // ─────────────────────────────────────────────────────────────
95
-
96
- describe('requireTeam', () => {
97
- let testDir: string;
98
- let globalDir: string;
99
-
100
- beforeEach(() => {
101
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
102
- globalDir = path.join(testDir, 'global');
103
- fs.mkdirSync(globalDir, { recursive: true });
104
- });
105
-
106
- afterEach(() => {
107
- vi.restoreAllMocks();
108
- if (fs.existsSync(testDir)) {
109
- fs.rmSync(testDir, { recursive: true, force: true });
110
- }
111
- });
112
-
113
- it('finds team ID from .tmux-team-id file in cwd', () => {
114
- const projectDir = path.join(testDir, 'project');
115
- fs.mkdirSync(projectDir, { recursive: true });
116
- fs.writeFileSync(path.join(projectDir, '.tmux-team-id'), 'test-team-123\n');
117
-
118
- const teamId = findCurrentTeamId(projectDir, globalDir);
119
- expect(teamId).toBe('test-team-123');
120
- });
121
-
122
- it('finds team ID from TMUX_TEAM_ID environment variable', () => {
123
- const originalEnv = process.env.TMUX_TEAM_ID;
124
- process.env.TMUX_TEAM_ID = 'env-team-456';
125
-
126
- try {
127
- const teamId = findCurrentTeamId(testDir, globalDir);
128
- expect(teamId).toBe('env-team-456');
129
- } finally {
130
- if (originalEnv) {
131
- process.env.TMUX_TEAM_ID = originalEnv;
132
- } else {
133
- delete process.env.TMUX_TEAM_ID;
134
- }
135
- }
136
- });
137
-
138
- it('validates team.json exists for team ID', async () => {
139
- const projectDir = path.join(testDir, 'project');
140
- fs.mkdirSync(projectDir, { recursive: true });
141
-
142
- // Create team directory with team.json
143
- const teamId = 'valid-team-id';
144
- const teamDir = path.join(globalDir, 'teams', teamId);
145
- fs.mkdirSync(teamDir, { recursive: true });
146
- fs.writeFileSync(
147
- path.join(teamDir, 'team.json'),
148
- JSON.stringify({ id: teamId, name: 'Test', createdAt: new Date().toISOString() })
149
- );
150
-
151
- // Link project to team
152
- linkTeam(projectDir, teamId);
153
-
154
- const ctx = createMockContext(globalDir, { cwd: projectDir });
155
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
156
-
157
- // Should not throw - team is valid
158
- await cmdPmTask(ctx, ['list']);
159
- expect(ctx.ui.logs.some((l) => l.includes('[info]') || l.includes('[table]'))).toBe(true);
160
- });
161
-
162
- it('exits with error when no .tmux-team-id found', async () => {
163
- const projectDir = path.join(testDir, 'empty-project');
164
- fs.mkdirSync(projectDir, { recursive: true });
165
-
166
- const ctx = createMockContext(globalDir, { cwd: projectDir });
167
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
168
-
169
- await expect(cmdPmTask(ctx, ['list'])).rejects.toThrow('Exit');
170
- expect(ctx.ui.errors[0]).toContain('No team found');
171
- });
172
-
173
- it('exits with error when team.json does not exist (stale ID)', async () => {
174
- const projectDir = path.join(testDir, 'stale-project');
175
- fs.mkdirSync(projectDir, { recursive: true });
176
-
177
- // Create .tmux-team-id pointing to non-existent team
178
- fs.writeFileSync(path.join(projectDir, '.tmux-team-id'), 'stale-team-id\n');
179
-
180
- const ctx = createMockContext(globalDir, { cwd: projectDir });
181
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
182
-
183
- await expect(cmdPmTask(ctx, ['list'])).rejects.toThrow('Exit');
184
- expect(ctx.ui.errors[0]).toContain('not found');
185
- });
186
- });
187
-
188
- // ─────────────────────────────────────────────────────────────
189
- // cmdPmInit tests
190
- // ─────────────────────────────────────────────────────────────
191
-
192
- describe('cmdPmInit', () => {
193
- let testDir: string;
194
- let globalDir: string;
195
- let projectDir: string;
196
-
197
- beforeEach(() => {
198
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
199
- globalDir = path.join(testDir, 'global');
200
- projectDir = path.join(testDir, 'project');
201
- fs.mkdirSync(globalDir, { recursive: true });
202
- fs.mkdirSync(projectDir, { recursive: true });
203
- });
204
-
205
- afterEach(() => {
206
- vi.restoreAllMocks();
207
- if (fs.existsSync(testDir)) {
208
- fs.rmSync(testDir, { recursive: true, force: true });
209
- }
210
- });
211
-
212
- it('creates team with generated UUID', async () => {
213
- const ctx = createMockContext(globalDir, { cwd: projectDir });
214
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
215
-
216
- await cmdPmInit(ctx, []);
217
-
218
- // Check that a team directory was created
219
- const teamsDir = getTeamsDir(globalDir);
220
- const teamDirs = fs.readdirSync(teamsDir);
221
- expect(teamDirs.length).toBe(1);
222
-
223
- // UUID format validation
224
- expect(teamDirs[0]).toMatch(/^[0-9a-f-]{36}$/);
225
- });
226
-
227
- it('uses --name flag for team name', async () => {
228
- const ctx = createMockContext(globalDir, { cwd: projectDir });
229
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
230
-
231
- await cmdPmInit(ctx, ['--name', 'My Custom Project']);
232
-
233
- expect(ctx.ui.logs.some((l) => l.includes('My Custom Project'))).toBe(true);
234
- });
235
-
236
- it('creates .tmux-team-id file in current directory', async () => {
237
- const ctx = createMockContext(globalDir, { cwd: projectDir });
238
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
239
-
240
- await cmdPmInit(ctx, ['--name', 'Test']);
241
-
242
- const idFile = path.join(projectDir, '.tmux-team-id');
243
- expect(fs.existsSync(idFile)).toBe(true);
244
-
245
- const teamId = fs.readFileSync(idFile, 'utf-8').trim();
246
- expect(teamId).toMatch(/^[0-9a-f-]{36}$/);
247
- });
248
-
249
- it('logs team_created event to audit log', async () => {
250
- const ctx = createMockContext(globalDir, { cwd: projectDir });
251
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
252
-
253
- await cmdPmInit(ctx, ['--name', 'Test']);
254
-
255
- // Read the events file
256
- const teamsDir = getTeamsDir(globalDir);
257
- const teamDirs = fs.readdirSync(teamsDir);
258
- const eventsFile = path.join(teamsDir, teamDirs[0], 'events.jsonl');
259
-
260
- expect(fs.existsSync(eventsFile)).toBe(true);
261
- const events = fs
262
- .readFileSync(eventsFile, 'utf-8')
263
- .trim()
264
- .split('\n')
265
- .map((l) => JSON.parse(l));
266
- expect(events[0].event).toBe('team_created');
267
- });
268
-
269
- it('outputs team info in JSON when --json flag set', async () => {
270
- const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
271
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
272
-
273
- await cmdPmInit(ctx, ['--name', 'JSON Test']);
274
-
275
- expect(ctx.ui.jsonData.length).toBe(1);
276
- const data = ctx.ui.jsonData[0] as { team: { name: string } };
277
- expect(data.team.name).toBe('JSON Test');
278
- });
279
- });
280
-
281
- // ─────────────────────────────────────────────────────────────
282
- // cmdPmMilestone tests
283
- // ─────────────────────────────────────────────────────────────
284
-
285
- describe('cmdPmMilestone', () => {
286
- let testDir: string;
287
- let globalDir: string;
288
- let projectDir: string;
289
- let teamId: string;
290
-
291
- beforeEach(async () => {
292
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
293
- globalDir = path.join(testDir, 'global');
294
- projectDir = path.join(testDir, 'project');
295
- fs.mkdirSync(globalDir, { recursive: true });
296
- fs.mkdirSync(projectDir, { recursive: true });
297
-
298
- // Initialize a team
299
- const ctx = createMockContext(globalDir, { cwd: projectDir });
300
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
301
- await cmdPmInit(ctx, ['--name', 'Test Project']);
302
- vi.restoreAllMocks();
303
-
304
- teamId = fs.readFileSync(path.join(projectDir, '.tmux-team-id'), 'utf-8').trim();
305
- });
306
-
307
- afterEach(() => {
308
- vi.restoreAllMocks();
309
- if (fs.existsSync(testDir)) {
310
- fs.rmSync(testDir, { recursive: true, force: true });
311
- }
312
- });
313
-
314
- it('creates milestone with given name', async () => {
315
- const ctx = createMockContext(globalDir, { cwd: projectDir });
316
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
317
-
318
- await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
319
-
320
- expect(ctx.ui.logs.some((l) => l.includes('Sprint 1'))).toBe(true);
321
-
322
- // Verify file was created
323
- const milestonePath = path.join(globalDir, 'teams', teamId, 'milestones', '1.json');
324
- expect(fs.existsSync(milestonePath)).toBe(true);
325
- });
326
-
327
- it('lists all milestones in table format', async () => {
328
- const ctx = createMockContext(globalDir, { cwd: projectDir });
329
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
330
-
331
- await cmdPmMilestone(ctx, ['add', 'Phase 1']);
332
- await cmdPmMilestone(ctx, ['add', 'Phase 2']);
333
-
334
- (ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
335
- await cmdPmMilestone(ctx, ['list']);
336
-
337
- expect(ctx.ui.table).toHaveBeenCalledTimes(1);
338
- expect(ctx.ui.table).toHaveBeenCalledWith(
339
- ['ID', 'NAME', 'STATUS'],
340
- expect.arrayContaining([
341
- expect.arrayContaining(['1', 'Phase 1']),
342
- expect.arrayContaining(['2', 'Phase 2']),
343
- ])
344
- );
345
- });
346
-
347
- it('lists milestones when called without subcommand', async () => {
348
- const ctx = createMockContext(globalDir, { cwd: projectDir });
349
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
350
-
351
- await cmdPmMilestone(ctx, ['add', 'Phase 1']);
352
-
353
- // Clear mock call history after add, then verify empty args triggers list
354
- (ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
355
- await cmdPmMilestone(ctx, []);
356
-
357
- expect(ctx.ui.table).toHaveBeenCalledTimes(1);
358
- expect(ctx.ui.table).toHaveBeenCalledWith(
359
- ['ID', 'NAME', 'STATUS'],
360
- expect.arrayContaining([expect.arrayContaining(['1', 'Phase 1'])])
361
- );
362
- });
363
-
364
- it('marks milestone as done', async () => {
365
- const ctx = createMockContext(globalDir, { cwd: projectDir });
366
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
367
-
368
- await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
369
- await cmdPmMilestone(ctx, ['done', '1']);
370
-
371
- expect(ctx.ui.logs.some((l) => l.includes('done'))).toBe(true);
372
-
373
- // Verify status was updated
374
- const milestonePath = path.join(globalDir, 'teams', teamId, 'milestones', '1.json');
375
- const milestone = JSON.parse(fs.readFileSync(milestonePath, 'utf-8'));
376
- expect(milestone.status).toBe('done');
377
- });
378
-
379
- it('exits with error for non-existent milestone', async () => {
380
- const ctx = createMockContext(globalDir, { cwd: projectDir });
381
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
382
-
383
- await expect(cmdPmMilestone(ctx, ['done', '999'])).rejects.toThrow('Exit');
384
- expect(ctx.ui.errors[0]).toContain('not found');
385
- });
386
-
387
- it('deletes milestone', async () => {
388
- const ctx = createMockContext(globalDir, { cwd: projectDir });
389
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
390
-
391
- await cmdPmMilestone(ctx, ['add', 'To Delete']);
392
- await cmdPmMilestone(ctx, ['delete', '1']);
393
-
394
- expect(ctx.ui.logs.some((l) => l.includes('deleted'))).toBe(true);
395
-
396
- // Verify milestone was deleted
397
- const milestonePath = path.join(globalDir, 'teams', teamId, 'milestones', '1.json');
398
- expect(fs.existsSync(milestonePath)).toBe(false);
399
- });
400
-
401
- it('deletes milestone with rm shorthand', async () => {
402
- const ctx = createMockContext(globalDir, { cwd: projectDir });
403
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
404
-
405
- await cmdPmMilestone(ctx, ['add', 'To Remove']);
406
- await cmdPmMilestone(ctx, ['rm', '1']);
407
-
408
- expect(ctx.ui.logs.some((l) => l.includes('deleted'))).toBe(true);
409
- });
410
-
411
- it('returns JSON on milestone delete with --json flag', async () => {
412
- const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
413
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
414
-
415
- await cmdPmMilestone(ctx, ['add', 'JSON Delete']);
416
- await cmdPmMilestone(ctx, ['delete', '1']);
417
-
418
- const output = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as {
419
- deleted: boolean;
420
- id: string;
421
- name: string;
422
- };
423
- expect(output.deleted).toBe(true);
424
- expect(output.id).toBe('1');
425
- expect(output.name).toBe('JSON Delete');
426
- });
427
-
428
- it('exits with error when deleting non-existent milestone', async () => {
429
- const ctx = createMockContext(globalDir, { cwd: projectDir });
430
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
431
-
432
- await expect(cmdPmMilestone(ctx, ['delete', '999'])).rejects.toThrow('Exit');
433
- expect(ctx.ui.errors[0]).toContain('not found');
434
- });
435
-
436
- it('routes "pm m add" to milestone add', async () => {
437
- const ctx = createMockContext(globalDir, { cwd: projectDir });
438
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
439
-
440
- await cmdPm(ctx, ['m', 'add', 'Shorthand Test']);
441
-
442
- expect(ctx.ui.logs.some((l) => l.includes('Shorthand Test'))).toBe(true);
443
- });
444
-
445
- it('creates milestone with --description flag', async () => {
446
- const ctx = createMockContext(globalDir, { cwd: projectDir });
447
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
448
-
449
- await cmdPmMilestone(ctx, ['add', 'Phase 1', '--description', 'Initial development']);
450
-
451
- // Verify doc file contains description
452
- const docPath = path.join(globalDir, 'teams', teamId, 'milestones', '1.md');
453
- const content = fs.readFileSync(docPath, 'utf-8');
454
- expect(content).toContain('Phase 1');
455
- expect(content).toContain('Initial development');
456
- });
457
-
458
- it('prints milestone doc by default', async () => {
459
- const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
460
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
461
-
462
- await cmdPmMilestone(ctx, ['add', 'Phase 1', '-d', 'Test description']);
463
-
464
- (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
465
- await cmdPmMilestone(ctx, ['doc', '1']);
466
-
467
- expect(ctx.ui.json).toHaveBeenCalledWith(
468
- expect.objectContaining({
469
- id: '1',
470
- doc: expect.stringContaining('Phase 1'),
471
- })
472
- );
473
- });
474
-
475
- it('returns milestone doc in JSON format', async () => {
476
- const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
477
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
478
-
479
- await cmdPmMilestone(ctx, ['add', 'Phase 1']);
480
- await cmdPmMilestone(ctx, ['doc', '1']);
481
-
482
- const jsonOutput = ctx.ui.jsonData.find(
483
- (d) => typeof d === 'object' && d !== null && 'doc' in d
484
- ) as { id: string; doc: string };
485
- expect(jsonOutput).toMatchObject({
486
- id: '1',
487
- doc: expect.stringContaining('Phase 1'),
488
- });
489
- });
490
-
491
- it('exits with error for non-existent milestone doc', async () => {
492
- const ctx = createMockContext(globalDir, { cwd: projectDir });
493
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
494
-
495
- await expect(cmdPmMilestone(ctx, ['doc', '999'])).rejects.toThrow('Exit');
496
- expect(ctx.ui.errors[0]).toContain('not found');
497
- });
498
-
499
- it('shows docPath with doc ref subcommand', async () => {
500
- const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
501
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
502
-
503
- await cmdPmMilestone(ctx, ['add', 'Phase 1']);
504
-
505
- (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
506
- await cmdPmMilestone(ctx, ['doc', '1', 'ref']);
507
-
508
- expect(ctx.ui.json).toHaveBeenCalledTimes(1);
509
- expect(ctx.ui.json).toHaveBeenCalledWith(
510
- expect.objectContaining({
511
- id: '1',
512
- docPath: expect.stringContaining('1.md'),
513
- })
514
- );
515
- });
516
-
517
- it('creates milestone with -d shorthand for description', async () => {
518
- const ctx = createMockContext(globalDir, { cwd: projectDir });
519
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
520
-
521
- await cmdPmMilestone(ctx, ['add', 'Sprint 1', '-d', 'Two week sprint']);
522
-
523
- const docPath = path.join(globalDir, 'teams', teamId, 'milestones', '1.md');
524
- const content = fs.readFileSync(docPath, 'utf-8');
525
- expect(content).toContain('Sprint 1');
526
- expect(content).toContain('Two week sprint');
527
- });
528
-
529
- it('prints full doc content including description', async () => {
530
- const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
531
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
532
-
533
- await cmdPmMilestone(ctx, ['add', 'Phase 1', '--description', 'Detailed description here']);
534
-
535
- (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
536
- await cmdPmMilestone(ctx, ['doc', '1']);
537
-
538
- expect(ctx.ui.json).toHaveBeenCalledTimes(1);
539
- expect(ctx.ui.json).toHaveBeenCalledWith(
540
- expect.objectContaining({
541
- id: '1',
542
- doc: expect.stringContaining('Detailed description here'),
543
- })
544
- );
545
- });
546
-
547
- it('sets milestone documentation with --body flag', async () => {
548
- const ctx = createMockContext(globalDir, { cwd: projectDir });
549
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
550
-
551
- await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
552
- await cmdPmMilestone(ctx, ['doc', '1', '--body', 'New milestone content']);
553
- expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
554
-
555
- // Verify the content was saved
556
- const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
557
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
558
- await cmdPmMilestone(jsonCtx, ['doc', '1']);
559
-
560
- expect(jsonCtx.ui.json).toHaveBeenCalledWith(
561
- expect.objectContaining({
562
- doc: 'New milestone content',
563
- })
564
- );
565
- });
566
-
567
- it('sets milestone documentation with --body-file flag', async () => {
568
- const ctx = createMockContext(globalDir, { cwd: projectDir });
569
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
570
-
571
- await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
572
-
573
- // Create a temp file with content
574
- const tempFile = path.join(projectDir, 'milestone-doc.md');
575
- fs.writeFileSync(tempFile, '# Milestone content from file');
576
-
577
- await cmdPmMilestone(ctx, ['doc', '1', '--body-file', tempFile]);
578
- expect(ctx.ui.logs.some((l) => l.includes('Saved') && l.includes('from'))).toBe(true);
579
-
580
- // Verify the content was saved
581
- const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
582
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
583
- await cmdPmMilestone(jsonCtx, ['doc', '1']);
584
-
585
- expect(jsonCtx.ui.json).toHaveBeenCalledWith(
586
- expect.objectContaining({
587
- doc: expect.stringContaining('Milestone content from file'),
588
- })
589
- );
590
- });
591
- });
592
-
593
- // ─────────────────────────────────────────────────────────────
594
- // cmdPmTask tests
595
- // ─────────────────────────────────────────────────────────────
596
-
597
- describe('cmdPmTask', () => {
598
- let testDir: string;
599
- let globalDir: string;
600
- let projectDir: string;
601
- let teamId: string;
602
-
603
- beforeEach(async () => {
604
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
605
- globalDir = path.join(testDir, 'global');
606
- projectDir = path.join(testDir, 'project');
607
- fs.mkdirSync(globalDir, { recursive: true });
608
- fs.mkdirSync(projectDir, { recursive: true });
609
-
610
- // Initialize a team
611
- const ctx = createMockContext(globalDir, { cwd: projectDir });
612
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
613
- await cmdPmInit(ctx, ['--name', 'Test Project']);
614
- vi.restoreAllMocks();
615
-
616
- teamId = fs.readFileSync(path.join(projectDir, '.tmux-team-id'), 'utf-8').trim();
617
- });
618
-
619
- afterEach(() => {
620
- vi.restoreAllMocks();
621
- if (fs.existsSync(testDir)) {
622
- fs.rmSync(testDir, { recursive: true, force: true });
623
- }
624
- });
625
-
626
- it('creates task with given title', async () => {
627
- const ctx = createMockContext(globalDir, { cwd: projectDir });
628
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
629
-
630
- await cmdPmTask(ctx, ['add', 'Implement login']);
631
-
632
- expect(ctx.ui.logs.some((l) => l.includes('Implement login'))).toBe(true);
633
- });
634
-
635
- it('creates task with milestone reference', async () => {
636
- const ctx = createMockContext(globalDir, { cwd: projectDir });
637
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
638
-
639
- // Create milestone first
640
- await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
641
- await cmdPmTask(ctx, ['add', 'Task with milestone', '--milestone', '1']);
642
-
643
- const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
644
- const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
645
- expect(task.milestone).toBe('1');
646
- });
647
-
648
- it('creates task with assignee', async () => {
649
- const ctx = createMockContext(globalDir, { cwd: projectDir });
650
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
651
-
652
- await cmdPmTask(ctx, ['add', 'Assigned task', '--assignee', 'claude']);
653
-
654
- const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
655
- const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
656
- expect(task.assignee).toBe('claude');
657
- });
658
-
659
- it('lists all tasks in table format', async () => {
660
- const ctx = createMockContext(globalDir, { cwd: projectDir });
661
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
662
-
663
- await cmdPmTask(ctx, ['add', 'Task 1']);
664
- await cmdPmTask(ctx, ['add', 'Task 2']);
665
-
666
- (ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
667
- await cmdPmTask(ctx, ['list']);
668
-
669
- expect(ctx.ui.table).toHaveBeenCalledTimes(1);
670
- expect(ctx.ui.table).toHaveBeenCalledWith(
671
- ['ID', 'TITLE', 'STATUS', 'MILESTONE'],
672
- expect.arrayContaining([
673
- expect.arrayContaining(['1', 'Task 1']),
674
- expect.arrayContaining(['2', 'Task 2']),
675
- ])
676
- );
677
- });
678
-
679
- it('lists tasks when called without subcommand', async () => {
680
- const ctx = createMockContext(globalDir, { cwd: projectDir });
681
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
682
-
683
- await cmdPmTask(ctx, ['add', 'Task 1']);
684
-
685
- // Clear mock call history after add, then verify empty args triggers list
686
- (ctx.ui.table as ReturnType<typeof vi.fn>).mockClear();
687
- await cmdPmTask(ctx, []);
688
-
689
- expect(ctx.ui.table).toHaveBeenCalledTimes(1);
690
- expect(ctx.ui.table).toHaveBeenCalledWith(
691
- ['ID', 'TITLE', 'STATUS', 'MILESTONE'],
692
- expect.arrayContaining([expect.arrayContaining(['1', 'Task 1'])])
693
- );
694
- });
695
-
696
- it('filters task list by status', async () => {
697
- const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
698
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
699
-
700
- await cmdPmTask(ctx, ['add', 'Pending task']);
701
- await cmdPmTask(ctx, ['add', 'Another task']);
702
- await cmdPmTask(ctx, ['done', '1']);
703
- await cmdPmTask(ctx, ['list', '--status', 'pending']);
704
-
705
- const lastJson = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { id: string }[];
706
- expect(lastJson).toHaveLength(1);
707
- expect(lastJson[0].id).toBe('2');
708
- });
709
-
710
- it('filters task list by milestone', async () => {
711
- const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
712
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
713
-
714
- await cmdPmMilestone(ctx, ['add', 'Sprint 1']);
715
- await cmdPmMilestone(ctx, ['add', 'Sprint 2']);
716
- await cmdPmTask(ctx, ['add', 'Task in Sprint 1', '--milestone', '1']);
717
- await cmdPmTask(ctx, ['add', 'Task in Sprint 2', '--milestone', '2']);
718
- await cmdPmTask(ctx, ['list', '--milestone', '1']);
719
-
720
- const lastJson = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { milestone: string }[];
721
- expect(lastJson).toHaveLength(1);
722
- expect(lastJson[0].milestone).toBe('1');
723
- });
724
-
725
- it('hides tasks without milestone when hideOrphanTasks is enabled', async () => {
726
- const ctx = createMockContext(globalDir, {
727
- json: true,
728
- cwd: projectDir,
729
- defaults: { hideOrphanTasks: true },
730
- });
731
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
732
-
733
- await cmdPmMilestone(ctx, ['add', 'Phase 1']);
734
- await cmdPmTask(ctx, ['add', 'With milestone', '--milestone', '1']);
735
- await cmdPmTask(ctx, ['add', 'Without milestone']);
736
- await cmdPmTask(ctx, ['list']);
737
-
738
- const tasks = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { title: string }[];
739
- expect(tasks).toHaveLength(1);
740
- expect(tasks[0].title).toBe('With milestone');
741
- });
742
-
743
- it('displays task details', async () => {
744
- const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
745
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
746
-
747
- await cmdPmTask(ctx, ['add', 'Show me', '--assignee', 'claude']);
748
- await cmdPmTask(ctx, ['show', '1']);
749
-
750
- const lastJson = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as { title: string };
751
- expect(lastJson.title).toBe('Show me');
752
- });
753
-
754
- it('updates task status', async () => {
755
- const ctx = createMockContext(globalDir, { cwd: projectDir });
756
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
757
-
758
- await cmdPmTask(ctx, ['add', 'Update me']);
759
- await cmdPmTask(ctx, ['update', '1', '--status', 'in_progress']);
760
-
761
- const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
762
- const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
763
- expect(task.status).toBe('in_progress');
764
- });
765
-
766
- it('updates task assignee', async () => {
767
- const ctx = createMockContext(globalDir, { cwd: projectDir });
768
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
769
-
770
- await cmdPmTask(ctx, ['add', 'Reassign me']);
771
- await cmdPmTask(ctx, ['update', '1', '--assignee', 'codex']);
772
-
773
- const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
774
- const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
775
- expect(task.assignee).toBe('codex');
776
- });
777
-
778
- it('marks task as done', async () => {
779
- const ctx = createMockContext(globalDir, { cwd: projectDir });
780
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
781
-
782
- await cmdPmTask(ctx, ['add', 'Complete me']);
783
- await cmdPmTask(ctx, ['done', '1']);
784
-
785
- const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
786
- const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
787
- expect(task.status).toBe('done');
788
- });
789
-
790
- it('exits with error for non-existent task', async () => {
791
- const ctx = createMockContext(globalDir, { cwd: projectDir });
792
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
793
-
794
- await expect(cmdPmTask(ctx, ['show', '999'])).rejects.toThrow('Exit');
795
- expect(ctx.ui.errors[0]).toContain('not found');
796
- });
797
-
798
- it('routes "pm t add" to task add', async () => {
799
- const ctx = createMockContext(globalDir, { cwd: projectDir });
800
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
801
-
802
- await cmdPm(ctx, ['t', 'add', 'Shorthand task']);
803
-
804
- expect(ctx.ui.logs.some((l) => l.includes('Shorthand task'))).toBe(true);
805
- });
806
- });
807
-
808
- // ─────────────────────────────────────────────────────────────
809
- // cmdPmTask doc tests
810
- // ─────────────────────────────────────────────────────────────
811
-
812
- describe('cmdPmTask doc', () => {
813
- let testDir: string;
814
- let globalDir: string;
815
- let projectDir: string;
816
-
817
- beforeEach(async () => {
818
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
819
- globalDir = path.join(testDir, 'global');
820
- projectDir = path.join(testDir, 'project');
821
- fs.mkdirSync(globalDir, { recursive: true });
822
- fs.mkdirSync(projectDir, { recursive: true });
823
-
824
- // Initialize a team and create a task
825
- const ctx = createMockContext(globalDir, { cwd: projectDir });
826
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
827
- await cmdPmInit(ctx, ['--name', 'Test Project']);
828
- await cmdPmTask(ctx, ['add', 'Test Task']);
829
- vi.restoreAllMocks();
830
- });
831
-
832
- afterEach(() => {
833
- vi.restoreAllMocks();
834
- if (fs.existsSync(testDir)) {
835
- fs.rmSync(testDir, { recursive: true, force: true });
836
- }
837
- });
838
-
839
- it('prints task documentation by default', async () => {
840
- const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
841
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
842
-
843
- (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
844
- await cmdPmTask(ctx, ['doc', '1']);
845
-
846
- expect(ctx.ui.json).toHaveBeenCalledWith(
847
- expect.objectContaining({
848
- id: '1',
849
- doc: expect.stringContaining('Test Task'),
850
- })
851
- );
852
- });
853
-
854
- it('opens documentation in $EDITOR with --edit flag', async () => {
855
- // This test is tricky because it spawns an editor
856
- // We'll just verify the command doesn't throw
857
- const ctx = createMockContext(globalDir, { cwd: projectDir });
858
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
859
-
860
- // Set a no-op editor
861
- const originalEditor = process.env.EDITOR;
862
- process.env.EDITOR = 'true'; // 'true' command exists and does nothing
863
-
864
- try {
865
- await cmdPmTask(ctx, ['doc', '1', '--edit']);
866
- expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
867
- } finally {
868
- if (originalEditor) {
869
- process.env.EDITOR = originalEditor;
870
- } else {
871
- delete process.env.EDITOR;
872
- }
873
- }
874
- });
875
-
876
- it('exits with error for non-existent task', async () => {
877
- const ctx = createMockContext(globalDir, { cwd: projectDir });
878
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
879
-
880
- await expect(cmdPmTask(ctx, ['doc', '999'])).rejects.toThrow('Exit');
881
- expect(ctx.ui.errors[0]).toContain('not found');
882
- });
883
-
884
- it('sets documentation with --body flag', async () => {
885
- const ctx = createMockContext(globalDir, { cwd: projectDir });
886
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
887
-
888
- await cmdPmTask(ctx, ['doc', '1', '--body', 'New content via --body']);
889
- expect(ctx.ui.logs.some((l) => l.includes('Saved'))).toBe(true);
890
-
891
- // Verify the content was saved
892
- const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
893
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
894
- await cmdPmTask(jsonCtx, ['doc', '1']);
895
-
896
- expect(jsonCtx.ui.json).toHaveBeenCalledWith(
897
- expect.objectContaining({
898
- doc: 'New content via --body',
899
- })
900
- );
901
- });
902
-
903
- it('sets documentation with --body-file flag', async () => {
904
- const ctx = createMockContext(globalDir, { cwd: projectDir });
905
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
906
-
907
- // Create a temp file with content
908
- const tempFile = path.join(projectDir, 'test-doc.md');
909
- fs.writeFileSync(tempFile, '# Content from file\n\nThis came from a file.');
910
-
911
- await cmdPmTask(ctx, ['doc', '1', '--body-file', tempFile]);
912
- expect(ctx.ui.logs.some((l) => l.includes('Saved') && l.includes('from'))).toBe(true);
913
-
914
- // Verify the content was saved
915
- const jsonCtx = createMockContext(globalDir, { cwd: projectDir, json: true });
916
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
917
- await cmdPmTask(jsonCtx, ['doc', '1']);
918
-
919
- expect(jsonCtx.ui.json).toHaveBeenCalledWith(
920
- expect.objectContaining({
921
- doc: expect.stringContaining('Content from file'),
922
- })
923
- );
924
- });
925
-
926
- it('exits with error for non-existent --body-file', async () => {
927
- const ctx = createMockContext(globalDir, { cwd: projectDir });
928
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
929
-
930
- await expect(
931
- cmdPmTask(ctx, ['doc', '1', '--body-file', '/nonexistent/file.md'])
932
- ).rejects.toThrow('Exit');
933
- expect(ctx.ui.errors[0]).toContain('File not found');
934
- });
935
- });
936
-
937
- // ─────────────────────────────────────────────────────────────
938
- // cmdPmLog tests
939
- // ─────────────────────────────────────────────────────────────
940
-
941
- describe('cmdPmLog', () => {
942
- let testDir: string;
943
- let globalDir: string;
944
- let projectDir: string;
945
-
946
- beforeEach(async () => {
947
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
948
- globalDir = path.join(testDir, 'global');
949
- projectDir = path.join(testDir, 'project');
950
- fs.mkdirSync(globalDir, { recursive: true });
951
- fs.mkdirSync(projectDir, { recursive: true });
952
-
953
- // Initialize a team
954
- const ctx = createMockContext(globalDir, { cwd: projectDir });
955
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
956
- await cmdPmInit(ctx, ['--name', 'Test Project']);
957
- vi.restoreAllMocks();
958
- });
959
-
960
- afterEach(() => {
961
- vi.restoreAllMocks();
962
- if (fs.existsSync(testDir)) {
963
- fs.rmSync(testDir, { recursive: true, force: true });
964
- }
965
- });
966
-
967
- it('displays audit events', async () => {
968
- const ctx = createMockContext(globalDir, { cwd: projectDir, json: true });
969
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
970
-
971
- // Create some events
972
- await cmdPmTask(ctx, ['add', 'Task 1']);
973
- await cmdPmTask(ctx, ['done', '1']);
974
-
975
- (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
976
- await cmdPmLog(ctx, []);
977
-
978
- // cmdPmLog outputs array of events directly
979
- expect(ctx.ui.json).toHaveBeenCalledWith(
980
- expect.arrayContaining([
981
- expect.objectContaining({ event: expect.stringMatching(/team_created|task/) }),
982
- ])
983
- );
984
- });
985
-
986
- it('limits number of events displayed', async () => {
987
- const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
988
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
989
-
990
- // Create multiple events
991
- await cmdPmTask(ctx, ['add', 'Task 1']);
992
- await cmdPmTask(ctx, ['add', 'Task 2']);
993
- await cmdPmTask(ctx, ['add', 'Task 3']);
994
- await cmdPmLog(ctx, ['--limit', '2']);
995
-
996
- const lastJson = ctx.ui.jsonData[ctx.ui.jsonData.length - 1] as unknown[];
997
- expect(lastJson.length).toBe(2);
998
- });
999
-
1000
- it('outputs events in JSON when --json flag set', async () => {
1001
- const ctx = createMockContext(globalDir, { json: true, cwd: projectDir });
1002
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1003
-
1004
- await cmdPmLog(ctx, []);
1005
-
1006
- expect(ctx.ui.jsonData.length).toBeGreaterThan(0);
1007
- expect(Array.isArray(ctx.ui.jsonData[ctx.ui.jsonData.length - 1])).toBe(true);
1008
- });
1009
-
1010
- it('shows info message when no events', async () => {
1011
- // Create a new project without events
1012
- const newProjectDir = path.join(testDir, 'empty-project');
1013
- fs.mkdirSync(newProjectDir, { recursive: true });
1014
-
1015
- const initCtx = createMockContext(globalDir, { cwd: newProjectDir });
1016
- vi.spyOn(process, 'cwd').mockReturnValue(newProjectDir);
1017
- await cmdPmInit(initCtx, ['--name', 'Empty']);
1018
- vi.restoreAllMocks();
1019
-
1020
- // Clear events file
1021
- const teamId = fs.readFileSync(path.join(newProjectDir, '.tmux-team-id'), 'utf-8').trim();
1022
- const eventsFile = path.join(globalDir, 'teams', teamId, 'events.jsonl');
1023
- fs.writeFileSync(eventsFile, '');
1024
-
1025
- const ctx = createMockContext(globalDir, { cwd: newProjectDir });
1026
- vi.spyOn(process, 'cwd').mockReturnValue(newProjectDir);
1027
-
1028
- await cmdPmLog(ctx, []);
1029
-
1030
- expect(ctx.ui.logs.some((l) => l.includes('No events'))).toBe(true);
1031
- });
1032
- });
1033
-
1034
- // ─────────────────────────────────────────────────────────────
1035
- // cmdPmList tests
1036
- // ─────────────────────────────────────────────────────────────
1037
-
1038
- describe('cmdPmList', () => {
1039
- let testDir: string;
1040
- let globalDir: string;
1041
-
1042
- beforeEach(() => {
1043
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
1044
- globalDir = path.join(testDir, 'global');
1045
- fs.mkdirSync(globalDir, { recursive: true });
1046
- });
1047
-
1048
- afterEach(() => {
1049
- vi.restoreAllMocks();
1050
- if (fs.existsSync(testDir)) {
1051
- fs.rmSync(testDir, { recursive: true, force: true });
1052
- }
1053
- });
1054
-
1055
- it('lists all teams in table format', async () => {
1056
- // Create multiple teams
1057
- const project1 = path.join(testDir, 'project1');
1058
- const project2 = path.join(testDir, 'project2');
1059
- fs.mkdirSync(project1, { recursive: true });
1060
- fs.mkdirSync(project2, { recursive: true });
1061
-
1062
- const ctx1 = createMockContext(globalDir, { cwd: project1 });
1063
- vi.spyOn(process, 'cwd').mockReturnValue(project1);
1064
- await cmdPmInit(ctx1, ['--name', 'Project 1']);
1065
- vi.restoreAllMocks();
1066
-
1067
- const ctx2 = createMockContext(globalDir, { cwd: project2 });
1068
- vi.spyOn(process, 'cwd').mockReturnValue(project2);
1069
- await cmdPmInit(ctx2, ['--name', 'Project 2']);
1070
- vi.restoreAllMocks();
1071
-
1072
- const ctx = createMockContext(globalDir);
1073
- await cmdPmList(ctx, []);
1074
-
1075
- expect(ctx.ui.table).toHaveBeenCalledTimes(1);
1076
- expect(ctx.ui.table).toHaveBeenCalledWith(
1077
- ['', 'ID', 'NAME', 'BACKEND', 'CREATED'],
1078
- expect.arrayContaining([
1079
- expect.arrayContaining(['Project 1']),
1080
- expect.arrayContaining(['Project 2']),
1081
- ])
1082
- );
1083
- });
1084
-
1085
- it('shows info message when no teams', async () => {
1086
- const ctx = createMockContext(globalDir);
1087
- await cmdPmList(ctx, []);
1088
-
1089
- expect(ctx.ui.logs.some((l) => l.includes('No teams'))).toBe(true);
1090
- });
1091
-
1092
- it('outputs teams in JSON when --json flag set', async () => {
1093
- // Create a team first
1094
- const project = path.join(testDir, 'project');
1095
- fs.mkdirSync(project, { recursive: true });
1096
-
1097
- const initCtx = createMockContext(globalDir, { cwd: project });
1098
- vi.spyOn(process, 'cwd').mockReturnValue(project);
1099
- await cmdPmInit(initCtx, ['--name', 'JSON Team']);
1100
- vi.restoreAllMocks();
1101
-
1102
- const ctx = createMockContext(globalDir, { json: true });
1103
- await cmdPmList(ctx, []);
1104
-
1105
- expect(ctx.ui.jsonData.length).toBe(1);
1106
- const data = ctx.ui.jsonData[0] as { teams: unknown[]; currentTeamId: string | null };
1107
- expect(data).toHaveProperty('teams');
1108
- expect(data).toHaveProperty('currentTeamId');
1109
- expect(Array.isArray(data.teams)).toBe(true);
1110
- });
1111
- });
1112
-
1113
- // ─────────────────────────────────────────────────────────────
1114
- // cmdPm router tests
1115
- // ─────────────────────────────────────────────────────────────
1116
-
1117
- describe('cmdPm router', () => {
1118
- let testDir: string;
1119
- let globalDir: string;
1120
- let projectDir: string;
1121
-
1122
- beforeEach(async () => {
1123
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
1124
- globalDir = path.join(testDir, 'global');
1125
- projectDir = path.join(testDir, 'project');
1126
- fs.mkdirSync(globalDir, { recursive: true });
1127
- fs.mkdirSync(projectDir, { recursive: true });
1128
-
1129
- // Initialize a team
1130
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1131
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1132
- await cmdPmInit(ctx, ['--name', 'Test Project']);
1133
- vi.restoreAllMocks();
1134
- });
1135
-
1136
- afterEach(() => {
1137
- vi.restoreAllMocks();
1138
- if (fs.existsSync(testDir)) {
1139
- fs.rmSync(testDir, { recursive: true, force: true });
1140
- }
1141
- });
1142
-
1143
- it('routes to correct subcommand', async () => {
1144
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1145
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1146
-
1147
- await cmdPm(ctx, ['task', 'add', 'Routed task']);
1148
-
1149
- expect(ctx.ui.logs.some((l) => l.includes('Routed task'))).toBe(true);
1150
- });
1151
-
1152
- it('expands m to milestone, t to task', async () => {
1153
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1154
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1155
-
1156
- await cmdPm(ctx, ['m', 'add', 'Milestone via m']);
1157
- await cmdPm(ctx, ['t', 'add', 'Task via t']);
1158
-
1159
- expect(ctx.ui.logs.some((l) => l.includes('Milestone via m'))).toBe(true);
1160
- expect(ctx.ui.logs.some((l) => l.includes('Task via t'))).toBe(true);
1161
- });
1162
-
1163
- it('exits with error for unknown subcommand', async () => {
1164
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1165
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1166
-
1167
- await expect(cmdPm(ctx, ['unknown'])).rejects.toThrow('Exit');
1168
- expect(ctx.ui.errors[0]).toContain('Unknown pm command');
1169
- });
1170
-
1171
- it('displays help for pm help', async () => {
1172
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1173
-
1174
- const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
1175
-
1176
- try {
1177
- await cmdPm(ctx, ['help']);
1178
- expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('tmux-team pm'));
1179
- } finally {
1180
- logSpy.mockRestore();
1181
- }
1182
- });
1183
- });
1184
-
1185
- // ─────────────────────────────────────────────────────────────
1186
- // parseStatus tests
1187
- // ─────────────────────────────────────────────────────────────
1188
-
1189
- describe('parseStatus', () => {
1190
- let testDir: string;
1191
- let globalDir: string;
1192
- let projectDir: string;
1193
-
1194
- beforeEach(async () => {
1195
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
1196
- globalDir = path.join(testDir, 'global');
1197
- projectDir = path.join(testDir, 'project');
1198
- fs.mkdirSync(globalDir, { recursive: true });
1199
- fs.mkdirSync(projectDir, { recursive: true });
1200
-
1201
- // Initialize a team
1202
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1203
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1204
- await cmdPmInit(ctx, ['--name', 'Test Project']);
1205
- await cmdPmTask(ctx, ['add', 'Test task']);
1206
- vi.restoreAllMocks();
1207
- });
1208
-
1209
- afterEach(() => {
1210
- vi.restoreAllMocks();
1211
- if (fs.existsSync(testDir)) {
1212
- fs.rmSync(testDir, { recursive: true, force: true });
1213
- }
1214
- });
1215
-
1216
- it('parses pending, in_progress, done', async () => {
1217
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1218
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1219
-
1220
- await cmdPmTask(ctx, ['update', '1', '--status', 'pending']);
1221
- await cmdPmTask(ctx, ['update', '1', '--status', 'in_progress']);
1222
- await cmdPmTask(ctx, ['update', '1', '--status', 'done']);
1223
-
1224
- // If we got here without errors, parsing worked
1225
- expect(true).toBe(true);
1226
- });
1227
-
1228
- it('normalizes in-progress to in_progress', async () => {
1229
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1230
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1231
-
1232
- await cmdPmTask(ctx, ['update', '1', '--status', 'in-progress']);
1233
-
1234
- const teamId = fs.readFileSync(path.join(projectDir, '.tmux-team-id'), 'utf-8').trim();
1235
- const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
1236
- const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
1237
- expect(task.status).toBe('in_progress');
1238
- });
1239
-
1240
- it('handles case insensitive input', async () => {
1241
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1242
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1243
-
1244
- await cmdPmTask(ctx, ['update', '1', '--status', 'DONE']);
1245
-
1246
- const teamId = fs.readFileSync(path.join(projectDir, '.tmux-team-id'), 'utf-8').trim();
1247
- const taskPath = path.join(globalDir, 'teams', teamId, 'tasks', '1.json');
1248
- const task = JSON.parse(fs.readFileSync(taskPath, 'utf-8'));
1249
- expect(task.status).toBe('done');
1250
- });
1251
-
1252
- it('throws error for invalid status', async () => {
1253
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1254
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1255
-
1256
- await expect(cmdPmTask(ctx, ['update', '1', '--status', 'invalid'])).rejects.toThrow(
1257
- 'Invalid status'
1258
- );
1259
- });
1260
- });
1261
-
1262
- // ─────────────────────────────────────────────────────────────
1263
- // Permission Integration Tests
1264
- // ─────────────────────────────────────────────────────────────
1265
-
1266
- describe('Permission integration', () => {
1267
- let testDir: string;
1268
- let globalDir: string;
1269
- let projectDir: string;
1270
- const originalEnv = { ...process.env };
1271
-
1272
- beforeEach(async () => {
1273
- // Disable pane detection in tests
1274
- delete process.env.TMUX;
1275
-
1276
- testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tmux-team-test-'));
1277
- globalDir = path.join(testDir, 'global');
1278
- projectDir = path.join(testDir, 'project');
1279
- fs.mkdirSync(globalDir, { recursive: true });
1280
- fs.mkdirSync(projectDir, { recursive: true });
1281
-
1282
- // Initialize a team and create a task
1283
- const ctx = createMockContext(globalDir, { cwd: projectDir });
1284
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1285
- await cmdPmInit(ctx, ['--name', 'Test Project']);
1286
- await cmdPmTask(ctx, ['add', 'Test task']);
1287
- await cmdPmMilestone(ctx, ['add', 'Test milestone']);
1288
- vi.restoreAllMocks();
1289
- });
1290
-
1291
- afterEach(() => {
1292
- process.env = { ...originalEnv };
1293
- vi.restoreAllMocks();
1294
- if (fs.existsSync(testDir)) {
1295
- fs.rmSync(testDir, { recursive: true, force: true });
1296
- }
1297
- });
1298
-
1299
- it('allows human to perform any action', async () => {
1300
- delete process.env.TMT_AGENT_NAME;
1301
- delete process.env.TMUX_TEAM_ACTOR;
1302
-
1303
- const ctx = createMockContext(globalDir, {
1304
- cwd: projectDir,
1305
- agents: { codex: { deny: ['pm:task:update(status)'] } },
1306
- });
1307
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1308
-
1309
- // Human should be able to update status even with deny pattern for codex
1310
- await cmdPmTask(ctx, ['update', '1', '--status', 'in_progress']);
1311
- expect(ctx.ui.logs.some((l) => l.includes('Updated'))).toBe(true);
1312
- });
1313
-
1314
- it('blocks agent when deny pattern matches status update', async () => {
1315
- process.env.TMT_AGENT_NAME = 'codex';
1316
-
1317
- const ctx = createMockContext(globalDir, {
1318
- cwd: projectDir,
1319
- agents: { codex: { deny: ['pm:task:update(status)'] } },
1320
- });
1321
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1322
-
1323
- await expect(cmdPmTask(ctx, ['update', '1', '--status', 'done'])).rejects.toThrow('Exit');
1324
- expect(ctx.ui.errors[0]).toContain('Permission denied');
1325
- expect(ctx.ui.errors[0]).toContain('pm:task:update(status)');
1326
- });
1327
-
1328
- it('blocks agent when deny pattern matches task done command', async () => {
1329
- process.env.TMT_AGENT_NAME = 'codex';
1330
-
1331
- const ctx = createMockContext(globalDir, {
1332
- cwd: projectDir,
1333
- agents: { codex: { deny: ['pm:task:update(status)'] } },
1334
- });
1335
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1336
-
1337
- // 'task done' is equivalent to 'task update --status done'
1338
- await expect(cmdPmTask(ctx, ['done', '1'])).rejects.toThrow('Exit');
1339
- expect(ctx.ui.errors[0]).toContain('Permission denied');
1340
- });
1341
-
1342
- it('allows agent to update assignee when only status is denied', async () => {
1343
- process.env.TMT_AGENT_NAME = 'codex';
1344
-
1345
- const ctx = createMockContext(globalDir, {
1346
- cwd: projectDir,
1347
- agents: { codex: { deny: ['pm:task:update(status)'] } },
1348
- });
1349
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1350
-
1351
- // Assignee update should be allowed
1352
- await cmdPmTask(ctx, ['update', '1', '--assignee', 'gemini']);
1353
- expect(ctx.ui.logs.some((l) => l.includes('Updated'))).toBe(true);
1354
- });
1355
-
1356
- it('blocks agent when wildcard deny pattern matches any field update', async () => {
1357
- process.env.TMT_AGENT_NAME = 'codex';
1358
-
1359
- const ctx = createMockContext(globalDir, {
1360
- cwd: projectDir,
1361
- agents: { codex: { deny: ['pm:task:update(*)'] } },
1362
- });
1363
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1364
-
1365
- await expect(cmdPmTask(ctx, ['update', '1', '--assignee', 'gemini'])).rejects.toThrow('Exit');
1366
- expect(ctx.ui.errors[0]).toContain('Permission denied');
1367
- });
1368
-
1369
- it('blocks agent when entire action is denied (no fields)', async () => {
1370
- process.env.TMT_AGENT_NAME = 'codex';
1371
-
1372
- const ctx = createMockContext(globalDir, {
1373
- cwd: projectDir,
1374
- agents: { codex: { deny: ['pm:task:create'] } },
1375
- });
1376
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1377
-
1378
- await expect(cmdPmTask(ctx, ['add', 'New task'])).rejects.toThrow('Exit');
1379
- expect(ctx.ui.errors[0]).toContain('Permission denied');
1380
- expect(ctx.ui.errors[0]).toContain('pm:task:create');
1381
- });
1382
-
1383
- it('allows agent without deny patterns', async () => {
1384
- process.env.TMT_AGENT_NAME = 'gemini';
1385
-
1386
- const ctx = createMockContext(globalDir, {
1387
- cwd: projectDir,
1388
- agents: { codex: { deny: ['pm:task:update(status)'] } }, // Only codex is restricted
1389
- });
1390
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1391
-
1392
- // gemini should be allowed
1393
- await cmdPmTask(ctx, ['update', '1', '--status', 'done']);
1394
- expect(ctx.ui.logs.some((l) => l.includes('Updated'))).toBe(true);
1395
- });
1396
-
1397
- it('blocks milestone status update when denied', async () => {
1398
- process.env.TMT_AGENT_NAME = 'codex';
1399
-
1400
- const ctx = createMockContext(globalDir, {
1401
- cwd: projectDir,
1402
- agents: { codex: { deny: ['pm:milestone:update(status)'] } },
1403
- });
1404
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1405
-
1406
- await expect(cmdPmMilestone(ctx, ['done', '1'])).rejects.toThrow('Exit');
1407
- expect(ctx.ui.errors[0]).toContain('Permission denied');
1408
- });
1409
-
1410
- it('blocks team creation when denied', async () => {
1411
- process.env.TMT_AGENT_NAME = 'codex';
1412
-
1413
- const newProjectDir = path.join(testDir, 'new-project');
1414
- fs.mkdirSync(newProjectDir, { recursive: true });
1415
-
1416
- const ctx = createMockContext(globalDir, {
1417
- cwd: newProjectDir,
1418
- agents: { codex: { deny: ['pm:team:create'] } },
1419
- });
1420
- vi.spyOn(process, 'cwd').mockReturnValue(newProjectDir);
1421
-
1422
- await expect(cmdPmInit(ctx, ['--name', 'New Project'])).rejects.toThrow('Exit');
1423
- expect(ctx.ui.errors[0]).toContain('Permission denied');
1424
- });
1425
-
1426
- it('blocks doc update but allows doc read', async () => {
1427
- process.env.TMT_AGENT_NAME = 'codex';
1428
-
1429
- const ctx = createMockContext(globalDir, {
1430
- cwd: projectDir,
1431
- json: true,
1432
- agents: { codex: { deny: ['pm:doc:update'] } },
1433
- });
1434
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1435
-
1436
- // Read should work
1437
- (ctx.ui.json as ReturnType<typeof vi.fn>).mockClear();
1438
- await cmdPmTask(ctx, ['doc', '1']);
1439
-
1440
- expect(ctx.ui.json).toHaveBeenCalledWith(
1441
- expect.objectContaining({
1442
- id: '1',
1443
- doc: expect.stringContaining('Test task'),
1444
- })
1445
- );
1446
- });
1447
-
1448
- it('uses TMUX_TEAM_ACTOR when TMT_AGENT_NAME is not set', async () => {
1449
- delete process.env.TMT_AGENT_NAME;
1450
- process.env.TMUX_TEAM_ACTOR = 'codex';
1451
-
1452
- const ctx = createMockContext(globalDir, {
1453
- cwd: projectDir,
1454
- agents: { codex: { deny: ['pm:task:update(status)'] } },
1455
- });
1456
- vi.spyOn(process, 'cwd').mockReturnValue(projectDir);
1457
-
1458
- await expect(cmdPmTask(ctx, ['update', '1', '--status', 'done'])).rejects.toThrow('Exit');
1459
- expect(ctx.ui.errors[0]).toContain('Permission denied');
1460
- expect(ctx.ui.errors[0]).toContain('codex');
1461
- });
1462
- });