@cspeach/cli 0.6.4 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +0 -0
- 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,53 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { formatTimeAgo } from '../time-ago.js';
|
|
3
|
-
describe('formatTimeAgo', () => {
|
|
4
|
-
const NOW = new Date('2026-05-09T20:00:00Z');
|
|
5
|
-
it('seconds', () => {
|
|
6
|
-
expect(formatTimeAgo('2026-05-09T19:59:30Z', NOW)).toBe('30s ago');
|
|
7
|
-
});
|
|
8
|
-
it('minutes', () => {
|
|
9
|
-
expect(formatTimeAgo('2026-05-09T19:55:00Z', NOW)).toBe('5m ago');
|
|
10
|
-
});
|
|
11
|
-
it('hours', () => {
|
|
12
|
-
expect(formatTimeAgo('2026-05-09T17:00:00Z', NOW)).toBe('3h ago');
|
|
13
|
-
});
|
|
14
|
-
it('yesterday at exactly 24h', () => {
|
|
15
|
-
expect(formatTimeAgo('2026-05-08T20:00:00Z', NOW)).toBe('yesterday');
|
|
16
|
-
});
|
|
17
|
-
it('yesterday at 47h', () => {
|
|
18
|
-
expect(formatTimeAgo('2026-05-07T21:00:00Z', NOW)).toBe('yesterday');
|
|
19
|
-
});
|
|
20
|
-
it('multiple days', () => {
|
|
21
|
-
expect(formatTimeAgo('2026-05-06T20:00:00Z', NOW)).toBe('3d ago');
|
|
22
|
-
});
|
|
23
|
-
it('weeks', () => {
|
|
24
|
-
expect(formatTimeAgo('2026-04-25T20:00:00Z', NOW)).toBe('2w ago');
|
|
25
|
-
});
|
|
26
|
-
it('months', () => {
|
|
27
|
-
// Feb 9 → May 9 ≈ 89 days, floor(89/30) = 2
|
|
28
|
-
expect(formatTimeAgo('2026-02-09T20:00:00Z', NOW)).toBe('2mo ago');
|
|
29
|
-
});
|
|
30
|
-
it('years', () => {
|
|
31
|
-
expect(formatTimeAgo('2024-05-09T20:00:00Z', NOW)).toBe('2y ago');
|
|
32
|
-
});
|
|
33
|
-
it('handles invalid date strings', () => {
|
|
34
|
-
expect(formatTimeAgo('not-a-date', NOW)).toBe('?');
|
|
35
|
-
});
|
|
36
|
-
it('clamps future timestamps to 0s', () => {
|
|
37
|
-
expect(formatTimeAgo('2030-01-01T00:00:00Z', NOW)).toBe('0s ago');
|
|
38
|
-
});
|
|
39
|
-
it('produces strings under 12 chars', () => {
|
|
40
|
-
const samples = [
|
|
41
|
-
formatTimeAgo('2026-05-09T19:59:30Z', NOW),
|
|
42
|
-
formatTimeAgo('2026-05-09T17:00:00Z', NOW),
|
|
43
|
-
formatTimeAgo('2026-05-08T20:00:00Z', NOW),
|
|
44
|
-
formatTimeAgo('2026-05-06T20:00:00Z', NOW),
|
|
45
|
-
formatTimeAgo('2026-04-25T20:00:00Z', NOW),
|
|
46
|
-
formatTimeAgo('2026-02-09T20:00:00Z', NOW),
|
|
47
|
-
formatTimeAgo('2024-05-09T20:00:00Z', NOW),
|
|
48
|
-
];
|
|
49
|
-
for (const s of samples) {
|
|
50
|
-
expect(s.length).toBeLessThan(12);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
});
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { canonicalise } from '../canonical.js';
|
|
3
|
-
describe('canonicalise', () => {
|
|
4
|
-
it('sorts keys alphabetically at every level', () => {
|
|
5
|
-
expect(canonicalise({ b: 1, a: 2 })).toBe('{"a":2,"b":1}');
|
|
6
|
-
expect(canonicalise({ z: { b: 1, a: 2 }, y: [3, 1] }))
|
|
7
|
-
.toBe('{"y":[3,1],"z":{"a":2,"b":1}}');
|
|
8
|
-
});
|
|
9
|
-
it('matches golden vector (proxy + CLI must produce identical bytes)', () => {
|
|
10
|
-
const input = {
|
|
11
|
-
version: '1.0.0',
|
|
12
|
-
subkey_id: 'subkey-2026q2',
|
|
13
|
-
published_at: '2026-04-23T00:00:00Z',
|
|
14
|
-
skills: [{ name: 'abap-generate', sha256: 'a'.repeat(64), updated_at: '2026-04-23T00:00:00Z', body_url: 'https://manifest.cspeach.dev/v1/skills-public/abap-generate' }],
|
|
15
|
-
};
|
|
16
|
-
// GOLDEN VECTOR — if this changes, update the proxy side in the same PR.
|
|
17
|
-
const expected = '{"published_at":"2026-04-23T00:00:00Z","skills":[{"body_url":"https://manifest.cspeach.dev/v1/skills-public/abap-generate","name":"abap-generate","sha256":"' + 'a'.repeat(64) + '","updated_at":"2026-04-23T00:00:00Z"}],"subkey_id":"subkey-2026q2","version":"1.0.0"}';
|
|
18
|
-
expect(canonicalise(input)).toBe(expected);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
const mockFetch = vi.fn();
|
|
3
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
4
|
-
// Mock signing-public-key module so tests run identically regardless of prebuild state.
|
|
5
|
-
// ed25519 public key: exactly 32 bytes → 44 base64 chars incl. padding.
|
|
6
|
-
vi.mock('../signing-public-key.js', () => ({
|
|
7
|
-
MANIFEST_PUBLIC_KEYS: ['AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='],
|
|
8
|
-
}));
|
|
9
|
-
// 64 zero bytes base64-encoded = 86 chars + '==' padding = 88 chars.
|
|
10
|
-
const INVALID_BUT_WELL_FORMED_SIGNATURE = 'A'.repeat(86) + '==';
|
|
11
|
-
const MANIFEST_URL = 'https://manifest.cspeach.dev/v1.json';
|
|
12
|
-
describe('ManifestClient', () => {
|
|
13
|
-
beforeEach(() => { mockFetch.mockReset(); });
|
|
14
|
-
it('fetches the manifest from the configured URL', async () => {
|
|
15
|
-
const manifest = {
|
|
16
|
-
version: '1.0.0',
|
|
17
|
-
subkey_id: 'subkey-2026q2',
|
|
18
|
-
published_at: '2026-04-23T00:00:00Z',
|
|
19
|
-
skills: [],
|
|
20
|
-
signature: 'not-verified-in-this-test',
|
|
21
|
-
};
|
|
22
|
-
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => manifest });
|
|
23
|
-
const { ManifestClient } = await import('../manifest-client.js');
|
|
24
|
-
const c = new ManifestClient(MANIFEST_URL);
|
|
25
|
-
const got = await c.fetchManifest();
|
|
26
|
-
expect(got.version).toBe('1.0.0');
|
|
27
|
-
expect(mockFetch).toHaveBeenCalledWith(MANIFEST_URL, expect.any(Object));
|
|
28
|
-
});
|
|
29
|
-
it('rejects a manifest with invalid signature', async () => {
|
|
30
|
-
const manifest = {
|
|
31
|
-
version: '1.0.0',
|
|
32
|
-
subkey_id: 'subkey-2026q2',
|
|
33
|
-
published_at: '2026-04-23T00:00:00Z',
|
|
34
|
-
skills: [],
|
|
35
|
-
signature: INVALID_BUT_WELL_FORMED_SIGNATURE,
|
|
36
|
-
};
|
|
37
|
-
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => manifest });
|
|
38
|
-
const { ManifestClient } = await import('../manifest-client.js');
|
|
39
|
-
const c = new ManifestClient(MANIFEST_URL);
|
|
40
|
-
await expect(c.fetchAndVerifyManifest()).rejects.toThrow(/manifest signature invalid/i);
|
|
41
|
-
});
|
|
42
|
-
it('surfaces a friendly error when manifest is not yet published (503)', async () => {
|
|
43
|
-
mockFetch.mockResolvedValueOnce({
|
|
44
|
-
ok: false,
|
|
45
|
-
status: 503,
|
|
46
|
-
json: async () => ({ error: 'manifest not yet published' }),
|
|
47
|
-
});
|
|
48
|
-
const { ManifestClient } = await import('../manifest-client.js');
|
|
49
|
-
const c = new ManifestClient(MANIFEST_URL);
|
|
50
|
-
await expect(c.fetchManifest()).rejects.toThrow(/not yet published|HTTP 503/i);
|
|
51
|
-
});
|
|
52
|
-
it('surfaces a friendly error when manifest fetch times out / network is down', async () => {
|
|
53
|
-
mockFetch.mockRejectedValueOnce(new Error('fetch failed: ECONNREFUSED'));
|
|
54
|
-
const { ManifestClient } = await import('../manifest-client.js');
|
|
55
|
-
const c = new ManifestClient(MANIFEST_URL);
|
|
56
|
-
await expect(c.fetchManifest()).rejects.toThrow(/ECONNREFUSED|fetch failed/);
|
|
57
|
-
});
|
|
58
|
-
it('verifies per-skill sha256 when body fetched', async () => {
|
|
59
|
-
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({
|
|
60
|
-
body: 'hello',
|
|
61
|
-
sha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824',
|
|
62
|
-
}) });
|
|
63
|
-
const { ManifestClient } = await import('../manifest-client.js');
|
|
64
|
-
const c = new ManifestClient(MANIFEST_URL);
|
|
65
|
-
const body = await c.fetchSkillBody('abap-generate', 'https://manifest.cspeach.dev/v1/skills-public/abap-generate', { expectedSha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' });
|
|
66
|
-
expect(body.body).toBe('hello');
|
|
67
|
-
});
|
|
68
|
-
it('throws if the recomputed body sha256 does not match the manifest-anchored value (tampered body)', async () => {
|
|
69
|
-
// The body is 'tampered-content', expected is the sha of 'hello'. The
|
|
70
|
-
// check is over the recomputed hash of the delivered bytes — the
|
|
71
|
-
// server-claimed sha field is untrusted and not consulted.
|
|
72
|
-
mockFetch.mockResolvedValueOnce({ ok: true, json: async () => ({
|
|
73
|
-
body: 'tampered-content',
|
|
74
|
-
// Include a *correct* server-claimed sha for the tampered body to
|
|
75
|
-
// confirm the recomputed check still rejects when only the body is
|
|
76
|
-
// wrong (I6 — server-claimed sha carries no security weight).
|
|
77
|
-
sha256: '772d54f10d1ce3976a92c59cf4b2d3ec42ed2e9f2ff6e7b9efce0bf06cfa9efe',
|
|
78
|
-
}) });
|
|
79
|
-
const { ManifestClient } = await import('../manifest-client.js');
|
|
80
|
-
const c = new ManifestClient(MANIFEST_URL);
|
|
81
|
-
await expect(c.fetchSkillBody('abap-generate', 'https://manifest.cspeach.dev/v1/skills-public/abap-generate', { expectedSha256: '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824' })).rejects.toThrow(/sha256/i);
|
|
82
|
-
});
|
|
83
|
-
it('accepts a manifest signed by the previous-rollover key (dual-key overlap)', async () => {
|
|
84
|
-
// This test is skipped in CI unless both current + previous keys are loaded.
|
|
85
|
-
// Documents the 30-day rollover behaviour per spec §13.1.
|
|
86
|
-
// Full end-to-end covered by Task A.16 Step 6.
|
|
87
|
-
});
|
|
88
|
-
});
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseFromFlag } from '../promotion-dispatch.js';
|
|
3
|
-
describe('parseFromFlag', () => {
|
|
4
|
-
it('returns fromPath when --from @file is present', () => {
|
|
5
|
-
expect(parseFromFlag('--from @./customer-aging-spec-gap-a1b2-v3.cspeach.json'))
|
|
6
|
-
.toEqual({ fromPath: './customer-aging-spec-gap-a1b2-v3.cspeach.json', rest: '' });
|
|
7
|
-
});
|
|
8
|
-
it('strips --from from the rest of the args', () => {
|
|
9
|
-
expect(parseFromFlag('developer-profile: senior --from @file.json includes: tests'))
|
|
10
|
-
.toEqual({ fromPath: 'file.json', rest: 'developer-profile: senior includes: tests' });
|
|
11
|
-
});
|
|
12
|
-
it('returns null fromPath when flag is absent', () => {
|
|
13
|
-
expect(parseFromFlag('developer-profile: senior'))
|
|
14
|
-
.toEqual({ fromPath: null, rest: 'developer-profile: senior' });
|
|
15
|
-
});
|
|
16
|
-
it('handles --from @"path with spaces.json"', () => {
|
|
17
|
-
expect(parseFromFlag('--from @"some path with spaces.cspeach.json"'))
|
|
18
|
-
.toEqual({ fromPath: 'some path with spaces.cspeach.json', rest: '' });
|
|
19
|
-
});
|
|
20
|
-
it('returns null when --from has no file argument', () => {
|
|
21
|
-
expect(parseFromFlag('--from'))
|
|
22
|
-
.toEqual({ fromPath: null, rest: '--from' });
|
|
23
|
-
});
|
|
24
|
-
it('handles paths containing @ (email-like names)', () => {
|
|
25
|
-
expect(parseFromFlag('--from @hello@bar.cspeach.json'))
|
|
26
|
-
.toEqual({ fromPath: 'hello@bar.cspeach.json', rest: '' });
|
|
27
|
-
});
|
|
28
|
-
});
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
-
const mockFetch = vi.fn();
|
|
3
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
4
|
-
const cfg = {
|
|
5
|
-
proxy_url: 'https://api.cspeach.dev/',
|
|
6
|
-
default_model: 'claude-opus-4-7',
|
|
7
|
-
effort: 'medium',
|
|
8
|
-
telemetry: 'minimal',
|
|
9
|
-
write_mode: 'approval-gated',
|
|
10
|
-
llm: { mode: 'managed' },
|
|
11
|
-
sap: {},
|
|
12
|
-
classifier: { safe_mode: false },
|
|
13
|
-
ui: { rendering: 'classic' },
|
|
14
|
-
};
|
|
15
|
-
describe('createManagedSkillSource', () => {
|
|
16
|
-
beforeEach(() => { mockFetch.mockReset(); });
|
|
17
|
-
it('parses the JSON shape returned by cspeach-proxy /v1/skills/:name', async () => {
|
|
18
|
-
mockFetch.mockResolvedValueOnce({
|
|
19
|
-
ok: true,
|
|
20
|
-
json: async () => ({
|
|
21
|
-
name: 'abap-generate',
|
|
22
|
-
body: '# skill body',
|
|
23
|
-
sha256: 'a'.repeat(64),
|
|
24
|
-
signature: 'sig-b64',
|
|
25
|
-
signedAt: '2026-04-23T00:00:00Z',
|
|
26
|
-
subkey_id: 'subkey-2026q2',
|
|
27
|
-
required_tools: ['sap_create_object'],
|
|
28
|
-
min_cli_version: '0.3.0',
|
|
29
|
-
}),
|
|
30
|
-
});
|
|
31
|
-
const { createManagedSkillSource } = await import('../source-managed.js');
|
|
32
|
-
const src = createManagedSkillSource(cfg, async () => 'bearer-token');
|
|
33
|
-
const body = await src.getSkillBody('abap-generate');
|
|
34
|
-
expect(body).toEqual({
|
|
35
|
-
name: 'abap-generate',
|
|
36
|
-
body: '# skill body',
|
|
37
|
-
sha256: 'a'.repeat(64),
|
|
38
|
-
signature: 'sig-b64',
|
|
39
|
-
signedAt: '2026-04-23T00:00:00Z',
|
|
40
|
-
});
|
|
41
|
-
// Trailing slash on proxy_url must be collapsed; skill name must be URL-encoded.
|
|
42
|
-
expect(mockFetch).toHaveBeenCalledWith('https://api.cspeach.dev/v1/skills/abap-generate', expect.objectContaining({ headers: { Authorization: 'Bearer bearer-token' } }));
|
|
43
|
-
});
|
|
44
|
-
it('throws with HTTP status on non-2xx', async () => {
|
|
45
|
-
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
|
|
46
|
-
const { createManagedSkillSource } = await import('../source-managed.js');
|
|
47
|
-
const src = createManagedSkillSource(cfg, async () => 'bearer-token');
|
|
48
|
-
await expect(src.getSkillBody('missing')).rejects.toThrow(/HTTP 404/);
|
|
49
|
-
});
|
|
50
|
-
it('lists skills from /v1/skills', async () => {
|
|
51
|
-
mockFetch.mockResolvedValueOnce({
|
|
52
|
-
ok: true,
|
|
53
|
-
json: async () => ({ skills: [{ name: 'abap-generate' }, { name: 'abap-explain' }] }),
|
|
54
|
-
});
|
|
55
|
-
const { createManagedSkillSource } = await import('../source-managed.js');
|
|
56
|
-
const src = createManagedSkillSource(cfg, async () => 'bearer-token');
|
|
57
|
-
const names = await src.listAvailableSkills();
|
|
58
|
-
expect(names).toEqual(['abap-generate', 'abap-explain']);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
-
import { registerBackground, recordEvent, consumeEvents, getBackground, __resetRegistryForTests, __isExitHandlerInstalledForTests, installExitHandler, MAX_EVENTS_PER_BG, } from '../subagent/_background-shared.js';
|
|
3
|
-
beforeEach(() => { __resetRegistryForTests(); });
|
|
4
|
-
describe('_background-shared registry', () => {
|
|
5
|
-
it('registerBackground returns a new entry indexable by bg_id', () => {
|
|
6
|
-
const entry = registerBackground({
|
|
7
|
-
bg_id: 'bg-test-1',
|
|
8
|
-
command: 'node',
|
|
9
|
-
argv: ['-e', 'console.log(1)'],
|
|
10
|
-
child: { kill: () => true, pid: 12345 },
|
|
11
|
-
});
|
|
12
|
-
expect(entry.bg_id).toBe('bg-test-1');
|
|
13
|
-
expect(entry.command).toBe('node');
|
|
14
|
-
expect(entry.closed).toBe(false);
|
|
15
|
-
expect(getBackground('bg-test-1')).toBe(entry);
|
|
16
|
-
});
|
|
17
|
-
it('recordEvent appends to the per-bg ring buffer; consumeEvents drains in FIFO order', () => {
|
|
18
|
-
registerBackground({ bg_id: 'bg-2', command: 'node', argv: [], child: { kill: () => true, pid: 1 } });
|
|
19
|
-
recordEvent('bg-2', { kind: 'stdout', text: 'first', ts: 1 });
|
|
20
|
-
recordEvent('bg-2', { kind: 'stdout', text: 'second', ts: 2 });
|
|
21
|
-
recordEvent('bg-2', { kind: 'stderr', text: 'err1', ts: 3 });
|
|
22
|
-
const r1 = consumeEvents('bg-2', 2);
|
|
23
|
-
expect(r1.events.map((e) => e.text)).toEqual(['first', 'second']);
|
|
24
|
-
expect(r1.dropped).toBe(0);
|
|
25
|
-
const r2 = consumeEvents('bg-2', 10);
|
|
26
|
-
expect(r2.events.map((e) => e.text)).toEqual(['err1']);
|
|
27
|
-
expect(r2.dropped).toBe(0);
|
|
28
|
-
const r3 = consumeEvents('bg-2', 10);
|
|
29
|
-
expect(r3.events).toHaveLength(0);
|
|
30
|
-
expect(r3.dropped).toBe(0);
|
|
31
|
-
});
|
|
32
|
-
it('FIFO eviction kicks in at MAX_EVENTS_PER_BG and surfaces dropped counter on next consume', () => {
|
|
33
|
-
registerBackground({ bg_id: 'bg-evict', command: 'node', argv: [], child: { kill: () => true, pid: 1 } });
|
|
34
|
-
for (let i = 0; i < MAX_EVENTS_PER_BG + 50; i++) {
|
|
35
|
-
recordEvent('bg-evict', { kind: 'stdout', text: `line ${i}`, ts: i });
|
|
36
|
-
}
|
|
37
|
-
const r = consumeEvents('bg-evict', MAX_EVENTS_PER_BG);
|
|
38
|
-
expect(r.events.length).toBe(MAX_EVENTS_PER_BG);
|
|
39
|
-
expect(r.dropped).toBe(50);
|
|
40
|
-
expect(r.events[0].text).toBe('line 50'); // oldest 50 evicted
|
|
41
|
-
// Subsequent consume reports 0 dropped — counter resets on read.
|
|
42
|
-
const r2 = consumeEvents('bg-evict', 10);
|
|
43
|
-
expect(r2.dropped).toBe(0);
|
|
44
|
-
});
|
|
45
|
-
it('consumeEvents on a missing bg_id returns null (caller surfaces error envelope)', () => {
|
|
46
|
-
expect(consumeEvents('does-not-exist', 10)).toBeNull();
|
|
47
|
-
});
|
|
48
|
-
it('recordEvent on a missing bg_id is a silent no-op (race: child wrote after monitor cleared)', () => {
|
|
49
|
-
expect(() => recordEvent('does-not-exist', { kind: 'stdout', text: 'x', ts: 1 })).not.toThrow();
|
|
50
|
-
});
|
|
51
|
-
it('installExitHandler is idempotent (calling twice does NOT register two listeners)', () => {
|
|
52
|
-
// Ordering note: this test must be the first-or-only caller of installExitHandler()
|
|
53
|
-
// in this file. __resetRegistryForTests() does NOT reset exitHandlerInstalled
|
|
54
|
-
// (handler is process-lifetime-scoped). If a future test in this file installs
|
|
55
|
-
// the handler before this one, the line-below assertion will fail.
|
|
56
|
-
expect(__isExitHandlerInstalledForTests()).toBe(false);
|
|
57
|
-
installExitHandler();
|
|
58
|
-
expect(__isExitHandlerInstalledForTests()).toBe(true);
|
|
59
|
-
installExitHandler(); // second call must be a no-op
|
|
60
|
-
expect(__isExitHandlerInstalledForTests()).toBe(true);
|
|
61
|
-
});
|
|
62
|
-
it('recordEvent marks the entry closed on `exit` kind', () => {
|
|
63
|
-
registerBackground({ bg_id: 'bg-close', command: 'node', argv: [], child: { kill: () => true, pid: 1 } });
|
|
64
|
-
expect(getBackground('bg-close').closed).toBe(false);
|
|
65
|
-
recordEvent('bg-close', { kind: 'exit', text: 'code=0', ts: 1, exitCode: 0, signal: null });
|
|
66
|
-
expect(getBackground('bg-close').closed).toBe(true);
|
|
67
|
-
expect(getBackground('bg-close').exitCode).toBe(0);
|
|
68
|
-
});
|
|
69
|
-
});
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { DEFAULT_SAFELIST, loadSafelist, buildSafeEnv, resolveExecutable, validateArgv, rejectPathSeparator, MAX_ARGS, MAX_ARG_BYTES, KILL_GRACE_MS, } from '../_command-shared.js';
|
|
3
|
-
describe('_command-shared', () => {
|
|
4
|
-
it('DEFAULT_SAFELIST contains npm/pnpm/ui5/cds/git/abaplint/node', () => {
|
|
5
|
-
for (const cmd of ['npm', 'pnpm', 'ui5', 'cds', 'git', 'abaplint', 'node']) {
|
|
6
|
-
expect(DEFAULT_SAFELIST.has(cmd)).toBe(true);
|
|
7
|
-
}
|
|
8
|
-
});
|
|
9
|
-
it('loadSafelist returns at least the defaults even when config is empty', async () => {
|
|
10
|
-
const set = await loadSafelist();
|
|
11
|
-
expect(set.has('npm')).toBe(true);
|
|
12
|
-
expect(set.has('git')).toBe(true);
|
|
13
|
-
});
|
|
14
|
-
it('buildSafeEnv strips CSPEACH_*, ANTHROPIC_*, SAP_* and passes PATH through', () => {
|
|
15
|
-
const orig = process.env.PATH;
|
|
16
|
-
process.env.CSPEACH_TOKEN_TEST = 'leak';
|
|
17
|
-
process.env.ANTHROPIC_API_KEY_TEST = 'leak';
|
|
18
|
-
process.env.SAP_PASSWORD_TEST = 'leak';
|
|
19
|
-
try {
|
|
20
|
-
const env = buildSafeEnv();
|
|
21
|
-
expect(env.PATH ?? env.Path).toBe(orig);
|
|
22
|
-
expect(env.CSPEACH_TOKEN_TEST).toBeUndefined();
|
|
23
|
-
expect(env.ANTHROPIC_API_KEY_TEST).toBeUndefined();
|
|
24
|
-
expect(env.SAP_PASSWORD_TEST).toBeUndefined();
|
|
25
|
-
}
|
|
26
|
-
finally {
|
|
27
|
-
delete process.env.CSPEACH_TOKEN_TEST;
|
|
28
|
-
delete process.env.ANTHROPIC_API_KEY_TEST;
|
|
29
|
-
delete process.env.SAP_PASSWORD_TEST;
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
it('resolveExecutable finds `node` on PATH (every CSPeach dev box has node)', async () => {
|
|
33
|
-
const resolved = await resolveExecutable('node');
|
|
34
|
-
expect(resolved).not.toBeNull();
|
|
35
|
-
expect(resolved).toMatch(/node(\.exe|\.cmd)?$/i);
|
|
36
|
-
});
|
|
37
|
-
it('validateArgv rejects > MAX_ARGS', () => {
|
|
38
|
-
const tooMany = Array.from({ length: MAX_ARGS + 1 }, () => 'x');
|
|
39
|
-
const err = validateArgv(tooMany);
|
|
40
|
-
expect(err).not.toBeNull();
|
|
41
|
-
expect(err).toMatch(/too many args/i);
|
|
42
|
-
});
|
|
43
|
-
it('validateArgv rejects an oversize element (> MAX_ARG_BYTES)', () => {
|
|
44
|
-
const argv = ['ok', 'x'.repeat(MAX_ARG_BYTES + 1)];
|
|
45
|
-
const err = validateArgv(argv);
|
|
46
|
-
expect(err).not.toBeNull();
|
|
47
|
-
expect(err).toMatch(/exceeds.*max length/i);
|
|
48
|
-
});
|
|
49
|
-
it('rejectPathSeparator rejects absolute and slash-bearing names', () => {
|
|
50
|
-
expect(rejectPathSeparator('/bin/sh')).toMatch(/separators|absolute/i);
|
|
51
|
-
expect(rejectPathSeparator('C:\\Windows\\System32\\cmd.exe')).toMatch(/separators|absolute/i);
|
|
52
|
-
expect(rejectPathSeparator('node/foo')).toMatch(/separators|absolute/i);
|
|
53
|
-
expect(rejectPathSeparator('node')).toBeNull();
|
|
54
|
-
});
|
|
55
|
-
it('exports KILL_GRACE_MS for downstream lifecycle managers', () => {
|
|
56
|
-
expect(typeof KILL_GRACE_MS).toBe('number');
|
|
57
|
-
expect(KILL_GRACE_MS).toBeGreaterThan(0);
|
|
58
|
-
});
|
|
59
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
// cspeach-cli/src/tools/__tests__/_filesystem-shared.test.ts
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
-
import { promises as fs } from 'node:fs';
|
|
4
|
-
import * as os from 'node:os';
|
|
5
|
-
import * as path from 'node:path';
|
|
6
|
-
import { resolveSafePath, PathOutsideRootError } from '../_filesystem-shared.js';
|
|
7
|
-
let root;
|
|
8
|
-
beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-fsshared-')); });
|
|
9
|
-
afterEach(async () => { await fs.rm(root, { recursive: true, force: true }); });
|
|
10
|
-
describe('resolveSafePath', () => {
|
|
11
|
-
it('returns the absolute path when input is inside root', () => {
|
|
12
|
-
const result = resolveSafePath(root, 'src/foo.ts');
|
|
13
|
-
expect(result).toBe(path.resolve(root, 'src/foo.ts'));
|
|
14
|
-
});
|
|
15
|
-
it('throws PathOutsideRootError on a `..` traversal that escapes root', () => {
|
|
16
|
-
expect(() => resolveSafePath(root, '../../etc/passwd')).toThrow(PathOutsideRootError);
|
|
17
|
-
});
|
|
18
|
-
it('throws on absolute paths outside root', () => {
|
|
19
|
-
expect(() => resolveSafePath(root, 'C:\\Windows\\System32\\drivers\\etc\\hosts')).toThrow(PathOutsideRootError);
|
|
20
|
-
});
|
|
21
|
-
it('allows absolute paths that resolve INSIDE root', () => {
|
|
22
|
-
const inside = path.join(root, 'inside.ts');
|
|
23
|
-
const result = resolveSafePath(root, inside);
|
|
24
|
-
expect(result).toBe(path.resolve(inside));
|
|
25
|
-
});
|
|
26
|
-
it('rejects null-byte paths (hostile input)', () => {
|
|
27
|
-
expect(() => resolveSafePath(root, 'foo\0bar')).toThrow(PathOutsideRootError);
|
|
28
|
-
});
|
|
29
|
-
it('treats empty string as root (resolves to root, no throw)', () => {
|
|
30
|
-
const result = resolveSafePath(root, '');
|
|
31
|
-
expect(result).toBe(path.resolve(root));
|
|
32
|
-
});
|
|
33
|
-
it('treats "." as root (resolves to root, no throw)', () => {
|
|
34
|
-
const result = resolveSafePath(root, '.');
|
|
35
|
-
expect(result).toBe(path.resolve(root));
|
|
36
|
-
});
|
|
37
|
-
it('allows paths that touch `..` but resolve cleanly inside root', () => {
|
|
38
|
-
// 'subdir/..' resolves to root itself — allowed.
|
|
39
|
-
const result = resolveSafePath(root, 'subdir/..');
|
|
40
|
-
expect(result).toBe(path.resolve(root));
|
|
41
|
-
});
|
|
42
|
-
});
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
// cspeach-cli/src/tools/__tests__/_flag.test.ts
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
-
import { isToolFlagOn, TOOL_FLAG_PREFIX, __resetFlagWarningsForTests } from '../_flag.js';
|
|
4
|
-
const FLAG = `${TOOL_FLAG_PREFIX}FILE_READ`;
|
|
5
|
-
beforeEach(() => { delete process.env[FLAG]; });
|
|
6
|
-
afterEach(() => { delete process.env[FLAG]; });
|
|
7
|
-
describe('isToolFlagOn', () => {
|
|
8
|
-
it('returns false when env var is unset', () => {
|
|
9
|
-
expect(isToolFlagOn('file_read')).toBe(false);
|
|
10
|
-
});
|
|
11
|
-
it('returns true only for exact value "on"', () => {
|
|
12
|
-
process.env[FLAG] = 'on';
|
|
13
|
-
expect(isToolFlagOn('file_read')).toBe(true);
|
|
14
|
-
});
|
|
15
|
-
it('rejects "ON", "true", "1", etc.', () => {
|
|
16
|
-
for (const v of ['ON', 'On', 'true', '1', 'yes', '']) {
|
|
17
|
-
process.env[FLAG] = v;
|
|
18
|
-
expect(isToolFlagOn('file_read')).toBe(false);
|
|
19
|
-
}
|
|
20
|
-
});
|
|
21
|
-
it('uppercases tool name correctly (file_read → CSPEACH_TOOL_FILE_READ)', () => {
|
|
22
|
-
process.env[FLAG] = 'on';
|
|
23
|
-
expect(isToolFlagOn('file_read')).toBe(true);
|
|
24
|
-
expect(isToolFlagOn('FILE_READ')).toBe(true); // case-insensitive on input
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
describe('isToolFlagOn — whitespace + misconfig handling', () => {
|
|
28
|
-
beforeEach(() => { __resetFlagWarningsForTests(); });
|
|
29
|
-
it('treats " on " (with whitespace) as on', () => {
|
|
30
|
-
process.env[FLAG] = ' on ';
|
|
31
|
-
expect(isToolFlagOn('file_read')).toBe(true);
|
|
32
|
-
});
|
|
33
|
-
it('warns to stderr ONCE when flag is set but not "on"', () => {
|
|
34
|
-
process.env[FLAG] = 'true';
|
|
35
|
-
const writes = [];
|
|
36
|
-
const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => {
|
|
37
|
-
writes.push(typeof c === 'string' ? c : c.toString());
|
|
38
|
-
return true;
|
|
39
|
-
});
|
|
40
|
-
try {
|
|
41
|
-
expect(isToolFlagOn('file_read')).toBe(false);
|
|
42
|
-
expect(isToolFlagOn('file_read')).toBe(false); // second call
|
|
43
|
-
const warnLines = writes.filter((s) => s.includes(FLAG));
|
|
44
|
-
expect(warnLines).toHaveLength(1); // one-shot
|
|
45
|
-
expect(warnLines[0]).toContain('did you mean');
|
|
46
|
-
}
|
|
47
|
-
finally {
|
|
48
|
-
spy.mockRestore();
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
it('does NOT warn when flag is unset', () => {
|
|
52
|
-
delete process.env[FLAG];
|
|
53
|
-
const writes = [];
|
|
54
|
-
const spy = vi.spyOn(process.stderr, 'write').mockImplementation((c) => {
|
|
55
|
-
writes.push(typeof c === 'string' ? c : c.toString());
|
|
56
|
-
return true;
|
|
57
|
-
});
|
|
58
|
-
try {
|
|
59
|
-
expect(isToolFlagOn('file_read')).toBe(false);
|
|
60
|
-
expect(writes.filter((s) => s.includes(FLAG))).toHaveLength(0);
|
|
61
|
-
}
|
|
62
|
-
finally {
|
|
63
|
-
spy.mockRestore();
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
});
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal ToolContext for filesystem-tool unit tests. Tests typically
|
|
3
|
-
* only need `cwd` populated; the rest get defaulted to safe stubs.
|
|
4
|
-
*
|
|
5
|
-
* Phase 3 helper — extend the shape here as new filesystem tools
|
|
6
|
-
* need new ctx fields. Single source of truth prevents drift across
|
|
7
|
-
* 5+ test files.
|
|
8
|
-
*/
|
|
9
|
-
export function makeCtx(cwd, overrides = {}) {
|
|
10
|
-
return {
|
|
11
|
-
adt: {},
|
|
12
|
-
sapAlias: 'TEST',
|
|
13
|
-
session: {},
|
|
14
|
-
cwd,
|
|
15
|
-
...overrides,
|
|
16
|
-
};
|
|
17
|
-
}
|