@cspeach/cli 0.6.3 → 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 (107) hide show
  1. package/dist/agent/__tests__/crash-recover.integration.test.js +254 -0
  2. package/dist/agent/__tests__/loop-save-hook.test.js +188 -0
  3. package/dist/agent/__tests__/maybe-build-project-context.test.js +247 -0
  4. package/dist/agent/__tests__/project-context-injection.test.js +161 -0
  5. package/dist/agent/__tests__/repair-partial.test.js +79 -0
  6. package/dist/agent/__tests__/retry-key.test.js +77 -0
  7. package/dist/agent/__tests__/sap-connection-adapter.test.js +132 -0
  8. package/dist/agent/__tests__/skill-checkpoint.test.js +154 -0
  9. package/dist/agent/__tests__/turn-assistant-text.test.js +73 -0
  10. package/dist/agent/__tests__/turn-error-ux.test.js +145 -0
  11. package/dist/agent/__tests__/turn-stream.test.js +100 -0
  12. package/dist/agent/__tests__/turn-watchdog.test.js +126 -0
  13. package/dist/agent/providers/__tests__/ai-hub-provider.test.js +30 -0
  14. package/dist/agent/providers/__tests__/byok-provider.test.js +32 -0
  15. package/dist/agent/providers/__tests__/factory.test.js +33 -0
  16. package/dist/agent/providers/__tests__/local-provider.test.js +55 -0
  17. package/dist/approvals/__tests__/advisory-prompt.test.js +57 -0
  18. package/dist/approvals/__tests__/advisory-render.test.js +54 -0
  19. package/dist/auth/__tests__/auth-file.test.js +42 -0
  20. package/dist/auth/__tests__/me.test.js +43 -0
  21. package/dist/classifier/__tests__/client.test.js +70 -0
  22. package/dist/commands/__tests__/config-set-write-mode.test.js +14 -0
  23. package/dist/commands/__tests__/login.test.js +152 -0
  24. package/dist/commands/__tests__/logout.test.js +39 -0
  25. package/dist/commands/__tests__/project-context-impact.test.js +173 -0
  26. package/dist/commands/__tests__/spec-gap-status.test.js +31 -0
  27. package/dist/commands/__tests__/whoami.test.js +91 -0
  28. package/dist/config/__tests__/llm-config.test.js +28 -0
  29. package/dist/config/__tests__/shell-exec-config.test.js +76 -0
  30. package/dist/config/__tests__/write-mode.test.js +36 -0
  31. package/dist/doctor/__tests__/check-forge-rules.test.js +134 -0
  32. package/dist/doctor/__tests__/check-llm-mode.test.js +21 -0
  33. package/dist/doctor/__tests__/check-write-mode.test.js +34 -0
  34. package/dist/index.js +0 -0
  35. package/dist/project-context/__tests__/conventions.test.js +221 -0
  36. package/dist/project-context/__tests__/detect.test.js +177 -0
  37. package/dist/project-context/__tests__/domain-abap-cloud.test.js +119 -0
  38. package/dist/project-context/__tests__/domain-abapgit.test.js +161 -0
  39. package/dist/project-context/__tests__/domain-cap.test.js +168 -0
  40. package/dist/project-context/__tests__/domain-fiori.test.js +282 -0
  41. package/dist/project-context/__tests__/git.test.js +131 -0
  42. package/dist/project-context/__tests__/index-files.test.js +195 -0
  43. package/dist/project-context/__tests__/index.test.js +133 -0
  44. package/dist/project-context/__tests__/render.test.js +322 -0
  45. package/dist/project-context/__tests__/types.test.js +73 -0
  46. package/dist/projects/__tests__/build.test.js +52 -0
  47. package/dist/projects/__tests__/canonicalize.test.js +47 -0
  48. package/dist/projects/__tests__/email-template.test.js +71 -0
  49. package/dist/projects/__tests__/expand-text-attachments.test.js +105 -0
  50. package/dist/projects/__tests__/extract-cca.test.js +141 -0
  51. package/dist/projects/__tests__/extract-design.test.js +62 -0
  52. package/dist/projects/__tests__/extract-estimate.test.js +58 -0
  53. package/dist/projects/__tests__/extract-modernize.test.js +127 -0
  54. package/dist/projects/__tests__/extract-spec-gap.test.js +122 -0
  55. package/dist/projects/__tests__/extract-test-coverage.test.js +133 -0
  56. package/dist/projects/__tests__/filename.test.js +32 -0
  57. package/dist/projects/__tests__/promote-command.test.js +170 -0
  58. package/dist/projects/__tests__/promote.test.js +181 -0
  59. package/dist/projects/__tests__/save-command.test.js +216 -0
  60. package/dist/projects/__tests__/save.test.js +41 -0
  61. package/dist/projects/__tests__/status.test.js +208 -0
  62. package/dist/projects/__tests__/types.test.js +41 -0
  63. package/dist/projects/__tests__/validate.test.js +165 -0
  64. package/dist/projects/__tests__/workspace.test.js +337 -0
  65. package/dist/repl/__tests__/current-transport.test.js +43 -0
  66. package/dist/repl/__tests__/diff-display.test.js +59 -0
  67. package/dist/repl/__tests__/file-picker.test.js +97 -0
  68. package/dist/repl/__tests__/rule8-detector.test.js +114 -0
  69. package/dist/repl/__tests__/safety-confirm.test.js +130 -0
  70. package/dist/repl/__tests__/safety-mode-state.test.js +42 -0
  71. package/dist/sap/__tests__/system-info.test.js +344 -0
  72. package/dist/session/__tests__/store-discovery.test.js +114 -0
  73. package/dist/session/__tests__/time-ago.test.js +53 -0
  74. package/dist/skills/__tests__/canonical.test.js +20 -0
  75. package/dist/skills/__tests__/manifest-client.test.js +88 -0
  76. package/dist/skills/__tests__/promotion-dispatch.test.js +28 -0
  77. package/dist/skills/__tests__/source-managed.test.js +60 -0
  78. package/dist/tools/__tests__/_background-shared.test.js +69 -0
  79. package/dist/tools/__tests__/_command-shared.test.js +59 -0
  80. package/dist/tools/__tests__/_filesystem-shared.test.js +42 -0
  81. package/dist/tools/__tests__/_flag.test.js +66 -0
  82. package/dist/tools/__tests__/_helpers.js +17 -0
  83. package/dist/tools/__tests__/_web-shared.test.js +143 -0
  84. package/dist/tools/__tests__/agent_run.test.js +166 -0
  85. package/dist/tools/__tests__/approval-advisory.test.js +66 -0
  86. package/dist/tools/__tests__/background_run.test.js +113 -0
  87. package/dist/tools/__tests__/convention_get.test.js +53 -0
  88. package/dist/tools/__tests__/file-edit.test.js +72 -0
  89. package/dist/tools/__tests__/file-read.test.js +81 -0
  90. package/dist/tools/__tests__/file-write.test.js +68 -0
  91. package/dist/tools/__tests__/glob.test.js +128 -0
  92. package/dist/tools/__tests__/grep.test.js +71 -0
  93. package/dist/tools/__tests__/monitor_emit.test.js +96 -0
  94. package/dist/tools/__tests__/playbook_get.test.js +109 -0
  95. package/dist/tools/__tests__/project_context_get.test.js +71 -0
  96. package/dist/tools/__tests__/registry-category.test.js +60 -0
  97. package/dist/tools/__tests__/sap-read-extras.test.js +196 -0
  98. package/dist/tools/__tests__/sap-write-extras.test.js +573 -0
  99. package/dist/tools/__tests__/schedule_create.test.js +85 -0
  100. package/dist/tools/__tests__/shell_exec.test.js +135 -0
  101. package/dist/tools/__tests__/transport-safety.test.js +106 -0
  102. package/dist/tools/__tests__/update-method-intercept.test.js +128 -0
  103. package/dist/tools/__tests__/web_fetch.test.js +329 -0
  104. package/dist/tools/__tests__/web_search.test.js +215 -0
  105. package/dist/tools/__tests__/write-mode.test.js +32 -0
  106. package/dist/ui/__tests__/login-banner.test.js +69 -0
  107. package/package.json +82 -83
@@ -0,0 +1,135 @@
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 { shellExecHandler } from '../shell/shell_exec.js';
6
+ import { makeCtx } from './_helpers.js';
7
+ let root;
8
+ const ctx = () => makeCtx(root);
9
+ beforeEach(async () => { root = await fs.mkdtemp(path.join(os.tmpdir(), 'cspeach-shell-')); });
10
+ afterEach(async () => { await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); });
11
+ describe('shell_exec handler', () => {
12
+ it('runs a safelisted command and surfaces stdout + exit code', async () => {
13
+ const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.stdout.write("hello")'] }, ctx());
14
+ expect(r.is_error).toBeFalsy();
15
+ expect(r.content).toMatch(/stdout:\s*hello/);
16
+ expect(r.content).toMatch(/exit code:\s*0/);
17
+ });
18
+ it('surfaces stderr separately from stdout', async () => {
19
+ const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.stderr.write("err"); process.stdout.write("out")'] }, ctx());
20
+ expect(r.content).toMatch(/stdout:\s*out/);
21
+ expect(r.content).toMatch(/stderr:\s*err/);
22
+ });
23
+ it('reports non-zero exit code without marking is_error', async () => {
24
+ const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.exit(42)'] }, ctx());
25
+ // Non-zero exit is a normal program outcome — the agent should see it
26
+ // and decide. is_error is reserved for "we refused / spawn failed".
27
+ expect(r.is_error).toBeFalsy();
28
+ expect(r.content).toMatch(/exit code:\s*42/);
29
+ });
30
+ it('shell metacharacters in args are passed literally — no shell parser', async () => {
31
+ // The string `; rm -rf /` becomes literal argv[1] to node, which echoes it.
32
+ // If a shell were interpreting, the `;` would split commands and the test
33
+ // output would be different (or the rm command would attempt to run).
34
+ const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.stdout.write(process.argv[1])', '; rm -rf /'] }, ctx());
35
+ expect(r.content).toMatch(/stdout:\s*; rm -rf \//);
36
+ expect(r.content).toMatch(/exit code:\s*0/);
37
+ });
38
+ it('refuses an absolute-path command even if its basename is safelisted', async () => {
39
+ const fakeAbsolute = process.platform === 'win32' ? 'C:\\Windows\\System32\\cmd.exe' : '/bin/sh';
40
+ const r = await shellExecHandler({ command: fakeAbsolute, args: [] }, ctx());
41
+ expect(r.is_error).toBe(true);
42
+ expect(r.content).toMatch(/safelist|absolute path/i);
43
+ });
44
+ it('refuses a non-safelisted command', async () => {
45
+ const r = await shellExecHandler({ command: 'curl', args: ['https://example.com'] }, ctx());
46
+ expect(r.is_error).toBe(true);
47
+ expect(r.content).toMatch(/safelist/i);
48
+ expect(r.content).toMatch(/curl/);
49
+ });
50
+ it('refuses a cwd outside the project root', async () => {
51
+ const outsideAbsolute = process.platform === 'win32' ? 'C:\\' : '/';
52
+ const r = await shellExecHandler({ command: 'node', args: ['-e', 'process.stdout.write(process.cwd())'], cwd: outsideAbsolute }, ctx());
53
+ expect(r.is_error).toBe(true);
54
+ expect(r.content).toMatch(/outside|cwd/i);
55
+ });
56
+ it('does NOT pass auth-secret env vars (CSPEACH_*, ANTHROPIC_*, SAP_*) to the child', async () => {
57
+ process.env.CSPEACH_TOKEN_TEST = 'must-not-leak';
58
+ process.env.ANTHROPIC_API_KEY_TEST = 'must-not-leak';
59
+ try {
60
+ const r = await shellExecHandler({
61
+ command: 'node',
62
+ args: [
63
+ '-e',
64
+ 'const k = ["CSPEACH_TOKEN_TEST","ANTHROPIC_API_KEY_TEST"].filter(x=>process.env[x]); process.stdout.write(k.join(","))',
65
+ ],
66
+ }, ctx());
67
+ expect(r.content).toMatch(/stdout:\s*\n/); // empty stdout — neither var visible to child
68
+ expect(r.content).not.toContain('must-not-leak');
69
+ }
70
+ finally {
71
+ delete process.env.CSPEACH_TOKEN_TEST;
72
+ delete process.env.ANTHROPIC_API_KEY_TEST;
73
+ }
74
+ });
75
+ it('kills a runaway child after the wall-clock timeout', async () => {
76
+ const start = Date.now();
77
+ const r = await shellExecHandler({ command: 'node', args: ['-e', 'setInterval(()=>{},1000)'], timeout_ms: 300 }, ctx());
78
+ const elapsed = Date.now() - start;
79
+ expect(r.is_error).toBe(true);
80
+ expect(r.content).toMatch(/timeout|timed out/i);
81
+ expect(elapsed).toBeLessThan(3_000); // generous — must be well under the 30s default
82
+ });
83
+ it('truncates stdout when it exceeds the per-stream cap', async () => {
84
+ // 2 MB of A's — should be truncated at 1 MB with a marker.
85
+ const r = await shellExecHandler({
86
+ command: 'node',
87
+ args: ['-e', 'process.stdout.write("A".repeat(2_000_000))'],
88
+ timeout_ms: 10_000,
89
+ }, ctx());
90
+ expect(r.is_error).toBeFalsy();
91
+ expect(r.content).toMatch(/\[truncated/i);
92
+ // Content should not contain the full 2 MB — be conservative, check length cap.
93
+ expect(r.content.length).toBeLessThan(1_200_000);
94
+ });
95
+ it('reports a structured error when the safelisted binary is not on PATH', async () => {
96
+ const origPath = process.env.PATH;
97
+ process.env.PATH = root; // PATH points only at empty tempdir — nothing installed.
98
+ try {
99
+ const r = await shellExecHandler({ command: 'abaplint', args: ['--version'] }, ctx());
100
+ expect(r.is_error).toBe(true);
101
+ expect(r.content).toMatch(/not installed|not found/i);
102
+ expect(r.content).toMatch(/abaplint/);
103
+ }
104
+ finally {
105
+ if (origPath === undefined)
106
+ delete process.env.PATH;
107
+ else
108
+ process.env.PATH = origPath;
109
+ }
110
+ });
111
+ it('rejects a non-string cwd with a clean error message (not a leaked TypeError)', async () => {
112
+ const r = await shellExecHandler({ command: 'node', args: [], cwd: 42 }, ctx());
113
+ expect(r.is_error).toBe(true);
114
+ expect(r.content).toMatch(/cwd must be a string/i);
115
+ expect(r.content).not.toMatch(/typeerror|includes is not a function/i);
116
+ });
117
+ it('rejects a non-number timeout_ms with a clean error message', async () => {
118
+ const r = await shellExecHandler({ command: 'node', args: [], timeout_ms: 'fast' }, ctx());
119
+ expect(r.is_error).toBe(true);
120
+ expect(r.content).toMatch(/timeout_ms must be a number/i);
121
+ });
122
+ it('rejects an argv array longer than the cap (defense-in-depth against memory exhaustion)', async () => {
123
+ const huge = Array.from({ length: 1_001 }, () => 'x');
124
+ const r = await shellExecHandler({ command: 'node', args: huge }, ctx());
125
+ expect(r.is_error).toBe(true);
126
+ expect(r.content).toMatch(/too many args/i);
127
+ });
128
+ it('rejects an individual arg larger than the per-arg cap', async () => {
129
+ const tooBig = 'x'.repeat(100_001);
130
+ const r = await shellExecHandler({ command: 'node', args: [tooBig] }, ctx());
131
+ expect(r.is_error).toBe(true);
132
+ expect(r.content).toMatch(/exceeds max length/i);
133
+ expect(r.content).toMatch(/index 0/);
134
+ });
135
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * B.2 tests: sap_transport_release safety confirmation gate (Rule 9).
3
+ *
4
+ * Verifies that:
5
+ * - When safety mode is ON and user confirms, presentSafetyConfirmation is called
6
+ * with the correct SafetyAction shape (rule=RULE_9_TRANSPORT_RELEASE) and
7
+ * transportRelease proceeds.
8
+ * - When safety mode is ON and user declines, cancelled_by_user is returned and
9
+ * transportRelease is NOT called.
10
+ */
11
+ import '../transport.js'; // triggers registration side-effect
12
+ import { getTool } from '../index.js';
13
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
14
+ // ---------------------------------------------------------------------------
15
+ // Mock @cspeach/sap-client (auditLog used by transport.ts)
16
+ // ---------------------------------------------------------------------------
17
+ vi.mock('@cspeach/sap-client', async (importOriginal) => {
18
+ const actual = await importOriginal();
19
+ return {
20
+ ...actual,
21
+ auditLog: vi.fn().mockResolvedValue(undefined),
22
+ };
23
+ });
24
+ // ---------------------------------------------------------------------------
25
+ // Mock verifyAndSpendApprovalId to bypass JWT check
26
+ // ---------------------------------------------------------------------------
27
+ const mockVerifyAndSpend = vi.hoisted(() => vi.fn());
28
+ vi.mock('../../approvals/jwt.js', () => ({
29
+ verifyAndSpendApprovalId: mockVerifyAndSpend,
30
+ mintApprovalId: vi.fn(),
31
+ }));
32
+ // ---------------------------------------------------------------------------
33
+ // Mock safety-mode-state and safety-confirm
34
+ // ---------------------------------------------------------------------------
35
+ const mockIsSafetyConfirmEnabled = vi.hoisted(() => vi.fn());
36
+ vi.mock('../../repl/safety-mode-state.js', () => ({
37
+ isSafetyConfirmEnabled: mockIsSafetyConfirmEnabled,
38
+ }));
39
+ const mockPresentSafetyConfirmation = vi.hoisted(() => vi.fn());
40
+ vi.mock('../../repl/safety-confirm.js', () => ({
41
+ presentSafetyConfirmation: mockPresentSafetyConfirmation,
42
+ }));
43
+ // ---------------------------------------------------------------------------
44
+ // Helper: build a minimal ToolContext stub
45
+ // ---------------------------------------------------------------------------
46
+ function makeCtx(adt) {
47
+ return {
48
+ adt: adt,
49
+ sapAlias: 'dev',
50
+ session: { toolCalls: [] },
51
+ cwd: '.',
52
+ };
53
+ }
54
+ beforeEach(() => {
55
+ vi.clearAllMocks();
56
+ // Default: approval always succeeds
57
+ mockVerifyAndSpend.mockResolvedValue({ ok: true, payload: {} });
58
+ // Default: safety mode OFF
59
+ mockIsSafetyConfirmEnabled.mockReturnValue(false);
60
+ });
61
+ // ---------------------------------------------------------------------------
62
+ // B.2 sap_transport_release — safety confirmation gate (Rule 9)
63
+ // ---------------------------------------------------------------------------
64
+ describe('sap_transport_release safety confirmation', () => {
65
+ it('calls presentSafetyConfirmation with correct SafetyAction and proceeds when confirmed', async () => {
66
+ mockIsSafetyConfirmEnabled.mockReturnValue(true);
67
+ mockPresentSafetyConfirmation.mockResolvedValue({ confirmed: true });
68
+ const mockTransportRelease = vi.fn().mockResolvedValue({
69
+ operation: 'release',
70
+ transportNumber: 'DEVK900123',
71
+ status: 'released',
72
+ });
73
+ const adt = { transportRelease: mockTransportRelease };
74
+ const tool = getTool('sap_transport_release');
75
+ const result = await tool.handler({ transport: 'DEVK900123', approval_id: 'dummy-jwt' }, makeCtx(adt));
76
+ // Safety confirmation must have been called with correct shape
77
+ expect(mockPresentSafetyConfirmation).toHaveBeenCalledOnce();
78
+ const action = mockPresentSafetyConfirmation.mock.calls[0][0];
79
+ expect(action.rule).toBe('RULE_9_TRANSPORT_RELEASE');
80
+ expect(action.op).toBe('transport_release');
81
+ expect(action.object).toEqual({ type: 'TRANSPORT', name: 'DEVK900123' });
82
+ expect(action.what_will_happen).toBeTruthy();
83
+ expect(action.rollback_available).toBe(false);
84
+ expect(action.transport).toBe('DEVK900123');
85
+ // transportRelease must have been called (confirmed)
86
+ expect(mockTransportRelease).toHaveBeenCalledOnce();
87
+ expect(mockTransportRelease).toHaveBeenCalledWith('DEVK900123');
88
+ const parsed = JSON.parse(result.content);
89
+ expect(parsed.released).toBe(true);
90
+ expect(result.is_error).toBeFalsy();
91
+ });
92
+ it('returns cancelled_by_user and does NOT call transportRelease when user declines', async () => {
93
+ mockIsSafetyConfirmEnabled.mockReturnValue(true);
94
+ mockPresentSafetyConfirmation.mockResolvedValue({ confirmed: false, reason: 'not ready' });
95
+ const mockTransportRelease = vi.fn();
96
+ const adt = { transportRelease: mockTransportRelease };
97
+ const tool = getTool('sap_transport_release');
98
+ const result = await tool.handler({ transport: 'DEVK900123', approval_id: 'dummy-jwt' }, makeCtx(adt));
99
+ expect(result.is_error).toBe(true);
100
+ const parsed = JSON.parse(result.content);
101
+ expect(parsed.error).toBe('cancelled_by_user');
102
+ expect(parsed.reason).toBe('not ready');
103
+ // Critical: transportRelease must NOT be called
104
+ expect(mockTransportRelease).not.toHaveBeenCalled();
105
+ });
106
+ });
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { interceptUpdateMethod, extractMethodBody, CancelledByUserError, } from '../update-method-intercept.js';
3
+ const SAMPLE_CLASS = `CLASS zcl_demo DEFINITION PUBLIC FINAL CREATE PUBLIC.
4
+ PUBLIC SECTION.
5
+ METHODS dispatch.
6
+ METHODS run_diff.
7
+ ENDCLASS.
8
+
9
+ CLASS zcl_demo IMPLEMENTATION.
10
+
11
+ METHOD dispatch.
12
+ DATA(lv_x) = 1.
13
+ lv_x = lv_x + 2.
14
+ " inline comment with " a quote
15
+ WRITE: lv_x.
16
+ ENDMETHOD.
17
+
18
+ METHOD run_diff.
19
+ RETURN.
20
+ ENDMETHOD.
21
+
22
+ ENDCLASS.`;
23
+ describe('extractMethodBody', () => {
24
+ it('returns the named method block including METHOD and ENDMETHOD', () => {
25
+ const body = extractMethodBody(SAMPLE_CLASS, 'dispatch');
26
+ expect(body).toMatch(/^\s*METHOD dispatch\.$/m);
27
+ expect(body).toMatch(/^\s*ENDMETHOD\.$/m);
28
+ expect(body).toContain('DATA(lv_x) = 1.');
29
+ // Should not bleed into run_diff's body.
30
+ expect(body).not.toContain('METHOD run_diff');
31
+ });
32
+ it('is case-insensitive for the method name', () => {
33
+ expect(extractMethodBody(SAMPLE_CLASS, 'DISPATCH')).toContain('DATA(lv_x)');
34
+ expect(extractMethodBody(SAMPLE_CLASS, 'Dispatch')).toContain('DATA(lv_x)');
35
+ });
36
+ it('returns empty string when method is not found', () => {
37
+ expect(extractMethodBody(SAMPLE_CLASS, 'nonexistent')).toBe('');
38
+ });
39
+ it('handles a method whose body contains an inline " comment', () => {
40
+ // The sample dispatch body has `" inline comment with " a quote` — the
41
+ // parser should NOT treat the `"` inside the comment as ending the body.
42
+ const body = extractMethodBody(SAMPLE_CLASS, 'dispatch');
43
+ expect(body).toContain('inline comment');
44
+ });
45
+ it('handles a method whose body contains string literals with keywords', () => {
46
+ const src = `CLASS x IMPLEMENTATION.
47
+ METHOD foo.
48
+ DATA(lv) = 'METHOD bar. ENDMETHOD.'.
49
+ DATA(lw) = \`ENDMETHOD pretend\`.
50
+ RETURN.
51
+ ENDMETHOD.
52
+
53
+ METHOD other.
54
+ WRITE 'hi'.
55
+ ENDMETHOD.
56
+ ENDCLASS.`;
57
+ const body = extractMethodBody(src, 'foo');
58
+ expect(body).toMatch(/RETURN\./);
59
+ // The parser must stop at the REAL ENDMETHOD (not the ones inside the
60
+ // string literals on lines 3 and 4), so the body should NOT bleed into
61
+ // METHOD other.
62
+ expect(body).not.toContain('METHOD other');
63
+ expect(body).not.toContain("WRITE 'hi'");
64
+ });
65
+ });
66
+ describe('interceptUpdateMethod', () => {
67
+ function makeClient(source) {
68
+ return {
69
+ getSource: vi.fn(async () => source),
70
+ };
71
+ }
72
+ it('returns the call when the hook confirms', async () => {
73
+ const client = makeClient(SAMPLE_CLASS);
74
+ const hook = vi.fn(async () => ({ confirmed: true }));
75
+ const call = {
76
+ className: 'ZCL_DEMO',
77
+ methodName: 'dispatch',
78
+ methodSource: 'METHOD dispatch.\n RETURN.\nENDMETHOD.',
79
+ transport: 'SXXK999999',
80
+ };
81
+ const result = await interceptUpdateMethod(client, call, hook);
82
+ expect(result).toBe(call);
83
+ expect(hook).toHaveBeenCalledOnce();
84
+ const passed = hook.mock.calls[0][0];
85
+ expect(passed.currentBody).toContain('DATA(lv_x) = 1.');
86
+ expect(passed.proposedBody).toBe(call.methodSource);
87
+ });
88
+ it('throws CancelledByUserError when the hook rejects', async () => {
89
+ const client = makeClient(SAMPLE_CLASS);
90
+ const hook = async () => ({ confirmed: false });
91
+ const call = {
92
+ className: 'ZCL_DEMO',
93
+ methodName: 'dispatch',
94
+ methodSource: 'METHOD dispatch.\nENDMETHOD.',
95
+ transport: 'SXXK999999',
96
+ };
97
+ await expect(interceptUpdateMethod(client, call, hook)).rejects.toThrow(CancelledByUserError);
98
+ });
99
+ it('passes empty currentBody when method does not exist on current class', async () => {
100
+ const client = makeClient(SAMPLE_CLASS);
101
+ const hook = vi.fn(async () => ({ confirmed: true }));
102
+ const call = {
103
+ className: 'ZCL_DEMO',
104
+ methodName: 'brand_new_method',
105
+ methodSource: 'METHOD brand_new_method.\n RETURN.\nENDMETHOD.',
106
+ transport: 'SXXK999999',
107
+ };
108
+ await interceptUpdateMethod(client, call, hook);
109
+ const passed = hook.mock.calls[0][0];
110
+ expect(passed.currentBody).toBe('');
111
+ expect(passed.proposedBody).toBe(call.methodSource);
112
+ });
113
+ it('propagates a getSource failure unchanged', async () => {
114
+ const client = {
115
+ getSource: async () => {
116
+ throw new Error('ADT 401');
117
+ },
118
+ };
119
+ const hook = async () => ({ confirmed: true });
120
+ const call = {
121
+ className: 'ZCL_DEMO',
122
+ methodName: 'dispatch',
123
+ methodSource: 'METHOD dispatch.\nENDMETHOD.',
124
+ transport: 'SXXK999999',
125
+ };
126
+ await expect(interceptUpdateMethod(client, call, hook)).rejects.toThrow('ADT 401');
127
+ });
128
+ });