@mndrk/memx 0.3.3 → 0.3.4

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.
Files changed (39) hide show
  1. package/README.md +98 -77
  2. package/coverage/clover.xml +1160 -0
  3. package/coverage/coverage-final.json +3 -0
  4. package/coverage/lcov-report/base.css +224 -0
  5. package/coverage/lcov-report/block-navigation.js +87 -0
  6. package/coverage/lcov-report/favicon.png +0 -0
  7. package/coverage/lcov-report/index.html +131 -0
  8. package/coverage/lcov-report/index.js.html +7255 -0
  9. package/coverage/lcov-report/mcp.js.html +1009 -0
  10. package/coverage/lcov-report/prettify.css +1 -0
  11. package/coverage/lcov-report/prettify.js +2 -0
  12. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  13. package/coverage/lcov-report/sorter.js +210 -0
  14. package/coverage/lcov.info +2017 -0
  15. package/index.js +651 -243
  16. package/package.json +24 -2
  17. package/test/additional.test.js +373 -0
  18. package/test/branches.test.js +247 -0
  19. package/test/commands.test.js +663 -0
  20. package/test/context.test.js +185 -0
  21. package/test/coverage.test.js +366 -0
  22. package/test/dispatch.test.js +220 -0
  23. package/test/edge-coverage.test.js +250 -0
  24. package/test/edge.test.js +434 -0
  25. package/test/final-coverage.test.js +316 -0
  26. package/test/final-edges.test.js +199 -0
  27. package/test/init-local.test.js +316 -0
  28. package/test/init.test.js +122 -0
  29. package/test/interactive.test.js +229 -0
  30. package/test/main-dispatch.test.js +164 -0
  31. package/test/main-full.test.js +590 -0
  32. package/test/main.test.js +197 -0
  33. package/test/mcp-server.test.js +320 -0
  34. package/test/mcp.test.js +288 -0
  35. package/test/more.test.js +312 -0
  36. package/test/new.test.js +175 -0
  37. package/test/skill.test.js +247 -0
  38. package/test/tasks-interactive.test.js +243 -0
  39. package/test/utils.test.js +367 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Tests for cmdNew and related functions
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ // Track exit calls
10
+ let mockExitCode = null;
11
+
12
+ // Mock child_process before requiring index.js
13
+ jest.mock('child_process', () => ({
14
+ execSync: jest.fn(),
15
+ spawnSync: jest.fn(() => ({ status: 0, stdout: '', stderr: '' })),
16
+ spawn: jest.fn()
17
+ }));
18
+
19
+ // Mock readline
20
+ jest.mock('readline', () => ({
21
+ createInterface: jest.fn(() => ({
22
+ question: jest.fn((q, cb) => cb('')),
23
+ close: jest.fn(),
24
+ on: jest.fn()
25
+ }))
26
+ }));
27
+
28
+ const {
29
+ cmdNew,
30
+ writeMemFile,
31
+ readMemFile,
32
+ CENTRAL_MEM,
33
+ loadIndex,
34
+ saveIndex,
35
+ } = require('../index.js');
36
+
37
+ const { spawnSync, execSync } = require('child_process');
38
+
39
+ const testDir = path.join(os.tmpdir(), 'memx-test-new-' + Date.now());
40
+ let memDir;
41
+ let originalExit;
42
+
43
+ beforeEach(() => {
44
+ memDir = path.join(testDir, '.mem');
45
+ fs.mkdirSync(memDir, { recursive: true });
46
+ fs.mkdirSync(path.join(memDir, '.git'), { recursive: true });
47
+ spawnSync.mockClear();
48
+ execSync.mockClear();
49
+ mockExitCode = null;
50
+ originalExit = process.exit;
51
+ process.exit = jest.fn((code) => {
52
+ mockExitCode = code;
53
+ throw new Error(`process.exit(${code})`);
54
+ });
55
+ jest.spyOn(console, 'log').mockImplementation();
56
+ });
57
+
58
+ afterEach(() => {
59
+ process.exit = originalExit;
60
+ if (fs.existsSync(testDir)) {
61
+ fs.rmSync(testDir, { recursive: true });
62
+ }
63
+ jest.restoreAllMocks();
64
+ });
65
+
66
+ describe('cmdNew', () => {
67
+ test('shows usage when no goal provided', () => {
68
+ const consoleSpy = jest.spyOn(console, 'log');
69
+
70
+ try {
71
+ cmdNew([], memDir);
72
+ } catch (e) {
73
+ expect(e.message).toContain('process.exit');
74
+ }
75
+
76
+ expect(mockExitCode).toBe(1);
77
+ expect(consoleSpy.mock.calls[0][0]).toContain('Usage');
78
+ });
79
+
80
+ test('shows JSON error when no goal in JSON mode', () => {
81
+ const consoleSpy = jest.spyOn(console, 'log');
82
+
83
+ try {
84
+ cmdNew(['--json'], memDir);
85
+ } catch (e) {
86
+ // expected
87
+ }
88
+
89
+ expect(mockExitCode).toBe(1);
90
+ const output = consoleSpy.mock.calls[0][0];
91
+ expect(output).toContain('error');
92
+ });
93
+
94
+ test('creates task with default provider', () => {
95
+ spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
96
+
97
+ const consoleSpy = jest.spyOn(console, 'log');
98
+ cmdNew(['Build', 'a', 'feature'], memDir);
99
+
100
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
101
+ expect(output).toContain('Created task');
102
+ expect(output).toContain('build-a-feature');
103
+ });
104
+
105
+ test('creates task with custom provider', () => {
106
+ spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
107
+
108
+ const consoleSpy = jest.spyOn(console, 'log');
109
+ cmdNew(['Build', 'something', '--provider', 'gemini'], memDir);
110
+
111
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
112
+ expect(output).toContain('gemini');
113
+ });
114
+
115
+ test('creates task with -P flag', () => {
116
+ spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
117
+
118
+ const consoleSpy = jest.spyOn(console, 'log');
119
+ cmdNew(['Test', 'task', '-P', 'ollama'], memDir);
120
+
121
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
122
+ expect(output).toContain('ollama');
123
+ });
124
+
125
+ test('creates task with custom directory', () => {
126
+ spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
127
+
128
+ const consoleSpy = jest.spyOn(console, 'log');
129
+ cmdNew(['My', 'task', '--dir', '/custom/path'], memDir);
130
+
131
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
132
+ expect(output).toContain('/custom/path');
133
+ });
134
+
135
+ test('outputs JSON in JSON mode', () => {
136
+ spawnSync.mockReturnValue({ status: 0, stdout: '', stderr: '' });
137
+
138
+ const consoleSpy = jest.spyOn(console, 'log');
139
+ cmdNew(['--json', 'JSON', 'task'], memDir);
140
+
141
+ const output = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0];
142
+ const parsed = JSON.parse(output);
143
+ expect(parsed.taskName).toBe('json-task');
144
+ expect(parsed.goal).toBe('JSON task');
145
+ });
146
+
147
+ test('handles error gracefully', () => {
148
+ spawnSync.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'git error' });
149
+
150
+ try {
151
+ cmdNew(['Failing', 'task'], memDir);
152
+ } catch (e) {
153
+ expect(e.message).toContain('process.exit');
154
+ }
155
+
156
+ expect(mockExitCode).toBe(1);
157
+ });
158
+
159
+ test('handles error in JSON mode', () => {
160
+ spawnSync.mockReturnValueOnce({ status: 1, stdout: '', stderr: 'git error' });
161
+
162
+ const consoleSpy = jest.spyOn(console, 'log');
163
+
164
+ try {
165
+ cmdNew(['--json', 'Failing', 'task'], memDir);
166
+ } catch (e) {
167
+ // expected
168
+ }
169
+
170
+ expect(mockExitCode).toBe(1);
171
+ const output = consoleSpy.mock.calls[consoleSpy.mock.calls.length - 1][0];
172
+ const parsed = JSON.parse(output);
173
+ expect(parsed.error).toBeDefined();
174
+ });
175
+ });
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Tests for skill-related functions
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ // Mock child_process before requiring index.js
10
+ jest.mock('child_process', () => ({
11
+ execSync: jest.fn(),
12
+ spawnSync: jest.fn(() => ({ status: 0, stdout: '', stderr: '' })),
13
+ spawn: jest.fn()
14
+ }));
15
+
16
+ // Mock readline
17
+ jest.mock('readline', () => ({
18
+ createInterface: jest.fn(() => ({
19
+ question: jest.fn((q, cb) => cb('')),
20
+ close: jest.fn(),
21
+ on: jest.fn()
22
+ }))
23
+ }));
24
+
25
+ const {
26
+ isSkillInstalled,
27
+ installSkillTo,
28
+ handleSkillCommand,
29
+ MEM_SKILL
30
+ } = require('../index.js');
31
+
32
+ describe('MEM_SKILL constant', () => {
33
+ test('is defined', () => {
34
+ expect(MEM_SKILL).toBeDefined();
35
+ expect(typeof MEM_SKILL).toBe('string');
36
+ });
37
+
38
+ test('contains mem description', () => {
39
+ expect(MEM_SKILL).toContain('mem');
40
+ expect(MEM_SKILL).toContain('Persistent');
41
+ });
42
+
43
+ test('contains command examples', () => {
44
+ expect(MEM_SKILL).toContain('mem context');
45
+ expect(MEM_SKILL).toContain('mem checkpoint');
46
+ expect(MEM_SKILL).toContain('mem learn');
47
+ });
48
+ });
49
+
50
+ describe('isSkillInstalled', () => {
51
+ const testHome = path.join(os.tmpdir(), 'memx-test-skill-' + Date.now());
52
+
53
+ beforeEach(() => {
54
+ fs.mkdirSync(testHome, { recursive: true });
55
+ // Temporarily override HOME
56
+ process.env.ORIGINAL_HOME = process.env.HOME;
57
+ process.env.HOME = testHome;
58
+ });
59
+
60
+ afterEach(() => {
61
+ process.env.HOME = process.env.ORIGINAL_HOME;
62
+ delete process.env.ORIGINAL_HOME;
63
+ if (fs.existsSync(testHome)) {
64
+ fs.rmSync(testHome, { recursive: true });
65
+ }
66
+ });
67
+
68
+ test('returns false when skill not installed for claude', () => {
69
+ // Note: This test may be affected by the actual HOME directory
70
+ // Since we can't easily mock the path module's join behavior
71
+ const result = isSkillInstalled('claude');
72
+ expect(typeof result).toBe('boolean');
73
+ });
74
+
75
+ test('returns false when skill not installed for gemini', () => {
76
+ const result = isSkillInstalled('gemini');
77
+ expect(typeof result).toBe('boolean');
78
+ });
79
+
80
+ test('returns true when skill is installed', () => {
81
+ const skillDir = path.join(testHome, '.claude', 'skills', 'mem');
82
+ fs.mkdirSync(skillDir, { recursive: true });
83
+ fs.writeFileSync(path.join(skillDir, 'SKILL.md'), MEM_SKILL);
84
+
85
+ const result = isSkillInstalled('claude');
86
+ expect(result).toBe(true);
87
+ });
88
+ });
89
+
90
+ describe('installSkillTo', () => {
91
+ const testHome = path.join(os.tmpdir(), 'memx-test-install-' + Date.now());
92
+
93
+ beforeEach(() => {
94
+ fs.mkdirSync(testHome, { recursive: true });
95
+ process.env.ORIGINAL_HOME = process.env.HOME;
96
+ process.env.HOME = testHome;
97
+ });
98
+
99
+ afterEach(() => {
100
+ process.env.HOME = process.env.ORIGINAL_HOME;
101
+ delete process.env.ORIGINAL_HOME;
102
+ if (fs.existsSync(testHome)) {
103
+ fs.rmSync(testHome, { recursive: true });
104
+ }
105
+ });
106
+
107
+ test('creates skill directory for claude', () => {
108
+ const result = installSkillTo('claude');
109
+ expect(result).toContain('.claude');
110
+ expect(result).toContain('skills');
111
+ expect(result).toContain('mem');
112
+
113
+ const skillFile = path.join(result, 'SKILL.md');
114
+ expect(fs.existsSync(skillFile)).toBe(true);
115
+ });
116
+
117
+ test('creates skill directory for gemini', () => {
118
+ const result = installSkillTo('gemini');
119
+ expect(result).toContain('.gemini');
120
+ expect(result).toContain('skills');
121
+ expect(result).toContain('mem');
122
+
123
+ const skillFile = path.join(result, 'SKILL.md');
124
+ expect(fs.existsSync(skillFile)).toBe(true);
125
+ });
126
+
127
+ test('writes correct skill content', () => {
128
+ const result = installSkillTo('claude');
129
+ const skillFile = path.join(result, 'SKILL.md');
130
+ const content = fs.readFileSync(skillFile, 'utf8');
131
+ expect(content).toBe(MEM_SKILL);
132
+ });
133
+
134
+ test('overwrites existing skill file', () => {
135
+ // Install first time
136
+ const result = installSkillTo('claude');
137
+ const skillFile = path.join(result, 'SKILL.md');
138
+
139
+ // Modify the file
140
+ fs.writeFileSync(skillFile, 'old content');
141
+
142
+ // Install again
143
+ installSkillTo('claude');
144
+ const content = fs.readFileSync(skillFile, 'utf8');
145
+ expect(content).toBe(MEM_SKILL);
146
+ });
147
+ });
148
+
149
+ describe('handleSkillCommand', () => {
150
+ const testHome = path.join(os.tmpdir(), 'memx-test-handle-' + Date.now());
151
+ let consoleSpy;
152
+
153
+ beforeEach(() => {
154
+ fs.mkdirSync(testHome, { recursive: true });
155
+ process.env.ORIGINAL_HOME = process.env.HOME;
156
+ process.env.HOME = testHome;
157
+ consoleSpy = jest.spyOn(console, 'log').mockImplementation();
158
+ });
159
+
160
+ afterEach(() => {
161
+ process.env.HOME = process.env.ORIGINAL_HOME;
162
+ delete process.env.ORIGINAL_HOME;
163
+ if (fs.existsSync(testHome)) {
164
+ fs.rmSync(testHome, { recursive: true });
165
+ }
166
+ consoleSpy.mockRestore();
167
+ });
168
+
169
+ test('shows skill content with no subcommand', () => {
170
+ handleSkillCommand(['skill']);
171
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
172
+ expect(output).toContain('/mem');
173
+ expect(output).toContain('LLM instructions');
174
+ });
175
+
176
+ test('shows skill content with "view" subcommand', () => {
177
+ handleSkillCommand(['skill', 'view']);
178
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
179
+ expect(output).toContain('/mem');
180
+ });
181
+
182
+ test('shows skill content with "show" subcommand', () => {
183
+ handleSkillCommand(['skill', 'show']);
184
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
185
+ expect(output).toContain('/mem');
186
+ });
187
+
188
+ test('installs to all providers with "install" subcommand', () => {
189
+ handleSkillCommand(['skill', 'install']);
190
+
191
+ const claudeSkill = path.join(testHome, '.claude', 'skills', 'mem', 'SKILL.md');
192
+ const geminiSkill = path.join(testHome, '.gemini', 'skills', 'mem', 'SKILL.md');
193
+
194
+ expect(fs.existsSync(claudeSkill)).toBe(true);
195
+ expect(fs.existsSync(geminiSkill)).toBe(true);
196
+ });
197
+
198
+ test('installs to all providers with "add" subcommand', () => {
199
+ handleSkillCommand(['skill', 'add']);
200
+
201
+ const claudeSkill = path.join(testHome, '.claude', 'skills', 'mem', 'SKILL.md');
202
+ expect(fs.existsSync(claudeSkill)).toBe(true);
203
+ });
204
+
205
+ test('installs to claude only with "install claude"', () => {
206
+ handleSkillCommand(['skill', 'install', 'claude']);
207
+
208
+ const claudeSkill = path.join(testHome, '.claude', 'skills', 'mem', 'SKILL.md');
209
+ const geminiSkill = path.join(testHome, '.gemini', 'skills', 'mem', 'SKILL.md');
210
+
211
+ expect(fs.existsSync(claudeSkill)).toBe(true);
212
+ expect(fs.existsSync(geminiSkill)).toBe(false);
213
+ });
214
+
215
+ test('installs to gemini only with "install gemini"', () => {
216
+ handleSkillCommand(['skill', 'install', 'gemini']);
217
+
218
+ const claudeSkill = path.join(testHome, '.claude', 'skills', 'mem', 'SKILL.md');
219
+ const geminiSkill = path.join(testHome, '.gemini', 'skills', 'mem', 'SKILL.md');
220
+
221
+ expect(fs.existsSync(claudeSkill)).toBe(false);
222
+ expect(fs.existsSync(geminiSkill)).toBe(true);
223
+ });
224
+
225
+ test('handles unknown target', () => {
226
+ handleSkillCommand(['skill', 'install', 'unknown']);
227
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
228
+ expect(output).toContain('Unknown target');
229
+ });
230
+
231
+ test('shows usage for unknown subcommand', () => {
232
+ handleSkillCommand(['skill', 'unknown']);
233
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
234
+ expect(output).toContain('mem skill');
235
+ expect(output).toContain('Commands');
236
+ });
237
+
238
+ test('shows installed status when skills exist', () => {
239
+ // Pre-install
240
+ installSkillTo('claude');
241
+
242
+ handleSkillCommand(['skill']);
243
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
244
+ expect(output).toContain('Installed');
245
+ expect(output).toContain('.claude');
246
+ });
247
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Tests for cmdTasks - focus on list mode and testable parts
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ // Mock child_process before requiring index.js
10
+ jest.mock('child_process', () => ({
11
+ execSync: jest.fn(),
12
+ spawnSync: jest.fn(() => ({ status: 0, stdout: '', stderr: '' })),
13
+ spawn: jest.fn()
14
+ }));
15
+
16
+ // Mock readline
17
+ jest.mock('readline', () => ({
18
+ createInterface: jest.fn(() => ({
19
+ question: jest.fn((q, cb) => cb('')),
20
+ close: jest.fn(),
21
+ on: jest.fn()
22
+ }))
23
+ }));
24
+
25
+ const {
26
+ cmdTasks,
27
+ writeMemFile,
28
+ readMemFile,
29
+ c
30
+ } = require('../index.js');
31
+
32
+ const { spawnSync } = require('child_process');
33
+
34
+ const testDir = path.join(os.tmpdir(), 'memx-tasks-' + Date.now());
35
+ let memDir;
36
+
37
+ beforeEach(() => {
38
+ memDir = path.join(testDir, '.mem');
39
+ fs.mkdirSync(memDir, { recursive: true });
40
+ fs.mkdirSync(path.join(memDir, '.git'), { recursive: true });
41
+ spawnSync.mockClear();
42
+ jest.spyOn(console, 'log').mockImplementation();
43
+ jest.spyOn(console, 'error').mockImplementation();
44
+ });
45
+
46
+ afterEach(() => {
47
+ if (fs.existsSync(testDir)) {
48
+ fs.rmSync(testDir, { recursive: true });
49
+ }
50
+ jest.restoreAllMocks();
51
+ });
52
+
53
+ describe('cmdTasks list mode', () => {
54
+ test('shows message when no task branches', () => {
55
+ spawnSync.mockImplementation((cmd, args) => {
56
+ if (args && args.includes('--show-current')) {
57
+ return { status: 0, stdout: 'main', stderr: '' };
58
+ }
59
+ if (args && args[0] === 'branch' && args[1] === '-a') {
60
+ return { status: 0, stdout: '* main', stderr: '' };
61
+ }
62
+ return { status: 0, stdout: '', stderr: '' };
63
+ });
64
+
65
+ const consoleSpy = jest.spyOn(console, 'log');
66
+ cmdTasks(['-l'], memDir);
67
+
68
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
69
+ expect(output).toContain('No tasks');
70
+ });
71
+
72
+ test('lists active tasks with -l flag', () => {
73
+ // The first call is for --show-current, second for branch -a, etc.
74
+ spawnSync
75
+ .mockReturnValueOnce({ status: 0, stdout: 'task/feature', stderr: '' })
76
+ .mockReturnValueOnce({ status: 0, stdout: ' main\n* task/feature\n task/other', stderr: '' })
77
+ .mockReturnValueOnce({ status: 0, stdout: '---\nstatus: active\n---\n\nTest goal', stderr: '' })
78
+ .mockReturnValueOnce({ status: 0, stdout: '---\nstatus: active\n---\n\nOther goal', stderr: '' });
79
+
80
+ writeMemFile(memDir, 'goal.md', '---\ntask: feature\nstatus: active\n---\n\n# Goal\n\nTest goal');
81
+ writeMemFile(memDir, 'state.md', '---\nstatus: active\n---\n\n');
82
+
83
+ const consoleSpy = jest.spyOn(console, 'log');
84
+ cmdTasks(['-l'], memDir);
85
+
86
+ expect(consoleSpy).toHaveBeenCalled();
87
+ const output = consoleSpy.mock.calls.map(c => c[0]).join('\n');
88
+ expect(output).toContain('feature');
89
+ });
90
+
91
+ test('shows done status with checkmark', () => {
92
+ spawnSync.mockImplementation((cmd, args) => {
93
+ if (args && args.includes('--show-current')) {
94
+ return { status: 0, stdout: 'main', stderr: '' };
95
+ }
96
+ if (args && args[0] === 'branch' && args[1] === '-a') {
97
+ return { status: 0, stdout: '* main\n task/done-task', stderr: '' };
98
+ }
99
+ if (args && args[0] === 'show') {
100
+ return { status: 0, stdout: '---\nstatus: done\n---\n\nCompleted task', stderr: '' };
101
+ }
102
+ return { status: 0, stdout: '', stderr: '' };
103
+ });
104
+
105
+ const consoleSpy = jest.spyOn(console, 'log');
106
+ cmdTasks(['-l'], memDir);
107
+
108
+ expect(consoleSpy).toHaveBeenCalled();
109
+ });
110
+
111
+ test('shows blocked status', () => {
112
+ spawnSync.mockImplementation((cmd, args) => {
113
+ if (args && args.includes('--show-current')) {
114
+ return { status: 0, stdout: 'main', stderr: '' };
115
+ }
116
+ if (args && args[0] === 'branch' && args[1] === '-a') {
117
+ return { status: 0, stdout: '* main\n task/blocked-task', stderr: '' };
118
+ }
119
+ if (args && args[0] === 'show') {
120
+ return { status: 0, stdout: '---\nstatus: blocked\n---\n\nBlocked task', stderr: '' };
121
+ }
122
+ return { status: 0, stdout: '', stderr: '' };
123
+ });
124
+
125
+ const consoleSpy = jest.spyOn(console, 'log');
126
+ cmdTasks(['-l'], memDir);
127
+
128
+ expect(consoleSpy).toHaveBeenCalled();
129
+ });
130
+
131
+ test('shows progress percentage', () => {
132
+ spawnSync.mockImplementation((cmd, args) => {
133
+ if (args && args.includes('--show-current')) {
134
+ return { status: 0, stdout: 'task/progress', stderr: '' };
135
+ }
136
+ if (args && args[0] === 'branch' && args[1] === '-a') {
137
+ return { status: 0, stdout: '* task/progress', stderr: '' };
138
+ }
139
+ if (args && args[0] === 'show') {
140
+ return { status: 0, stdout: '---\nstatus: active\n---\n\n## Progress: 50%', stderr: '' };
141
+ }
142
+ return { status: 0, stdout: '', stderr: '' };
143
+ });
144
+
145
+ const consoleSpy = jest.spyOn(console, 'log');
146
+ cmdTasks(['-l'], memDir);
147
+
148
+ expect(consoleSpy).toHaveBeenCalled();
149
+ });
150
+
151
+ test('handles git show failure gracefully', () => {
152
+ spawnSync.mockImplementation((cmd, args) => {
153
+ if (args && args.includes('--show-current')) {
154
+ return { status: 0, stdout: 'main', stderr: '' };
155
+ }
156
+ if (args && args[0] === 'branch' && args[1] === '-a') {
157
+ return { status: 0, stdout: ' task/feature', stderr: '' };
158
+ }
159
+ if (args && args[0] === 'show') {
160
+ return { status: 1, stdout: '', stderr: 'not found' };
161
+ }
162
+ return { status: 0, stdout: '', stderr: '' };
163
+ });
164
+
165
+ const consoleSpy = jest.spyOn(console, 'log');
166
+ cmdTasks(['-l'], memDir);
167
+
168
+ expect(consoleSpy).toHaveBeenCalled();
169
+ });
170
+
171
+ test('marks current task', () => {
172
+ spawnSync.mockImplementation((cmd, args) => {
173
+ if (args && args.includes('--show-current')) {
174
+ return { status: 0, stdout: 'task/current-one', stderr: '' };
175
+ }
176
+ if (args && args[0] === 'branch' && args[1] === '-a') {
177
+ return { status: 0, stdout: '* task/current-one\n task/other', stderr: '' };
178
+ }
179
+ if (args && args[0] === 'show') {
180
+ return { status: 0, stdout: '---\nstatus: active\n---\n\n', stderr: '' };
181
+ }
182
+ return { status: 0, stdout: '', stderr: '' };
183
+ });
184
+
185
+ const consoleSpy = jest.spyOn(console, 'log');
186
+ cmdTasks(['-l'], memDir);
187
+
188
+ // Verify the task is marked as current
189
+ expect(consoleSpy).toHaveBeenCalled();
190
+ });
191
+ });
192
+
193
+ describe('cmdTasks non-interactive when stdin not TTY', () => {
194
+ test('falls back to list mode when stdin is not TTY', () => {
195
+ // Save original isTTY
196
+ const originalIsTTY = process.stdin.isTTY;
197
+ process.stdin.isTTY = false;
198
+
199
+ spawnSync.mockImplementation((cmd, args) => {
200
+ if (args && args.includes('--show-current')) {
201
+ return { status: 0, stdout: 'task/test', stderr: '' };
202
+ }
203
+ if (args && args[0] === 'branch' && args[1] === '-a') {
204
+ return { status: 0, stdout: '* task/test', stderr: '' };
205
+ }
206
+ if (args && args[0] === 'show') {
207
+ return { status: 0, stdout: '---\nstatus: active\n---\n\n', stderr: '' };
208
+ }
209
+ return { status: 0, stdout: '', stderr: '' };
210
+ });
211
+
212
+ const consoleSpy = jest.spyOn(console, 'log');
213
+ cmdTasks([], memDir);
214
+
215
+ // Should show list output since stdin is not TTY
216
+ expect(consoleSpy).toHaveBeenCalled();
217
+
218
+ // Restore
219
+ process.stdin.isTTY = originalIsTTY;
220
+ });
221
+ });
222
+
223
+ describe('cmdTasks with remote branches', () => {
224
+ test('includes remote task branches', () => {
225
+ spawnSync.mockImplementation((cmd, args) => {
226
+ if (args && args.includes('--show-current')) {
227
+ return { status: 0, stdout: 'main', stderr: '' };
228
+ }
229
+ if (args && args[0] === 'branch' && args[1] === '-a') {
230
+ return { status: 0, stdout: '* main\n remotes/origin/task/remote-feature', stderr: '' };
231
+ }
232
+ if (args && args[0] === 'show') {
233
+ return { status: 0, stdout: '---\nstatus: active\n---\n\n', stderr: '' };
234
+ }
235
+ return { status: 0, stdout: '', stderr: '' };
236
+ });
237
+
238
+ const consoleSpy = jest.spyOn(console, 'log');
239
+ cmdTasks(['-l'], memDir);
240
+
241
+ expect(consoleSpy).toHaveBeenCalled();
242
+ });
243
+ });