@cspeach/cli 0.6.4 → 0.7.0
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/intent-system-prompt.js +46 -0
- package/dist/agent/loop.js +180 -18
- package/dist/agent/parallel-write-guard.js +71 -0
- package/dist/agent/repair-partial.js +88 -1
- package/dist/agent/retry-cap.js +121 -0
- package/dist/agent/summarise-via-provider.js +51 -0
- package/dist/approvals/jwt.js +33 -12
- package/dist/commands/auto-compact.js +94 -0
- package/dist/commands/compact.js +265 -0
- package/dist/commands/cost.js +56 -0
- package/dist/commands/help.js +21 -14
- package/dist/config/loader.js +39 -0
- package/dist/cost/cost-log.js +113 -0
- package/dist/cost/pricing.js +91 -0
- package/dist/index.js +0 -0
- package/dist/one-shot.js +1 -1
- package/dist/renderer/markdown.js +7 -1
- package/dist/renderer/status-footer.js +37 -14
- package/dist/renderer/thinking-heartbeat.js +15 -3
- package/dist/renderer/tool-widget.js +6 -1
- package/dist/renderer/tty.js +27 -0
- package/dist/repl/cspeach-shell-detect.js +33 -0
- package/dist/repl/post-turn-status.js +68 -0
- package/dist/repl/slash-completer.js +2 -0
- package/dist/repl/slash-picker.js +2 -0
- package/dist/repl.js +371 -46
- package/dist/sap-errors/parse-adt-exception.js +149 -0
- package/dist/session/schema.js +2 -1
- package/dist/session/store.js +19 -0
- package/dist/tools/_filesystem-shared.js +9 -1
- package/dist/tools/ask-question.js +19 -0
- package/dist/tools/sap-read.js +56 -4
- package/dist/tools/sap-write.js +19 -0
- package/dist/tools/subagent/schedule_draft_create.js +137 -0
- package/dist/ui/alt-screen.js +120 -0
- package/dist/ui/app.js +39 -15
- package/dist/ui/ask-question-emitter.js +13 -0
- package/dist/ui/body.js +39 -23
- package/dist/ui/coaching-picker-classic.js +8 -1
- package/dist/ui/footer.js +6 -1
- package/dist/ui/header.js +19 -4
- package/dist/ui/sap-state-store.js +13 -1
- package/dist/ui/sidebar.js +5 -3
- package/dist/ui/status-emitter.js +19 -0
- package/dist/ui/status-row.js +30 -4
- package/dist/ui/widgets/ask-question-modal.js +132 -0
- package/dist/ui/widgets/coaching-picker.js +55 -8
- package/package.json +86 -82
- package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
- package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
- package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
- package/dist/agent/__tests__/project-context-injection.test.js +0 -161
- package/dist/agent/__tests__/repair-partial.test.js +0 -79
- package/dist/agent/__tests__/retry-key.test.js +0 -77
- package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
- package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
- package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
- package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
- package/dist/agent/__tests__/turn-stream.test.js +0 -100
- package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
- package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
- package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
- package/dist/agent/providers/__tests__/factory.test.js +0 -33
- package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
- package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
- package/dist/approvals/__tests__/advisory-render.test.js +0 -54
- package/dist/auth/__tests__/auth-file.test.js +0 -42
- package/dist/auth/__tests__/me.test.js +0 -43
- package/dist/classifier/__tests__/client.test.js +0 -70
- package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
- package/dist/commands/__tests__/login.test.js +0 -152
- package/dist/commands/__tests__/logout.test.js +0 -39
- package/dist/commands/__tests__/project-context-impact.test.js +0 -173
- package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
- package/dist/commands/__tests__/whoami.test.js +0 -91
- package/dist/config/__tests__/llm-config.test.js +0 -28
- package/dist/config/__tests__/shell-exec-config.test.js +0 -76
- package/dist/config/__tests__/write-mode.test.js +0 -36
- package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
- package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
- package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
- package/dist/project-context/__tests__/conventions.test.js +0 -221
- package/dist/project-context/__tests__/detect.test.js +0 -177
- package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
- package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
- package/dist/project-context/__tests__/domain-cap.test.js +0 -168
- package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
- package/dist/project-context/__tests__/git.test.js +0 -131
- package/dist/project-context/__tests__/index-files.test.js +0 -195
- package/dist/project-context/__tests__/index.test.js +0 -133
- package/dist/project-context/__tests__/render.test.js +0 -322
- package/dist/project-context/__tests__/types.test.js +0 -73
- package/dist/projects/__tests__/build.test.js +0 -52
- package/dist/projects/__tests__/canonicalize.test.js +0 -47
- package/dist/projects/__tests__/email-template.test.js +0 -71
- package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
- package/dist/projects/__tests__/extract-cca.test.js +0 -141
- package/dist/projects/__tests__/extract-design.test.js +0 -62
- package/dist/projects/__tests__/extract-estimate.test.js +0 -58
- package/dist/projects/__tests__/extract-modernize.test.js +0 -127
- package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
- package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
- package/dist/projects/__tests__/filename.test.js +0 -32
- package/dist/projects/__tests__/promote-command.test.js +0 -170
- package/dist/projects/__tests__/promote.test.js +0 -181
- package/dist/projects/__tests__/save-command.test.js +0 -216
- package/dist/projects/__tests__/save.test.js +0 -41
- package/dist/projects/__tests__/status.test.js +0 -208
- package/dist/projects/__tests__/types.test.js +0 -41
- package/dist/projects/__tests__/validate.test.js +0 -165
- package/dist/projects/__tests__/workspace.test.js +0 -337
- package/dist/repl/__tests__/current-transport.test.js +0 -43
- package/dist/repl/__tests__/diff-display.test.js +0 -59
- package/dist/repl/__tests__/file-picker.test.js +0 -97
- package/dist/repl/__tests__/rule8-detector.test.js +0 -114
- package/dist/repl/__tests__/safety-confirm.test.js +0 -130
- package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
- package/dist/sap/__tests__/system-info.test.js +0 -344
- package/dist/session/__tests__/store-discovery.test.js +0 -114
- package/dist/session/__tests__/time-ago.test.js +0 -53
- package/dist/skills/__tests__/canonical.test.js +0 -20
- package/dist/skills/__tests__/manifest-client.test.js +0 -88
- package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
- package/dist/skills/__tests__/source-managed.test.js +0 -60
- package/dist/tools/__tests__/_background-shared.test.js +0 -69
- package/dist/tools/__tests__/_command-shared.test.js +0 -59
- package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
- package/dist/tools/__tests__/_flag.test.js +0 -66
- package/dist/tools/__tests__/_helpers.js +0 -17
- package/dist/tools/__tests__/_web-shared.test.js +0 -143
- package/dist/tools/__tests__/agent_run.test.js +0 -166
- package/dist/tools/__tests__/approval-advisory.test.js +0 -66
- package/dist/tools/__tests__/background_run.test.js +0 -113
- package/dist/tools/__tests__/convention_get.test.js +0 -53
- package/dist/tools/__tests__/file-edit.test.js +0 -72
- package/dist/tools/__tests__/file-read.test.js +0 -81
- package/dist/tools/__tests__/file-write.test.js +0 -68
- package/dist/tools/__tests__/glob.test.js +0 -128
- package/dist/tools/__tests__/grep.test.js +0 -71
- package/dist/tools/__tests__/monitor_emit.test.js +0 -96
- package/dist/tools/__tests__/playbook_get.test.js +0 -109
- package/dist/tools/__tests__/project_context_get.test.js +0 -71
- package/dist/tools/__tests__/registry-category.test.js +0 -60
- package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
- package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
- package/dist/tools/__tests__/schedule_create.test.js +0 -85
- package/dist/tools/__tests__/shell_exec.test.js +0 -135
- package/dist/tools/__tests__/transport-safety.test.js +0 -106
- package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
- package/dist/tools/__tests__/web_fetch.test.js +0 -329
- package/dist/tools/__tests__/web_search.test.js +0 -215
- package/dist/tools/__tests__/write-mode.test.js +0 -32
- package/dist/ui/__tests__/login-banner.test.js +0 -69
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
// cspeach-cli/src/tools/__tests__/_web-shared.test.ts
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
-
import { validateUrl, isPrivateIp, BlockedHostError, BlockedProtocolError, MalformedUrlError, } from '../web/_web-shared.js';
|
|
4
|
-
// Test helper: stub the DNS resolver to return a fixed address list.
|
|
5
|
-
function fakeResolver(addresses) {
|
|
6
|
-
return {
|
|
7
|
-
resolveAll: async () => addresses,
|
|
8
|
-
};
|
|
9
|
-
}
|
|
10
|
-
let origAllowPrivate;
|
|
11
|
-
beforeEach(() => {
|
|
12
|
-
origAllowPrivate = process.env.CSPEACH_WEB_ALLOW_PRIVATE;
|
|
13
|
-
delete process.env.CSPEACH_WEB_ALLOW_PRIVATE;
|
|
14
|
-
});
|
|
15
|
-
afterEach(() => {
|
|
16
|
-
if (origAllowPrivate === undefined)
|
|
17
|
-
delete process.env.CSPEACH_WEB_ALLOW_PRIVATE;
|
|
18
|
-
else
|
|
19
|
-
process.env.CSPEACH_WEB_ALLOW_PRIVATE = origAllowPrivate;
|
|
20
|
-
});
|
|
21
|
-
describe('isPrivateIp', () => {
|
|
22
|
-
it('flags IPv4 RFC1918 + loopback + link-local + multicast + reserved as private', () => {
|
|
23
|
-
expect(isPrivateIp('0.0.0.0')).toBe(true);
|
|
24
|
-
expect(isPrivateIp('127.0.0.1')).toBe(true);
|
|
25
|
-
expect(isPrivateIp('10.0.0.1')).toBe(true);
|
|
26
|
-
expect(isPrivateIp('172.16.0.1')).toBe(true);
|
|
27
|
-
expect(isPrivateIp('172.31.255.255')).toBe(true);
|
|
28
|
-
expect(isPrivateIp('192.168.1.1')).toBe(true);
|
|
29
|
-
expect(isPrivateIp('169.254.169.254')).toBe(true); // AWS / Azure metadata
|
|
30
|
-
expect(isPrivateIp('224.0.0.1')).toBe(true); // multicast
|
|
31
|
-
expect(isPrivateIp('255.255.255.255')).toBe(true); // broadcast
|
|
32
|
-
// 100.64.0.0/10 — CGNAT / shared address space (RFC 6598). Used by
|
|
33
|
-
// Fargate / Cloud Run / ACI for task-local networking. CSPeach must
|
|
34
|
-
// treat as private to avoid task-to-task SSRF inside a container host.
|
|
35
|
-
expect(isPrivateIp('100.64.0.1')).toBe(true);
|
|
36
|
-
expect(isPrivateIp('100.127.255.255')).toBe(true);
|
|
37
|
-
});
|
|
38
|
-
it('flags IPv6 loopback + ULA + link-local + unspecified as private', () => {
|
|
39
|
-
expect(isPrivateIp('::1')).toBe(true);
|
|
40
|
-
expect(isPrivateIp('fc00::1')).toBe(true);
|
|
41
|
-
expect(isPrivateIp('fe80::1')).toBe(true);
|
|
42
|
-
expect(isPrivateIp('::')).toBe(true);
|
|
43
|
-
// Link-local boundary: fe80::/10 spans fe80–febf. fe8: / febf: must
|
|
44
|
-
// resolve correctly — see the boundary test below for the false-positive
|
|
45
|
-
// case the regex used to allow.
|
|
46
|
-
expect(isPrivateIp('febf::1')).toBe(true); // link-local, top of range
|
|
47
|
-
});
|
|
48
|
-
it('flags IPv4-mapped IPv6 in hex form (Node URL parser normalization)', () => {
|
|
49
|
-
// Node's URL parser normalises https://[::ffff:192.168.1.1]/ to
|
|
50
|
-
// [::ffff:c0a8:101] (hex 16-bit-group form). Without explicit handling,
|
|
51
|
-
// an attacker bypasses RFC1918 + loopback by URL-encoding the address.
|
|
52
|
-
expect(isPrivateIp('::ffff:c0a8:101')).toBe(true); // 192.168.1.1 in hex
|
|
53
|
-
expect(isPrivateIp('::ffff:7f00:1')).toBe(true); // 127.0.0.1 in hex
|
|
54
|
-
expect(isPrivateIp('::ffff:0a00:1')).toBe(true); // 10.0.0.1 in hex
|
|
55
|
-
expect(isPrivateIp('::ffff:8080:8080')).toBe(false); // 128.128.128.128 — public, in hex
|
|
56
|
-
});
|
|
57
|
-
it('does NOT flag public IPv4 + IPv6 addresses', () => {
|
|
58
|
-
expect(isPrivateIp('8.8.8.8')).toBe(false);
|
|
59
|
-
expect(isPrivateIp('1.1.1.1')).toBe(false);
|
|
60
|
-
expect(isPrivateIp('172.15.0.1')).toBe(false); // just outside 172.16.0.0/12
|
|
61
|
-
expect(isPrivateIp('172.32.0.1')).toBe(false); // just outside the other end
|
|
62
|
-
expect(isPrivateIp('100.63.255.255')).toBe(false); // just below CGNAT
|
|
63
|
-
expect(isPrivateIp('100.128.0.1')).toBe(false); // just above CGNAT
|
|
64
|
-
expect(isPrivateIp('2001:db8::1')).toBe(false); // documentation prefix — counts as public for our purposes
|
|
65
|
-
expect(isPrivateIp('2606:4700:4700::1111')).toBe(false); // Cloudflare DNS
|
|
66
|
-
// Link-local regex must NOT false-positive on three-nibble groups.
|
|
67
|
-
// 'fe8:' / 'feb:' are global unicast, not fe80::/10 link-local.
|
|
68
|
-
expect(isPrivateIp('fe8::1')).toBe(false);
|
|
69
|
-
expect(isPrivateIp('fec0::1')).toBe(false); // site-local (deprecated, not blocked)
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
describe('validateUrl — protocol + scheme', () => {
|
|
73
|
-
it('accepts a valid HTTPS URL with public-resolving hostname', async () => {
|
|
74
|
-
const v = await validateUrl('https://example.com/foo?bar=baz', {
|
|
75
|
-
deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]),
|
|
76
|
-
});
|
|
77
|
-
expect(v.url.hostname).toBe('example.com');
|
|
78
|
-
expect(v.pinnedAddress).toBe('93.184.216.34');
|
|
79
|
-
expect(typeof v.pinnedLookup).toBe('function');
|
|
80
|
-
});
|
|
81
|
-
it('rejects http:// (HTTPS-only)', async () => {
|
|
82
|
-
await expect(validateUrl('http://example.com/', {
|
|
83
|
-
deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]),
|
|
84
|
-
})).rejects.toThrow(BlockedProtocolError);
|
|
85
|
-
});
|
|
86
|
-
it('rejects file:// and ftp:// and javascript: schemes', async () => {
|
|
87
|
-
for (const u of ['file:///etc/passwd', 'ftp://example.com/', 'javascript:alert(1)']) {
|
|
88
|
-
await expect(validateUrl(u, { deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]) })).rejects.toThrow(BlockedProtocolError);
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
it('rejects a malformed URL with MalformedUrlError', async () => {
|
|
92
|
-
await expect(validateUrl('not a url', { deps: fakeResolver([]) })).rejects.toThrow(MalformedUrlError);
|
|
93
|
-
});
|
|
94
|
-
it('pinnedLookup returns ENOTFOUND when called with a hostname other than the pinned one', async () => {
|
|
95
|
-
// Defends against a misconfigured https.request that re-dispatches the
|
|
96
|
-
// lookup with a hostname different to the one we validated. We MUST
|
|
97
|
-
// refuse that call — silently honouring it re-opens DNS rebinding.
|
|
98
|
-
const v = await validateUrl('https://example.com/', {
|
|
99
|
-
deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]),
|
|
100
|
-
});
|
|
101
|
-
const err = await new Promise((resolve) => {
|
|
102
|
-
v.pinnedLookup('attacker-controlled.com', {}, (e) => resolve(e));
|
|
103
|
-
});
|
|
104
|
-
expect(err).not.toBeNull();
|
|
105
|
-
expect(err?.code).toBe('ENOTFOUND');
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
describe('validateUrl — hostname + IP blocklists', () => {
|
|
109
|
-
it('rejects literal localhost hostname (string match BEFORE DNS)', async () => {
|
|
110
|
-
await expect(validateUrl('https://localhost/', {
|
|
111
|
-
deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]), // even if DNS lies, blocked on name
|
|
112
|
-
})).rejects.toThrow(BlockedHostError);
|
|
113
|
-
});
|
|
114
|
-
it('rejects .local mDNS suffixes', async () => {
|
|
115
|
-
await expect(validateUrl('https://mymac.local/', { deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]) })).rejects.toThrow(BlockedHostError);
|
|
116
|
-
});
|
|
117
|
-
it('rejects metadata.google.internal by literal name match', async () => {
|
|
118
|
-
await expect(validateUrl('https://metadata.google.internal/computeMetadata/v1/', {
|
|
119
|
-
deps: fakeResolver([{ address: '93.184.216.34', family: 4 }]),
|
|
120
|
-
})).rejects.toThrow(BlockedHostError);
|
|
121
|
-
});
|
|
122
|
-
it('rejects a public-looking hostname that resolves to a private IP (DNS-rebinding defense)', async () => {
|
|
123
|
-
await expect(validateUrl('https://attacker-controlled.example.com/', {
|
|
124
|
-
deps: fakeResolver([{ address: '127.0.0.1', family: 4 }]),
|
|
125
|
-
})).rejects.toThrow(BlockedHostError);
|
|
126
|
-
});
|
|
127
|
-
it('rejects a hostname where ANY resolved address is private (mixed result)', async () => {
|
|
128
|
-
// Some resolvers return BOTH a public and a private IP; we must reject if EITHER is private.
|
|
129
|
-
await expect(validateUrl('https://mixed.example.com/', {
|
|
130
|
-
deps: fakeResolver([
|
|
131
|
-
{ address: '93.184.216.34', family: 4 },
|
|
132
|
-
{ address: '169.254.169.254', family: 4 },
|
|
133
|
-
]),
|
|
134
|
-
})).rejects.toThrow(BlockedHostError);
|
|
135
|
-
});
|
|
136
|
-
it('accepts a private-IP-resolving URL when CSPEACH_WEB_ALLOW_PRIVATE=on is set', async () => {
|
|
137
|
-
process.env.CSPEACH_WEB_ALLOW_PRIVATE = 'on';
|
|
138
|
-
const v = await validateUrl('https://dev.localhost.test/', {
|
|
139
|
-
deps: fakeResolver([{ address: '127.0.0.1', family: 4 }]),
|
|
140
|
-
});
|
|
141
|
-
expect(v.pinnedAddress).toBe('127.0.0.1');
|
|
142
|
-
});
|
|
143
|
-
});
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import { agentRunHandler, MAX_AGENT_DEPTH, DEFAULT_MAX_TOKENS, HARD_MAX_TOKENS } from '../subagent/agent_run.js';
|
|
3
|
-
import { makeCtx } from './_helpers.js';
|
|
4
|
-
// runTurn import is hoisted-mocked below so we can inspect the args
|
|
5
|
-
// agent_run handed it without actually invoking the LLM.
|
|
6
|
-
const { runTurnMock } = vi.hoisted(() => ({ runTurnMock: vi.fn() }));
|
|
7
|
-
vi.mock('../../agent/loop.js', () => ({
|
|
8
|
-
runTurn: runTurnMock,
|
|
9
|
-
}));
|
|
10
|
-
function makeSkillSource(body = '# fake skill body') {
|
|
11
|
-
return {
|
|
12
|
-
async getSkillBody(name) {
|
|
13
|
-
return { name, body, sha256: 'fake', signature: 'fake', signedAt: '2026-05-14T00:00:00Z' };
|
|
14
|
-
},
|
|
15
|
-
async listAvailableSkills() { return ['abap-review']; },
|
|
16
|
-
describe() { return 'fake-source'; },
|
|
17
|
-
};
|
|
18
|
-
}
|
|
19
|
-
function makeProvider() {
|
|
20
|
-
return { kind: 'anthropic', name: 'fake-provider' };
|
|
21
|
-
}
|
|
22
|
-
let savedDepth;
|
|
23
|
-
beforeEach(() => {
|
|
24
|
-
runTurnMock.mockReset();
|
|
25
|
-
savedDepth = process.env.CSPEACH_AGENT_DEPTH;
|
|
26
|
-
delete process.env.CSPEACH_AGENT_DEPTH;
|
|
27
|
-
});
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
if (savedDepth === undefined)
|
|
30
|
-
delete process.env.CSPEACH_AGENT_DEPTH;
|
|
31
|
-
else
|
|
32
|
-
process.env.CSPEACH_AGENT_DEPTH = savedDepth;
|
|
33
|
-
vi.restoreAllMocks();
|
|
34
|
-
});
|
|
35
|
-
describe('agent_run handler', () => {
|
|
36
|
-
it('invokes runTurn with a fresh child session and returns the child assistant text', async () => {
|
|
37
|
-
runTurnMock.mockImplementation(async (params) => {
|
|
38
|
-
// Simulate the child loop pushing an assistant text block.
|
|
39
|
-
params.ctx.session.messages.push({
|
|
40
|
-
role: 'assistant',
|
|
41
|
-
content: [{ type: 'text', text: 'child says hi' }],
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
const ctx = makeCtx(process.cwd(), {
|
|
45
|
-
provider: makeProvider(),
|
|
46
|
-
skillSource: makeSkillSource(),
|
|
47
|
-
});
|
|
48
|
-
const r = await agentRunHandler({ skill: 'abap-review', task: 'review ZCL_X' }, ctx);
|
|
49
|
-
expect(r.is_error).toBeFalsy();
|
|
50
|
-
expect(r.content).toContain('child says hi');
|
|
51
|
-
expect(runTurnMock).toHaveBeenCalledOnce();
|
|
52
|
-
const callArgs = runTurnMock.mock.calls[0][0];
|
|
53
|
-
expect(callArgs.ctx).not.toBe(ctx);
|
|
54
|
-
expect(callArgs.ctx.session).not.toBe(ctx.session);
|
|
55
|
-
// I2 fix: chunkEmitter is now a null-sink EventEmitter (not undefined)
|
|
56
|
-
// so subagent stdout is silently dropped without polluting parent UI.
|
|
57
|
-
expect(callArgs.chunkEmitter).toBeDefined();
|
|
58
|
-
expect(callArgs.chunkEmitter.listenerCount?.('chunk') ?? 0).toBe(0);
|
|
59
|
-
expect(callArgs.ctx.previewHook).toBeUndefined();
|
|
60
|
-
expect(callArgs.ctx.chunkEmitter).toBeUndefined();
|
|
61
|
-
expect(callArgs.skill).toBe('abap-review');
|
|
62
|
-
});
|
|
63
|
-
it('I1 regression — child session.usage is a clone, not a shared reference', async () => {
|
|
64
|
-
runTurnMock.mockImplementation(async () => { });
|
|
65
|
-
const parentSession = {
|
|
66
|
-
messages: [],
|
|
67
|
-
usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 10 },
|
|
68
|
-
};
|
|
69
|
-
const ctx = makeCtx(process.cwd(), {
|
|
70
|
-
provider: makeProvider(),
|
|
71
|
-
skillSource: makeSkillSource(),
|
|
72
|
-
session: parentSession,
|
|
73
|
-
});
|
|
74
|
-
await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
|
|
75
|
-
const callArgs = runTurnMock.mock.calls[0][0];
|
|
76
|
-
expect(callArgs.ctx.session.usage).not.toBe(parentSession.usage);
|
|
77
|
-
expect(callArgs.ctx.session.usage).toEqual(parentSession.usage);
|
|
78
|
-
});
|
|
79
|
-
it('rejects when ctx.provider is missing (not wired)', async () => {
|
|
80
|
-
const ctx = makeCtx(process.cwd(), { skillSource: makeSkillSource() });
|
|
81
|
-
const r = await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
|
|
82
|
-
expect(r.is_error).toBe(true);
|
|
83
|
-
expect(r.content).toMatch(/provider/i);
|
|
84
|
-
expect(runTurnMock).not.toHaveBeenCalled();
|
|
85
|
-
});
|
|
86
|
-
it('rejects when ctx.skillSource is missing', async () => {
|
|
87
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider() });
|
|
88
|
-
const r = await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
|
|
89
|
-
expect(r.is_error).toBe(true);
|
|
90
|
-
expect(r.content).toMatch(/skillSource|skill source/i);
|
|
91
|
-
expect(runTurnMock).not.toHaveBeenCalled();
|
|
92
|
-
});
|
|
93
|
-
it('rejects missing skill arg', async () => {
|
|
94
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
95
|
-
const r = await agentRunHandler({ task: 't' }, ctx);
|
|
96
|
-
expect(r.is_error).toBe(true);
|
|
97
|
-
expect(r.content).toMatch(/skill/i);
|
|
98
|
-
expect(runTurnMock).not.toHaveBeenCalled();
|
|
99
|
-
});
|
|
100
|
-
it('rejects missing task arg', async () => {
|
|
101
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
102
|
-
const r = await agentRunHandler({ skill: 'abap-review' }, ctx);
|
|
103
|
-
expect(r.is_error).toBe(true);
|
|
104
|
-
expect(r.content).toMatch(/task/i);
|
|
105
|
-
expect(runTurnMock).not.toHaveBeenCalled();
|
|
106
|
-
});
|
|
107
|
-
it('refuses to dispatch when depth would exceed MAX_AGENT_DEPTH', async () => {
|
|
108
|
-
process.env.CSPEACH_AGENT_DEPTH = String(MAX_AGENT_DEPTH);
|
|
109
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
110
|
-
const r = await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
|
|
111
|
-
expect(r.is_error).toBe(true);
|
|
112
|
-
expect(r.content).toMatch(/depth/i);
|
|
113
|
-
expect(runTurnMock).not.toHaveBeenCalled();
|
|
114
|
-
});
|
|
115
|
-
it('increments CSPEACH_AGENT_DEPTH for the child before invoking runTurn', async () => {
|
|
116
|
-
let observed;
|
|
117
|
-
runTurnMock.mockImplementation(async () => {
|
|
118
|
-
observed = process.env.CSPEACH_AGENT_DEPTH;
|
|
119
|
-
});
|
|
120
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
121
|
-
await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
|
|
122
|
-
expect(observed).toBe('1');
|
|
123
|
-
});
|
|
124
|
-
it('restores CSPEACH_AGENT_DEPTH to its prior value after runTurn returns', async () => {
|
|
125
|
-
process.env.CSPEACH_AGENT_DEPTH = '1';
|
|
126
|
-
runTurnMock.mockResolvedValueOnce(undefined);
|
|
127
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
128
|
-
await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
|
|
129
|
-
expect(process.env.CSPEACH_AGENT_DEPTH).toBe('1');
|
|
130
|
-
});
|
|
131
|
-
it('clamps max_tokens to HARD_MAX_TOKENS', async () => {
|
|
132
|
-
let observedMaxTokens;
|
|
133
|
-
runTurnMock.mockImplementation(async (params) => {
|
|
134
|
-
observedMaxTokens = params.maxTokensOverride;
|
|
135
|
-
});
|
|
136
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
137
|
-
await agentRunHandler({ skill: 'abap-review', task: 't', max_tokens: 999_999_999 }, ctx);
|
|
138
|
-
expect(observedMaxTokens).toBe(HARD_MAX_TOKENS);
|
|
139
|
-
});
|
|
140
|
-
it('uses DEFAULT_MAX_TOKENS when max_tokens omitted', async () => {
|
|
141
|
-
let observedMaxTokens;
|
|
142
|
-
runTurnMock.mockImplementation(async (params) => {
|
|
143
|
-
observedMaxTokens = params.maxTokensOverride;
|
|
144
|
-
});
|
|
145
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
146
|
-
await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
|
|
147
|
-
expect(observedMaxTokens).toBe(DEFAULT_MAX_TOKENS);
|
|
148
|
-
});
|
|
149
|
-
it('forwards a runTurn rejection as a structured error envelope', async () => {
|
|
150
|
-
runTurnMock.mockRejectedValueOnce(new Error('child loop crashed'));
|
|
151
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
152
|
-
const r = await agentRunHandler({ skill: 'abap-review', task: 't' }, ctx);
|
|
153
|
-
expect(r.is_error).toBe(true);
|
|
154
|
-
// M5: assert BOTH wrapper AND inner message — guards against the
|
|
155
|
-
// wrapper swallowing the underlying error text.
|
|
156
|
-
expect(r.content).toMatch(/subagent failed/i);
|
|
157
|
-
expect(r.content).toMatch(/child loop crashed/i);
|
|
158
|
-
});
|
|
159
|
-
it('validates skill name shape (defense in depth on ctx.skillSource lookup key)', async () => {
|
|
160
|
-
const ctx = makeCtx(process.cwd(), { provider: makeProvider(), skillSource: makeSkillSource() });
|
|
161
|
-
const r = await agentRunHandler({ skill: '../etc/passwd', task: 't' }, ctx);
|
|
162
|
-
expect(r.is_error).toBe(true);
|
|
163
|
-
expect(r.content).toMatch(/invalid skill/i);
|
|
164
|
-
expect(runTurnMock).not.toHaveBeenCalled();
|
|
165
|
-
});
|
|
166
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
// Mock loadConfig while preserving the rest of the module's exports so that
|
|
3
|
-
// approval.ts's other imports (WriteMode, isValidWriteMode, etc., if any) stay
|
|
4
|
-
// intact at import time.
|
|
5
|
-
const mockLoadConfig = vi.fn();
|
|
6
|
-
vi.mock('../../config/loader.js', async () => {
|
|
7
|
-
const actual = await vi.importActual('../../config/loader.js');
|
|
8
|
-
return { ...actual, loadConfig: mockLoadConfig };
|
|
9
|
-
});
|
|
10
|
-
// Mock the prompt (don't mock the renderer — it's pure).
|
|
11
|
-
const mockPromptAdvisory = vi.fn();
|
|
12
|
-
vi.mock('../../approvals/advisory-prompt.js', () => ({
|
|
13
|
-
promptAdvisory: mockPromptAdvisory,
|
|
14
|
-
}));
|
|
15
|
-
describe('handleAdvisoryApproval', () => {
|
|
16
|
-
const baseRequest = {
|
|
17
|
-
summary: 'Create ZCL_FOO',
|
|
18
|
-
risk: 'low',
|
|
19
|
-
transport: 'DEVK900123',
|
|
20
|
-
changes: [
|
|
21
|
-
{ op: 'create', object: 'ZCL_FOO', type: 'CLAS', diff: 'CLASS...' },
|
|
22
|
-
],
|
|
23
|
-
};
|
|
24
|
-
beforeEach(() => {
|
|
25
|
-
mockLoadConfig.mockReset();
|
|
26
|
-
mockPromptAdvisory.mockReset();
|
|
27
|
-
});
|
|
28
|
-
it('does not mint approval_ids when write_mode=advisory-only (outcome=applied)', async () => {
|
|
29
|
-
mockLoadConfig.mockResolvedValue({
|
|
30
|
-
write_mode: 'advisory-only',
|
|
31
|
-
sap: { DEV: { auto_approve: 'never' } },
|
|
32
|
-
});
|
|
33
|
-
mockPromptAdvisory.mockResolvedValue('applied');
|
|
34
|
-
const { handleAdvisoryApproval } = await import('../approval.js');
|
|
35
|
-
const result = await handleAdvisoryApproval(baseRequest, { sapAlias: 'DEV' });
|
|
36
|
-
const payload = JSON.parse(result.content);
|
|
37
|
-
expect(payload.approval_ids).toBeUndefined();
|
|
38
|
-
expect(payload.advisory_outcome).toBe('applied');
|
|
39
|
-
expect(String(payload.ai_directive).toLowerCase()).toContain('propos');
|
|
40
|
-
expect(String(payload.ai_directive).toLowerCase()).toContain('do not attempt');
|
|
41
|
-
});
|
|
42
|
-
it('returns outcome=skipped when developer chooses skip', async () => {
|
|
43
|
-
mockLoadConfig.mockResolvedValue({
|
|
44
|
-
write_mode: 'advisory-only',
|
|
45
|
-
sap: { DEV: { auto_approve: 'never' } },
|
|
46
|
-
});
|
|
47
|
-
mockPromptAdvisory.mockResolvedValue('skipped');
|
|
48
|
-
const { handleAdvisoryApproval } = await import('../approval.js');
|
|
49
|
-
const result = await handleAdvisoryApproval(baseRequest, { sapAlias: 'DEV' });
|
|
50
|
-
const payload = JSON.parse(result.content);
|
|
51
|
-
expect(payload.advisory_outcome).toBe('skipped');
|
|
52
|
-
expect(String(payload.ai_directive).toLowerCase()).toContain('skipped');
|
|
53
|
-
});
|
|
54
|
-
it('returns outcome=aborted when developer chooses abort', async () => {
|
|
55
|
-
mockLoadConfig.mockResolvedValue({
|
|
56
|
-
write_mode: 'advisory-only',
|
|
57
|
-
sap: { DEV: { auto_approve: 'never' } },
|
|
58
|
-
});
|
|
59
|
-
mockPromptAdvisory.mockResolvedValue('aborted');
|
|
60
|
-
const { handleAdvisoryApproval } = await import('../approval.js');
|
|
61
|
-
const result = await handleAdvisoryApproval(baseRequest, { sapAlias: 'DEV' });
|
|
62
|
-
const payload = JSON.parse(result.content);
|
|
63
|
-
expect(payload.advisory_outcome).toBe('aborted');
|
|
64
|
-
expect(String(payload.ai_directive).toLowerCase()).toContain('abort');
|
|
65
|
-
});
|
|
66
|
-
});
|
|
@@ -1,113 +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 { backgroundRunHandler } from '../subagent/background_run.js';
|
|
6
|
-
import { getBackground, consumeEvents, __resetRegistryForTests, } from '../subagent/_background-shared.js';
|
|
7
|
-
import { makeCtx } from './_helpers.js';
|
|
8
|
-
let root;
|
|
9
|
-
const ctx = () => makeCtx(root);
|
|
10
|
-
beforeEach(async () => {
|
|
11
|
-
__resetRegistryForTests();
|
|
12
|
-
root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-bg-'));
|
|
13
|
-
});
|
|
14
|
-
afterEach(async () => { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); });
|
|
15
|
-
/** Wait for the entry to be marked closed. Polls; ceiling so the test fails clearly. */
|
|
16
|
-
// If you see flakes on slow CI, bump timeoutMs ceiling — don't tighten the poll interval.
|
|
17
|
-
async function waitForExit(bg_id, timeoutMs = 5000) {
|
|
18
|
-
const deadline = Date.now() + timeoutMs;
|
|
19
|
-
while (Date.now() < deadline) {
|
|
20
|
-
const e = getBackground(bg_id);
|
|
21
|
-
if (e?.closed)
|
|
22
|
-
return;
|
|
23
|
-
await new Promise((r) => setTimeout(r, 25));
|
|
24
|
-
}
|
|
25
|
-
throw new Error(`background ${bg_id} did not close within ${timeoutMs}ms`);
|
|
26
|
-
}
|
|
27
|
-
describe('background_run handler', () => {
|
|
28
|
-
it('spawns a safelisted command and returns a bg_id immediately', async () => {
|
|
29
|
-
const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.stdout.write("hello"); process.exit(0)'] }, ctx());
|
|
30
|
-
expect(r.is_error).toBeFalsy();
|
|
31
|
-
expect(r.content).toMatch(/bg_id:\s*bg-[a-f0-9-]+/);
|
|
32
|
-
const m = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/);
|
|
33
|
-
expect(m).not.toBeNull();
|
|
34
|
-
expect(getBackground(m[1])).toBeDefined();
|
|
35
|
-
});
|
|
36
|
-
it('records stdout events captured from the child', async () => {
|
|
37
|
-
const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.stdout.write("line1\\nline2\\n"); process.exit(0)'] }, ctx());
|
|
38
|
-
const bg_id = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/)[1];
|
|
39
|
-
await waitForExit(bg_id);
|
|
40
|
-
const consumed = consumeEvents(bg_id, 100);
|
|
41
|
-
expect(consumed).not.toBeNull();
|
|
42
|
-
const stdoutTexts = consumed.events.filter((e) => e.kind === 'stdout').map((e) => e.text);
|
|
43
|
-
expect(stdoutTexts.join('')).toMatch(/line1[\s\S]*line2/);
|
|
44
|
-
expect(consumed.closed).toBe(true);
|
|
45
|
-
expect(consumed.exitCode).toBe(0);
|
|
46
|
-
});
|
|
47
|
-
it('records stderr events separately from stdout', async () => {
|
|
48
|
-
const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.stderr.write("oops\\n"); process.exit(1)'] }, ctx());
|
|
49
|
-
const bg_id = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/)[1];
|
|
50
|
-
await waitForExit(bg_id);
|
|
51
|
-
const consumed = consumeEvents(bg_id, 100);
|
|
52
|
-
const stderrTexts = consumed.events.filter((e) => e.kind === 'stderr').map((e) => e.text);
|
|
53
|
-
expect(stderrTexts.join('')).toMatch(/oops/);
|
|
54
|
-
expect(consumed.exitCode).toBe(1);
|
|
55
|
-
});
|
|
56
|
-
it('refuses a non-safelisted command', async () => {
|
|
57
|
-
const r = await backgroundRunHandler({ command: 'curl', args: ['https://example.com'] }, ctx());
|
|
58
|
-
expect(r.is_error).toBe(true);
|
|
59
|
-
expect(r.content).toMatch(/safelist/i);
|
|
60
|
-
});
|
|
61
|
-
it('refuses an absolute path even if basename is safelisted', async () => {
|
|
62
|
-
const abs = process.platform === 'win32' ? 'C:\\Windows\\System32\\cmd.exe' : '/bin/sh';
|
|
63
|
-
const r = await backgroundRunHandler({ command: abs, args: [] }, ctx());
|
|
64
|
-
expect(r.is_error).toBe(true);
|
|
65
|
-
expect(r.content).toMatch(/separators|absolute/i);
|
|
66
|
-
});
|
|
67
|
-
it('rejects argv > MAX_ARGS', async () => {
|
|
68
|
-
const tooMany = Array.from({ length: 1001 }, () => 'x');
|
|
69
|
-
const r = await backgroundRunHandler({ command: 'node', args: tooMany }, ctx());
|
|
70
|
-
expect(r.is_error).toBe(true);
|
|
71
|
-
expect(r.content).toMatch(/too many args/i);
|
|
72
|
-
});
|
|
73
|
-
it('refuses cwd outside the project root', async () => {
|
|
74
|
-
const outside = process.platform === 'win32' ? 'C:\\' : '/';
|
|
75
|
-
const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.exit(0)'], cwd: outside }, ctx());
|
|
76
|
-
expect(r.is_error).toBe(true);
|
|
77
|
-
expect(r.content).toMatch(/outside|cwd/i);
|
|
78
|
-
});
|
|
79
|
-
it('does NOT leak CSPEACH_*/ANTHROPIC_*/SAP_* env vars to the child', async () => {
|
|
80
|
-
process.env.CSPEACH_BG_TEST_LEAK = 'must-not-leak';
|
|
81
|
-
process.env.ANTHROPIC_BG_TEST_LEAK = 'must-not-leak';
|
|
82
|
-
process.env.FOO_BG_TEST_LEAK_CANARY = 'must-not-leak';
|
|
83
|
-
try {
|
|
84
|
-
const r = await backgroundRunHandler({
|
|
85
|
-
command: 'node',
|
|
86
|
-
args: [
|
|
87
|
-
'-e',
|
|
88
|
-
'const k = ["CSPEACH_BG_TEST_LEAK","ANTHROPIC_BG_TEST_LEAK","FOO_BG_TEST_LEAK_CANARY"].filter(x=>process.env[x]); process.stdout.write(k.join(","))',
|
|
89
|
-
],
|
|
90
|
-
}, ctx());
|
|
91
|
-
const bg_id = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/)[1];
|
|
92
|
-
await waitForExit(bg_id);
|
|
93
|
-
const consumed = consumeEvents(bg_id, 100);
|
|
94
|
-
const text = consumed.events.map((e) => e.text).join('');
|
|
95
|
-
expect(text).not.toContain('must-not-leak');
|
|
96
|
-
}
|
|
97
|
-
finally {
|
|
98
|
-
delete process.env.CSPEACH_BG_TEST_LEAK;
|
|
99
|
-
delete process.env.ANTHROPIC_BG_TEST_LEAK;
|
|
100
|
-
delete process.env.FOO_BG_TEST_LEAK_CANARY;
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
it('records exit event with exitCode + signal', async () => {
|
|
104
|
-
const r = await backgroundRunHandler({ command: 'node', args: ['-e', 'process.exit(7)'] }, ctx());
|
|
105
|
-
const bg_id = r.content.match(/bg_id:\s*(bg-[a-f0-9-]+)/)[1];
|
|
106
|
-
await waitForExit(bg_id);
|
|
107
|
-
const consumed = consumeEvents(bg_id, 100);
|
|
108
|
-
const exitEv = consumed.events.find((e) => e.kind === 'exit');
|
|
109
|
-
expect(exitEv).toBeDefined();
|
|
110
|
-
expect(exitEv.exitCode).toBe(7);
|
|
111
|
-
expect(consumed.exitCode).toBe(7);
|
|
112
|
-
});
|
|
113
|
-
});
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
-
import * as os from 'node:os';
|
|
3
|
-
import * as path from 'node:path';
|
|
4
|
-
import { promises as fs } from 'node:fs';
|
|
5
|
-
import { conventionGetHandler } from '../project/convention_get.js';
|
|
6
|
-
import { makeCtx } from './_helpers.js';
|
|
7
|
-
import { __resetProjectContextCacheForTests } from '../../agent/maybe-build-project-context.js';
|
|
8
|
-
let tmpRoot;
|
|
9
|
-
beforeEach(async () => {
|
|
10
|
-
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-convention-get-'));
|
|
11
|
-
__resetProjectContextCacheForTests();
|
|
12
|
-
});
|
|
13
|
-
afterEach(async () => {
|
|
14
|
-
try {
|
|
15
|
-
await fs.rm(tmpRoot, { recursive: true, force: true });
|
|
16
|
-
}
|
|
17
|
-
catch { /* ignore */ }
|
|
18
|
-
__resetProjectContextCacheForTests();
|
|
19
|
-
vi.restoreAllMocks();
|
|
20
|
-
});
|
|
21
|
-
describe('convention_get', () => {
|
|
22
|
-
it("returns the full conventions object as JSON when field='all'", async () => {
|
|
23
|
-
const r = await conventionGetHandler({ field: 'all' }, makeCtx(tmpRoot));
|
|
24
|
-
expect(r.is_error).toBeFalsy();
|
|
25
|
-
const parsed = JSON.parse(r.content);
|
|
26
|
-
expect(parsed.style).toBeDefined();
|
|
27
|
-
expect(parsed.namingPatterns).toBeDefined();
|
|
28
|
-
expect(typeof parsed.style.indentSize).toBe('number');
|
|
29
|
-
});
|
|
30
|
-
it("returns the indent size as a number when field='indentSize'", async () => {
|
|
31
|
-
const r = await conventionGetHandler({ field: 'indentSize' }, makeCtx(tmpRoot));
|
|
32
|
-
expect(r.is_error).toBeFalsy();
|
|
33
|
-
expect(r.content.trim()).toMatch(/^\d+$/);
|
|
34
|
-
});
|
|
35
|
-
it("returns the EOL style when field='eol'", async () => {
|
|
36
|
-
const r = await conventionGetHandler({ field: 'eol' }, makeCtx(tmpRoot));
|
|
37
|
-
expect(r.is_error).toBeFalsy();
|
|
38
|
-
expect(['lf', 'crlf']).toContain(r.content.trim());
|
|
39
|
-
});
|
|
40
|
-
it("returns the namingPatterns object as JSON when field='namingPatterns'", async () => {
|
|
41
|
-
const r = await conventionGetHandler({ field: 'namingPatterns' }, makeCtx(tmpRoot));
|
|
42
|
-
expect(r.is_error).toBeFalsy();
|
|
43
|
-
const parsed = JSON.parse(r.content);
|
|
44
|
-
expect(parsed).toHaveProperty('classes');
|
|
45
|
-
expect(parsed).toHaveProperty('interfaces');
|
|
46
|
-
expect(parsed).toHaveProperty('cdsViews');
|
|
47
|
-
});
|
|
48
|
-
it('rejects an unknown field with a structured error', async () => {
|
|
49
|
-
const r = await conventionGetHandler({ field: 'flavor' }, makeCtx(tmpRoot));
|
|
50
|
-
expect(r.is_error).toBe(true);
|
|
51
|
-
expect(r.content).toMatch(/unknown field|invalid field/i);
|
|
52
|
-
});
|
|
53
|
-
});
|
|
@@ -1,72 +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 { fileEditHandler } from '../filesystem/file-edit.js';
|
|
6
|
-
import { makeCtx } from './_helpers.js';
|
|
7
|
-
let root;
|
|
8
|
-
const ctx = () => makeCtx(root);
|
|
9
|
-
beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-file-edit-')); });
|
|
10
|
-
afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
|
|
11
|
-
describe('file_edit handler', () => {
|
|
12
|
-
it('happy path — replaces a unique string and returns count', async () => {
|
|
13
|
-
await fs.writeFile(path.join(root, 'hello.ts'), 'hello world\nsecond line\n', 'utf-8');
|
|
14
|
-
const r = await fileEditHandler({ path: 'hello.ts', old_string: 'hello world', new_string: 'goodbye world' }, ctx());
|
|
15
|
-
expect(r.is_error).toBeFalsy();
|
|
16
|
-
expect(r.content).toMatch(/edited.*hello\.ts.*1 replacement/i);
|
|
17
|
-
const updated = await fs.readFile(path.join(root, 'hello.ts'), 'utf-8');
|
|
18
|
-
expect(updated).toContain('goodbye world');
|
|
19
|
-
expect(updated).not.toContain('hello world');
|
|
20
|
-
expect(updated).toContain('second line');
|
|
21
|
-
});
|
|
22
|
-
it('errors when old_string is not found in the file', async () => {
|
|
23
|
-
await fs.writeFile(path.join(root, 'foo.ts'), 'line one\nline two\n', 'utf-8');
|
|
24
|
-
const r = await fileEditHandler({ path: 'foo.ts', old_string: 'does not exist', new_string: 'replacement' }, ctx());
|
|
25
|
-
expect(r.is_error).toBe(true);
|
|
26
|
-
expect(r.content).toMatch(/not found/i);
|
|
27
|
-
});
|
|
28
|
-
it('errors when old_string matches multiple times without replace_all', async () => {
|
|
29
|
-
await fs.writeFile(path.join(root, 'dup.ts'), 'foo bar\nfoo baz\n', 'utf-8');
|
|
30
|
-
const r = await fileEditHandler({ path: 'dup.ts', old_string: 'foo', new_string: 'qux' }, ctx());
|
|
31
|
-
expect(r.is_error).toBe(true);
|
|
32
|
-
expect(r.content).toMatch(/matches 2 times/i);
|
|
33
|
-
expect(r.content).toMatch(/replace_all/i);
|
|
34
|
-
// file must be unchanged
|
|
35
|
-
const unchanged = await fs.readFile(path.join(root, 'dup.ts'), 'utf-8');
|
|
36
|
-
expect(unchanged).toContain('foo bar');
|
|
37
|
-
});
|
|
38
|
-
it('replace_all=true replaces all occurrences', async () => {
|
|
39
|
-
await fs.writeFile(path.join(root, 'multi.ts'), 'foo bar\nfoo baz\nfoo qux\n', 'utf-8');
|
|
40
|
-
const r = await fileEditHandler({ path: 'multi.ts', old_string: 'foo', new_string: 'boo', replace_all: true }, ctx());
|
|
41
|
-
expect(r.is_error).toBeFalsy();
|
|
42
|
-
expect(r.content).toMatch(/3 replacements/i);
|
|
43
|
-
const updated = await fs.readFile(path.join(root, 'multi.ts'), 'utf-8');
|
|
44
|
-
expect(updated).toBe('boo bar\nboo baz\nboo qux\n');
|
|
45
|
-
});
|
|
46
|
-
it('refuses path traversal (../foo)', async () => {
|
|
47
|
-
const r = await fileEditHandler({ path: '../../etc/passwd', old_string: 'root', new_string: 'nobody' }, ctx());
|
|
48
|
-
expect(r.is_error).toBe(true);
|
|
49
|
-
expect(r.content).toMatch(/outside/i);
|
|
50
|
-
});
|
|
51
|
-
it('refuses denylisted path (.cspeach/auth.json)', async () => {
|
|
52
|
-
await fs.mkdir(path.join(root, '.cspeach'), { recursive: true });
|
|
53
|
-
await fs.writeFile(path.join(root, '.cspeach', 'auth.json'), '{"token":"secret"}', 'utf-8');
|
|
54
|
-
const r = await fileEditHandler({ path: '.cspeach/auth.json', old_string: 'secret', new_string: 'redacted' }, ctx());
|
|
55
|
-
expect(r.is_error).toBe(true);
|
|
56
|
-
expect(r.content).toMatch(/sensitive path/i);
|
|
57
|
-
// file must be unchanged
|
|
58
|
-
const unchanged = await fs.readFile(path.join(root, '.cspeach', 'auth.json'), 'utf-8');
|
|
59
|
-
expect(unchanged).toContain('secret');
|
|
60
|
-
});
|
|
61
|
-
it('errors when old_string === new_string (no-op edit refused)', async () => {
|
|
62
|
-
await fs.writeFile(path.join(root, 'noop.ts'), 'some content\n', 'utf-8');
|
|
63
|
-
const r = await fileEditHandler({ path: 'noop.ts', old_string: 'some content', new_string: 'some content' }, ctx());
|
|
64
|
-
expect(r.is_error).toBe(true);
|
|
65
|
-
expect(r.content).toMatch(/no-op|old_string.*new_string.*identical|same/i);
|
|
66
|
-
});
|
|
67
|
-
it('errors when the file does not exist', async () => {
|
|
68
|
-
const r = await fileEditHandler({ path: 'nonexistent.ts', old_string: 'foo', new_string: 'bar' }, ctx());
|
|
69
|
-
expect(r.is_error).toBe(true);
|
|
70
|
-
expect(r.content).toMatch(/not found|ENOENT/i);
|
|
71
|
-
});
|
|
72
|
-
});
|