@cspeach/cli 0.6.3 → 0.6.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.
- package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
- package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
- package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
- package/dist/agent/__tests__/project-context-injection.test.js +161 -0
- package/dist/agent/__tests__/repair-partial.test.js +79 -0
- package/dist/agent/__tests__/retry-key.test.js +77 -0
- package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
- package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
- package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
- package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
- package/dist/agent/__tests__/turn-stream.test.js +100 -0
- package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
- package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
- package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
- package/dist/agent/providers/__tests__/factory.test.js +33 -0
- package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
- package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
- package/dist/approvals/__tests__/advisory-render.test.js +54 -0
- package/dist/auth/__tests__/auth-file.test.js +42 -0
- package/dist/auth/__tests__/me.test.js +43 -0
- package/dist/classifier/__tests__/client.test.js +70 -0
- package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
- package/dist/commands/__tests__/login.test.js +152 -0
- package/dist/commands/__tests__/logout.test.js +39 -0
- package/dist/commands/__tests__/project-context-impact.test.js +173 -0
- package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
- package/dist/commands/__tests__/whoami.test.js +91 -0
- package/dist/config/__tests__/llm-config.test.js +28 -0
- package/dist/config/__tests__/shell-exec-config.test.js +76 -0
- package/dist/config/__tests__/write-mode.test.js +36 -0
- package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
- package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
- package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
- package/dist/index.js +0 -0
- package/dist/project-context/__tests__/conventions.test.js +221 -0
- package/dist/project-context/__tests__/detect.test.js +177 -0
- package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
- package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
- package/dist/project-context/__tests__/domain-cap.test.js +168 -0
- package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
- package/dist/project-context/__tests__/git.test.js +131 -0
- package/dist/project-context/__tests__/index-files.test.js +195 -0
- package/dist/project-context/__tests__/index.test.js +133 -0
- package/dist/project-context/__tests__/render.test.js +322 -0
- package/dist/project-context/__tests__/types.test.js +73 -0
- package/dist/projects/__tests__/build.test.js +52 -0
- package/dist/projects/__tests__/canonicalize.test.js +47 -0
- package/dist/projects/__tests__/email-template.test.js +71 -0
- package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
- package/dist/projects/__tests__/extract-cca.test.js +141 -0
- package/dist/projects/__tests__/extract-design.test.js +62 -0
- package/dist/projects/__tests__/extract-estimate.test.js +58 -0
- package/dist/projects/__tests__/extract-modernize.test.js +127 -0
- package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
- package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
- package/dist/projects/__tests__/filename.test.js +32 -0
- package/dist/projects/__tests__/promote-command.test.js +170 -0
- package/dist/projects/__tests__/promote.test.js +181 -0
- package/dist/projects/__tests__/save-command.test.js +216 -0
- package/dist/projects/__tests__/save.test.js +41 -0
- package/dist/projects/__tests__/status.test.js +208 -0
- package/dist/projects/__tests__/types.test.js +41 -0
- package/dist/projects/__tests__/validate.test.js +165 -0
- package/dist/projects/__tests__/workspace.test.js +337 -0
- package/dist/repl/__tests__/current-transport.test.js +43 -0
- package/dist/repl/__tests__/diff-display.test.js +59 -0
- package/dist/repl/__tests__/file-picker.test.js +97 -0
- package/dist/repl/__tests__/rule8-detector.test.js +114 -0
- package/dist/repl/__tests__/safety-confirm.test.js +130 -0
- package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
- package/dist/sap/__tests__/system-info.test.js +344 -0
- package/dist/session/__tests__/store-discovery.test.js +114 -0
- package/dist/session/__tests__/time-ago.test.js +53 -0
- package/dist/skills/__tests__/canonical.test.js +20 -0
- package/dist/skills/__tests__/manifest-client.test.js +88 -0
- package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
- package/dist/skills/__tests__/source-managed.test.js +60 -0
- package/dist/tools/__tests__/_background-shared.test.js +69 -0
- package/dist/tools/__tests__/_command-shared.test.js +59 -0
- package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
- package/dist/tools/__tests__/_flag.test.js +66 -0
- package/dist/tools/__tests__/_helpers.js +17 -0
- package/dist/tools/__tests__/_web-shared.test.js +143 -0
- package/dist/tools/__tests__/agent_run.test.js +166 -0
- package/dist/tools/__tests__/approval-advisory.test.js +66 -0
- package/dist/tools/__tests__/background_run.test.js +113 -0
- package/dist/tools/__tests__/convention_get.test.js +53 -0
- package/dist/tools/__tests__/file-edit.test.js +72 -0
- package/dist/tools/__tests__/file-read.test.js +81 -0
- package/dist/tools/__tests__/file-write.test.js +68 -0
- package/dist/tools/__tests__/glob.test.js +128 -0
- package/dist/tools/__tests__/grep.test.js +71 -0
- package/dist/tools/__tests__/monitor_emit.test.js +96 -0
- package/dist/tools/__tests__/playbook_get.test.js +109 -0
- package/dist/tools/__tests__/project_context_get.test.js +71 -0
- package/dist/tools/__tests__/registry-category.test.js +60 -0
- package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
- package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
- package/dist/tools/__tests__/schedule_create.test.js +85 -0
- package/dist/tools/__tests__/shell_exec.test.js +135 -0
- package/dist/tools/__tests__/transport-safety.test.js +106 -0
- package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
- package/dist/tools/__tests__/web_fetch.test.js +329 -0
- package/dist/tools/__tests__/web_search.test.js +215 -0
- package/dist/tools/__tests__/write-mode.test.js +32 -0
- package/dist/ui/__tests__/login-banner.test.js +69 -0
- package/package.json +82 -83
|
@@ -0,0 +1,573 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
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
|
+
});
|