@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.
Files changed (108) hide show
  1. package/README.md +116 -108
  2. package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
  3. package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
  4. package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
  5. package/dist/agent/__tests__/project-context-injection.test.js +161 -0
  6. package/dist/agent/__tests__/repair-partial.test.js +79 -0
  7. package/dist/agent/__tests__/retry-key.test.js +77 -0
  8. package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
  9. package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
  10. package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
  11. package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
  12. package/dist/agent/__tests__/turn-stream.test.js +100 -0
  13. package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
  14. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
  15. package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
  16. package/dist/agent/providers/__tests__/factory.test.js +33 -0
  17. package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
  18. package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
  19. package/dist/approvals/__tests__/advisory-render.test.js +54 -0
  20. package/dist/auth/__tests__/auth-file.test.js +42 -0
  21. package/dist/auth/__tests__/me.test.js +43 -0
  22. package/dist/classifier/__tests__/client.test.js +70 -0
  23. package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
  24. package/dist/commands/__tests__/login.test.js +152 -0
  25. package/dist/commands/__tests__/logout.test.js +39 -0
  26. package/dist/commands/__tests__/project-context-impact.test.js +173 -0
  27. package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
  28. package/dist/commands/__tests__/whoami.test.js +91 -0
  29. package/dist/config/__tests__/llm-config.test.js +28 -0
  30. package/dist/config/__tests__/shell-exec-config.test.js +76 -0
  31. package/dist/config/__tests__/write-mode.test.js +36 -0
  32. package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
  33. package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
  34. package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
  35. package/dist/index.js +0 -0
  36. package/dist/project-context/__tests__/conventions.test.js +221 -0
  37. package/dist/project-context/__tests__/detect.test.js +177 -0
  38. package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
  39. package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
  40. package/dist/project-context/__tests__/domain-cap.test.js +168 -0
  41. package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
  42. package/dist/project-context/__tests__/git.test.js +131 -0
  43. package/dist/project-context/__tests__/index-files.test.js +195 -0
  44. package/dist/project-context/__tests__/index.test.js +133 -0
  45. package/dist/project-context/__tests__/render.test.js +322 -0
  46. package/dist/project-context/__tests__/types.test.js +73 -0
  47. package/dist/projects/__tests__/build.test.js +52 -0
  48. package/dist/projects/__tests__/canonicalize.test.js +47 -0
  49. package/dist/projects/__tests__/email-template.test.js +71 -0
  50. package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
  51. package/dist/projects/__tests__/extract-cca.test.js +141 -0
  52. package/dist/projects/__tests__/extract-design.test.js +62 -0
  53. package/dist/projects/__tests__/extract-estimate.test.js +58 -0
  54. package/dist/projects/__tests__/extract-modernize.test.js +127 -0
  55. package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
  56. package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
  57. package/dist/projects/__tests__/filename.test.js +32 -0
  58. package/dist/projects/__tests__/promote-command.test.js +170 -0
  59. package/dist/projects/__tests__/promote.test.js +181 -0
  60. package/dist/projects/__tests__/save-command.test.js +216 -0
  61. package/dist/projects/__tests__/save.test.js +41 -0
  62. package/dist/projects/__tests__/status.test.js +208 -0
  63. package/dist/projects/__tests__/types.test.js +41 -0
  64. package/dist/projects/__tests__/validate.test.js +165 -0
  65. package/dist/projects/__tests__/workspace.test.js +337 -0
  66. package/dist/repl/__tests__/current-transport.test.js +43 -0
  67. package/dist/repl/__tests__/diff-display.test.js +59 -0
  68. package/dist/repl/__tests__/file-picker.test.js +97 -0
  69. package/dist/repl/__tests__/rule8-detector.test.js +114 -0
  70. package/dist/repl/__tests__/safety-confirm.test.js +130 -0
  71. package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
  72. package/dist/sap/__tests__/system-info.test.js +344 -0
  73. package/dist/session/__tests__/store-discovery.test.js +114 -0
  74. package/dist/session/__tests__/time-ago.test.js +53 -0
  75. package/dist/skills/__tests__/canonical.test.js +20 -0
  76. package/dist/skills/__tests__/manifest-client.test.js +88 -0
  77. package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
  78. package/dist/skills/__tests__/source-managed.test.js +60 -0
  79. package/dist/tools/__tests__/_background-shared.test.js +69 -0
  80. package/dist/tools/__tests__/_command-shared.test.js +59 -0
  81. package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
  82. package/dist/tools/__tests__/_flag.test.js +66 -0
  83. package/dist/tools/__tests__/_helpers.js +17 -0
  84. package/dist/tools/__tests__/_web-shared.test.js +143 -0
  85. package/dist/tools/__tests__/agent_run.test.js +166 -0
  86. package/dist/tools/__tests__/approval-advisory.test.js +66 -0
  87. package/dist/tools/__tests__/background_run.test.js +113 -0
  88. package/dist/tools/__tests__/convention_get.test.js +53 -0
  89. package/dist/tools/__tests__/file-edit.test.js +72 -0
  90. package/dist/tools/__tests__/file-read.test.js +81 -0
  91. package/dist/tools/__tests__/file-write.test.js +68 -0
  92. package/dist/tools/__tests__/glob.test.js +128 -0
  93. package/dist/tools/__tests__/grep.test.js +71 -0
  94. package/dist/tools/__tests__/monitor_emit.test.js +96 -0
  95. package/dist/tools/__tests__/playbook_get.test.js +109 -0
  96. package/dist/tools/__tests__/project_context_get.test.js +71 -0
  97. package/dist/tools/__tests__/registry-category.test.js +60 -0
  98. package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
  99. package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
  100. package/dist/tools/__tests__/schedule_create.test.js +85 -0
  101. package/dist/tools/__tests__/shell_exec.test.js +135 -0
  102. package/dist/tools/__tests__/transport-safety.test.js +106 -0
  103. package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
  104. package/dist/tools/__tests__/web_fetch.test.js +329 -0
  105. package/dist/tools/__tests__/web_search.test.js +215 -0
  106. package/dist/tools/__tests__/write-mode.test.js +32 -0
  107. package/dist/ui/__tests__/login-banner.test.js +69 -0
  108. package/package.json +82 -83
@@ -0,0 +1,337 @@
1
+ import { afterAll, beforeAll, describe, it, expect } from 'vitest';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import os from 'node:os';
5
+ import { detectDesignShape, resolveAtToken, formatProjectFileList, findProjectRoot, defaultWorkspaceFor } from '../workspace.js';
6
+ const FILES = [
7
+ {
8
+ path: '/ws/customer-aging-design-fd79-v1.cspeach.json',
9
+ filename: 'customer-aging-design-fd79-v1.cspeach.json',
10
+ kind: 'envelope',
11
+ artefactType: 'design',
12
+ version: 1,
13
+ title: 'Customer Aging',
14
+ sizeBytes: 4321,
15
+ mtimeMs: 1_700_000_000_000,
16
+ },
17
+ {
18
+ path: '/ws/sales-order-spec-gap-b9b0-v2.cspeach.json',
19
+ filename: 'sales-order-spec-gap-b9b0-v2.cspeach.json',
20
+ kind: 'envelope',
21
+ artefactType: 'spec-gap',
22
+ version: 2,
23
+ title: 'Sales Order',
24
+ sizeBytes: 8765,
25
+ mtimeMs: 1_700_000_500_000,
26
+ },
27
+ {
28
+ path: '/ws/sales-order-design-b9b0-v1.cspeach.json',
29
+ filename: 'sales-order-design-b9b0-v1.cspeach.json',
30
+ kind: 'envelope',
31
+ artefactType: 'design',
32
+ version: 1,
33
+ title: 'Sales Order',
34
+ sizeBytes: 5432,
35
+ mtimeMs: 1_700_000_900_000,
36
+ },
37
+ {
38
+ path: '/ws/customer-aging-spec.txt',
39
+ filename: 'customer-aging-spec.txt',
40
+ kind: 'text',
41
+ artefactType: 'unknown',
42
+ version: null,
43
+ title: null,
44
+ sizeBytes: 1200,
45
+ mtimeMs: 1_700_001_000_000,
46
+ },
47
+ ];
48
+ describe('resolveAtToken', () => {
49
+ it('returns absolute paths verbatim — no workspace lookup', () => {
50
+ const r = resolveAtToken({
51
+ token: '/abs/path/to/file.cspeach.json',
52
+ cwd: '/some/cwd',
53
+ workspace: '/ws',
54
+ files: FILES,
55
+ });
56
+ expect(r).toEqual({ kind: 'path', path: '/abs/path/to/file.cspeach.json' });
57
+ });
58
+ it('returns Windows absolute paths verbatim', () => {
59
+ const r = resolveAtToken({
60
+ token: 'C:\\Users\\test\\file.cspeach.json',
61
+ cwd: 'C:\\cwd',
62
+ workspace: 'C:\\ws',
63
+ files: FILES,
64
+ });
65
+ // path.isAbsolute on Windows recognizes C:\... — on POSIX it does not.
66
+ // We only assert that on Windows the kind is 'path' and the path
67
+ // matches exactly. On POSIX this token would route through the
68
+ // path-with-slash branch instead.
69
+ if (process.platform === 'win32') {
70
+ expect(r.kind).toBe('path');
71
+ if (r.kind === 'path')
72
+ expect(r.path).toBe('C:\\Users\\test\\file.cspeach.json');
73
+ }
74
+ });
75
+ it('treats ./ and ../ tokens as cwd-relative', () => {
76
+ const r = resolveAtToken({
77
+ token: './foo.cspeach.json',
78
+ cwd: '/home/me',
79
+ workspace: '/ws',
80
+ files: FILES,
81
+ });
82
+ expect(r.kind).toBe('path');
83
+ if (r.kind === 'path') {
84
+ // path.join normalises ./ away on POSIX and produces backslashes on
85
+ // Windows — assert via endsWith on the platform-correct fragment.
86
+ expect(r.path.endsWith(join('home', 'me', 'foo.cspeach.json'))).toBe(true);
87
+ }
88
+ });
89
+ it('finds a unique match by substring on bare tokens', () => {
90
+ const r = resolveAtToken({
91
+ token: 'customer-aging-design',
92
+ cwd: '/cwd',
93
+ workspace: '/ws',
94
+ files: FILES,
95
+ });
96
+ expect(r).toEqual({
97
+ kind: 'path',
98
+ path: '/ws/customer-aging-design-fd79-v1.cspeach.json',
99
+ });
100
+ });
101
+ it('matches case-insensitively', () => {
102
+ const r = resolveAtToken({
103
+ token: 'CUSTOMER-AGING-DESIGN',
104
+ cwd: '/cwd',
105
+ workspace: '/ws',
106
+ files: FILES,
107
+ });
108
+ expect(r.kind).toBe('path');
109
+ });
110
+ it('returns ambiguous when multiple files match', () => {
111
+ const r = resolveAtToken({
112
+ token: 'sales-order',
113
+ cwd: '/cwd',
114
+ workspace: '/ws',
115
+ files: FILES,
116
+ });
117
+ expect(r.kind).toBe('ambiguous');
118
+ if (r.kind === 'ambiguous') {
119
+ expect(r.matches).toHaveLength(2);
120
+ expect(r.matches.map((m) => m.filename).sort()).toEqual([
121
+ 'sales-order-design-b9b0-v1.cspeach.json',
122
+ 'sales-order-spec-gap-b9b0-v2.cspeach.json',
123
+ ]);
124
+ }
125
+ });
126
+ it('returns notFound when no file matches', () => {
127
+ const r = resolveAtToken({
128
+ token: 'nonexistent',
129
+ cwd: '/cwd',
130
+ workspace: '/ws',
131
+ files: FILES,
132
+ });
133
+ expect(r).toEqual({ kind: 'notFound', token: 'nonexistent', workspace: '/ws' });
134
+ });
135
+ it('finds by an internal id fragment', () => {
136
+ // Generated filenames include a random id like b9b0; users should
137
+ // be able to type just that fragment.
138
+ const r = resolveAtToken({
139
+ token: 'fd79',
140
+ cwd: '/cwd',
141
+ workspace: '/ws',
142
+ files: FILES,
143
+ });
144
+ expect(r.kind).toBe('path');
145
+ if (r.kind === 'path') {
146
+ expect(r.path).toBe('/ws/customer-aging-design-fd79-v1.cspeach.json');
147
+ }
148
+ });
149
+ it('treats embedded slashes as cwd-relative paths', () => {
150
+ const r = resolveAtToken({
151
+ token: 'subdir/file.cspeach.json',
152
+ cwd: '/home/me',
153
+ workspace: '/ws',
154
+ files: FILES,
155
+ });
156
+ expect(r.kind).toBe('path');
157
+ if (r.kind === 'path') {
158
+ // Platform-correct separator from path.join (\\ on Windows, / on POSIX).
159
+ expect(r.path.endsWith(join('subdir', 'file.cspeach.json'))).toBe(true);
160
+ }
161
+ });
162
+ it('returns notFound with empty workspace', () => {
163
+ const r = resolveAtToken({
164
+ token: 'anything',
165
+ cwd: '/cwd',
166
+ workspace: '/ws',
167
+ files: [],
168
+ });
169
+ expect(r.kind).toBe('notFound');
170
+ });
171
+ });
172
+ describe('formatProjectFileList', () => {
173
+ it('renders an empty workspace with a hint', () => {
174
+ const out = formatProjectFileList([], '/ws');
175
+ expect(out).toContain('Workspace: /ws');
176
+ expect(out).toContain('empty');
177
+ });
178
+ it('renders files with type, version, and a usage hint', () => {
179
+ const out = formatProjectFileList(FILES, '/ws');
180
+ expect(out).toContain('Workspace: /ws');
181
+ expect(out).toContain('customer-aging-design-fd79-v1.cspeach.json');
182
+ expect(out).toContain('design');
183
+ expect(out).toContain('v1');
184
+ expect(out).toContain('Use @<partial-name>');
185
+ expect(out).toContain('4 file(s)');
186
+ });
187
+ it('renders text files with a "text" tag and byte size', () => {
188
+ const out = formatProjectFileList(FILES, '/ws');
189
+ expect(out).toContain('customer-aging-spec.txt');
190
+ expect(out).toContain('text');
191
+ // 1200 bytes → "1 KB" (rounded)
192
+ expect(out).toMatch(/customer-aging-spec\.txt[\s\S]*1 KB/);
193
+ });
194
+ it('finds bare-name matches across both envelope and text kinds', () => {
195
+ const r = resolveAtToken({
196
+ token: 'customer-aging-spec',
197
+ cwd: '/cwd',
198
+ workspace: '/ws',
199
+ files: FILES,
200
+ });
201
+ expect(r.kind).toBe('path');
202
+ if (r.kind === 'path') {
203
+ expect(r.path).toBe('/ws/customer-aging-spec.txt');
204
+ }
205
+ });
206
+ });
207
+ describe('detectDesignShape', () => {
208
+ let tmpDir;
209
+ beforeAll(() => {
210
+ tmpDir = mkdtempSync(join(os.tmpdir(), 'cspeach-detect-shape-'));
211
+ });
212
+ afterAll(() => {
213
+ rmSync(tmpDir, { recursive: true, force: true });
214
+ });
215
+ function writeEnvelope(name, content) {
216
+ const path = join(tmpDir, name);
217
+ writeFileSync(path, JSON.stringify({ artefactType: 'design', content }));
218
+ return path;
219
+ }
220
+ it('returns "rap" when an object type contains Behavior Definition', () => {
221
+ const p = writeEnvelope('rap-bdef.json', {
222
+ objects: [
223
+ { id: 'o1', name: 'ZTBL', type: 'TABL' },
224
+ { id: 'o2', name: 'ZBDEF_ORDER', type: 'Behavior Definition (BDEF)' },
225
+ ],
226
+ });
227
+ expect(detectDesignShape(p)).toBe('rap');
228
+ });
229
+ it('returns "rap" when only a Service Binding is present', () => {
230
+ const p = writeEnvelope('rap-srvb.json', {
231
+ objects: [
232
+ { id: 'o1', name: 'ZBND_ORDER_UI_V4', type: 'Service Binding' },
233
+ ],
234
+ });
235
+ expect(detectDesignShape(p)).toBe('rap');
236
+ });
237
+ it('returns "rap" for the abbreviated SRVD marker', () => {
238
+ const p = writeEnvelope('rap-srvd.json', {
239
+ objects: [
240
+ { id: 'o1', name: 'ZSRV_ORDER', type: 'SRVD' },
241
+ ],
242
+ });
243
+ expect(detectDesignShape(p)).toBe('rap');
244
+ });
245
+ it('returns "classic" for a report + class + interface design', () => {
246
+ const p = writeEnvelope('classic.json', {
247
+ objects: [
248
+ { id: 'o1', name: 'ZSD_R_OPEN_ORDERS', type: 'PROG (executable)' },
249
+ { id: 'o2', name: 'ZCL_SD_OPEN_ORDERS', type: 'CLAS (global class)' },
250
+ { id: 'o3', name: 'ZIF_SD_OPEN_ORDERS', type: 'INTF' },
251
+ { id: 'o4', name: 'ZI_SO_OPEN', type: 'DDLS (CDS interface view)' },
252
+ ],
253
+ });
254
+ expect(detectDesignShape(p)).toBe('classic');
255
+ });
256
+ it('returns "unknown" when content.objects is missing', () => {
257
+ const p = writeEnvelope('no-objects.json', {});
258
+ expect(detectDesignShape(p)).toBe('unknown');
259
+ });
260
+ it('returns "unknown" when content.objects is empty', () => {
261
+ const p = writeEnvelope('empty-objects.json', { objects: [] });
262
+ expect(detectDesignShape(p)).toBe('unknown');
263
+ });
264
+ it('returns "unknown" on malformed JSON', () => {
265
+ const p = join(tmpDir, 'bad.json');
266
+ writeFileSync(p, '{this is not JSON');
267
+ expect(detectDesignShape(p)).toBe('unknown');
268
+ });
269
+ it('returns "unknown" on missing file', () => {
270
+ expect(detectDesignShape(join(tmpDir, 'does-not-exist.json'))).toBe('unknown');
271
+ });
272
+ it('matches case-insensitively on the type field', () => {
273
+ const p = writeEnvelope('rap-mixed-case.json', {
274
+ objects: [
275
+ { id: 'o1', name: 'ZBP_ORDER', type: 'abap behavior pool' },
276
+ ],
277
+ });
278
+ expect(detectDesignShape(p)).toBe('rap');
279
+ });
280
+ });
281
+ describe('findProjectRoot', () => {
282
+ let tmpDir;
283
+ beforeAll(() => {
284
+ tmpDir = mkdtempSync(join(os.tmpdir(), 'cspeach-find-root-'));
285
+ });
286
+ afterAll(() => {
287
+ rmSync(tmpDir, { recursive: true, force: true });
288
+ });
289
+ it('returns the dir containing .git when invoked there', () => {
290
+ const root = join(tmpDir, 'repo-1');
291
+ mkdirSync(root, { recursive: true });
292
+ mkdirSync(join(root, '.git'), { recursive: true });
293
+ expect(findProjectRoot(root)).toBe(root);
294
+ });
295
+ it('walks up from a deep subdirectory to the .git dir', () => {
296
+ const root = join(tmpDir, 'repo-2');
297
+ const deep = join(root, 'a', 'b', 'c', 'd');
298
+ mkdirSync(deep, { recursive: true });
299
+ mkdirSync(join(root, '.git'), { recursive: true });
300
+ expect(findProjectRoot(deep)).toBe(root);
301
+ });
302
+ it('finds a .git FILE (submodule case), not just .git dir', () => {
303
+ const root = join(tmpDir, 'repo-3');
304
+ const deep = join(root, 'sub');
305
+ mkdirSync(deep, { recursive: true });
306
+ writeFileSync(join(root, '.git'), 'gitdir: ../parent-repo/.git/modules/repo-3\n');
307
+ expect(findProjectRoot(deep)).toBe(root);
308
+ });
309
+ it('returns null when no .git is found anywhere up the tree', () => {
310
+ const orphan = join(tmpDir, 'no-git', 'deep');
311
+ mkdirSync(orphan, { recursive: true });
312
+ // No .git anywhere under tmpDir hierarchy except sibling dirs above.
313
+ // The walk hits the OS filesystem root without matching.
314
+ expect(findProjectRoot(orphan)).toBe(null);
315
+ });
316
+ });
317
+ describe('defaultWorkspaceFor', () => {
318
+ let tmpDir;
319
+ beforeAll(() => {
320
+ tmpDir = mkdtempSync(join(os.tmpdir(), 'cspeach-default-ws-'));
321
+ });
322
+ afterAll(() => {
323
+ rmSync(tmpDir, { recursive: true, force: true });
324
+ });
325
+ it('returns <git-root>/.cspeach when cwd is inside a git repo', () => {
326
+ const root = join(tmpDir, 'repo');
327
+ const deep = join(root, 'sub', 'sub2');
328
+ mkdirSync(deep, { recursive: true });
329
+ mkdirSync(join(root, '.git'), { recursive: true });
330
+ expect(defaultWorkspaceFor(deep)).toBe(join(root, '.cspeach'));
331
+ });
332
+ it('falls back to <cwd>/.cspeach when no git root exists', () => {
333
+ const orphan = join(tmpDir, 'no-git-here');
334
+ mkdirSync(orphan, { recursive: true });
335
+ expect(defaultWorkspaceFor(orphan)).toBe(join(orphan, '.cspeach'));
336
+ });
337
+ });
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { getCurrentTransport, setCurrentTransport, clearCurrentTransport, handleTransportCommand, } from '../current-transport.js';
3
+ describe('current-transport', () => {
4
+ beforeEach(() => clearCurrentTransport());
5
+ it('getCurrentTransport() returns null on initial state', () => {
6
+ expect(getCurrentTransport()).toBeNull();
7
+ });
8
+ it('setCurrentTransport then getCurrentTransport returns the trkorr', () => {
9
+ setCurrentTransport('DEVK900123');
10
+ expect(getCurrentTransport()).toBe('DEVK900123');
11
+ });
12
+ it('clearCurrentTransport resets state to null', () => {
13
+ setCurrentTransport('DEVK900123');
14
+ clearCurrentTransport();
15
+ expect(getCurrentTransport()).toBeNull();
16
+ });
17
+ it('handleTransportCommand("") returns "No current transport set" when null', () => {
18
+ const msg = handleTransportCommand('');
19
+ expect(msg).toMatch(/No current transport set/);
20
+ });
21
+ it('handleTransportCommand("") returns "Current transport: TRKORR" when set', () => {
22
+ setCurrentTransport('DEVK900456');
23
+ const msg = handleTransportCommand('');
24
+ expect(msg).toBe('Current transport: DEVK900456');
25
+ });
26
+ it('handleTransportCommand("<trkorr>") sets transport and returns confirmation', () => {
27
+ const msg = handleTransportCommand('DEVK900123');
28
+ expect(getCurrentTransport()).toBe('DEVK900123');
29
+ expect(msg).toBe('Current transport set to DEVK900123.');
30
+ });
31
+ it('handleTransportCommand("clear") (lowercase) clears and returns confirmation', () => {
32
+ setCurrentTransport('DEVK900123');
33
+ const msg = handleTransportCommand('clear');
34
+ expect(getCurrentTransport()).toBeNull();
35
+ expect(msg).toBe('Current transport cleared.');
36
+ });
37
+ it('handleTransportCommand("CLEAR") (uppercase) also clears — case-insensitive', () => {
38
+ setCurrentTransport('DEVK900123');
39
+ const msg = handleTransportCommand('CLEAR');
40
+ expect(getCurrentTransport()).toBeNull();
41
+ expect(msg).toBe('Current transport cleared.');
42
+ });
43
+ });
@@ -0,0 +1,59 @@
1
+ // chalk strips colors when stdout isn't a TTY (vitest is not). Force colors
2
+ // ON before chalk is imported so the ANSI assertions below are meaningful.
3
+ process.env.FORCE_COLOR = '1';
4
+ import { describe, it, expect } from 'vitest';
5
+ import { renderDiff } from '../diff-display.js';
6
+ describe('renderDiff', () => {
7
+ it('renders unified diff with header when sources differ', () => {
8
+ const out = renderDiff('foo\nbar', 'foo\nbaz', {
9
+ label: 'method dispatch',
10
+ syntaxHighlight: false,
11
+ });
12
+ // diff library uses \t between filename and header; assert via a regex
13
+ // that allows either tab or space and includes the label + role.
14
+ expect(out).toMatch(/^--- method dispatch[\s]+\(current\)/m);
15
+ expect(out).toMatch(/^\+\+\+ method dispatch[\s]+\(proposed\)/m);
16
+ expect(out).toMatch(/-bar/);
17
+ expect(out).toMatch(/\+baz/);
18
+ });
19
+ it('returns "No changes" message when sources are identical', () => {
20
+ const out = renderDiff('same\nsame', 'same\nsame', {
21
+ label: 'identity',
22
+ syntaxHighlight: false,
23
+ });
24
+ expect(out).toContain('No changes');
25
+ expect(out).toContain('identity');
26
+ });
27
+ it('strips ANSI when syntaxHighlight is false', () => {
28
+ const out = renderDiff('a', 'b', { label: 'x', syntaxHighlight: false });
29
+ // eslint-disable-next-line no-control-regex
30
+ expect(out).not.toMatch(/\x1b\[3[12]m/);
31
+ });
32
+ it('applies green/red ANSI when syntaxHighlight is true', () => {
33
+ const out = renderDiff('a', 'b', { label: 'x', syntaxHighlight: true });
34
+ // eslint-disable-next-line no-control-regex
35
+ expect(out).toMatch(/\x1b\[31m-a/);
36
+ // eslint-disable-next-line no-control-regex
37
+ expect(out).toMatch(/\x1b\[32m\+b/);
38
+ });
39
+ it('applies cyan ANSI to @@ hunk headers when syntaxHighlight is true', () => {
40
+ const out = renderDiff('line1\nline2\nline3', 'line1\nCHANGED\nline3', {
41
+ label: 'hunk',
42
+ syntaxHighlight: true,
43
+ });
44
+ // eslint-disable-next-line no-control-regex
45
+ expect(out).toMatch(/\x1b\[36m@@/);
46
+ });
47
+ it('does not colorize the +++ / --- file header lines', () => {
48
+ const out = renderDiff('a', 'b', { label: 'x', syntaxHighlight: true });
49
+ // The header lines should be plain, not green/red.
50
+ const headerCurrent = out.split('\n').find((l) => l.startsWith('--- x'));
51
+ const headerProposed = out.split('\n').find((l) => l.startsWith('+++ x'));
52
+ expect(headerCurrent).toBeDefined();
53
+ expect(headerProposed).toBeDefined();
54
+ // eslint-disable-next-line no-control-regex
55
+ expect(headerCurrent).not.toMatch(/\x1b\[31m/);
56
+ // eslint-disable-next-line no-control-regex
57
+ expect(headerProposed).not.toMatch(/\x1b\[32m/);
58
+ });
59
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ // Mock @inquirer/prompts via vi.hoisted — same pattern as
3
+ // safety-confirm.test.ts. The picker uses `search` for step 1 and
4
+ // `select` for step 2; both are mocked independently so each test
5
+ // can control which step fires.
6
+ const searchMock = vi.hoisted(() => vi.fn());
7
+ const selectMock = vi.hoisted(() => vi.fn());
8
+ vi.mock('@inquirer/prompts', () => ({
9
+ search: searchMock,
10
+ select: selectMock,
11
+ __esModule: true,
12
+ }));
13
+ // Mock withInquirer as a pass-through — the real implementation
14
+ // pauses the registered readline, but in unit tests there is no
15
+ // registered readline and no stdin to manage.
16
+ vi.mock('../inquirer-guard.js', () => ({
17
+ withInquirer: (fn) => fn(),
18
+ __esModule: true,
19
+ }));
20
+ // Mock the workspace helpers — listProjectFiles returns a controlled
21
+ // fixture, detectDesignShape reports whatever the test wants.
22
+ const listMock = vi.hoisted(() => vi.fn());
23
+ const detectMock = vi.hoisted(() => vi.fn());
24
+ vi.mock('../../projects/workspace.js', () => ({
25
+ listProjectFiles: listMock,
26
+ detectDesignShape: detectMock,
27
+ __esModule: true,
28
+ }));
29
+ const FILES = [
30
+ {
31
+ path: '/ws/sales-order-design-fd79-v1.cspeach.json',
32
+ filename: 'sales-order-design-fd79-v1.cspeach.json',
33
+ kind: 'envelope',
34
+ artefactType: 'design',
35
+ version: 1,
36
+ title: 'Sales Order',
37
+ sizeBytes: 4321,
38
+ mtimeMs: 1_700_000_000_000,
39
+ },
40
+ ];
41
+ describe('openFilePicker', () => {
42
+ beforeEach(() => {
43
+ searchMock.mockReset();
44
+ selectMock.mockReset();
45
+ listMock.mockReset();
46
+ detectMock.mockReset();
47
+ detectMock.mockReturnValue('classic');
48
+ });
49
+ it('returns kind:"empty" when the workspace has no files', async () => {
50
+ listMock.mockResolvedValue([]);
51
+ const { openFilePicker } = await import('../file-picker.js');
52
+ const result = await openFilePicker();
53
+ expect(result).toEqual({ kind: 'empty' });
54
+ // search and select must NOT have been called — picker short-circuits
55
+ // before opening any inquirer prompt.
56
+ expect(searchMock).not.toHaveBeenCalled();
57
+ expect(selectMock).not.toHaveBeenCalled();
58
+ });
59
+ it('returns kind:"cancelled" when the file-search step is cancelled', async () => {
60
+ listMock.mockResolvedValue(FILES);
61
+ searchMock.mockResolvedValue('__cspeach_cancel__');
62
+ const { openFilePicker } = await import('../file-picker.js');
63
+ const result = await openFilePicker();
64
+ expect(result).toEqual({ kind: 'cancelled' });
65
+ expect(selectMock).not.toHaveBeenCalled();
66
+ });
67
+ it('returns kind:"cancelled" when the action-select step is cancelled', async () => {
68
+ listMock.mockResolvedValue(FILES);
69
+ searchMock.mockResolvedValue(FILES[0].filename);
70
+ selectMock.mockResolvedValue('__cspeach_cancel__');
71
+ const { openFilePicker } = await import('../file-picker.js');
72
+ const result = await openFilePicker();
73
+ expect(result).toEqual({ kind: 'cancelled' });
74
+ });
75
+ it('returns kind:"cancelled" when withInquirer throws (simulates Ctrl+C)', async () => {
76
+ listMock.mockResolvedValue(FILES);
77
+ searchMock.mockRejectedValue(new Error('User aborted'));
78
+ const { openFilePicker } = await import('../file-picker.js');
79
+ const result = await openFilePicker();
80
+ expect(result).toEqual({ kind: 'cancelled' });
81
+ });
82
+ it('returns kind:"command" with the synthesized slash command on a successful pick', async () => {
83
+ listMock.mockResolvedValue(FILES);
84
+ searchMock.mockResolvedValue(FILES[0].filename);
85
+ selectMock.mockResolvedValue('/abap-rap --from');
86
+ detectMock.mockReturnValue('rap');
87
+ const { openFilePicker } = await import('../file-picker.js');
88
+ const result = await openFilePicker();
89
+ expect(result).toEqual({
90
+ kind: 'command',
91
+ command: '/abap-rap --from @sales-order-design-fd79-v1.cspeach.json',
92
+ });
93
+ // detectDesignShape should be called for design envelopes so the
94
+ // action menu can recommend the right BUILD skill.
95
+ expect(detectMock).toHaveBeenCalledWith(FILES[0].path);
96
+ });
97
+ });
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { recordWriteOp, shouldGateRule8, resetRule8State, getWriteOpsThisTurn, renderPlanSummary, } from '../rule8-detector.js';
3
+ // Module-scope singleton — must reset between tests to prevent leakage.
4
+ beforeEach(() => resetRule8State());
5
+ describe('rule8-detector — initial state', () => {
6
+ it('shouldGateRule8() returns false on a fresh turn', () => {
7
+ expect(shouldGateRule8()).toBe(false);
8
+ });
9
+ it('getWriteOpsThisTurn() is empty on a fresh turn', () => {
10
+ expect(getWriteOpsThisTurn().length).toBe(0);
11
+ });
12
+ });
13
+ describe('rule8-detector — after 1 recordWriteOp', () => {
14
+ it('shouldGateRule8() returns true after first write (gate fires on 2nd call)', () => {
15
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
16
+ expect(shouldGateRule8()).toBe(true);
17
+ });
18
+ it('getWriteOpsThisTurn().length === 1', () => {
19
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
20
+ expect(getWriteOpsThisTurn().length).toBe(1);
21
+ });
22
+ it('recorded op has correct tool and args', () => {
23
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
24
+ const ops = getWriteOpsThisTurn();
25
+ expect(ops[0].tool).toBe('sap_set_source');
26
+ expect(ops[0].args).toEqual({ type: 'CLAS', name: 'ZCL_FOO' });
27
+ });
28
+ it('recorded op has a valid ISO timestamp', () => {
29
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
30
+ const ops = getWriteOpsThisTurn();
31
+ expect(ops[0].ts).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
32
+ });
33
+ });
34
+ describe('rule8-detector — after 2 recordWriteOps', () => {
35
+ it('shouldGateRule8() still returns true', () => {
36
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
37
+ recordWriteOp('sap_create_object', { type: 'CLAS', name: 'ZCL_BAR' });
38
+ expect(shouldGateRule8()).toBe(true);
39
+ });
40
+ it('getWriteOpsThisTurn().length === 2', () => {
41
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
42
+ recordWriteOp('sap_create_object', { type: 'CLAS', name: 'ZCL_BAR' });
43
+ expect(getWriteOpsThisTurn().length).toBe(2);
44
+ });
45
+ });
46
+ describe('rule8-detector — resetRule8State', () => {
47
+ it('clears the write-ops list', () => {
48
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
49
+ resetRule8State();
50
+ expect(getWriteOpsThisTurn().length).toBe(0);
51
+ });
52
+ it('shouldGateRule8() returns false after reset', () => {
53
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
54
+ resetRule8State();
55
+ expect(shouldGateRule8()).toBe(false);
56
+ });
57
+ });
58
+ describe('rule8-detector — renderPlanSummary', () => {
59
+ it('with no prior writes, summary has count=1 and shows proposed-next only', () => {
60
+ const summary = renderPlanSummary({ tool: 'sap_create_object', args: { type: 'CLAS', name: 'ZCL_NEW' } });
61
+ expect(summary).toContain('1 write ops this turn');
62
+ expect(summary).toContain('sap_create_object');
63
+ expect(summary).toContain('CLAS ZCL_NEW');
64
+ expect(summary).toContain('← about to dispatch');
65
+ // No prior entries — only the proposed-next line should appear
66
+ expect(summary.split('\n').filter((l) => l.includes('← about to dispatch')).length).toBe(1);
67
+ });
68
+ it('with 1 prior write, summary has count=2 and lists the prior op and proposed-next', () => {
69
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO', transport: 'DEVK900001' });
70
+ const summary = renderPlanSummary({ tool: 'sap_create_object', args: { type: 'INTF', name: 'ZIF_BAR' } });
71
+ expect(summary).toContain('2 write ops this turn');
72
+ // Prior write appears without the arrow marker
73
+ expect(summary).toContain('sap_set_source');
74
+ expect(summary).toContain('CLAS ZCL_FOO');
75
+ expect(summary).toContain('transport: DEVK900001');
76
+ // Proposed next
77
+ expect(summary).toContain('sap_create_object');
78
+ expect(summary).toContain('INTF ZIF_BAR');
79
+ expect(summary).toContain('← about to dispatch');
80
+ });
81
+ it('with 2 prior writes, summary has count=3 and shows all prior ops and proposed-next', () => {
82
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
83
+ recordWriteOp('sap_create_object', { type: 'TABL', name: 'ZORDERS' });
84
+ const summary = renderPlanSummary({ tool: 'sap_activate', args: { type: 'CLAS', name: 'ZCL_FOO' } });
85
+ expect(summary).toContain('3 write ops this turn');
86
+ expect(summary).toContain('sap_set_source');
87
+ expect(summary).toContain('sap_create_object');
88
+ expect(summary).toContain('sap_activate');
89
+ expect(summary).toContain('← about to dispatch');
90
+ });
91
+ it('summarizeArgs with class_name+method_name fields formats correctly', () => {
92
+ const summary = renderPlanSummary({
93
+ tool: 'sap_update_method',
94
+ args: { class_name: 'ZCL_FOO', method_name: 'do_work', transport: 'DEVK900002' },
95
+ });
96
+ expect(summary).toContain('ZCL_FOO->do_work');
97
+ expect(summary).toContain('transport: DEVK900002');
98
+ });
99
+ it('summarizeArgs with no recognisable key fields falls back to "(no key args)"', () => {
100
+ const summary = renderPlanSummary({ tool: 'sap_activate', args: {} });
101
+ expect(summary).toContain('(no key args)');
102
+ });
103
+ });
104
+ describe('rule8-detector — getWriteOpsThisTurn is read-only', () => {
105
+ it('returns a ReadonlyArray (cannot mutate via the returned reference)', () => {
106
+ recordWriteOp('sap_set_source', { type: 'CLAS', name: 'ZCL_FOO' });
107
+ const ops = getWriteOpsThisTurn();
108
+ // TypeScript will prevent .push at compile time; at runtime the reference
109
+ // points to the internal array. We test that the length stays stable.
110
+ expect(ops.length).toBe(1);
111
+ // A second call returns the updated reference (length 1 still — no extra push)
112
+ expect(getWriteOpsThisTurn().length).toBe(1);
113
+ });
114
+ });