@cspeach/cli 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/index.js +0 -0
  2. package/package.json +86 -82
  3. package/dist/agent/__tests__/crash-recover.integration.test.js +0 -254
  4. package/dist/agent/__tests__/loop-save-hook.test.js +0 -188
  5. package/dist/agent/__tests__/maybe-build-project-context.test.js +0 -247
  6. package/dist/agent/__tests__/project-context-injection.test.js +0 -161
  7. package/dist/agent/__tests__/repair-partial.test.js +0 -79
  8. package/dist/agent/__tests__/retry-key.test.js +0 -77
  9. package/dist/agent/__tests__/sap-connection-adapter.test.js +0 -132
  10. package/dist/agent/__tests__/skill-checkpoint.test.js +0 -154
  11. package/dist/agent/__tests__/turn-assistant-text.test.js +0 -73
  12. package/dist/agent/__tests__/turn-error-ux.test.js +0 -145
  13. package/dist/agent/__tests__/turn-stream.test.js +0 -100
  14. package/dist/agent/__tests__/turn-watchdog.test.js +0 -126
  15. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +0 -30
  16. package/dist/agent/providers/__tests__/byok-provider.test.js +0 -32
  17. package/dist/agent/providers/__tests__/factory.test.js +0 -33
  18. package/dist/agent/providers/__tests__/local-provider.test.js +0 -55
  19. package/dist/approvals/__tests__/advisory-prompt.test.js +0 -57
  20. package/dist/approvals/__tests__/advisory-render.test.js +0 -54
  21. package/dist/auth/__tests__/auth-file.test.js +0 -42
  22. package/dist/auth/__tests__/me.test.js +0 -43
  23. package/dist/classifier/__tests__/client.test.js +0 -70
  24. package/dist/commands/__tests__/config-set-write-mode.test.js +0 -14
  25. package/dist/commands/__tests__/login.test.js +0 -152
  26. package/dist/commands/__tests__/logout.test.js +0 -39
  27. package/dist/commands/__tests__/project-context-impact.test.js +0 -173
  28. package/dist/commands/__tests__/spec-gap-status.test.js +0 -31
  29. package/dist/commands/__tests__/whoami.test.js +0 -91
  30. package/dist/config/__tests__/llm-config.test.js +0 -28
  31. package/dist/config/__tests__/shell-exec-config.test.js +0 -76
  32. package/dist/config/__tests__/write-mode.test.js +0 -36
  33. package/dist/doctor/__tests__/check-forge-rules.test.js +0 -134
  34. package/dist/doctor/__tests__/check-llm-mode.test.js +0 -21
  35. package/dist/doctor/__tests__/check-write-mode.test.js +0 -34
  36. package/dist/project-context/__tests__/conventions.test.js +0 -221
  37. package/dist/project-context/__tests__/detect.test.js +0 -177
  38. package/dist/project-context/__tests__/domain-abap-cloud.test.js +0 -119
  39. package/dist/project-context/__tests__/domain-abapgit.test.js +0 -161
  40. package/dist/project-context/__tests__/domain-cap.test.js +0 -168
  41. package/dist/project-context/__tests__/domain-fiori.test.js +0 -282
  42. package/dist/project-context/__tests__/git.test.js +0 -131
  43. package/dist/project-context/__tests__/index-files.test.js +0 -195
  44. package/dist/project-context/__tests__/index.test.js +0 -133
  45. package/dist/project-context/__tests__/render.test.js +0 -322
  46. package/dist/project-context/__tests__/types.test.js +0 -73
  47. package/dist/projects/__tests__/build.test.js +0 -52
  48. package/dist/projects/__tests__/canonicalize.test.js +0 -47
  49. package/dist/projects/__tests__/email-template.test.js +0 -71
  50. package/dist/projects/__tests__/expand-text-attachments.test.js +0 -105
  51. package/dist/projects/__tests__/extract-cca.test.js +0 -141
  52. package/dist/projects/__tests__/extract-design.test.js +0 -62
  53. package/dist/projects/__tests__/extract-estimate.test.js +0 -58
  54. package/dist/projects/__tests__/extract-modernize.test.js +0 -127
  55. package/dist/projects/__tests__/extract-spec-gap.test.js +0 -122
  56. package/dist/projects/__tests__/extract-test-coverage.test.js +0 -133
  57. package/dist/projects/__tests__/filename.test.js +0 -32
  58. package/dist/projects/__tests__/promote-command.test.js +0 -170
  59. package/dist/projects/__tests__/promote.test.js +0 -181
  60. package/dist/projects/__tests__/save-command.test.js +0 -216
  61. package/dist/projects/__tests__/save.test.js +0 -41
  62. package/dist/projects/__tests__/status.test.js +0 -208
  63. package/dist/projects/__tests__/types.test.js +0 -41
  64. package/dist/projects/__tests__/validate.test.js +0 -165
  65. package/dist/projects/__tests__/workspace.test.js +0 -337
  66. package/dist/repl/__tests__/current-transport.test.js +0 -43
  67. package/dist/repl/__tests__/diff-display.test.js +0 -59
  68. package/dist/repl/__tests__/file-picker.test.js +0 -97
  69. package/dist/repl/__tests__/rule8-detector.test.js +0 -114
  70. package/dist/repl/__tests__/safety-confirm.test.js +0 -130
  71. package/dist/repl/__tests__/safety-mode-state.test.js +0 -42
  72. package/dist/sap/__tests__/system-info.test.js +0 -344
  73. package/dist/session/__tests__/store-discovery.test.js +0 -114
  74. package/dist/session/__tests__/time-ago.test.js +0 -53
  75. package/dist/skills/__tests__/canonical.test.js +0 -20
  76. package/dist/skills/__tests__/manifest-client.test.js +0 -88
  77. package/dist/skills/__tests__/promotion-dispatch.test.js +0 -28
  78. package/dist/skills/__tests__/source-managed.test.js +0 -60
  79. package/dist/tools/__tests__/_background-shared.test.js +0 -69
  80. package/dist/tools/__tests__/_command-shared.test.js +0 -59
  81. package/dist/tools/__tests__/_filesystem-shared.test.js +0 -42
  82. package/dist/tools/__tests__/_flag.test.js +0 -66
  83. package/dist/tools/__tests__/_helpers.js +0 -17
  84. package/dist/tools/__tests__/_web-shared.test.js +0 -143
  85. package/dist/tools/__tests__/agent_run.test.js +0 -166
  86. package/dist/tools/__tests__/approval-advisory.test.js +0 -66
  87. package/dist/tools/__tests__/background_run.test.js +0 -113
  88. package/dist/tools/__tests__/convention_get.test.js +0 -53
  89. package/dist/tools/__tests__/file-edit.test.js +0 -72
  90. package/dist/tools/__tests__/file-read.test.js +0 -81
  91. package/dist/tools/__tests__/file-write.test.js +0 -68
  92. package/dist/tools/__tests__/glob.test.js +0 -128
  93. package/dist/tools/__tests__/grep.test.js +0 -71
  94. package/dist/tools/__tests__/monitor_emit.test.js +0 -96
  95. package/dist/tools/__tests__/playbook_get.test.js +0 -109
  96. package/dist/tools/__tests__/project_context_get.test.js +0 -71
  97. package/dist/tools/__tests__/registry-category.test.js +0 -60
  98. package/dist/tools/__tests__/sap-read-extras.test.js +0 -196
  99. package/dist/tools/__tests__/sap-write-extras.test.js +0 -573
  100. package/dist/tools/__tests__/schedule_create.test.js +0 -85
  101. package/dist/tools/__tests__/shell_exec.test.js +0 -135
  102. package/dist/tools/__tests__/transport-safety.test.js +0 -106
  103. package/dist/tools/__tests__/update-method-intercept.test.js +0 -128
  104. package/dist/tools/__tests__/web_fetch.test.js +0 -329
  105. package/dist/tools/__tests__/web_search.test.js +0 -215
  106. package/dist/tools/__tests__/write-mode.test.js +0 -32
  107. package/dist/ui/__tests__/login-banner.test.js +0 -69
@@ -1,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
- }