|
| 1 | +import { describe, expect, mock, test, beforeAll } from 'bun:test' |
| 2 | +import { logMock } from '../../../tests/mocks/log.js' |
| 3 | +import { debugMock } from '../../../tests/mocks/debug.js' |
| 4 | + |
| 5 | +// Mock bun:bundle before anything that calls feature() |
| 6 | +mock.module('bun:bundle', () => ({ feature: (_name: string) => false })) |
| 7 | + |
| 8 | +// Cut bootstrap/state.ts side-effect chain |
| 9 | +mock.module('src/utils/log.ts', logMock) |
| 10 | +mock.module('src/utils/debug.ts', debugMock) |
| 11 | +mock.module('src/utils/config.ts', () => ({ |
| 12 | + getGlobalConfig: () => ({}), |
| 13 | + saveGlobalConfig: async () => {}, |
| 14 | + getGlobalConfigWriteCount: () => 0, |
| 15 | +})) |
| 16 | +mock.module('src/utils/settings/settings.js', () => ({ |
| 17 | + getCachedOrLoadSettings: async () => ({}), |
| 18 | + getCachedSettings: () => ({}), |
| 19 | +})) |
| 20 | + |
| 21 | +let parsePopArgs: ( |
| 22 | + args: string, |
| 23 | +) => import('../../services/pushStack/state.js').PopOptions | string |
| 24 | + |
| 25 | +beforeAll(async () => { |
| 26 | + const popModule = await import('../pop/pop.js') |
| 27 | + parsePopArgs = popModule.parsePopArgs |
| 28 | +}) |
| 29 | + |
| 30 | +describe('parsePopArgs', () => { |
| 31 | + test('empty args yields empty options', () => { |
| 32 | + const result = parsePopArgs('') |
| 33 | + expect(typeof result).toBe('object') |
| 34 | + expect(result).toEqual({}) |
| 35 | + }) |
| 36 | + |
| 37 | + test('--discard sets discard flag', () => { |
| 38 | + const result = parsePopArgs('--discard') |
| 39 | + expect(result).toEqual({ discard: true }) |
| 40 | + }) |
| 41 | + |
| 42 | + test('--keep-code sets keepCode flag', () => { |
| 43 | + const result = parsePopArgs('--keep-code') |
| 44 | + expect(result).toEqual({ keepCode: true }) |
| 45 | + }) |
| 46 | + |
| 47 | + test('--to #N parses marker number', () => { |
| 48 | + const result = parsePopArgs('--to #2') |
| 49 | + expect(result).toEqual({ to: 2 }) |
| 50 | + }) |
| 51 | + |
| 52 | + test('--to N (without #) parses marker number', () => { |
| 53 | + const result = parsePopArgs('--to 3') |
| 54 | + expect(result).toEqual({ to: 3 }) |
| 55 | + }) |
| 56 | + |
| 57 | + test('--to=N= syntax works', () => { |
| 58 | + const result = parsePopArgs('--to=1') |
| 59 | + expect(result).toEqual({ to: 1 }) |
| 60 | + }) |
| 61 | + |
| 62 | + test('combined flags work', () => { |
| 63 | + const result = parsePopArgs('--to #1 --keep-code') |
| 64 | + expect(result).toEqual({ to: 1, keepCode: true }) |
| 65 | + }) |
| 66 | + |
| 67 | + test('--to missing number returns error string', () => { |
| 68 | + const result = parsePopArgs('--to') |
| 69 | + expect(typeof result).toBe('string') |
| 70 | + expect(result as string).toMatch(/Missing marker/) |
| 71 | + }) |
| 72 | + |
| 73 | + test('--to 0 returns error (must be >= 1)', () => { |
| 74 | + const result = parsePopArgs('--to 0') |
| 75 | + expect(typeof result).toBe('string') |
| 76 | + }) |
| 77 | + |
| 78 | + test('--to #abc returns error', () => { |
| 79 | + const result = parsePopArgs('--to #abc') |
| 80 | + expect(typeof result).toBe('string') |
| 81 | + expect(result as string).toMatch(/Invalid marker/) |
| 82 | + }) |
| 83 | + |
| 84 | + test('unknown flag returns error string', () => { |
| 85 | + const result = parsePopArgs('--unknown') |
| 86 | + expect(typeof result).toBe('string') |
| 87 | + expect(result as string).toMatch(/Unknown option/) |
| 88 | + }) |
| 89 | +}) |
| 90 | + |
| 91 | +describe('push stack state', () => { |
| 92 | + test('MAX_PUSH_DEPTH is 3', async () => { |
| 93 | + const { MAX_PUSH_DEPTH } = await import('../../services/pushStack/state.js') |
| 94 | + expect(MAX_PUSH_DEPTH).toBe(3) |
| 95 | + }) |
| 96 | + |
| 97 | + test('getPushStackMirror starts empty', async () => { |
| 98 | + const { getPushStackMirror } = await import( |
| 99 | + '../../services/pushStack/state.js' |
| 100 | + ) |
| 101 | + const mirror = getPushStackMirror() |
| 102 | + expect(Array.isArray(mirror)).toBe(true) |
| 103 | + // May have stale content if other tests ran first — just verify it's readable |
| 104 | + expect(mirror).toBeDefined() |
| 105 | + }) |
| 106 | + |
| 107 | + test('setPushStackMirror round-trips', async () => { |
| 108 | + const { getPushStackMirror, setPushStackMirror } = await import( |
| 109 | + '../../services/pushStack/state.js' |
| 110 | + ) |
| 111 | + const marker = { |
| 112 | + id: 'test-id', |
| 113 | + messageUuid: |
| 114 | + '00000000-0000-0000-0000-000000000001' as `${string}-${string}-${string}-${string}-${string}`, |
| 115 | + note: 'test note', |
| 116 | + timestamp: Date.now(), |
| 117 | + anchorPreview: 'preview text', |
| 118 | + } |
| 119 | + setPushStackMirror([marker]) |
| 120 | + const stack = getPushStackMirror() |
| 121 | + expect(stack.length).toBe(1) |
| 122 | + expect(stack[0]!.id).toBe('test-id') |
| 123 | + expect(stack[0]!.note).toBe('test note') |
| 124 | + // Restore |
| 125 | + setPushStackMirror([]) |
| 126 | + }) |
| 127 | +}) |
| 128 | + |
| 129 | +describe('DIGEST_PROMPT and DIGEST_TEMPLATE', () => { |
| 130 | + test('DIGEST_PROMPT contains required four-section structure', async () => { |
| 131 | + const { DIGEST_PROMPT } = await import( |
| 132 | + '../../services/pushStack/digestPrompt.js' |
| 133 | + ) |
| 134 | + expect(DIGEST_PROMPT).toContain('[Decisions]') |
| 135 | + expect(DIGEST_PROMPT).toContain('[Rejected]') |
| 136 | + expect(DIGEST_PROMPT).toContain('[Open questions]') |
| 137 | + expect(DIGEST_PROMPT).toContain('[Action items]') |
| 138 | + expect(DIGEST_PROMPT).toContain('<analysis>') |
| 139 | + expect(DIGEST_PROMPT).toContain('<summary>') |
| 140 | + }) |
| 141 | + |
| 142 | + test('DIGEST_TEMPLATE is a non-empty string', async () => { |
| 143 | + const { DIGEST_TEMPLATE } = await import( |
| 144 | + '../../services/pushStack/digestPrompt.js' |
| 145 | + ) |
| 146 | + expect(typeof DIGEST_TEMPLATE).toBe('string') |
| 147 | + expect(DIGEST_TEMPLATE.length).toBeGreaterThan(0) |
| 148 | + }) |
| 149 | +}) |
| 150 | + |
| 151 | +describe('pop command integration', () => { |
| 152 | + test('empty stack returns error text', async () => { |
| 153 | + const { call } = await import('../pop/pop.js') |
| 154 | + const ctx = { |
| 155 | + getAppState: () => ({ pushStack: [] as unknown[] }), |
| 156 | + applyPop: undefined, |
| 157 | + } as unknown as import('../../Tool.js').ToolUseContext |
| 158 | + const result = await call('', ctx) |
| 159 | + expect(result.type).toBe('text') |
| 160 | + expect((result as { type: string; value: string }).value).toMatch( |
| 161 | + /No push point to pop/, |
| 162 | + ) |
| 163 | + }) |
| 164 | + |
| 165 | + test('stack overflow --to N returns error text', async () => { |
| 166 | + const { call } = await import('../pop/pop.js') |
| 167 | + const ctx = { |
| 168 | + getAppState: () => ({ |
| 169 | + pushStack: [ |
| 170 | + { |
| 171 | + id: 'a', |
| 172 | + messageUuid: '00000000-0000-0000-0000-000000000001', |
| 173 | + note: '', |
| 174 | + timestamp: 0, |
| 175 | + anchorPreview: '', |
| 176 | + }, |
| 177 | + ], |
| 178 | + }), |
| 179 | + applyPop: undefined, |
| 180 | + } as unknown as import('../../Tool.js').ToolUseContext |
| 181 | + const result = await call('--to #5', ctx) |
| 182 | + expect(result.type).toBe('text') |
| 183 | + expect((result as { type: string; value: string }).value).toMatch( |
| 184 | + /No push point #5/, |
| 185 | + ) |
| 186 | + }) |
| 187 | + |
| 188 | + test('interactive session with no applyPop returns error text', async () => { |
| 189 | + const { call } = await import('../pop/pop.js') |
| 190 | + const ctx = { |
| 191 | + getAppState: () => ({ |
| 192 | + pushStack: [ |
| 193 | + { |
| 194 | + id: 'a', |
| 195 | + messageUuid: '00000000-0000-0000-0000-000000000001', |
| 196 | + note: '', |
| 197 | + timestamp: 0, |
| 198 | + anchorPreview: '', |
| 199 | + }, |
| 200 | + ], |
| 201 | + }), |
| 202 | + applyPop: undefined, |
| 203 | + } as unknown as import('../../Tool.js').ToolUseContext |
| 204 | + const result = await call('', ctx) |
| 205 | + expect(result.type).toBe('text') |
| 206 | + expect((result as { type: string; value: string }).value).toMatch( |
| 207 | + /interactive session/, |
| 208 | + ) |
| 209 | + }) |
| 210 | + |
| 211 | + test('invalid args returns error text', async () => { |
| 212 | + const { call } = await import('../pop/pop.js') |
| 213 | + const ctx = { |
| 214 | + getAppState: () => ({ pushStack: [] as unknown[] }), |
| 215 | + } as unknown as import('../../Tool.js').ToolUseContext |
| 216 | + const result = await call('--unknown-flag', ctx) |
| 217 | + expect(result.type).toBe('text') |
| 218 | + expect((result as { type: string; value: string }).value).toMatch( |
| 219 | + /Unknown option/, |
| 220 | + ) |
| 221 | + }) |
| 222 | +}) |
| 223 | + |
| 224 | +describe('push command integration', () => { |
| 225 | + test('--list on empty stack returns empty message', async () => { |
| 226 | + const { call } = await import('../push/push.js') |
| 227 | + const ctx = { |
| 228 | + getAppState: () => ({ pushStack: [] }), |
| 229 | + pushContextMark: undefined, |
| 230 | + } as unknown as import('../../Tool.js').ToolUseContext |
| 231 | + const result = await call('--list', ctx) |
| 232 | + expect(result.type).toBe('text') |
| 233 | + expect((result as { type: string; value: string }).value).toMatch( |
| 234 | + /No active push points/, |
| 235 | + ) |
| 236 | + }) |
| 237 | + |
| 238 | + test('--list with items renders stack', async () => { |
| 239 | + const { call } = await import('../push/push.js') |
| 240 | + const now = Date.now() |
| 241 | + const ctx = { |
| 242 | + getAppState: () => ({ |
| 243 | + pushStack: [ |
| 244 | + { |
| 245 | + id: 'a', |
| 246 | + messageUuid: '00000000-0000-0000-0000-000000000001', |
| 247 | + note: 'discuss API', |
| 248 | + timestamp: now - 60000, |
| 249 | + anchorPreview: 'let me think...', |
| 250 | + }, |
| 251 | + { |
| 252 | + id: 'b', |
| 253 | + messageUuid: '00000000-0000-0000-0000-000000000002', |
| 254 | + note: '', |
| 255 | + timestamp: now - 30000, |
| 256 | + anchorPreview: 'another point', |
| 257 | + }, |
| 258 | + ], |
| 259 | + }), |
| 260 | + pushContextMark: undefined, |
| 261 | + } as unknown as import('../../Tool.js').ToolUseContext |
| 262 | + const result = await call('--list', ctx) |
| 263 | + expect(result.type).toBe('text') |
| 264 | + const text = (result as { type: string; value: string }).value |
| 265 | + expect(text).toContain('Push stack (2)') |
| 266 | + expect(text).toContain('#1') |
| 267 | + expect(text).toContain('#2') |
| 268 | + expect(text).toContain('discuss API') |
| 269 | + }) |
| 270 | + |
| 271 | + test('no pushContextMark returns not-interactive error', async () => { |
| 272 | + const { call } = await import('../push/push.js') |
| 273 | + const ctx = { |
| 274 | + getAppState: () => ({ pushStack: [] }), |
| 275 | + pushContextMark: undefined, |
| 276 | + } as unknown as import('../../Tool.js').ToolUseContext |
| 277 | + const result = await call('my note', ctx) |
| 278 | + expect(result.type).toBe('text') |
| 279 | + expect((result as { type: string; value: string }).value).toMatch( |
| 280 | + /interactive session/, |
| 281 | + ) |
| 282 | + }) |
| 283 | + |
| 284 | + test('with pushContextMark invokes it and returns skip', async () => { |
| 285 | + const { call } = await import('../push/push.js') |
| 286 | + let capturedNote = '' |
| 287 | + const ctx = { |
| 288 | + getAppState: () => ({ pushStack: [] }), |
| 289 | + pushContextMark: (note: string) => { |
| 290 | + capturedNote = note |
| 291 | + }, |
| 292 | + } as unknown as import('../../Tool.js').ToolUseContext |
| 293 | + const result = await call('test branch note', ctx) |
| 294 | + expect(result.type).toBe('skip') |
| 295 | + expect(capturedNote).toBe('test branch note') |
| 296 | + }) |
| 297 | +}) |
0 commit comments