@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,130 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { presentSafetyConfirmation, } from '../safety-confirm.js';
3
- // `@inquirer/prompts` is ESM. With vitest >=2.0 the safe mocking pattern is
4
- // `vi.hoisted` for the spy reference + a factory that returns it. The
5
- // `__esModule: true` flag stops vitest's CJS-interop layer from wrapping the
6
- // mock in a `default` export.
7
- //
8
- // 2026-05-01: presentSafetyConfirmation switched from confirm() to select()
9
- // to fix a stdin-handoff race. The mock now returns 'apply' / 'cancel' /
10
- // 'release' values rather than booleans — see safety-confirm.ts comments.
11
- const selectMock = vi.hoisted(() => vi.fn());
12
- vi.mock('@inquirer/prompts', () => ({
13
- select: selectMock,
14
- __esModule: true,
15
- }));
16
- function makeFakeAction() {
17
- return {
18
- rule: 'RULE_10_VERIFY_AFTER_WRITE',
19
- op: 'sap_activate',
20
- object: { type: 'CLAS', name: 'ZCL_X' },
21
- what_will_happen: 'Activate; verify via sap_inactive_objects.',
22
- rollback_available: false,
23
- transport: 'S4HK903361',
24
- };
25
- }
26
- describe('presentSafetyConfirmation', () => {
27
- beforeEach(() => vi.clearAllMocks());
28
- it('shows a structured card with rule label, op, object, what_will_happen', async () => {
29
- selectMock.mockResolvedValue('apply');
30
- const action = {
31
- rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
32
- op: 'sap_set_source',
33
- object: { type: 'CLAS', name: 'ZCL_X' },
34
- what_will_happen: 'A snapshot will be taken, then the full class source will be replaced.',
35
- rollback_available: true,
36
- transport: 'S4HK903361',
37
- };
38
- const out = await presentSafetyConfirmation(action);
39
- expect(out.confirmed).toBe(true);
40
- expect(selectMock).toHaveBeenCalledWith(expect.objectContaining({
41
- message: expect.stringContaining('RULE 7'),
42
- }));
43
- // Card should mention the op and object name.
44
- const msg = selectMock.mock.calls[0][0].message;
45
- expect(msg).toContain('sap_set_source');
46
- expect(msg).toContain('ZCL_X');
47
- expect(msg).toContain('A snapshot will be taken');
48
- });
49
- it('returns confirmed=false with a reason when user declines first prompt', async () => {
50
- selectMock.mockResolvedValue('cancel');
51
- const out = await presentSafetyConfirmation(makeFakeAction());
52
- expect(out.confirmed).toBe(false);
53
- expect(out.reason).toMatch(/declined/);
54
- });
55
- it('escalates RULE_8_BATCH_PLAN with explicit batch_count in the card', async () => {
56
- selectMock.mockResolvedValue('apply');
57
- const action = {
58
- rule: 'RULE_8_BATCH_PLAN',
59
- op: 'create_rap_stack',
60
- object: { type: 'BATCH', name: 'RAP_STACK_X' },
61
- what_will_happen: '8 objects will be created in sequence.',
62
- rollback_available: false,
63
- transport: 'S4HK903361',
64
- batch_count: 8,
65
- };
66
- await presentSafetyConfirmation(action);
67
- const msg = selectMock.mock.calls[0][0].message;
68
- expect(msg).toMatch(/8 objects/);
69
- });
70
- it('asks twice for RULE_9_TRANSPORT_RELEASE (irreversibility ack)', async () => {
71
- selectMock.mockResolvedValueOnce('apply').mockResolvedValueOnce('release');
72
- const action = {
73
- rule: 'RULE_9_TRANSPORT_RELEASE',
74
- op: 'sap_transport',
75
- object: { type: 'TRANSPORT', name: 'S4HK903361' },
76
- what_will_happen: 'Transport S4HK903361 will be RELEASED — irreversible.',
77
- rollback_available: false,
78
- transport: 'S4HK903361',
79
- };
80
- const out = await presentSafetyConfirmation(action);
81
- expect(out.confirmed).toBe(true);
82
- expect(selectMock).toHaveBeenCalledTimes(2);
83
- // Second prompt should mention irreversibility.
84
- const secondMsg = selectMock.mock.calls[1][0].message;
85
- expect(secondMsg).toMatch(/IRREVERSIBLE/);
86
- expect(secondMsg).toMatch(/S4HK903361/);
87
- });
88
- it('returns confirmed=false when user declines the irreversibility ack', async () => {
89
- selectMock.mockResolvedValueOnce('apply').mockResolvedValueOnce('cancel');
90
- const action = {
91
- rule: 'RULE_9_TRANSPORT_RELEASE',
92
- op: 'sap_transport',
93
- object: { type: 'TRANSPORT', name: 'S4HK903361' },
94
- what_will_happen: 'Release.',
95
- rollback_available: false,
96
- transport: 'S4HK903361',
97
- };
98
- const out = await presentSafetyConfirmation(action);
99
- expect(out.confirmed).toBe(false);
100
- expect(out.reason).toMatch(/irreversibility/);
101
- });
102
- it('does not ask twice for non-RULE_9 actions', async () => {
103
- selectMock.mockResolvedValue('apply');
104
- await presentSafetyConfirmation(makeFakeAction()); // RULE_10
105
- expect(selectMock).toHaveBeenCalledTimes(1);
106
- });
107
- /** Strip ANSI escape codes so regex assertions don't fail on color-coded
108
- * segments that split visible words (e.g. "rollback:" is dim, the value is
109
- * red — the raw message has \x1B[...] between them). */
110
- const stripAnsi = (s) => s.replace(/\x1B\[[0-9;]*m/g, '');
111
- it('shows rollback availability in the card', async () => {
112
- selectMock.mockResolvedValue('apply');
113
- await presentSafetyConfirmation({
114
- rule: 'RULE_7_SNAPSHOT_BEFORE_WRITE',
115
- op: 'sap_set_source',
116
- object: { type: 'CLAS', name: 'ZCL_X' },
117
- what_will_happen: 'Replace.',
118
- rollback_available: true,
119
- transport: 'S4HK903361',
120
- });
121
- const msg = stripAnsi(selectMock.mock.calls[0][0].message);
122
- expect(msg).toMatch(/rollback:\s+available/);
123
- });
124
- it('marks rollback NOT available when applicable', async () => {
125
- selectMock.mockResolvedValue('apply');
126
- await presentSafetyConfirmation(makeFakeAction());
127
- const msg = stripAnsi(selectMock.mock.calls[0][0].message);
128
- expect(msg).toMatch(/rollback:\s+NOT available/);
129
- });
130
- });
@@ -1,42 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { isSafetyConfirmEnabled, setSafetyConfirmEnabled, handleSafetyModeCommand, } from '../safety-mode-state.js';
3
- describe('safety-mode-state', () => {
4
- beforeEach(() => setSafetyConfirmEnabled(false));
5
- it('starts OFF by default', () => {
6
- expect(isSafetyConfirmEnabled()).toBe(false);
7
- });
8
- it('setSafetyConfirmEnabled flips the flag', () => {
9
- setSafetyConfirmEnabled(true);
10
- expect(isSafetyConfirmEnabled()).toBe(true);
11
- setSafetyConfirmEnabled(false);
12
- expect(isSafetyConfirmEnabled()).toBe(false);
13
- });
14
- it('handleSafetyModeCommand("on") enables the mode', () => {
15
- const msg = handleSafetyModeCommand('on');
16
- expect(isSafetyConfirmEnabled()).toBe(true);
17
- expect(msg).toMatch(/ON/);
18
- });
19
- it('handleSafetyModeCommand("off") disables the mode', () => {
20
- setSafetyConfirmEnabled(true);
21
- const msg = handleSafetyModeCommand('off');
22
- expect(isSafetyConfirmEnabled()).toBe(false);
23
- expect(msg).toMatch(/OFF/);
24
- });
25
- it('handleSafetyModeCommand with no args returns current status without flipping', () => {
26
- setSafetyConfirmEnabled(true);
27
- const msg = handleSafetyModeCommand('');
28
- expect(isSafetyConfirmEnabled()).toBe(true);
29
- expect(msg).toMatch(/currently ON/);
30
- });
31
- it('handleSafetyModeCommand with unknown arg leaves state unchanged', () => {
32
- setSafetyConfirmEnabled(true);
33
- handleSafetyModeCommand('maybe');
34
- expect(isSafetyConfirmEnabled()).toBe(true);
35
- });
36
- it('is case-insensitive on the toggle arg', () => {
37
- handleSafetyModeCommand('ON');
38
- expect(isSafetyConfirmEnabled()).toBe(true);
39
- handleSafetyModeCommand('OFF');
40
- expect(isSafetyConfirmEnabled()).toBe(false);
41
- });
42
- });
@@ -1,344 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
- import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
5
- // Stub auditLog at module load — system-info.ts now records every probe
6
- // outcome (success / parser-empty / threw) to ~/.cspeach/cspeach.log.
7
- // Without this mock the test suite would scribble probe entries into the
8
- // developer's real audit log, since audit-log.ts captures os.homedir()
9
- // at module-import time (before our beforeEach swaps HOME to a temp dir).
10
- //
11
- // importOriginal pass-through is required: parseCversTree depends on
12
- // xmlParser.parseXml from the same module, so we keep the real exports
13
- // for everything except auditLog.
14
- vi.mock('@cspeach/sap-client', async (importOriginal) => {
15
- const actual = await importOriginal();
16
- return {
17
- ...actual,
18
- auditLog: vi.fn(async () => { }),
19
- };
20
- });
21
- import { classify, getSapSystemInfo, renderSapSystemBlock, parseCvers, parseCversAny, parseCversTree, _resetSystemInfoCache, } from '../system-info.js';
22
- /**
23
- * Avoid touching the user's real ~/.cspeach. Set HOME / USERPROFILE to a
24
- * temp dir for the whole suite so the disk cache writes there.
25
- */
26
- const TEST_HOME = mkdtempSync(join(tmpdir(), 'cspeach-sysinfo-'));
27
- const prevHome = { HOME: process.env['HOME'], USERPROFILE: process.env['USERPROFILE'] };
28
- beforeEach(() => {
29
- process.env['HOME'] = TEST_HOME;
30
- process.env['USERPROFILE'] = TEST_HOME;
31
- _resetSystemInfoCache();
32
- });
33
- afterEach(() => {
34
- process.env['HOME'] = prevHome.HOME;
35
- process.env['USERPROFILE'] = prevHome.USERPROFILE;
36
- });
37
- // ── classify (pure) ──────────────────────────────────────────────────────────
38
- describe('classify', () => {
39
- it('detects S/4HANA when S4CORE is present', () => {
40
- const info = classify('S4H', { SAP_BASIS: '757', S4CORE: '107' });
41
- expect(info.platform).toBe('S/4HANA');
42
- expect(info.release).toBe('S/4HANA 2022');
43
- expect(info.abapVersion).toBe('7.57');
44
- });
45
- it('falls back to S4FND when S4CORE is missing', () => {
46
- const info = classify('S4H', { SAP_BASIS: '758', S4FND: '108' });
47
- expect(info.platform).toBe('S/4HANA');
48
- expect(info.release).toBe('S/4HANA 2023');
49
- });
50
- it('classifies as ECC when SAP_BASIS < 750 and no S4CORE', () => {
51
- const info = classify('ECC', { SAP_BASIS: '740' });
52
- expect(info.platform).toBe('ECC');
53
- expect(info.release).toBe('ECC 6.0 EHP7');
54
- expect(info.abapVersion).toBe('7.40');
55
- });
56
- it('classifies as NetWeaver when SAP_BASIS >= 750 without S4CORE', () => {
57
- // SAP_BASIS 750+ on a non-S/4 system is suite-on-HANA / NW
58
- const info = classify('NW', { SAP_BASIS: '752' });
59
- expect(info.platform).toBe('NetWeaver');
60
- });
61
- it('returns unknown on completely empty CVERS', () => {
62
- const info = classify('X', {});
63
- expect(info.platform).toBe('unknown');
64
- expect(info.release).toBe('unknown');
65
- expect(info.abapVersion).toBe('unknown');
66
- });
67
- it('returns a graceful label for unknown S4CORE versions', () => {
68
- const info = classify('S4H', { SAP_BASIS: '760', S4CORE: '999' });
69
- expect(info.platform).toBe('S/4HANA');
70
- expect(info.release).toContain('S4CORE 999');
71
- });
72
- it('always tags deployment as on-prem-or-private-cloud', () => {
73
- // Public-cloud detection isn't reliable from CVERS; if we got here,
74
- // we're on a system that allows ADT — that rules out Public Cloud.
75
- const info = classify('S4H', { SAP_BASIS: '757', S4CORE: '107' });
76
- expect(info.deployment).toBe('on-prem-or-private-cloud');
77
- });
78
- });
79
- // ── getSapSystemInfo (caching + live query) ──────────────────────────────────
80
- describe('getSapSystemInfo', () => {
81
- function makeAdt(rows) {
82
- return {
83
- sqlQuery: vi.fn(async () => ({
84
- columns: ['COMPONENT', 'RELEASE'],
85
- rows,
86
- })),
87
- };
88
- }
89
- it('queries CVERS once on first call and caches in memory', async () => {
90
- const adt = makeAdt([
91
- { COMPONENT: 'SAP_BASIS', RELEASE: '757' },
92
- { COMPONENT: 'S4CORE', RELEASE: '107' },
93
- ]);
94
- const a = await getSapSystemInfo(adt, 'S4H');
95
- const b = await getSapSystemInfo(adt, 'S4H');
96
- expect(a?.platform).toBe('S/4HANA');
97
- expect(b).toBe(a); // exact same cached object
98
- expect(adt.sqlQuery).toHaveBeenCalledTimes(1);
99
- });
100
- it('returns null silently on query failure (does not throw)', async () => {
101
- const adt = {
102
- sqlQuery: vi.fn(async () => { throw new Error('SAP unreachable'); }),
103
- };
104
- const result = await getSapSystemInfo(adt, 'S4H');
105
- expect(result).toBeNull();
106
- });
107
- it('writes a disk cache entry after a successful live query', async () => {
108
- const adt = makeAdt([
109
- { COMPONENT: 'SAP_BASIS', RELEASE: '758' },
110
- { COMPONENT: 'S4CORE', RELEASE: '108' },
111
- ]);
112
- await getSapSystemInfo(adt, 'PROD');
113
- const cacheFile = join(TEST_HOME, '.cspeach', 'sap-cache', 'PROD.json');
114
- expect(existsSync(cacheFile)).toBe(true);
115
- const persisted = JSON.parse(readFileSync(cacheFile, 'utf8'));
116
- expect(persisted.platform).toBe('S/4HANA');
117
- expect(persisted.release).toBe('S/4HANA 2023');
118
- });
119
- it('serves a fresh disk cache without re-querying', async () => {
120
- const adt1 = makeAdt([{ COMPONENT: 'SAP_BASIS', RELEASE: '757' }, { COMPONENT: 'S4CORE', RELEASE: '107' }]);
121
- await getSapSystemInfo(adt1, 'S4H');
122
- expect(adt1.sqlQuery).toHaveBeenCalledTimes(1);
123
- // Reset the in-memory cache (simulating a new process), keep the disk
124
- _resetSystemInfoCache();
125
- const adt2 = makeAdt([]); // would return empty if it re-queried
126
- const result = await getSapSystemInfo(adt2, 'S4H');
127
- expect(result?.release).toBe('S/4HANA 2022');
128
- expect(adt2.sqlQuery).not.toHaveBeenCalled();
129
- });
130
- it('sanitises alias for the disk filename (security)', async () => {
131
- const adt = makeAdt([{ COMPONENT: 'SAP_BASIS', RELEASE: '757' }]);
132
- // Aliases with path separators or `..` would otherwise allow directory traversal.
133
- await getSapSystemInfo(adt, '../evil/alias');
134
- // The sanitised alias becomes "..evilalias" — still inside cache dir.
135
- const sanitisedFile = join(TEST_HOME, '.cspeach', 'sap-cache', '.._evil_alias.json');
136
- expect(existsSync(sanitisedFile)).toBe(true);
137
- });
138
- });
139
- // ── renderSapSystemBlock ─────────────────────────────────────────────────────
140
- describe('renderSapSystemBlock', () => {
141
- it('renders an XML attribute block with all five fields', () => {
142
- const block = renderSapSystemBlock({
143
- alias: 'S4H',
144
- platform: 'S/4HANA',
145
- release: 'S/4HANA 2022',
146
- abapVersion: '7.57',
147
- deployment: 'on-prem-or-private-cloud',
148
- components: { SAP_BASIS: '757', S4CORE: '107' },
149
- queriedAt: '2026-05-06T20:00:00Z',
150
- });
151
- expect(block).toContain('alias="S4H"');
152
- expect(block).toContain('platform="S/4HANA"');
153
- expect(block).toContain('release="S/4HANA 2022"');
154
- expect(block).toContain('abap_version="7.57"');
155
- expect(block).toContain('deployment="on-prem-or-private-cloud"');
156
- });
157
- it('returns an empty string for null input', () => {
158
- expect(renderSapSystemBlock(null)).toBe('');
159
- });
160
- it('escapes attribute-breaking chars', () => {
161
- const block = renderSapSystemBlock({
162
- alias: 'foo"bar',
163
- platform: 'S/4HANA',
164
- release: 'release & co',
165
- abapVersion: '7.57',
166
- deployment: 'on-prem-or-private-cloud',
167
- components: {},
168
- queriedAt: '',
169
- });
170
- expect(block).toContain('alias="foo&quot;bar"');
171
- expect(block).toContain('release="release &amp; co"');
172
- });
173
- });
174
- // ── parseCvers (column-major SAP ADT response) ───────────────────────────────
175
- describe('parseCvers', () => {
176
- it('parses the real S/4HANA SAP ADT response shape', () => {
177
- // Captured 2026-05-06 from a live S/4HANA system. Column-major:
178
- // each <columns> block holds metadata + a <dataSet> with <data>
179
- // cells covering ALL rows for that column.
180
- const xml = `<?xml version="1.0" encoding="utf-8"?>
181
- <dataPreview:tableData xmlns:dataPreview="http://www.sap.com/adt/dataPreview">
182
- <dataPreview:totalRows>3</dataPreview:totalRows>
183
- <dataPreview:columns>
184
- <dataPreview:metadata dataPreview:name="COMPONENT" dataPreview:type="C"/>
185
- <dataPreview:dataSet>
186
- <dataPreview:data>SAP_BASIS</dataPreview:data>
187
- <dataPreview:data>S4CORE</dataPreview:data>
188
- <dataPreview:data>SAP_ABA</dataPreview:data>
189
- </dataPreview:dataSet>
190
- </dataPreview:columns>
191
- <dataPreview:columns>
192
- <dataPreview:metadata dataPreview:name="RELEASE" dataPreview:type="C"/>
193
- <dataPreview:dataSet>
194
- <dataPreview:data>757</dataPreview:data>
195
- <dataPreview:data>107</dataPreview:data>
196
- <dataPreview:data>757</dataPreview:data>
197
- </dataPreview:dataSet>
198
- </dataPreview:columns>
199
- </dataPreview:tableData>`;
200
- const result = parseCvers(xml);
201
- expect(result).toEqual({
202
- SAP_BASIS: '757',
203
- S4CORE: '107',
204
- SAP_ABA: '757',
205
- });
206
- });
207
- it('returns empty object on missing COMPONENT or RELEASE column', () => {
208
- const xml = `<?xml version="1.0"?>
209
- <dataPreview:tableData xmlns:dataPreview="http://www.sap.com/adt/dataPreview">
210
- <dataPreview:columns>
211
- <dataPreview:metadata dataPreview:name="MANDT"/>
212
- <dataPreview:dataSet><dataPreview:data>000</dataPreview:data></dataPreview:dataSet>
213
- </dataPreview:columns>
214
- </dataPreview:tableData>`;
215
- expect(parseCvers(xml)).toEqual({});
216
- });
217
- it('returns empty on completely empty response', () => {
218
- expect(parseCvers('')).toEqual({});
219
- expect(parseCvers('<dataPreview:tableData/>')).toEqual({});
220
- });
221
- });
222
- // ── parseCversTree (system-agnostic tree walker) ──────────────────────────────
223
- describe('parseCversTree', () => {
224
- it('handles the documented column-major shape (metadata + dataSet)', async () => {
225
- const xml = `<?xml version="1.0"?>
226
- <dataPreview:tableData xmlns:dataPreview="http://www.sap.com/adt/dataPreview">
227
- <dataPreview:columns>
228
- <dataPreview:metadata dataPreview:name="COMPONENT"/>
229
- <dataPreview:dataSet>
230
- <dataPreview:data>SAP_BASIS</dataPreview:data>
231
- <dataPreview:data>S4CORE</dataPreview:data>
232
- </dataPreview:dataSet>
233
- </dataPreview:columns>
234
- <dataPreview:columns>
235
- <dataPreview:metadata dataPreview:name="RELEASE"/>
236
- <dataPreview:dataSet>
237
- <dataPreview:data>757</dataPreview:data>
238
- <dataPreview:data>107</dataPreview:data>
239
- </dataPreview:dataSet>
240
- </dataPreview:columns>
241
- </dataPreview:tableData>`;
242
- const result = await parseCversTree(xml);
243
- expect(result).toEqual({ SAP_BASIS: '757', S4CORE: '107' });
244
- });
245
- it('handles row-major shape (columnName lists + dataRow children)', async () => {
246
- // Older NetWeaver / row-oriented data preview shape — names listed
247
- // upfront, values come grouped by row.
248
- const xml = `<?xml version="1.0"?>
249
- <dataPreview:tableData xmlns:dataPreview="http://www.sap.com/adt/dataPreview">
250
- <dataPreview:columns>
251
- <dataPreview:columnName>COMPONENT</dataPreview:columnName>
252
- <dataPreview:columnName>RELEASE</dataPreview:columnName>
253
- </dataPreview:columns>
254
- <dataPreview:dataRow>
255
- <dataPreview:dataCell>SAP_BASIS</dataPreview:dataCell>
256
- <dataPreview:dataCell>740</dataPreview:dataCell>
257
- </dataPreview:dataRow>
258
- <dataPreview:dataRow>
259
- <dataPreview:dataCell>SAP_ABA</dataPreview:dataCell>
260
- <dataPreview:dataCell>740</dataPreview:dataCell>
261
- </dataPreview:dataRow>
262
- </dataPreview:tableData>`;
263
- const result = await parseCversTree(xml);
264
- expect(result).toEqual({ SAP_BASIS: '740', SAP_ABA: '740' });
265
- });
266
- it('is namespace-agnostic — works without any prefix at all', async () => {
267
- // Some SAP versions strip namespaces entirely on the wire.
268
- const xml = `<?xml version="1.0"?>
269
- <tableData>
270
- <columns>
271
- <metadata name="COMPONENT"/>
272
- <dataSet>
273
- <data>SAP_BASIS</data>
274
- <data>S4CORE</data>
275
- </dataSet>
276
- </columns>
277
- <columns>
278
- <metadata name="RELEASE"/>
279
- <dataSet>
280
- <data>758</data>
281
- <data>108</data>
282
- </dataSet>
283
- </columns>
284
- </tableData>`;
285
- const result = await parseCversTree(xml);
286
- expect(result).toEqual({ SAP_BASIS: '758', S4CORE: '108' });
287
- });
288
- it('returns empty for a body with neither shape', async () => {
289
- expect(await parseCversTree('<empty/>')).toEqual({});
290
- expect(await parseCversTree('')).toEqual({});
291
- });
292
- it('does not throw on malformed XML', async () => {
293
- const result = await parseCversTree('this is not xml at all');
294
- expect(result).toEqual({});
295
- });
296
- });
297
- // ── parseCversAny (parser fallback chain) ─────────────────────────────────────
298
- describe('parseCversAny', () => {
299
- it('uses the cheap regex parser when it succeeds', async () => {
300
- // Same shape parseCvers handles — chain should not need to fall back.
301
- const xml = `<?xml version="1.0"?>
302
- <dataPreview:tableData xmlns:dataPreview="http://www.sap.com/adt/dataPreview">
303
- <dataPreview:columns>
304
- <dataPreview:metadata dataPreview:name="COMPONENT"/>
305
- <dataPreview:dataSet><dataPreview:data>SAP_BASIS</dataPreview:data></dataPreview:dataSet>
306
- </dataPreview:columns>
307
- <dataPreview:columns>
308
- <dataPreview:metadata dataPreview:name="RELEASE"/>
309
- <dataPreview:dataSet><dataPreview:data>757</dataPreview:data></dataPreview:dataSet>
310
- </dataPreview:columns>
311
- </dataPreview:tableData>`;
312
- const result = await parseCversAny(xml);
313
- expect(result).toEqual({ SAP_BASIS: '757' });
314
- });
315
- it('falls back to the tree walker when regex returns empty', async () => {
316
- // Row-major shape — parseCvers (column-major regex) cannot read this.
317
- // The chain MUST escalate to parseCversTree.
318
- const xml = `<?xml version="1.0"?>
319
- <tableData>
320
- <columns>
321
- <columnName>COMPONENT</columnName>
322
- <columnName>RELEASE</columnName>
323
- </columns>
324
- <dataRow>
325
- <dataCell>SAP_BASIS</dataCell>
326
- <dataCell>740</dataCell>
327
- </dataRow>
328
- </tableData>`;
329
- expect(parseCvers(xml)).toEqual({}); // confirm Attempt 1 fails
330
- const result = await parseCversAny(xml); // Attempt 2 should succeed
331
- expect(result).toEqual({ SAP_BASIS: '740' });
332
- });
333
- it('returns empty when every parser fails (caller will dump)', async () => {
334
- const result = await parseCversAny('<unknown_shape><nonsense/></unknown_shape>');
335
- expect(result).toEqual({});
336
- });
337
- });
338
- // Cleanup the temp dir after the suite
339
- afterEach(() => {
340
- try {
341
- rmSync(TEST_HOME, { recursive: true, force: true });
342
- }
343
- catch { /* best-effort */ }
344
- });
@@ -1,114 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { promises as fs } from 'node:fs';
3
- import * as os from 'node:os';
4
- import * as path from 'node:path';
5
- import { newSession } from '../schema.js';
6
- import { saveSession, listSessions, countInterruptedSessions } from '../store.js';
7
- // Tests redirect HOME so the global ~/.cspeach/sessions/ directory is replaced
8
- // by a per-test temp dir. listSessions / countInterruptedSessions read from
9
- // that dir under the hood.
10
- const ORIGINAL_HOME = os.homedir();
11
- describe('listSessions + countInterruptedSessions (Round A enrichment)', () => {
12
- let tempHome;
13
- beforeEach(async () => {
14
- tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'session-discovery-'));
15
- process.env.HOME = tempHome;
16
- process.env.USERPROFILE = tempHome;
17
- });
18
- afterEach(async () => {
19
- process.env.HOME = ORIGINAL_HOME;
20
- process.env.USERPROFILE = ORIGINAL_HOME;
21
- await fs.rm(tempHome, { recursive: true, force: true });
22
- });
23
- function makeSampleSession(overrides = {}) {
24
- const id = Math.random().toString(16).slice(2, 10);
25
- const s = newSession(id, 'S4H', 'abap-cca', 'claude-opus-4-7');
26
- Object.assign(s, overrides);
27
- return s;
28
- }
29
- it('returns empty array when no sessions exist', async () => {
30
- const r = await listSessions();
31
- expect(r).toEqual([]);
32
- });
33
- it('counts zero interrupted sessions when none exist', async () => {
34
- expect(await countInterruptedSessions()).toBe(0);
35
- });
36
- it('extracts promptSnippet from the most recent user message', async () => {
37
- const s = makeSampleSession();
38
- s.messages = [
39
- { role: 'user', content: '/abap-cca on ZDPR_PAYMENTS' },
40
- { role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
41
- { role: 'user', content: 'continue and also check ZSO_*' },
42
- ];
43
- await saveSession(s);
44
- const list = await listSessions();
45
- expect(list).toHaveLength(1);
46
- expect(list[0]?.promptSnippet).toBe('continue and also check ZSO_*');
47
- expect(list[0]?.userTurnCount).toBe(2);
48
- });
49
- it('strips <session_context> wrapper from the snippet', async () => {
50
- const s = makeSampleSession();
51
- s.messages = [
52
- {
53
- role: 'user',
54
- content: '<session_context>\nfacts: foo bar\n</session_context>\n\n/abap-cca on ZDPR_PAYMENTS',
55
- },
56
- ];
57
- await saveSession(s);
58
- const list = await listSessions();
59
- expect(list[0]?.promptSnippet).toBe('/abap-cca on ZDPR_PAYMENTS');
60
- });
61
- it('caps promptSnippet at 80 chars', async () => {
62
- const s = makeSampleSession();
63
- s.messages = [{ role: 'user', content: 'a'.repeat(200) }];
64
- await saveSession(s);
65
- const list = await listSessions();
66
- expect(list[0]?.promptSnippet?.length).toBe(80);
67
- });
68
- it('reports interrupted=true when turnInterrupted flag is set', async () => {
69
- const s = makeSampleSession();
70
- s.turnInterrupted = true;
71
- s.turnInterruptedReason = 'terminated';
72
- s.messages = [{ role: 'user', content: '/abap-cca on Z*' }];
73
- await saveSession(s);
74
- const list = await listSessions();
75
- expect(list[0]?.interrupted).toBe(true);
76
- expect(list[0]?.interruptedReason).toBe('terminated');
77
- });
78
- it('reports interrupted=false when flag absent (older sessions)', async () => {
79
- const s = makeSampleSession();
80
- s.messages = [{ role: 'user', content: 'hello' }];
81
- // Don't set turnInterrupted at all.
82
- await saveSession(s);
83
- const list = await listSessions();
84
- expect(list[0]?.interrupted).toBe(false);
85
- });
86
- it('countInterruptedSessions counts only interrupted ones', async () => {
87
- const a = makeSampleSession({ id: 'aaaaaaaa', started_at: '2026-05-09T20:00:00.000Z' });
88
- a.turnInterrupted = true;
89
- a.messages = [{ role: 'user', content: 'one' }];
90
- await saveSession(a);
91
- const b = makeSampleSession({ id: 'bbbbbbbb', started_at: '2026-05-09T21:00:00.000Z' });
92
- b.turnInterrupted = false;
93
- b.messages = [{ role: 'user', content: 'two' }];
94
- await saveSession(b);
95
- const c = makeSampleSession({ id: 'cccccccc', started_at: '2026-05-09T22:00:00.000Z' });
96
- c.turnInterrupted = true;
97
- c.messages = [{ role: 'user', content: 'three' }];
98
- await saveSession(c);
99
- expect(await countInterruptedSessions()).toBe(2);
100
- });
101
- it('sorts most-recent-last-turn first', async () => {
102
- const old = makeSampleSession({ id: 'aaaaaaaa', started_at: '2026-05-08T00:00:00.000Z' });
103
- old.last_turn_at = '2026-05-08T00:00:00.000Z';
104
- old.messages = [{ role: 'user', content: 'old prompt' }];
105
- await saveSession(old);
106
- const fresh = makeSampleSession({ id: 'bbbbbbbb', started_at: '2026-05-09T20:00:00.000Z' });
107
- fresh.last_turn_at = '2026-05-09T20:00:00.000Z';
108
- fresh.messages = [{ role: 'user', content: 'fresh prompt' }];
109
- await saveSession(fresh);
110
- const list = await listSessions();
111
- expect(list[0]?.id).toBe('bbbbbbbb');
112
- expect(list[1]?.id).toBe('aaaaaaaa');
113
- });
114
- });