@cspeach/cli 0.6.4 → 0.6.5

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 (107) hide show
  1. package/dist/index.js +0 -0
  2. package/package.json +86 -82
  3. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  4. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  5. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  6. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  7. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  8. package/dist/agent/__tests__/retry-key.test.js +0 -77
  9. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  10. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  11. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  12. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  13. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  14. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  15. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  16. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  17. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  18. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  19. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  20. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  21. package/dist/auth/__tests__/auth-file.test.js +0 -42
  22. package/dist/auth/__tests__/me.test.js +0 -43
  23. package/dist/classifier/__tests__/client.test.js +0 -70
  24. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  25. package/dist/commands/__tests__/login.test.js +0 -152
  26. package/dist/commands/__tests__/logout.test.js +0 -39
  27. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  28. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  29. package/dist/commands/__tests__/whoami.test.js +0 -91
  30. package/dist/config/__tests__/llm-config.test.js +0 -28
  31. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  32. package/dist/config/__tests__/write-mode.test.js +0 -36
  33. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  34. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  35. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  36. package/dist/project-context/__tests__/conventions.test.js +0 -221
  37. package/dist/project-context/__tests__/detect.test.js +0 -177
  38. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  39. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  40. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  41. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  42. package/dist/project-context/__tests__/git.test.js +0 -131
  43. package/dist/project-context/__tests__/index-files.test.js +0 -195
  44. package/dist/project-context/__tests__/index.test.js +0 -133
  45. package/dist/project-context/__tests__/render.test.js +0 -322
  46. package/dist/project-context/__tests__/types.test.js +0 -73
  47. package/dist/projects/__tests__/build.test.js +0 -52
  48. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  49. package/dist/projects/__tests__/email-template.test.js +0 -71
  50. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  51. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  52. package/dist/projects/__tests__/extract-design.test.js +0 -62
  53. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  54. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  55. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  56. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  57. package/dist/projects/__tests__/filename.test.js +0 -32
  58. package/dist/projects/__tests__/promote-command.test.js +0 -170
  59. package/dist/projects/__tests__/promote.test.js +0 -181
  60. package/dist/projects/__tests__/save-command.test.js +0 -216
  61. package/dist/projects/__tests__/save.test.js +0 -41
  62. package/dist/projects/__tests__/status.test.js +0 -208
  63. package/dist/projects/__tests__/types.test.js +0 -41
  64. package/dist/projects/__tests__/validate.test.js +0 -165
  65. package/dist/projects/__tests__/workspace.test.js +0 -337
  66. package/dist/repl/__tests__/current-transport.test.js +0 -43
  67. package/dist/repl/__tests__/diff-display.test.js +0 -59
  68. package/dist/repl/__tests__/file-picker.test.js +0 -97
  69. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  70. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  71. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  72. package/dist/sap/__tests__/system-info.test.js +0 -344
  73. package/dist/session/__tests__/store-discovery.test.js +0 -114
  74. package/dist/session/__tests__/time-ago.test.js +0 -53
  75. package/dist/skills/__tests__/canonical.test.js +0 -20
  76. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  77. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  78. package/dist/skills/__tests__/source-managed.test.js +0 -60
  79. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  80. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  81. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  82. package/dist/tools/__tests__/_flag.test.js +0 -66
  83. package/dist/tools/__tests__/_helpers.js +0 -17
  84. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  85. package/dist/tools/__tests__/agent_run.test.js +0 -166
  86. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  87. package/dist/tools/__tests__/background_run.test.js +0 -113
  88. package/dist/tools/__tests__/convention_get.test.js +0 -53
  89. package/dist/tools/__tests__/file-edit.test.js +0 -72
  90. package/dist/tools/__tests__/file-read.test.js +0 -81
  91. package/dist/tools/__tests__/file-write.test.js +0 -68
  92. package/dist/tools/__tests__/glob.test.js +0 -128
  93. package/dist/tools/__tests__/grep.test.js +0 -71
  94. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  95. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  96. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  97. package/dist/tools/__tests__/registry-category.test.js +0 -60
  98. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  99. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  100. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  101. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  102. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  103. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  104. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  105. package/dist/tools/__tests__/web_search.test.js +0 -215
  106. package/dist/tools/__tests__/write-mode.test.js +0 -32
  107. package/dist/ui/__tests__/login-banner.test.js +0 -69
@@ -1,573 +0,0 @@
1
- /**
2
- * Tests for the 8 new tools added in Phase 3-Lite A.5 + A.6:
3
- *
4
- * A.5 (sap-write.ts): sap_lock, sap_unlock, sap_service_binding_publish,
5
- * sap_message_maintain (STUB), sap_number_range_intervals (STUB)
6
- * A.6 (snapshot.ts): sap_snapshot_list, sap_snapshot_read, sap_snapshot_cleanup
7
- *
8
- * B.1 (sap-write.ts): sap_update_method — diff-preview gate (4 tests)
9
- *
10
- * B.2 (sap-write.ts): sap_set_source safety confirmation — 2 tests
11
- * sap_delete_object safety confirmation — 2 tests
12
- *
13
- * Pattern: partial mock as `{ method: vi.fn() } as unknown as AdtClient`.
14
- * STUB tools: assert is_error + not_implemented envelope; assert no AdtClient
15
- * method was called.
16
- * Each tool gets 2 tests = 16 tests total (A.5 + A.6) + 4 new (B.1) = 20 + 4 (B.2) = 24.
17
- */
18
- import '../sap-write.js'; // triggers registration side-effect
19
- import '../snapshot.js'; // triggers registration side-effect
20
- import { getTool } from '../index.js';
21
- import { describe, it, expect, vi, beforeEach } from 'vitest';
22
- import { CancelledByUserError } from '../update-method-intercept.js';
23
- // ---------------------------------------------------------------------------
24
- // Mock @cspeach/sap-client snapshots namespace
25
- // ---------------------------------------------------------------------------
26
- const mockSnapshotsList = vi.hoisted(() => vi.fn());
27
- const mockSnapshotsRead = vi.hoisted(() => vi.fn());
28
- const mockSnapshotsCleanup = vi.hoisted(() => vi.fn());
29
- // preserve `take` stub so autoSnapshot in sap-write.ts doesn't fail
30
- const mockSnapshotsTake = vi.hoisted(() => vi.fn());
31
- vi.mock('@cspeach/sap-client', async (importOriginal) => {
32
- const actual = await importOriginal();
33
- return {
34
- ...actual,
35
- snapshots: {
36
- take: mockSnapshotsTake,
37
- list: mockSnapshotsList,
38
- read: mockSnapshotsRead,
39
- cleanup: mockSnapshotsCleanup,
40
- },
41
- };
42
- });
43
- // ---------------------------------------------------------------------------
44
- // B.1: Mock verifyAndSpendApprovalId to bypass JWT check
45
- // ---------------------------------------------------------------------------
46
- const mockVerifyAndSpend = vi.hoisted(() => vi.fn());
47
- vi.mock('../../approvals/jwt.js', () => ({
48
- verifyAndSpendApprovalId: mockVerifyAndSpend,
49
- mintApprovalId: vi.fn(),
50
- }));
51
- // ---------------------------------------------------------------------------
52
- // B.1: Mock interceptUpdateMethod so we can control its behaviour
53
- // ---------------------------------------------------------------------------
54
- const mockInterceptUpdateMethod = vi.hoisted(() => vi.fn());
55
- vi.mock('../update-method-intercept.js', async (importOriginal) => {
56
- const actual = await importOriginal();
57
- return {
58
- ...actual,
59
- interceptUpdateMethod: mockInterceptUpdateMethod,
60
- };
61
- });
62
- // ---------------------------------------------------------------------------
63
- // B.1: Mock session pending helpers so WAL doesn't hit the filesystem
64
- // ---------------------------------------------------------------------------
65
- vi.mock('../../session/pending.js', () => ({
66
- appendPending: vi.fn().mockResolvedValue(undefined),
67
- finalizeToolCall: vi.fn().mockResolvedValue(undefined),
68
- hasUnfinishedMutations: vi.fn().mockReturnValue(false),
69
- }));
70
- // ---------------------------------------------------------------------------
71
- // B.1: Mock verify helper — syntax-check gate used after write
72
- // ---------------------------------------------------------------------------
73
- vi.mock('../verify.js', () => ({
74
- verifySyntax: vi.fn().mockResolvedValue({ ok: true, errors: [], warnings: [] }),
75
- verifyActive: vi.fn().mockResolvedValue({ active: true }),
76
- }));
77
- // ---------------------------------------------------------------------------
78
- // B.2: Mock safety-confirm and safety-mode-state
79
- // ---------------------------------------------------------------------------
80
- const mockIsSafetyConfirmEnabled = vi.hoisted(() => vi.fn());
81
- vi.mock('../../repl/safety-mode-state.js', () => ({
82
- isSafetyConfirmEnabled: mockIsSafetyConfirmEnabled,
83
- }));
84
- const mockPresentSafetyConfirmation = vi.hoisted(() => vi.fn());
85
- vi.mock('../../repl/safety-confirm.js', () => ({
86
- presentSafetyConfirmation: mockPresentSafetyConfirmation,
87
- }));
88
- // ---------------------------------------------------------------------------
89
- // Helper: build a minimal ToolContext stub
90
- // ---------------------------------------------------------------------------
91
- function makeCtx(adt, extras) {
92
- return {
93
- adt: adt,
94
- sapAlias: 'dev',
95
- session: { toolCalls: [] },
96
- cwd: '.',
97
- ...extras,
98
- };
99
- }
100
- beforeEach(() => {
101
- vi.clearAllMocks();
102
- // Default: approval always succeeds
103
- mockVerifyAndSpend.mockResolvedValue({ ok: true, payload: {} });
104
- // Default: snapshot take is a no-op
105
- mockSnapshotsTake.mockResolvedValue({ id: 'snap1' });
106
- // Default: safety mode OFF so existing tests are unaffected
107
- mockIsSafetyConfirmEnabled.mockReturnValue(false);
108
- });
109
- // ---------------------------------------------------------------------------
110
- // A.5.1 sap_lock
111
- // ---------------------------------------------------------------------------
112
- describe('sap_lock', () => {
113
- it('calls lock(type, name) and returns JSON result', async () => {
114
- const fakeResult = { lockHandle: 'abc123', status: 'locked' };
115
- const adt = { lock: vi.fn().mockResolvedValue(fakeResult) };
116
- const tool = getTool('sap_lock');
117
- const result = await tool.handler({ type: 'CLAS', name: 'ZCL_FOO' }, makeCtx(adt));
118
- expect(adt.lock).toHaveBeenCalledWith('CLAS', 'ZCL_FOO');
119
- expect(JSON.parse(result.content)).toEqual(fakeResult);
120
- });
121
- it('propagates error when lock throws', async () => {
122
- const adt = { lock: vi.fn().mockRejectedValue(new Error('already locked')) };
123
- const tool = getTool('sap_lock');
124
- await expect(tool.handler({ type: 'CLAS', name: 'ZCL_FOO' }, makeCtx(adt))).rejects.toThrow('already locked');
125
- });
126
- });
127
- // ---------------------------------------------------------------------------
128
- // A.5.2 sap_unlock
129
- // ---------------------------------------------------------------------------
130
- describe('sap_unlock', () => {
131
- it('calls unlock(type, name, lockHandle) and returns ok status', async () => {
132
- const adt = { unlock: vi.fn().mockResolvedValue(undefined) };
133
- const tool = getTool('sap_unlock');
134
- const result = await tool.handler({ type: 'CLAS', name: 'ZCL_FOO', lockHandle: 'abc123' }, makeCtx(adt));
135
- expect(adt.unlock).toHaveBeenCalledWith('CLAS', 'ZCL_FOO', 'abc123');
136
- expect(JSON.parse(result.content)).toEqual({ status: 'ok' });
137
- });
138
- it('propagates error when unlock throws', async () => {
139
- const adt = { unlock: vi.fn().mockRejectedValue(new Error('invalid lock handle')) };
140
- const tool = getTool('sap_unlock');
141
- await expect(tool.handler({ type: 'CLAS', name: 'ZCL_FOO', lockHandle: 'bad' }, makeCtx(adt))).rejects.toThrow('invalid lock handle');
142
- });
143
- });
144
- // ---------------------------------------------------------------------------
145
- // A.5.3 sap_service_binding_publish
146
- // ---------------------------------------------------------------------------
147
- describe('sap_service_binding_publish', () => {
148
- it('calls serviceBindingPublish(name) and returns JSON result', async () => {
149
- const fakeResult = { status: 200 };
150
- const adt = { serviceBindingPublish: vi.fn().mockResolvedValue(fakeResult) };
151
- const tool = getTool('sap_service_binding_publish');
152
- const result = await tool.handler({ name: 'ZSALES_UI_V4' }, makeCtx(adt));
153
- expect(adt.serviceBindingPublish).toHaveBeenCalledWith('ZSALES_UI_V4');
154
- expect(JSON.parse(result.content)).toEqual(fakeResult);
155
- });
156
- it('propagates error when serviceBindingPublish throws', async () => {
157
- const adt = {
158
- serviceBindingPublish: vi.fn().mockRejectedValue(new Error('ADT 404')),
159
- };
160
- const tool = getTool('sap_service_binding_publish');
161
- await expect(tool.handler({ name: 'ZMISSING_UI_V4' }, makeCtx(adt))).rejects.toThrow('ADT 404');
162
- });
163
- });
164
- // ---------------------------------------------------------------------------
165
- // A.5.4 sap_message_maintain (STUB)
166
- // ---------------------------------------------------------------------------
167
- describe('sap_message_maintain', () => {
168
- it('returns not_implemented envelope for fully-populated args and is_error: true', async () => {
169
- // STUB: no ADT method is called — do NOT set up any adt mock method
170
- const adt = { messageMaintain: vi.fn() };
171
- const tool = getTool('sap_message_maintain');
172
- const result = await tool.handler({
173
- messageClass: 'ZMSG_SALES',
174
- messageNumber: '001',
175
- messageText: 'Order &1 created',
176
- messageType: 'S',
177
- }, makeCtx(adt));
178
- expect(result.is_error).toBe(true);
179
- const parsed = JSON.parse(result.content);
180
- expect(parsed.error).toBe('not_implemented');
181
- expect(parsed.detail).toBeTruthy();
182
- // Critical: STUB must not call any AdtClient method
183
- expect(adt.messageMaintain).not.toHaveBeenCalled();
184
- });
185
- it('returns same not_implemented envelope for minimally-populated args', async () => {
186
- const adt = { messageMaintain: vi.fn() };
187
- const tool = getTool('sap_message_maintain');
188
- const result = await tool.handler({ messageClass: 'ZMSG', messageNumber: '001', messageText: 'x' }, makeCtx(adt));
189
- expect(result.is_error).toBe(true);
190
- expect(JSON.parse(result.content).error).toBe('not_implemented');
191
- expect(adt.messageMaintain).not.toHaveBeenCalled();
192
- });
193
- });
194
- // ---------------------------------------------------------------------------
195
- // A.5.5 sap_number_range_intervals (STUB)
196
- // ---------------------------------------------------------------------------
197
- describe('sap_number_range_intervals', () => {
198
- it('returns not_implemented envelope for fully-populated args and is_error: true', async () => {
199
- const adt = { numberRangeIntervals: vi.fn() };
200
- const tool = getTool('sap_number_range_intervals');
201
- const result = await tool.handler({
202
- nrObject: 'ZNROB_SALES',
203
- subobject: '',
204
- intervals: [{ from: '0000000001', to: '0000009999', ext: false }],
205
- }, makeCtx(adt));
206
- expect(result.is_error).toBe(true);
207
- const parsed = JSON.parse(result.content);
208
- expect(parsed.error).toBe('not_implemented');
209
- expect(parsed.detail).toBeTruthy();
210
- expect(adt.numberRangeIntervals).not.toHaveBeenCalled();
211
- });
212
- it('returns same not_implemented envelope for minimally-populated args', async () => {
213
- const adt = { numberRangeIntervals: vi.fn() };
214
- const tool = getTool('sap_number_range_intervals');
215
- const result = await tool.handler({ nrObject: 'ZNROB_SALES' }, makeCtx(adt));
216
- expect(result.is_error).toBe(true);
217
- expect(JSON.parse(result.content).error).toBe('not_implemented');
218
- expect(adt.numberRangeIntervals).not.toHaveBeenCalled();
219
- });
220
- });
221
- // ---------------------------------------------------------------------------
222
- // A.6.1 sap_snapshot_list
223
- // ---------------------------------------------------------------------------
224
- describe('sap_snapshot_list', () => {
225
- it('calls snapshots.list with type and name when provided', async () => {
226
- const fakeEntries = [{ id: 'snap1', type: 'CLAS', name: 'ZCL_FOO' }];
227
- mockSnapshotsList.mockResolvedValue(fakeEntries);
228
- const tool = getTool('sap_snapshot_list');
229
- const result = await tool.handler({ type: 'CLAS', name: 'ZCL_FOO' }, makeCtx({}));
230
- expect(mockSnapshotsList).toHaveBeenCalledWith('CLAS', 'ZCL_FOO');
231
- expect(JSON.parse(result.content)).toEqual(fakeEntries);
232
- });
233
- it('propagates error when snapshots.list throws', async () => {
234
- mockSnapshotsList.mockRejectedValue(new Error('snapshot dir not found'));
235
- const tool = getTool('sap_snapshot_list');
236
- await expect(tool.handler({}, makeCtx({}))).rejects.toThrow('snapshot dir not found');
237
- });
238
- });
239
- // ---------------------------------------------------------------------------
240
- // A.6.2 sap_snapshot_read
241
- // ---------------------------------------------------------------------------
242
- describe('sap_snapshot_read', () => {
243
- it('calls snapshots.read and returns raw source as content', async () => {
244
- const fakeSource = 'CLASS ZCL_FOO DEFINITION.\nENDCLASS.';
245
- mockSnapshotsRead.mockResolvedValue(fakeSource);
246
- const tool = getTool('sap_snapshot_read');
247
- const result = await tool.handler({ type: 'CLAS', name: 'ZCL_FOO', snapshotId: 'snap1.abap' }, makeCtx({}));
248
- expect(mockSnapshotsRead).toHaveBeenCalledWith('CLAS', 'ZCL_FOO', 'snap1.abap');
249
- // sap_snapshot_read returns raw text (not stringified)
250
- expect(result.content).toBe(fakeSource);
251
- });
252
- it('propagates error when snapshots.read throws', async () => {
253
- mockSnapshotsRead.mockRejectedValue(new Error('snapshot not found'));
254
- const tool = getTool('sap_snapshot_read');
255
- await expect(tool.handler({ type: 'CLAS', name: 'ZCL_FOO', snapshotId: 'missing.abap' }, makeCtx({}))).rejects.toThrow('snapshot not found');
256
- });
257
- });
258
- // ---------------------------------------------------------------------------
259
- // A.6.3 sap_snapshot_cleanup
260
- // ---------------------------------------------------------------------------
261
- describe('sap_snapshot_cleanup', () => {
262
- it('calls snapshots.cleanup with retentionHours and returns deleted count', async () => {
263
- mockSnapshotsCleanup.mockResolvedValue(7);
264
- const tool = getTool('sap_snapshot_cleanup');
265
- const result = await tool.handler({ retentionHours: 48 }, makeCtx({}));
266
- expect(mockSnapshotsCleanup).toHaveBeenCalledWith(48);
267
- expect(JSON.parse(result.content)).toEqual({ deleted: 7 });
268
- });
269
- it('propagates error when snapshots.cleanup throws', async () => {
270
- mockSnapshotsCleanup.mockRejectedValue(new Error('permission denied'));
271
- const tool = getTool('sap_snapshot_cleanup');
272
- await expect(tool.handler({}, makeCtx({}))).rejects.toThrow('permission denied');
273
- });
274
- });
275
- // ---------------------------------------------------------------------------
276
- // B.1 sap_update_method — diff-preview gate (Rule 7a)
277
- //
278
- // Mocking strategy:
279
- // - verifyAndSpendApprovalId: mocked globally above (returns ok:true by default)
280
- // - interceptUpdateMethod: mocked globally above (we control resolve/throw per test)
281
- // - autoSnapshot: relies on ctx.adt.getSource + snapshots.take (both mocked)
282
- // - appendPending / finalizeToolCall: mocked globally above
283
- // - verifySyntax: mocked globally above (returns ok:true by default)
284
- // ---------------------------------------------------------------------------
285
- // Minimal class source used by autoSnapshot's ctx.adt.getSource call.
286
- const SAMPLE_CLASS_SOURCE = `CLASS zcl_demo DEFINITION PUBLIC FINAL CREATE PUBLIC.
287
- PUBLIC SECTION.
288
- METHODS do_work.
289
- ENDCLASS.
290
-
291
- CLASS zcl_demo IMPLEMENTATION.
292
- METHOD do_work.
293
- RETURN.
294
- ENDMETHOD.
295
- ENDCLASS.`;
296
- describe('sap_update_method', () => {
297
- // B.1 test 1 — Happy path: confirmed preview → updateMethod called once
298
- it('calls updateMethod when interceptUpdateMethod confirms (previewHook resolves confirmed)', async () => {
299
- const mockUpdateMethod = vi.fn().mockResolvedValue({ status: 'ok' });
300
- const mockGetSource = vi.fn().mockResolvedValue(SAMPLE_CLASS_SOURCE);
301
- // interceptUpdateMethod resolves (confirmed) → flow continues to write
302
- mockInterceptUpdateMethod.mockResolvedValue(undefined);
303
- const previewHook = vi.fn().mockResolvedValue({ confirmed: true });
304
- const adt = { updateMethod: mockUpdateMethod, getSource: mockGetSource };
305
- const tool = getTool('sap_update_method');
306
- const result = await tool.handler({
307
- class_name: 'ZCL_DEMO',
308
- method_name: 'do_work',
309
- method_source: ' DATA lv_x TYPE i.\n lv_x = 42.',
310
- transport: 'SXXK999999',
311
- approval_id: 'dummy-jwt',
312
- }, makeCtx(adt, { previewHook }));
313
- expect(mockInterceptUpdateMethod).toHaveBeenCalledOnce();
314
- const [minimalArg, callArg, hookArg] = mockInterceptUpdateMethod.mock.calls[0];
315
- expect(typeof minimalArg.getSource).toBe('function');
316
- expect(callArg.className).toBe('ZCL_DEMO');
317
- expect(callArg.methodName).toBe('do_work');
318
- expect(hookArg).toBe(previewHook);
319
- expect(mockUpdateMethod).toHaveBeenCalledOnce();
320
- expect(mockUpdateMethod).toHaveBeenCalledWith('ZCL_DEMO', 'do_work', ' DATA lv_x TYPE i.\n lv_x = 42.', 'SXXK999999');
321
- const parsed = JSON.parse(result.content);
322
- expect(parsed.updated).toBe(true);
323
- expect(result.is_error).toBeFalsy();
324
- });
325
- // B.1 test 2 — previewHook missing: handler returns preview_hook_unavailable; updateMethod NOT called
326
- it('returns preview_hook_unavailable envelope when ctx.previewHook is undefined', async () => {
327
- const mockUpdateMethod = vi.fn();
328
- const mockGetSource = vi.fn().mockResolvedValue(SAMPLE_CLASS_SOURCE);
329
- const adt = { updateMethod: mockUpdateMethod, getSource: mockGetSource };
330
- const tool = getTool('sap_update_method');
331
- const result = await tool.handler({
332
- class_name: 'ZCL_DEMO',
333
- method_name: 'do_work',
334
- method_source: ' RETURN.',
335
- approval_id: 'dummy-jwt',
336
- }, makeCtx(adt));
337
- expect(result.is_error).toBe(true);
338
- const parsed = JSON.parse(result.content);
339
- expect(parsed.error).toBe('preview_hook_unavailable');
340
- expect(parsed.detail).toBeTruthy();
341
- // Critical: updateMethod must NOT be called
342
- expect(mockUpdateMethod).not.toHaveBeenCalled();
343
- expect(mockInterceptUpdateMethod).not.toHaveBeenCalled();
344
- });
345
- // B.1 test 3 — User declines: CancelledByUserError thrown → cancelled_by_user envelope; updateMethod NOT called
346
- it('returns cancelled_by_user envelope when interceptUpdateMethod throws CancelledByUserError', async () => {
347
- const mockUpdateMethod = vi.fn();
348
- const mockGetSource = vi.fn().mockResolvedValue(SAMPLE_CLASS_SOURCE);
349
- const cancelledCall = {
350
- className: 'ZCL_DEMO', methodName: 'do_work',
351
- methodSource: ' RETURN.', transport: '',
352
- };
353
- mockInterceptUpdateMethod.mockRejectedValue(new CancelledByUserError(cancelledCall));
354
- const previewHook = vi.fn().mockResolvedValue({ confirmed: false });
355
- const adt = { updateMethod: mockUpdateMethod, getSource: mockGetSource };
356
- const tool = getTool('sap_update_method');
357
- const result = await tool.handler({
358
- class_name: 'ZCL_DEMO',
359
- method_name: 'do_work',
360
- method_source: ' RETURN.',
361
- approval_id: 'dummy-jwt',
362
- }, makeCtx(adt, { previewHook }));
363
- expect(result.is_error).toBe(true);
364
- const parsed = JSON.parse(result.content);
365
- expect(parsed.error).toBe('cancelled_by_user');
366
- expect(parsed.detail).toBeTruthy();
367
- // Critical: updateMethod must NOT be called
368
- expect(mockUpdateMethod).not.toHaveBeenCalled();
369
- });
370
- // B.1 test 4 — getSource rejects (preview_failed path): handler returns preview_failed; updateMethod NOT called
371
- it('returns preview_failed envelope when ctx.adt.getSource rejects inside interceptUpdateMethod', async () => {
372
- const mockUpdateMethod = vi.fn();
373
- // getSource rejects — interceptUpdateMethod will throw a plain Error (not CancelledByUserError)
374
- mockInterceptUpdateMethod.mockRejectedValue(new Error('ADT 404 class not found'));
375
- const previewHook = vi.fn();
376
- const adt = { updateMethod: mockUpdateMethod, getSource: vi.fn().mockRejectedValue(new Error('ADT 404')) };
377
- const tool = getTool('sap_update_method');
378
- const result = await tool.handler({
379
- class_name: 'ZCL_MISSING',
380
- method_name: 'do_work',
381
- method_source: ' RETURN.',
382
- approval_id: 'dummy-jwt',
383
- }, makeCtx(adt, { previewHook }));
384
- expect(result.is_error).toBe(true);
385
- const parsed = JSON.parse(result.content);
386
- expect(parsed.error).toBe('preview_failed');
387
- expect(parsed.detail).toContain('ADT 404');
388
- // Critical: updateMethod must NOT be called
389
- expect(mockUpdateMethod).not.toHaveBeenCalled();
390
- });
391
- });
392
- // ---------------------------------------------------------------------------
393
- // B.2 sap_set_source — safety confirmation gate (Rule 7)
394
- // ---------------------------------------------------------------------------
395
- describe('sap_set_source safety confirmation', () => {
396
- it('calls presentSafetyConfirmation with correct SafetyAction and proceeds when confirmed', async () => {
397
- mockIsSafetyConfirmEnabled.mockReturnValue(true);
398
- mockPresentSafetyConfirmation.mockResolvedValue({ confirmed: true });
399
- // setSource returns a WriteResult — handler now checks writeStatus / error
400
- // (regression fix: HTTP 4xx returns rather than throws). Success path = no
401
- // error and writeStatus < 400. See memory/project_phase3_lite_regression.md.
402
- const mockSetSource = vi.fn().mockResolvedValue({ writeStatus: 200 });
403
- // autoSnapshot calls ctx.adt.getSource — provide a stub so the snapshot gate passes
404
- const mockGetSource = vi.fn().mockResolvedValue('CLASS ZCL_MYCLASS DEFINITION.\nENDCLASS.\n');
405
- const adt = { setSource: mockSetSource, getSource: mockGetSource };
406
- const tool = getTool('sap_set_source');
407
- const result = await tool.handler({
408
- name: 'ZCL_MYCLASS',
409
- type: 'CLAS',
410
- source: 'CLASS ZCL_MYCLASS DEFINITION.\nENDCLASS.\n',
411
- transport: 'DEVK900001',
412
- approval_id: 'dummy-jwt',
413
- }, makeCtx(adt));
414
- // Safety confirmation must have been called with correct shape
415
- expect(mockPresentSafetyConfirmation).toHaveBeenCalledOnce();
416
- const action = mockPresentSafetyConfirmation.mock.calls[0][0];
417
- expect(action.rule).toBe('RULE_7_SNAPSHOT_BEFORE_WRITE');
418
- expect(action.op).toBe('set_source');
419
- expect(action.object).toEqual({ type: 'CLAS', name: 'ZCL_MYCLASS' });
420
- expect(action.what_will_happen).toBeTruthy();
421
- expect(action.rollback_available).toBe(true);
422
- expect(action.transport).toBe('DEVK900001');
423
- // setSource must have been called (confirmed)
424
- expect(mockSetSource).toHaveBeenCalledOnce();
425
- const parsed = JSON.parse(result.content);
426
- expect(parsed.written).toBe(true);
427
- expect(result.is_error).toBeFalsy();
428
- });
429
- it('returns cancelled_by_user and does NOT call setSource when user declines', async () => {
430
- mockIsSafetyConfirmEnabled.mockReturnValue(true);
431
- mockPresentSafetyConfirmation.mockResolvedValue({ confirmed: false, reason: 'test decline' });
432
- const mockSetSource = vi.fn();
433
- // autoSnapshot calls ctx.adt.getSource — provide a stub so snapshot gate passes before Gate 3
434
- const mockGetSource = vi.fn().mockResolvedValue('CLASS ZCL_MYCLASS DEFINITION.\nENDCLASS.\n');
435
- const adt = { setSource: mockSetSource, getSource: mockGetSource };
436
- const tool = getTool('sap_set_source');
437
- const result = await tool.handler({
438
- name: 'ZCL_MYCLASS',
439
- type: 'CLAS',
440
- source: 'CLASS ZCL_MYCLASS DEFINITION.\nENDCLASS.\n',
441
- approval_id: 'dummy-jwt',
442
- }, makeCtx(adt));
443
- expect(result.is_error).toBe(true);
444
- const parsed = JSON.parse(result.content);
445
- expect(parsed.error).toBe('cancelled_by_user');
446
- expect(parsed.reason).toBe('test decline');
447
- // Critical: setSource must NOT be called
448
- expect(mockSetSource).not.toHaveBeenCalled();
449
- });
450
- });
451
- // ---------------------------------------------------------------------------
452
- // B.2 sap_delete_object — safety confirmation gate (Rule 7)
453
- // ---------------------------------------------------------------------------
454
- describe('sap_delete_object safety confirmation', () => {
455
- it('calls presentSafetyConfirmation with correct SafetyAction and proceeds when confirmed', async () => {
456
- mockIsSafetyConfirmEnabled.mockReturnValue(true);
457
- mockPresentSafetyConfirmation.mockResolvedValue({ confirmed: true });
458
- const mockDeleteObject = vi.fn().mockResolvedValue({ status: 200 });
459
- // objectStructure called in post-check — return rejection (object gone)
460
- const mockObjectStructure = vi.fn().mockRejectedValue(new Error('not found'));
461
- const adt = { deleteObject: mockDeleteObject, objectStructure: mockObjectStructure };
462
- const tool = getTool('sap_delete_object');
463
- const result = await tool.handler({
464
- name: 'ZCL_OLD',
465
- type: 'CLAS',
466
- transport: 'DEVK900002',
467
- approval_id: 'dummy-jwt',
468
- }, makeCtx(adt));
469
- // Safety confirmation must have been called with correct shape
470
- expect(mockPresentSafetyConfirmation).toHaveBeenCalledOnce();
471
- const action = mockPresentSafetyConfirmation.mock.calls[0][0];
472
- expect(action.rule).toBe('RULE_7_SNAPSHOT_BEFORE_WRITE');
473
- expect(action.op).toBe('delete_object');
474
- expect(action.object).toEqual({ type: 'CLAS', name: 'ZCL_OLD' });
475
- expect(action.what_will_happen).toBeTruthy();
476
- expect(action.rollback_available).toBe(true);
477
- expect(action.transport).toBe('DEVK900002');
478
- // deleteObject must have been called (confirmed)
479
- expect(mockDeleteObject).toHaveBeenCalledOnce();
480
- const parsed = JSON.parse(result.content);
481
- expect(parsed.deleted).toBe(true);
482
- expect(result.is_error).toBeFalsy();
483
- });
484
- it('returns cancelled_by_user and does NOT call deleteObject when user declines', async () => {
485
- mockIsSafetyConfirmEnabled.mockReturnValue(true);
486
- mockPresentSafetyConfirmation.mockResolvedValue({ confirmed: false, reason: 'no delete' });
487
- const mockDeleteObject = vi.fn();
488
- const adt = { deleteObject: mockDeleteObject };
489
- const tool = getTool('sap_delete_object');
490
- const result = await tool.handler({
491
- name: 'ZCL_OLD',
492
- type: 'CLAS',
493
- approval_id: 'dummy-jwt',
494
- }, makeCtx(adt));
495
- expect(result.is_error).toBe(true);
496
- const parsed = JSON.parse(result.content);
497
- expect(parsed.error).toBe('cancelled_by_user');
498
- expect(parsed.reason).toBe('no delete');
499
- // Critical: deleteObject must NOT be called
500
- expect(mockDeleteObject).not.toHaveBeenCalled();
501
- });
502
- });
503
- // ---------------------------------------------------------------------------
504
- // Regression guard 2026-05-01 — handlers MUST check the AdtClient return value.
505
- // Phase 3-Lite shipped with sap_set_source / sap_update_method / sap_delete_object
506
- // silently reporting success when the underlying ADT REST call returned a 4xx
507
- // without throwing (the SDK only throws on catastrophic failure). The LLM
508
- // believed writes succeeded; SAP source was unchanged. Full diagnosis at
509
- // memory/project_phase3_lite_regression.md.
510
- // ---------------------------------------------------------------------------
511
- describe('regression: handlers surface AdtClient HTTP-level failures', () => {
512
- it('sap_set_source returns is_error when AdtClient.setSource resolves with writeStatus >= 400', async () => {
513
- mockIsSafetyConfirmEnabled.mockReturnValue(false);
514
- const mockSetSource = vi.fn().mockResolvedValue({
515
- writeStatus: 423,
516
- error: 'Write failed with HTTP 423: locked',
517
- });
518
- const mockGetSource = vi.fn().mockResolvedValue('REPORT z. WRITE / 1.');
519
- const adt = { setSource: mockSetSource, getSource: mockGetSource };
520
- const tool = getTool('sap_set_source');
521
- const result = await tool.handler({ name: 'Z_PROBE', type: 'PROG', source: 'REPORT z. WRITE / 2.', approval_id: 'dummy-jwt' }, makeCtx(adt));
522
- expect(result.is_error).toBe(true);
523
- const parsed = JSON.parse(result.content);
524
- expect(parsed.error).toBe('write_failed');
525
- expect(parsed.write_status).toBe(423);
526
- expect(parsed.detail).toContain('423');
527
- // setSource WAS called — the failure happened on the server side.
528
- expect(mockSetSource).toHaveBeenCalledOnce();
529
- });
530
- it('sap_update_method returns is_error when AdtClient.updateMethod resolves with writeStatus >= 400', async () => {
531
- // Need to mock interceptUpdateMethod (B.1 wiring) so the diff-preview gate proceeds.
532
- mockInterceptUpdateMethod.mockResolvedValue(undefined);
533
- const previewHook = vi.fn().mockResolvedValue({ confirmed: true });
534
- const mockUpdateMethod = vi.fn().mockResolvedValue({
535
- writeStatus: 412,
536
- error: 'Write failed with HTTP 412: precondition failed',
537
- });
538
- const mockGetSource = vi.fn().mockResolvedValue('CLASS zcl. METHOD do. ENDMETHOD. ENDCLASS.');
539
- const adt = {
540
- updateMethod: mockUpdateMethod,
541
- getSource: mockGetSource,
542
- };
543
- const tool = getTool('sap_update_method');
544
- const result = await tool.handler({
545
- class_name: 'ZCL_X',
546
- method_name: 'do',
547
- method_source: ' WRITE 1.',
548
- transport: 'DEVK900003',
549
- approval_id: 'dummy-jwt',
550
- }, makeCtx(adt, { previewHook }));
551
- expect(result.is_error).toBe(true);
552
- const parsed = JSON.parse(result.content);
553
- expect(parsed.error).toBe('update_failed');
554
- expect(parsed.write_status).toBe(412);
555
- expect(mockUpdateMethod).toHaveBeenCalledOnce();
556
- });
557
- it('sap_delete_object returns is_error when AdtClient.deleteObject resolves with status >= 400', async () => {
558
- mockIsSafetyConfirmEnabled.mockReturnValue(false);
559
- const mockDeleteObject = vi.fn().mockResolvedValue({
560
- status: 403,
561
- error: 'Delete forbidden — package locked',
562
- });
563
- const mockGetSource = vi.fn().mockResolvedValue('REPORT z.');
564
- const adt = { deleteObject: mockDeleteObject, getSource: mockGetSource };
565
- const tool = getTool('sap_delete_object');
566
- const result = await tool.handler({ name: 'Z_PROBE', type: 'PROG', approval_id: 'dummy-jwt' }, makeCtx(adt));
567
- expect(result.is_error).toBe(true);
568
- const parsed = JSON.parse(result.content);
569
- expect(parsed.error).toBe('delete_failed');
570
- expect(parsed.status).toBe(403);
571
- expect(mockDeleteObject).toHaveBeenCalledOnce();
572
- });
573
- });
@@ -1,85 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { scheduleCreateHandler, __setSchedulesDirForTests } from '../subagent/schedule_create.js';
6
- import { makeCtx } from './_helpers.js';
7
- let dir;
8
- const ctx = () => makeCtx(process.cwd());
9
- beforeEach(async () => {
10
- dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-schedules-'));
11
- __setSchedulesDirForTests(dir);
12
- });
13
- afterEach(async () => {
14
- __setSchedulesDirForTests(null); // restore default
15
- await fs.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
16
- });
17
- async function listJsonFiles() {
18
- const entries = await fs.readdir(dir);
19
- return entries.filter((e) => e.endsWith('.json'));
20
- }
21
- describe('schedule_create handler', () => {
22
- it('persists a oneoff schedule with crypto.randomUUID filename', async () => {
23
- const r = await scheduleCreateHandler({ kind: 'oneoff', when: '2099-06-01T10:00:00Z', command: 'echo hello' }, ctx());
24
- expect(r.is_error).toBeFalsy();
25
- const files = await listJsonFiles();
26
- expect(files).toHaveLength(1);
27
- expect(files[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/i);
28
- const body = JSON.parse(await fs.readFile(path.join(dir, files[0]), 'utf-8'));
29
- expect(body.kind).toBe('oneoff');
30
- expect(body.when).toBe('2099-06-01T10:00:00Z');
31
- expect(body.command).toBe('echo hello');
32
- expect(typeof body.id).toBe('string');
33
- expect(typeof body.createdAt).toBe('string');
34
- });
35
- it('persists a cron schedule', async () => {
36
- const r = await scheduleCreateHandler({ kind: 'cron', when: '0 9 * * 1-5', command: 'pnpm test' }, ctx());
37
- expect(r.is_error).toBeFalsy();
38
- const files = await listJsonFiles();
39
- const body = JSON.parse(await fs.readFile(path.join(dir, files[0]), 'utf-8'));
40
- expect(body.kind).toBe('cron');
41
- expect(body.when).toBe('0 9 * * 1-5');
42
- });
43
- it('rejects missing kind', async () => {
44
- const r = await scheduleCreateHandler({ when: '2099-06-01T10:00:00Z', command: 'echo' }, ctx());
45
- expect(r.is_error).toBe(true);
46
- expect(r.content).toMatch(/kind/i);
47
- });
48
- it('rejects unknown kind', async () => {
49
- const r = await scheduleCreateHandler({ kind: 'twice-monthly', when: '?', command: 'echo' }, ctx());
50
- expect(r.is_error).toBe(true);
51
- expect(r.content).toMatch(/kind/i);
52
- });
53
- it('rejects oneoff with a non-ISO `when`', async () => {
54
- const r = await scheduleCreateHandler({ kind: 'oneoff', when: 'tomorrow at 10', command: 'echo' }, ctx());
55
- expect(r.is_error).toBe(true);
56
- expect(r.content).toMatch(/ISO/i);
57
- });
58
- it('rejects oneoff with a `when` in the past', async () => {
59
- const r = await scheduleCreateHandler({ kind: 'oneoff', when: '2000-01-01T00:00:00Z', command: 'echo' }, ctx());
60
- expect(r.is_error).toBe(true);
61
- expect(r.content).toMatch(/past|future/i);
62
- });
63
- it('rejects calendar-invalid dates that Date.parse would silently roll forward (e.g. Feb 30)', async () => {
64
- const r = await scheduleCreateHandler({ kind: 'oneoff', when: '2099-02-30T00:00:00Z', command: 'echo' }, ctx());
65
- expect(r.is_error).toBe(true);
66
- expect(r.content).toMatch(/calendar|valid|rolls/i);
67
- });
68
- it('rejects empty command', async () => {
69
- const r = await scheduleCreateHandler({ kind: 'oneoff', when: '2099-06-01T10:00:00Z', command: '' }, ctx());
70
- expect(r.is_error).toBe(true);
71
- expect(r.content).toMatch(/command/i);
72
- });
73
- it('creates the schedules dir if missing', async () => {
74
- const nested = path.join(dir, 'nested', 'dir');
75
- __setSchedulesDirForTests(nested);
76
- const r = await scheduleCreateHandler({ kind: 'oneoff', when: '2099-06-01T10:00:00Z', command: 'echo' }, ctx());
77
- expect(r.is_error).toBeFalsy();
78
- const stat = await fs.stat(nested);
79
- expect(stat.isDirectory()).toBe(true);
80
- });
81
- it('mentions in the response that scheduled jobs are persisted but NOT yet executed', async () => {
82
- const r = await scheduleCreateHandler({ kind: 'oneoff', when: '2099-06-01T10:00:00Z', command: 'echo' }, ctx());
83
- expect(r.content).toMatch(/persisted|not yet executed|runner/i);
84
- });
85
- });