@cspeach/cli 0.6.2 → 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/README.md +116 -108
- 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,152 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } 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 { runLoginCommand } from '../login.js';
|
|
6
|
+
import { __setAuthFilePathForTests, readAuthFile } from '../../auth/auth-file.js';
|
|
7
|
+
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
|
|
8
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
9
|
+
const { openMock } = vi.hoisted(() => ({ openMock: vi.fn() }));
|
|
10
|
+
let dir;
|
|
11
|
+
let authPath;
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
fetchMock.mockReset();
|
|
14
|
+
openMock.mockReset();
|
|
15
|
+
openMock.mockResolvedValue(undefined);
|
|
16
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-login-'));
|
|
17
|
+
authPath = path.join(dir, 'auth.json');
|
|
18
|
+
__setAuthFilePathForTests(authPath);
|
|
19
|
+
});
|
|
20
|
+
afterEach(async () => {
|
|
21
|
+
__setAuthFilePathForTests(null);
|
|
22
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
23
|
+
});
|
|
24
|
+
describe('cspeach login', () => {
|
|
25
|
+
it('happy path: start → open browser → poll → save auth file (non-BYOK)', async () => {
|
|
26
|
+
fetchMock.mockImplementation(async (url) => {
|
|
27
|
+
if (url.endsWith('/v1/auth/device/start')) {
|
|
28
|
+
return new Response(JSON.stringify({
|
|
29
|
+
device_code: 'dev-1', user_code: 'AAAA-BBBB',
|
|
30
|
+
verification_url: 'https://cspeach.dev/device',
|
|
31
|
+
verification_url_complete: 'https://cspeach.dev/device?code=AAAA-BBBB',
|
|
32
|
+
expires_in: 600, interval: 0,
|
|
33
|
+
}), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
34
|
+
}
|
|
35
|
+
if (url.endsWith('/v1/auth/device/poll')) {
|
|
36
|
+
return new Response(JSON.stringify({ api_key: 'csp_live_abc' + 'x'.repeat(29), customer_id: 'cust-1' }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
37
|
+
}
|
|
38
|
+
if (url.endsWith('/v1/me')) {
|
|
39
|
+
return new Response(JSON.stringify({ email: 'c@a.com', plan: 'trial-managed', trial_mode: 'managed-capped' }), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`unexpected URL ${url}`);
|
|
42
|
+
});
|
|
43
|
+
const lines = [];
|
|
44
|
+
const result = await runLoginCommand({
|
|
45
|
+
proxyUrl: 'https://api.cspeach.dev',
|
|
46
|
+
open: openMock,
|
|
47
|
+
print: (s) => lines.push(s),
|
|
48
|
+
prompt: async () => '',
|
|
49
|
+
pollIntervalMs: 0,
|
|
50
|
+
maxAttempts: 3,
|
|
51
|
+
});
|
|
52
|
+
expect(result).toBe(0);
|
|
53
|
+
expect(openMock).toHaveBeenCalledWith('https://cspeach.dev/device?code=AAAA-BBBB');
|
|
54
|
+
expect(lines.some((l) => /AAAA-BBBB/.test(l))).toBe(true);
|
|
55
|
+
const saved = await readAuthFile();
|
|
56
|
+
expect(saved?.apiKey).toBe('csp_live_abc' + 'x'.repeat(29));
|
|
57
|
+
expect(saved?.customerId).toBe('cust-1');
|
|
58
|
+
expect(saved?.byokAnthropicKey).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
it('BYOK customer: prompts for Anthropic key and saves it', async () => {
|
|
61
|
+
fetchMock.mockImplementation(async (url) => {
|
|
62
|
+
if (url.endsWith('/v1/auth/device/start')) {
|
|
63
|
+
return new Response(JSON.stringify({ device_code: 'dev-2', user_code: 'CCCC-DDDD', verification_url: 'x', verification_url_complete: 'x', expires_in: 600, interval: 0 }), { status: 200 });
|
|
64
|
+
}
|
|
65
|
+
if (url.endsWith('/v1/auth/device/poll')) {
|
|
66
|
+
return new Response(JSON.stringify({ api_key: 'csp_live_byok' + 'y'.repeat(28), customer_id: 'cust-byok' }), { status: 200 });
|
|
67
|
+
}
|
|
68
|
+
if (url.endsWith('/v1/me')) {
|
|
69
|
+
return new Response(JSON.stringify({ email: 'b@a.com', plan: 'trial-byok', trial_mode: 'byok' }), { status: 200 });
|
|
70
|
+
}
|
|
71
|
+
throw new Error('unexpected');
|
|
72
|
+
});
|
|
73
|
+
let promptCount = 0;
|
|
74
|
+
const result = await runLoginCommand({
|
|
75
|
+
proxyUrl: 'https://api.cspeach.dev', open: openMock, print: () => { },
|
|
76
|
+
prompt: async (q) => { promptCount++; return q.includes('Anthropic') ? ('sk-ant-byok-' + 'x'.repeat(30)) : ''; },
|
|
77
|
+
pollIntervalMs: 0, maxAttempts: 3,
|
|
78
|
+
});
|
|
79
|
+
expect(result).toBe(0);
|
|
80
|
+
expect(promptCount).toBeGreaterThan(0);
|
|
81
|
+
const saved = await readAuthFile();
|
|
82
|
+
expect(saved?.byokAnthropicKey).toBe('sk-ant-byok-' + 'x'.repeat(30));
|
|
83
|
+
});
|
|
84
|
+
it('poll returns authorization_pending the first time, success the second', async () => {
|
|
85
|
+
let pollCount = 0;
|
|
86
|
+
fetchMock.mockImplementation(async (url) => {
|
|
87
|
+
if (url.endsWith('/v1/auth/device/start')) {
|
|
88
|
+
return new Response(JSON.stringify({ device_code: 'dev-3', user_code: 'EEEE-FFFF', verification_url: 'x', verification_url_complete: 'x', expires_in: 600, interval: 0 }), { status: 200 });
|
|
89
|
+
}
|
|
90
|
+
if (url.endsWith('/v1/auth/device/poll')) {
|
|
91
|
+
pollCount++;
|
|
92
|
+
if (pollCount === 1) {
|
|
93
|
+
return new Response(JSON.stringify({ error: 'authorization_pending' }), { status: 400 });
|
|
94
|
+
}
|
|
95
|
+
return new Response(JSON.stringify({ api_key: 'csp_live_p' + 'p'.repeat(31), customer_id: 'cust-p' }), { status: 200 });
|
|
96
|
+
}
|
|
97
|
+
if (url.endsWith('/v1/me')) {
|
|
98
|
+
return new Response(JSON.stringify({ email: 'p@a.com', plan: 'solo', trial_mode: null }), { status: 200 });
|
|
99
|
+
}
|
|
100
|
+
throw new Error('unexpected');
|
|
101
|
+
});
|
|
102
|
+
const result = await runLoginCommand({
|
|
103
|
+
proxyUrl: 'https://api.cspeach.dev', open: openMock, print: () => { },
|
|
104
|
+
prompt: async () => '', pollIntervalMs: 0, maxAttempts: 5,
|
|
105
|
+
});
|
|
106
|
+
expect(result).toBe(0);
|
|
107
|
+
expect(pollCount).toBe(2);
|
|
108
|
+
});
|
|
109
|
+
it('returns non-zero when poll keeps returning pending past maxAttempts', async () => {
|
|
110
|
+
fetchMock.mockImplementation(async (url) => {
|
|
111
|
+
if (url.endsWith('/v1/auth/device/start')) {
|
|
112
|
+
return new Response(JSON.stringify({ device_code: 'd', user_code: 'X', verification_url: 'x', verification_url_complete: 'x', expires_in: 600, interval: 0 }), { status: 200 });
|
|
113
|
+
}
|
|
114
|
+
return new Response(JSON.stringify({ error: 'authorization_pending' }), { status: 400 });
|
|
115
|
+
});
|
|
116
|
+
const result = await runLoginCommand({
|
|
117
|
+
proxyUrl: 'https://api.cspeach.dev', open: openMock, print: () => { },
|
|
118
|
+
prompt: async () => '', pollIntervalMs: 0, maxAttempts: 2,
|
|
119
|
+
});
|
|
120
|
+
expect(result).not.toBe(0);
|
|
121
|
+
});
|
|
122
|
+
it('returns non-zero when start endpoint fails', async () => {
|
|
123
|
+
fetchMock.mockImplementation(async () => new Response('{"error":"server"}', { status: 500 }));
|
|
124
|
+
const result = await runLoginCommand({
|
|
125
|
+
proxyUrl: 'https://api.cspeach.dev', open: openMock, print: () => { },
|
|
126
|
+
prompt: async () => '', pollIntervalMs: 0, maxAttempts: 2,
|
|
127
|
+
});
|
|
128
|
+
expect(result).not.toBe(0);
|
|
129
|
+
});
|
|
130
|
+
it('does not crash when open() throws (fallback to manual URL print)', async () => {
|
|
131
|
+
openMock.mockRejectedValue(new Error('xdg-open not installed'));
|
|
132
|
+
fetchMock.mockImplementation(async (url) => {
|
|
133
|
+
if (url.endsWith('/v1/auth/device/start')) {
|
|
134
|
+
return new Response(JSON.stringify({ device_code: 'd', user_code: 'M', verification_url: 'https://cspeach.dev/device', verification_url_complete: 'https://cspeach.dev/device?code=M', expires_in: 600, interval: 0 }), { status: 200 });
|
|
135
|
+
}
|
|
136
|
+
if (url.endsWith('/v1/auth/device/poll')) {
|
|
137
|
+
return new Response(JSON.stringify({ api_key: 'csp_live_' + 'z'.repeat(32), customer_id: 'c' }), { status: 200 });
|
|
138
|
+
}
|
|
139
|
+
if (url.endsWith('/v1/me')) {
|
|
140
|
+
return new Response(JSON.stringify({ email: 'z@a.com', plan: 'solo' }), { status: 200 });
|
|
141
|
+
}
|
|
142
|
+
throw new Error('unexpected');
|
|
143
|
+
});
|
|
144
|
+
const lines = [];
|
|
145
|
+
const result = await runLoginCommand({
|
|
146
|
+
proxyUrl: 'https://api.cspeach.dev', open: openMock, print: (s) => lines.push(s),
|
|
147
|
+
prompt: async () => '', pollIntervalMs: 0, maxAttempts: 3,
|
|
148
|
+
});
|
|
149
|
+
expect(result).toBe(0);
|
|
150
|
+
expect(lines.some((l) => l.includes('https://cspeach.dev/device'))).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
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 { runLogoutCommand } from '../logout.js';
|
|
6
|
+
import { __setAuthFilePathForTests, writeAuthFile } from '../../auth/auth-file.js';
|
|
7
|
+
let dir;
|
|
8
|
+
let authPath;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-logout-'));
|
|
11
|
+
authPath = path.join(dir, 'auth.json');
|
|
12
|
+
__setAuthFilePathForTests(authPath);
|
|
13
|
+
});
|
|
14
|
+
afterEach(async () => {
|
|
15
|
+
__setAuthFilePathForTests(null);
|
|
16
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
describe('cspeach logout', () => {
|
|
19
|
+
it('deletes the auth file when present', async () => {
|
|
20
|
+
await writeAuthFile({ apiKey: 'csp_live_x', customerId: 'c-1', byokAnthropicKey: null });
|
|
21
|
+
const lines = [];
|
|
22
|
+
const result = await runLogoutCommand({ print: (s) => lines.push(s) });
|
|
23
|
+
expect(result).toBe(0);
|
|
24
|
+
await expect(fs.access(authPath)).rejects.toBeTruthy();
|
|
25
|
+
expect(lines.some((l) => /Signed out/i.test(l))).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
it('prints "Already signed out" when file absent', async () => {
|
|
28
|
+
const lines = [];
|
|
29
|
+
const result = await runLogoutCommand({ print: (s) => lines.push(s) });
|
|
30
|
+
expect(result).toBe(0);
|
|
31
|
+
expect(lines.some((l) => /Already signed out/i.test(l))).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
it('returns 0', async () => {
|
|
34
|
+
// Both branches above already assert exit code 0; this test pins
|
|
35
|
+
// the contract that logout never returns non-zero.
|
|
36
|
+
const result = await runLogoutCommand({ print: () => { } });
|
|
37
|
+
expect(result).toBe(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,173 @@
|
|
|
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 { analyzeProjectContextImpact, renderImpactReport, } from '../project-context-impact.js';
|
|
6
|
+
let dir;
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-impact-'));
|
|
9
|
+
});
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
12
|
+
});
|
|
13
|
+
async function writeSession(t) {
|
|
14
|
+
const turns = t.turns ?? 1;
|
|
15
|
+
const messages = [];
|
|
16
|
+
for (let i = 0; i < turns; i++) {
|
|
17
|
+
const isFirst = i === 0;
|
|
18
|
+
const content = isFirst && t.flagOn
|
|
19
|
+
? '<project_context type="abapgit">\n <root>/x</root>\n</project_context>\n\nthe ask'
|
|
20
|
+
: 'follow-up';
|
|
21
|
+
messages.push({ role: 'user', content });
|
|
22
|
+
messages.push({ role: 'assistant', content: 'reply' });
|
|
23
|
+
}
|
|
24
|
+
const toolCalls = [];
|
|
25
|
+
for (let i = 0; i < t.toolCallCount; i++) {
|
|
26
|
+
toolCalls.push({
|
|
27
|
+
tool_use_id: `tu${i}`,
|
|
28
|
+
tool: 'sap_get_source',
|
|
29
|
+
args: {},
|
|
30
|
+
result: 'ok',
|
|
31
|
+
is_error: t.hadErrorTool === true && i === 0,
|
|
32
|
+
duration_ms: 100,
|
|
33
|
+
completed_at: '2026-05-10T10:00:00Z',
|
|
34
|
+
completed: true,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const session = {
|
|
38
|
+
schema_version: 1,
|
|
39
|
+
id: t.id,
|
|
40
|
+
started_at: t.startedAt ?? '2026-05-10T10:00:00Z',
|
|
41
|
+
last_turn_at: t.startedAt ?? '2026-05-10T10:00:00Z',
|
|
42
|
+
sap_system: null,
|
|
43
|
+
skill: t.skill ?? 'abap-cca',
|
|
44
|
+
model: 'claude-opus-4-7',
|
|
45
|
+
messages,
|
|
46
|
+
toolCalls,
|
|
47
|
+
usage: { input_tokens: t.inputTokens, output_tokens: t.outputTokens, cache_read_input_tokens: 0 },
|
|
48
|
+
last_skill: t.skill ?? 'abap-cca',
|
|
49
|
+
last_object: null,
|
|
50
|
+
lastUserPrompt: null,
|
|
51
|
+
awaitingSkillAnswer: false,
|
|
52
|
+
};
|
|
53
|
+
await fs.writeFile(path.join(dir, `${t.id}.json`), JSON.stringify(session), 'utf-8');
|
|
54
|
+
}
|
|
55
|
+
describe('analyzeProjectContextImpact — empty state', () => {
|
|
56
|
+
it('returns zero counts when sessionsDir does not exist', async () => {
|
|
57
|
+
const report = await analyzeProjectContextImpact({ dir: path.join(dir, 'nope') });
|
|
58
|
+
expect(report.totalSessions).toBe(0);
|
|
59
|
+
expect(report.flagOn.count).toBe(0);
|
|
60
|
+
expect(report.flagOff.count).toBe(0);
|
|
61
|
+
});
|
|
62
|
+
it('returns zero counts when dir is empty', async () => {
|
|
63
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
64
|
+
expect(report.totalSessions).toBe(0);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('analyzeProjectContextImpact — classification', () => {
|
|
68
|
+
it('classifies a session by whether the first user message contains <project_context', async () => {
|
|
69
|
+
await writeSession({ id: 'a1', flagOn: false, inputTokens: 1000, outputTokens: 200, toolCallCount: 2 });
|
|
70
|
+
await writeSession({ id: 'a2', flagOn: true, inputTokens: 1500, outputTokens: 250, toolCallCount: 1 });
|
|
71
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
72
|
+
expect(report.totalSessions).toBe(2);
|
|
73
|
+
expect(report.flagOn.count).toBe(1);
|
|
74
|
+
expect(report.flagOff.count).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
it('only counts the FIRST user message — a later block doesn\'t flip the flag', async () => {
|
|
77
|
+
// Edge case: the model echoes <project_context> in turn 3. The
|
|
78
|
+
// session is still "flag off" because turn 1 had a plain message.
|
|
79
|
+
const session = {
|
|
80
|
+
schema_version: 1,
|
|
81
|
+
id: 'echo',
|
|
82
|
+
started_at: '2026-05-10T10:00:00Z',
|
|
83
|
+
last_turn_at: '2026-05-10T10:00:00Z',
|
|
84
|
+
sap_system: null,
|
|
85
|
+
skill: null,
|
|
86
|
+
model: 'm',
|
|
87
|
+
messages: [
|
|
88
|
+
{ role: 'user', content: 'plain ask, flag was off' },
|
|
89
|
+
{ role: 'assistant', content: 'reply mentioning <project_context type=...>' },
|
|
90
|
+
{ role: 'user', content: 'follow-up' },
|
|
91
|
+
],
|
|
92
|
+
toolCalls: [],
|
|
93
|
+
usage: { input_tokens: 100, output_tokens: 50, cache_read_input_tokens: 0 },
|
|
94
|
+
last_skill: null,
|
|
95
|
+
last_object: null,
|
|
96
|
+
lastUserPrompt: null,
|
|
97
|
+
awaitingSkillAnswer: false,
|
|
98
|
+
};
|
|
99
|
+
await fs.writeFile(path.join(dir, 'echo.json'), JSON.stringify(session), 'utf-8');
|
|
100
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
101
|
+
expect(report.flagOff.count).toBe(1);
|
|
102
|
+
expect(report.flagOn.count).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
describe('analyzeProjectContextImpact — averages + delta', () => {
|
|
106
|
+
it('computes per-session averages correctly', async () => {
|
|
107
|
+
// 2 flag-off sessions: 1000+2000 input, 200+400 output, 2+4 tools → averages 1500/300/3
|
|
108
|
+
await writeSession({ id: 'off1', flagOn: false, inputTokens: 1000, outputTokens: 200, toolCallCount: 2 });
|
|
109
|
+
await writeSession({ id: 'off2', flagOn: false, inputTokens: 2000, outputTokens: 400, toolCallCount: 4 });
|
|
110
|
+
// 2 flag-on sessions: 1200+1800 input, 250+350 output, 1+3 tools → averages 1500/300/2
|
|
111
|
+
await writeSession({ id: 'on1', flagOn: true, inputTokens: 1200, outputTokens: 250, toolCallCount: 1 });
|
|
112
|
+
await writeSession({ id: 'on2', flagOn: true, inputTokens: 1800, outputTokens: 350, toolCallCount: 3 });
|
|
113
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
114
|
+
expect(report.flagOff.avgInputTokens).toBe(1500);
|
|
115
|
+
expect(report.flagOff.avgOutputTokens).toBe(300);
|
|
116
|
+
expect(report.flagOff.avgToolCalls).toBe(3);
|
|
117
|
+
expect(report.flagOn.avgInputTokens).toBe(1500);
|
|
118
|
+
expect(report.flagOn.avgOutputTokens).toBe(300);
|
|
119
|
+
expect(report.flagOn.avgToolCalls).toBe(2);
|
|
120
|
+
});
|
|
121
|
+
it('computes error-tool rate as fraction of sessions with at least one error', async () => {
|
|
122
|
+
await writeSession({ id: 'clean1', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 2, hadErrorTool: false });
|
|
123
|
+
await writeSession({ id: 'err1', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 2, hadErrorTool: true });
|
|
124
|
+
await writeSession({ id: 'err2', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 2, hadErrorTool: true });
|
|
125
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
126
|
+
// 2 of 3 sessions had at least one error → 0.667 (rounded to 3 places).
|
|
127
|
+
expect(report.flagOff.errorToolRate).toBeCloseTo(0.667, 2);
|
|
128
|
+
});
|
|
129
|
+
it('returns top 5 skills sorted by frequency', async () => {
|
|
130
|
+
for (let i = 0; i < 3; i++)
|
|
131
|
+
await writeSession({ id: `cca${i}`, flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, skill: 'abap-cca' });
|
|
132
|
+
for (let i = 0; i < 2; i++)
|
|
133
|
+
await writeSession({ id: `rev${i}`, flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, skill: 'abap-review' });
|
|
134
|
+
await writeSession({ id: 'mod', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, skill: 'abap-modernize' });
|
|
135
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
136
|
+
expect(report.flagOff.topSkills[0]).toEqual({ skill: 'abap-cca', n: 3 });
|
|
137
|
+
expect(report.flagOff.topSkills[1]).toEqual({ skill: 'abap-review', n: 2 });
|
|
138
|
+
expect(report.flagOff.topSkills[2]).toEqual({ skill: 'abap-modernize', n: 1 });
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe('analyzeProjectContextImpact — fault tolerance + filtering', () => {
|
|
142
|
+
it('skips malformed JSON files and reports them in skippedSessions', async () => {
|
|
143
|
+
await writeSession({ id: 'good', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1 });
|
|
144
|
+
await fs.writeFile(path.join(dir, 'broken.json'), '{ not json', 'utf-8');
|
|
145
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
146
|
+
expect(report.totalSessions).toBe(1);
|
|
147
|
+
expect(report.skippedSessions).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
it('--since cutoff excludes sessions older than the date', async () => {
|
|
150
|
+
await writeSession({ id: 'old', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, startedAt: '2024-01-01T00:00:00Z' });
|
|
151
|
+
await writeSession({ id: 'new', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1, startedAt: '2026-04-01T00:00:00Z' });
|
|
152
|
+
const report = await analyzeProjectContextImpact({ dir, since: '2026-01-01' });
|
|
153
|
+
expect(report.totalSessions).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
describe('renderImpactReport', () => {
|
|
157
|
+
it('renders an empty-bucket message when one side has no sessions', async () => {
|
|
158
|
+
await writeSession({ id: 'only', flagOn: false, inputTokens: 100, outputTokens: 50, toolCallCount: 1 });
|
|
159
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
160
|
+
const text = renderImpactReport(report);
|
|
161
|
+
expect(text).toContain('Need at least one session in each bucket');
|
|
162
|
+
});
|
|
163
|
+
it('renders a comparison table when both buckets have sessions', async () => {
|
|
164
|
+
await writeSession({ id: 'off', flagOn: false, inputTokens: 1000, outputTokens: 200, toolCallCount: 3 });
|
|
165
|
+
await writeSession({ id: 'on', flagOn: true, inputTokens: 1500, outputTokens: 250, toolCallCount: 2 });
|
|
166
|
+
const report = await analyzeProjectContextImpact({ dir });
|
|
167
|
+
const text = renderImpactReport(report);
|
|
168
|
+
expect(text).toContain('Per-session averages');
|
|
169
|
+
expect(text).toContain('input tokens');
|
|
170
|
+
expect(text).toContain('tool calls');
|
|
171
|
+
expect(text).toContain('Top skills');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { runSpecGapStatus } from '../spec-gap-status.js';
|
|
6
|
+
import { buildEnvelope } from '../../projects/build.js';
|
|
7
|
+
describe('runSpecGapStatus', () => {
|
|
8
|
+
it('reads files and prints summary', async () => {
|
|
9
|
+
const tmp = mkdtempSync(join(tmpdir(), 'cspeach-cmd-'));
|
|
10
|
+
try {
|
|
11
|
+
const env = buildEnvelope({
|
|
12
|
+
artefactType: 'spec-gap',
|
|
13
|
+
extract: { title: 'Test', content: { inputType: 'User Story', inferredDomain: 'X', sections: [] }, items: [] },
|
|
14
|
+
author: { name: 'L', role: 'consultant' },
|
|
15
|
+
source: { skill: 'abap-spec-gap', skillVersion: '1.0', input: '', tokensUsed: 0, model: 'm' },
|
|
16
|
+
});
|
|
17
|
+
const path = join(tmp, 'a.cspeach.json');
|
|
18
|
+
writeFileSync(path, JSON.stringify(env));
|
|
19
|
+
const log = vi.fn();
|
|
20
|
+
await runSpecGapStatus({ files: [path], log });
|
|
21
|
+
expect(log.mock.calls.flat().join('\n')).toContain('Test — spec-gap');
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('errors gracefully on missing file', async () => {
|
|
28
|
+
const log = vi.fn();
|
|
29
|
+
await expect(runSpecGapStatus({ files: ['/does/not/exist.cspeach.json'], log })).rejects.toThrow();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } 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 { runWhoamiCommand } from '../whoami.js';
|
|
6
|
+
import { __setAuthFilePathForTests, writeAuthFile } from '../../auth/auth-file.js';
|
|
7
|
+
const { fetchMock } = vi.hoisted(() => ({ fetchMock: vi.fn() }));
|
|
8
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
9
|
+
let dir;
|
|
10
|
+
let authPath;
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
fetchMock.mockReset();
|
|
13
|
+
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-whoami-'));
|
|
14
|
+
authPath = path.join(dir, 'auth.json');
|
|
15
|
+
__setAuthFilePathForTests(authPath);
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
__setAuthFilePathForTests(null);
|
|
19
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
describe('cspeach whoami', () => {
|
|
22
|
+
it('returns 0 + prints "Not signed in" message when no auth file', async () => {
|
|
23
|
+
const lines = [];
|
|
24
|
+
const result = await runWhoamiCommand({
|
|
25
|
+
proxyUrl: 'https://api.cspeach.dev',
|
|
26
|
+
print: (s) => lines.push(s),
|
|
27
|
+
});
|
|
28
|
+
expect(result).toBe(0);
|
|
29
|
+
expect(lines.some((l) => /Not signed in/i.test(l))).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
it('returns 0 + prints email/plan/trial when /v1/me succeeds', async () => {
|
|
32
|
+
await writeAuthFile({ apiKey: 'csp_live_x', customerId: 'c-1', byokAnthropicKey: null });
|
|
33
|
+
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
34
|
+
email: 'alice@acme.com', company: 'Acme', plan: 'trial-managed',
|
|
35
|
+
trial_mode: 'managed-capped', trial_ends_at: '2099-01-01T00:00:00Z',
|
|
36
|
+
trial_token_cap_input: 200_000, trial_token_cap_output: 50_000,
|
|
37
|
+
usage_input_tokens: 47_000, usage_output_tokens: 8_000,
|
|
38
|
+
entitlements: { enabled_skills: [], agent_mode: 'skill', enabled_features: [] },
|
|
39
|
+
}), { status: 200 }));
|
|
40
|
+
const lines = [];
|
|
41
|
+
const result = await runWhoamiCommand({
|
|
42
|
+
proxyUrl: 'https://api.cspeach.dev',
|
|
43
|
+
print: (s) => lines.push(s),
|
|
44
|
+
});
|
|
45
|
+
expect(result).toBe(0);
|
|
46
|
+
expect(lines.some((l) => /alice@acme\.com/.test(l))).toBe(true);
|
|
47
|
+
expect(lines.some((l) => /trial-managed/.test(l))).toBe(true);
|
|
48
|
+
expect(lines.some((l) => /managed-capped/.test(l))).toBe(true);
|
|
49
|
+
expect(lines.some((l) => /47000.*200000/.test(l))).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('returns 1 + prints "/v1/me returned 401" when auth file present but stale', async () => {
|
|
52
|
+
await writeAuthFile({ apiKey: 'csp_live_stale', customerId: 'c-1', byokAnthropicKey: null });
|
|
53
|
+
fetchMock.mockResolvedValueOnce(new Response('{"error":"invalid_api_key"}', { status: 401 }));
|
|
54
|
+
const lines = [];
|
|
55
|
+
const result = await runWhoamiCommand({
|
|
56
|
+
proxyUrl: 'https://api.cspeach.dev',
|
|
57
|
+
print: (s) => lines.push(s),
|
|
58
|
+
});
|
|
59
|
+
expect(result).toBe(1);
|
|
60
|
+
expect(lines.some((l) => /401/.test(l))).toBe(true);
|
|
61
|
+
expect(lines.some((l) => /cspeach login/.test(l))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
it('includes skills line only when enabled_skills is non-empty', async () => {
|
|
64
|
+
await writeAuthFile({ apiKey: 'csp_live_x', customerId: 'c-1', byokAnthropicKey: null });
|
|
65
|
+
// First call: empty skills → no Skills: line.
|
|
66
|
+
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
67
|
+
email: 'a@b.com', company: null, plan: 'solo',
|
|
68
|
+
trial_mode: null, trial_ends_at: null,
|
|
69
|
+
trial_token_cap_input: null, trial_token_cap_output: null,
|
|
70
|
+
usage_input_tokens: 0, usage_output_tokens: 0,
|
|
71
|
+
entitlements: { enabled_skills: [], agent_mode: 'skill', enabled_features: [] },
|
|
72
|
+
}), { status: 200 }));
|
|
73
|
+
const linesEmpty = [];
|
|
74
|
+
await runWhoamiCommand({ proxyUrl: 'https://api.cspeach.dev', print: (s) => linesEmpty.push(s) });
|
|
75
|
+
expect(linesEmpty.some((l) => /^Skills:/.test(l))).toBe(false);
|
|
76
|
+
// Second call: non-empty skills → Skills: line present.
|
|
77
|
+
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
|
78
|
+
email: 'a@b.com', company: null, plan: 'solo',
|
|
79
|
+
trial_mode: null, trial_ends_at: null,
|
|
80
|
+
trial_token_cap_input: null, trial_token_cap_output: null,
|
|
81
|
+
usage_input_tokens: 0, usage_output_tokens: 0,
|
|
82
|
+
entitlements: { enabled_skills: ['abap-cca', 'abap-radar'], agent_mode: 'skill', enabled_features: [] },
|
|
83
|
+
}), { status: 200 }));
|
|
84
|
+
const linesFull = [];
|
|
85
|
+
await runWhoamiCommand({ proxyUrl: 'https://api.cspeach.dev', print: (s) => linesFull.push(s) });
|
|
86
|
+
const skillsLine = linesFull.find((l) => /^Skills:/.test(l));
|
|
87
|
+
expect(skillsLine).toBeTruthy();
|
|
88
|
+
expect(skillsLine).toMatch(/abap-cca/);
|
|
89
|
+
expect(skillsLine).toMatch(/abap-radar/);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
const pathsHolder = { tmpHome: '' };
|
|
6
|
+
vi.mock('../paths.js', () => ({
|
|
7
|
+
cspeachRoot: () => pathsHolder.tmpHome,
|
|
8
|
+
configFile: () => path.join(pathsHolder.tmpHome, 'config.toml'),
|
|
9
|
+
}));
|
|
10
|
+
const { loadConfig } = await import('../loader.js');
|
|
11
|
+
describe('CspeachConfig.llm', () => {
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
pathsHolder.tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-test-'));
|
|
14
|
+
});
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
await fs.rm(pathsHolder.tmpHome, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
it('defaults to mode=managed', async () => {
|
|
19
|
+
const cfg = await loadConfig();
|
|
20
|
+
expect(cfg.llm.mode).toBe('managed');
|
|
21
|
+
});
|
|
22
|
+
it('accepts mode values: managed, byok, ai-hub, local', () => {
|
|
23
|
+
const valid = [
|
|
24
|
+
'managed', 'byok', 'ai-hub', 'local',
|
|
25
|
+
];
|
|
26
|
+
expect(valid.length).toBe(4);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } 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
|
+
let tempHome;
|
|
6
|
+
let origHome;
|
|
7
|
+
let origUserProfile;
|
|
8
|
+
beforeEach(async () => {
|
|
9
|
+
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-shell-cfg-'));
|
|
10
|
+
origHome = process.env.HOME;
|
|
11
|
+
origUserProfile = process.env.USERPROFILE;
|
|
12
|
+
process.env.HOME = tempHome;
|
|
13
|
+
if (process.platform === 'win32')
|
|
14
|
+
process.env.USERPROFILE = tempHome;
|
|
15
|
+
vi.resetModules();
|
|
16
|
+
});
|
|
17
|
+
afterEach(async () => {
|
|
18
|
+
if (origHome === undefined)
|
|
19
|
+
delete process.env.HOME;
|
|
20
|
+
else
|
|
21
|
+
process.env.HOME = origHome;
|
|
22
|
+
if (process.platform === 'win32') {
|
|
23
|
+
if (origUserProfile === undefined)
|
|
24
|
+
delete process.env.USERPROFILE;
|
|
25
|
+
else
|
|
26
|
+
process.env.USERPROFILE = origUserProfile;
|
|
27
|
+
}
|
|
28
|
+
await fs.rm(tempHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
|
29
|
+
});
|
|
30
|
+
describe('CspeachConfig — shell_exec block', () => {
|
|
31
|
+
it('loads an empty shell_exec block as undefined when config file is missing', async () => {
|
|
32
|
+
const { loadConfig } = await import('../loader.js');
|
|
33
|
+
const cfg = await loadConfig();
|
|
34
|
+
expect(cfg.shell_exec).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
it('parses shell_exec.allow as a string array', async () => {
|
|
37
|
+
const cspeachDir = path.join(tempHome, '.cspeach');
|
|
38
|
+
await fs.mkdir(cspeachDir, { recursive: true });
|
|
39
|
+
await fs.writeFile(path.join(cspeachDir, 'config.toml'), `[shell_exec]\nallow = ["docker", "make"]\n`, 'utf-8');
|
|
40
|
+
const { loadConfig } = await import('../loader.js');
|
|
41
|
+
const cfg = await loadConfig();
|
|
42
|
+
expect(cfg.shell_exec?.allow).toEqual(['docker', 'make']);
|
|
43
|
+
});
|
|
44
|
+
it('treats shell_exec block as optional — empty TOML leaves it undefined', async () => {
|
|
45
|
+
const cspeachDir = path.join(tempHome, '.cspeach');
|
|
46
|
+
await fs.mkdir(cspeachDir, { recursive: true });
|
|
47
|
+
await fs.writeFile(path.join(cspeachDir, 'config.toml'), `default_model = "claude-opus-4-7"\n`, 'utf-8');
|
|
48
|
+
const { loadConfig } = await import('../loader.js');
|
|
49
|
+
const cfg = await loadConfig();
|
|
50
|
+
expect(cfg.shell_exec).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
it('coerces non-array allow values to empty array (defensive)', async () => {
|
|
53
|
+
const cspeachDir = path.join(tempHome, '.cspeach');
|
|
54
|
+
await fs.mkdir(cspeachDir, { recursive: true });
|
|
55
|
+
await fs.writeFile(path.join(cspeachDir, 'config.toml'), `[shell_exec]\nallow = "docker"\n`, 'utf-8');
|
|
56
|
+
const { loadConfig } = await import('../loader.js');
|
|
57
|
+
const cfg = await loadConfig();
|
|
58
|
+
expect(cfg.shell_exec?.allow).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
it('rejects when allow contains a mixed-type TOML array (@iarna/toml throws at parse time)', async () => {
|
|
61
|
+
// @iarna/toml enforces strict single-type inline arrays — ["docker", 42, "make"] is
|
|
62
|
+
// a TomlError at parse time, before our sanitiser ever runs. loadConfig re-throws
|
|
63
|
+
// (only ENOENT is silently absorbed). The runtime filter in loadConfig (Array.isArray
|
|
64
|
+
// + typeof === 'string' guard) is dead-code as far as TOML inputs go — it only fires
|
|
65
|
+
// if a non-TOML caller passes a hand-built Partial<CspeachConfig>. We assert the
|
|
66
|
+
// throw here so a corrupt config.toml surfaces as a startup error rather than
|
|
67
|
+
// silently producing garbage.
|
|
68
|
+
const cspeachDir = path.join(tempHome, '.cspeach');
|
|
69
|
+
await fs.mkdir(cspeachDir, { recursive: true });
|
|
70
|
+
await fs.writeFile(path.join(cspeachDir, 'config.toml'), `[shell_exec]\nallow = ["docker", 42, "make"]\n`, 'utf-8');
|
|
71
|
+
const { loadConfig } = await import('../loader.js');
|
|
72
|
+
// @iarna/toml throws on mixed-type inline arrays; loadConfig should re-throw
|
|
73
|
+
// (non-ENOENT errors bubble up — user has a corrupt config.toml).
|
|
74
|
+
await expect(loadConfig()).rejects.toThrow();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
// paths.ts offers no env-var override, so mock it: each test sets `tmpHome`
|
|
6
|
+
// and the mocked configFile() resolves to tmpHome/config.toml. Using a
|
|
7
|
+
// mutable holder keeps the mock stable while letting each test point at a
|
|
8
|
+
// fresh temp directory.
|
|
9
|
+
const pathsHolder = { tmpHome: '' };
|
|
10
|
+
vi.mock('../paths.js', () => ({
|
|
11
|
+
cspeachRoot: () => pathsHolder.tmpHome,
|
|
12
|
+
configFile: () => path.join(pathsHolder.tmpHome, 'config.toml'),
|
|
13
|
+
}));
|
|
14
|
+
const { loadConfig } = await import('../loader.js');
|
|
15
|
+
describe('write_mode in CspeachConfig', () => {
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
pathsHolder.tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-test-'));
|
|
18
|
+
});
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await fs.rm(pathsHolder.tmpHome, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
it('defaults to approval-gated when no config file exists', async () => {
|
|
23
|
+
const cfg = await loadConfig();
|
|
24
|
+
expect(cfg.write_mode).toBe('approval-gated');
|
|
25
|
+
});
|
|
26
|
+
it('accepts a valid value from disk', async () => {
|
|
27
|
+
await fs.writeFile(path.join(pathsHolder.tmpHome, 'config.toml'), 'write_mode = "advisory-only"\n');
|
|
28
|
+
const cfg = await loadConfig();
|
|
29
|
+
expect(cfg.write_mode).toBe('advisory-only');
|
|
30
|
+
});
|
|
31
|
+
it('falls back to default when disk value is not one of the three literals', async () => {
|
|
32
|
+
await fs.writeFile(path.join(pathsHolder.tmpHome, 'config.toml'), 'write_mode = "yolo"\n');
|
|
33
|
+
const cfg = await loadConfig();
|
|
34
|
+
expect(cfg.write_mode).toBe('approval-gated');
|
|
35
|
+
});
|
|
36
|
+
});
|