|
1 | 1 | import { existsSync } from 'fs'; |
2 | 2 | import * as fsPromises from 'fs/promises'; |
3 | 3 | import * as os from 'os'; |
| 4 | +import * as path from 'path'; |
4 | 5 |
|
5 | 6 | import { |
6 | 7 | collectAsyncSubagentResults, |
@@ -33,36 +34,55 @@ const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>; |
33 | 34 | const mockFsPromises = fsPromises as jest.Mocked<typeof fsPromises>; |
34 | 35 | const mockOs = os as jest.Mocked<typeof os>; |
35 | 36 |
|
| 37 | +const isWindows = process.platform === 'win32'; |
| 38 | +// On Windows, path.resolve prepends the current drive letter to |
| 39 | +// drive-relative Unix-style inputs and path.join uses backslashes, so |
| 40 | +// derive the expected fragments from the source helpers instead of literals. |
| 41 | +const encodeRegExp = (value: string) => new RegExp(value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); |
| 42 | +const expectEncodedAs = (encoded: string, posixEncoded: string) => { |
| 43 | + // On Windows the resolved drive prefix ("D:") encodes to "D-" ahead of |
| 44 | + // the POSIX-shaped expectation. |
| 45 | + const prefix = isWindows ? '[a-zA-Z]-' : ''; |
| 46 | + expect(encoded).toMatch(new RegExp(`^${prefix}${encodeRegExp(posixEncoded).source}$`)); |
| 47 | +}; |
| 48 | +const encodedTestVault = encodeVaultPathForSDK('/Users/test/vault'); |
| 49 | +const expectedProjectsPath = path.join('/Users/test', '.qoder', 'projects'); |
| 50 | +const expectedSessionPath = path.join(expectedProjectsPath, encodedTestVault, 'session-abc.jsonl'); |
| 51 | +const expectedArtifactsDir = path.join(expectedProjectsPath, encodedTestVault, 'session-abc'); |
| 52 | +const expectedSidecarPath = path.join(expectedArtifactsDir, 'subagents', 'agent-a123.jsonl'); |
| 53 | + |
36 | 54 | describe('sdkSession', () => { |
37 | 55 | beforeEach(() => { |
38 | 56 | jest.clearAllMocks(); |
39 | 57 | mockOs.homedir.mockReturnValue('/Users/test'); |
40 | 58 | }); |
41 | 59 |
|
42 | 60 | describe('encodeVaultPathForSDK', () => { |
| 61 | + // eslint-disable-next-line jest/expect-expect |
43 | 62 | it('encodes vault path by replacing all non-alphanumeric chars with dash', () => { |
44 | 63 | const encoded = encodeVaultPathForSDK('/Users/test/vault'); |
45 | 64 | // SDK replaces ALL non-alphanumeric characters with `-` |
46 | | - expect(encoded).toBe('-Users-test-vault'); |
| 65 | + expectEncodedAs(encoded, '-Users-test-vault'); |
47 | 66 | }); |
48 | 67 |
|
| 68 | + // eslint-disable-next-line jest/expect-expect |
49 | 69 | it('handles paths with spaces and special characters', () => { |
50 | 70 | const encoded = encodeVaultPathForSDK("/Users/test/My Vault's~Data"); |
51 | | - expect(encoded).toBe('-Users-test-My-Vault-s-Data'); |
| 71 | + expectEncodedAs(encoded, '-Users-test-My-Vault-s-Data'); |
52 | 72 | }); |
53 | 73 |
|
54 | 74 | it('handles Unicode characters (Chinese, Japanese, etc.)', () => { |
55 | 75 | // Unicode characters should be replaced with `-` to match SDK behavior |
56 | 76 | const encoded = encodeVaultPathForSDK('/Volumes/[Work]弘毅之鹰/学习/东京大学/2025年 秋'); |
57 | 77 | // All non-alphanumeric (including Chinese, brackets) become `-` |
58 | | - expect(encoded).toBe('-Volumes--Work--------------2025---'); |
| 78 | + expectEncodedAs(encoded, '-Volumes--Work--------------2025---'); |
59 | 79 | // Verify only ASCII alphanumeric and dash remain |
60 | 80 | expect(encoded).toMatch(/^[a-zA-Z0-9-]+$/); |
61 | 81 | }); |
62 | 82 |
|
63 | 83 | it('handles brackets and other special characters', () => { |
64 | 84 | const encoded = encodeVaultPathForSDK('/Users/test/[my-vault](notes)'); |
65 | | - expect(encoded).toBe('-Users-test--my-vault--notes-'); |
| 85 | + expectEncodedAs(encoded, '-Users-test--my-vault--notes-'); |
66 | 86 | expect(encoded).not.toContain('['); |
67 | 87 | expect(encoded).not.toContain(']'); |
68 | 88 | expect(encoded).not.toContain('('); |
@@ -100,7 +120,9 @@ describe('sdkSession', () => { |
100 | 120 | describe('getSDKProjectsPath', () => { |
101 | 121 | it('returns path under home directory', () => { |
102 | 122 | const projectsPath = getSDKProjectsPath(); |
103 | | - expect(projectsPath).toBe('/Users/test/.qoder/projects'); |
| 123 | + // Build the expectation with path.join so separators match the |
| 124 | + // source on both POSIX and Windows hosts. |
| 125 | + expect(projectsPath).toBe(path.join('/Users/test', '.qoder', 'projects')); |
104 | 126 | }); |
105 | 127 | }); |
106 | 128 |
|
@@ -135,7 +157,9 @@ describe('sdkSession', () => { |
135 | 157 | describe('getSDKSessionPath', () => { |
136 | 158 | it('constructs correct session file path', () => { |
137 | 159 | const sessionPath = getSDKSessionPath('/Users/test/vault', 'session-123'); |
138 | | - expect(sessionPath).toContain('.qoder/projects'); |
| 160 | + // Avoid asserting the separator so the check holds on Windows too. |
| 161 | + expect(sessionPath).toContain('.qoder'); |
| 162 | + expect(sessionPath).toContain('projects'); |
139 | 163 | expect(sessionPath).toContain('session-123.jsonl'); |
140 | 164 | }); |
141 | 165 |
|
@@ -185,9 +209,7 @@ describe('sdkSession', () => { |
185 | 209 |
|
186 | 210 | await deleteSDKSession('/Users/test/vault', 'session-abc'); |
187 | 211 |
|
188 | | - expect(mockFsPromises.unlink).toHaveBeenCalledWith( |
189 | | - '/Users/test/.qoder/projects/-Users-test-vault/session-abc.jsonl' |
190 | | - ); |
| 212 | + expect(mockFsPromises.unlink).toHaveBeenCalledWith(expectedSessionPath); |
191 | 213 | }); |
192 | 214 |
|
193 | 215 | it('does nothing when session file does not exist', async () => { |
@@ -221,11 +243,9 @@ describe('sdkSession', () => { |
221 | 243 |
|
222 | 244 | await deleteSDKSessionArtifacts('/Users/test/vault', 'session-abc'); |
223 | 245 |
|
224 | | - expect(mockFsPromises.unlink).toHaveBeenCalledWith( |
225 | | - '/Users/test/.qoder/projects/-Users-test-vault/session-abc.jsonl' |
226 | | - ); |
| 246 | + expect(mockFsPromises.unlink).toHaveBeenCalledWith(expectedSessionPath); |
227 | 247 | expect(mockFsPromises.rm).toHaveBeenCalledWith( |
228 | | - '/Users/test/.qoder/projects/-Users-test-vault/session-abc', |
| 248 | + expectedArtifactsDir, |
229 | 249 | { recursive: true, force: true }, |
230 | 250 | ); |
231 | 251 | }); |
@@ -318,7 +338,7 @@ describe('sdkSession', () => { |
318 | 338 | ); |
319 | 339 |
|
320 | 340 | expect(mockFsPromises.readFile).toHaveBeenCalledWith( |
321 | | - '/Users/test/.qoder/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl', |
| 341 | + expectedSidecarPath, |
322 | 342 | 'utf-8' |
323 | 343 | ); |
324 | 344 | expect(toolCalls).toHaveLength(1); |
@@ -376,7 +396,7 @@ describe('sdkSession', () => { |
376 | 396 |
|
377 | 397 | expect(result).toBe('Final answer'); |
378 | 398 | expect(mockFsPromises.readFile).toHaveBeenCalledWith( |
379 | | - '/Users/test/.qoder/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl', |
| 399 | + expectedSidecarPath, |
380 | 400 | 'utf-8' |
381 | 401 | ); |
382 | 402 | }); |
@@ -2404,7 +2424,8 @@ describe('sdkSession', () => { |
2404 | 2424 | it('loads subagent tool calls from sidecar JSONL', async () => { |
2405 | 2425 | mockExistsSync.mockReturnValue(true); |
2406 | 2426 | mockFsPromises.readFile.mockImplementation(async (filePath: any) => { |
2407 | | - const p = String(filePath); |
| 2427 | + // Normalize separators so the branch checks work on Windows hosts. |
| 2428 | + const p = String(filePath).replace(/\\/g, '/'); |
2408 | 2429 | if (p.includes('subagents/agent-ae5eb9a.jsonl')) { |
2409 | 2430 | return [ |
2410 | 2431 | '{"type":"assistant","timestamp":"2024-01-15T10:02:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Grep","input":{"pattern":"TODO"}}]}}', |
|
0 commit comments