Skip to content

Commit 15b01ea

Browse files
Ajit Pratap Singhclaude
authored andcommitted
test(vscode): add unit tests for getBinaryPath() binary resolution
Extract getBinaryPath() into utils/binaryResolver.ts for testability. Tests cover: user setting priority, bundled binary detection, PATH fallback, Windows .exe handling, and platform-specific checks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9ba51d9 commit 15b01ea

4 files changed

Lines changed: 341 additions & 23 deletions

File tree

vscode-extension/src/extension.ts

Lines changed: 12 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import {
2323
PerformanceTimer,
2424
showMetricsReport,
2525
createPerformanceStatusBarItem,
26-
updatePerformanceStatusBar
26+
updatePerformanceStatusBar,
27+
resolveBinaryPath
2728
} from './utils';
2829

2930
let client: LanguageClient | undefined;
@@ -39,31 +40,19 @@ let metrics: MetricsCollector;
3940
* 1. User-configured explicit path (gosqlx.executablePath setting, if non-empty)
4041
* 2. Bundled binary at <extensionPath>/bin/gosqlx[.exe]
4142
* 3. PATH lookup ("gosqlx" — the old default behavior)
43+
*
44+
* Delegates to the extracted resolveBinaryPath() utility for testability.
4245
*/
4346
async function getBinaryPath(): Promise<string> {
4447
const config = vscode.workspace.getConfiguration('gosqlx');
45-
const userPath = config.get<string>('executablePath', '');
46-
47-
// 1. Explicit user setting (non-empty means user override)
48-
if (userPath) {
49-
return userPath;
50-
}
51-
52-
// 2. Bundled binary
53-
if (extensionContext) {
54-
const binaryName = process.platform === 'win32' ? 'gosqlx.exe' : 'gosqlx';
55-
const bundledPath = path.join(extensionContext.extensionPath, 'bin', binaryName);
56-
try {
57-
await fs.promises.access(bundledPath, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
58-
return bundledPath;
59-
} catch {
60-
// Bundled binary not found or not executable, fall through to PATH lookup
61-
outputChannel?.appendLine(`Bundled binary not found at ${bundledPath}, falling back to PATH`);
62-
}
63-
}
64-
65-
// 3. Fall back to PATH lookup
66-
return 'gosqlx';
48+
return resolveBinaryPath({
49+
extensionPath: extensionContext?.extensionPath,
50+
platform: process.platform,
51+
getConfig: (key: string, defaultValue: string) =>
52+
config.get<string>(key, defaultValue),
53+
checkAccess: (filePath: string, mode: number) =>
54+
fs.promises.access(filePath, mode)
55+
});
6756
}
6857

6958
export async function activate(context: vscode.ExtensionContext): Promise<void> {
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import * as assert from 'assert';
2+
import * as path from 'path';
3+
import * as fs from 'fs';
4+
import { getBinaryPath, BinaryResolverDeps } from '../../utils/binaryResolver';
5+
6+
/**
7+
* Unit tests for getBinaryPath() binary resolution logic.
8+
*
9+
* The function resolves the gosqlx executable via a three-step fallback:
10+
* 1. User-configured explicit path (gosqlx.executablePath)
11+
* 2. Bundled binary at <extensionPath>/bin/gosqlx[.exe]
12+
* 3. PATH lookup ("gosqlx")
13+
*/
14+
15+
// ---------------------------------------------------------------------------
16+
// Helpers
17+
// ---------------------------------------------------------------------------
18+
19+
/** Builds a BinaryResolverDeps with sensible defaults that can be overridden. */
20+
function makeDeps(overrides: Partial<BinaryResolverDeps> = {}): BinaryResolverDeps {
21+
return {
22+
extensionPath: '/mock/extension',
23+
platform: 'linux',
24+
getConfig: (_key: string, defaultValue: string) => defaultValue,
25+
checkAccess: () => Promise.reject(new Error('ENOENT')),
26+
...overrides,
27+
};
28+
}
29+
30+
// ---------------------------------------------------------------------------
31+
// 1. User-configured path
32+
// ---------------------------------------------------------------------------
33+
34+
suite('getBinaryPath — user-configured path', () => {
35+
36+
test('returns user-configured path when setting is non-empty', async () => {
37+
const deps = makeDeps({
38+
getConfig: (key: string, defaultValue: string) =>
39+
key === 'executablePath' ? '/usr/local/bin/gosqlx-custom' : defaultValue,
40+
});
41+
const result = await getBinaryPath(deps);
42+
assert.strictEqual(result, '/usr/local/bin/gosqlx-custom');
43+
});
44+
45+
test('skips user path when setting is empty string', async () => {
46+
const deps = makeDeps({
47+
getConfig: (key: string, defaultValue: string) =>
48+
key === 'executablePath' ? '' : defaultValue,
49+
});
50+
// With no bundled binary and empty user path, should fall back to 'gosqlx'
51+
const result = await getBinaryPath(deps);
52+
assert.strictEqual(result, 'gosqlx');
53+
});
54+
55+
test('returns user path even when bundled binary exists', async () => {
56+
const deps = makeDeps({
57+
getConfig: (key: string, defaultValue: string) =>
58+
key === 'executablePath' ? '/custom/gosqlx' : defaultValue,
59+
// Bundled binary would succeed, but user path takes priority
60+
checkAccess: () => Promise.resolve(),
61+
});
62+
const result = await getBinaryPath(deps);
63+
assert.strictEqual(result, '/custom/gosqlx');
64+
});
65+
});
66+
67+
// ---------------------------------------------------------------------------
68+
// 2. Bundled binary detection
69+
// ---------------------------------------------------------------------------
70+
71+
suite('getBinaryPath — bundled binary', () => {
72+
73+
test('returns bundled binary path when it exists and is executable', async () => {
74+
const deps = makeDeps({
75+
extensionPath: '/home/user/.vscode/extensions/gosqlx-1.0.0',
76+
platform: 'linux',
77+
checkAccess: () => Promise.resolve(),
78+
});
79+
const result = await getBinaryPath(deps);
80+
assert.strictEqual(
81+
result,
82+
path.join('/home/user/.vscode/extensions/gosqlx-1.0.0', 'bin', 'gosqlx')
83+
);
84+
});
85+
86+
test('falls back to PATH when bundled binary does not exist', async () => {
87+
const deps = makeDeps({
88+
extensionPath: '/ext',
89+
checkAccess: () => Promise.reject(new Error('ENOENT')),
90+
});
91+
const result = await getBinaryPath(deps);
92+
assert.strictEqual(result, 'gosqlx');
93+
});
94+
95+
test('falls back to PATH when extensionPath is undefined', async () => {
96+
const deps = makeDeps({
97+
extensionPath: undefined,
98+
checkAccess: () => Promise.resolve(), // would succeed, but path is undefined
99+
});
100+
const result = await getBinaryPath(deps);
101+
assert.strictEqual(result, 'gosqlx');
102+
});
103+
104+
test('checks correct bundled path using extensionPath + bin + binaryName', async () => {
105+
let checkedPath = '';
106+
const deps = makeDeps({
107+
extensionPath: '/my/ext',
108+
platform: 'darwin',
109+
checkAccess: (filePath: string) => {
110+
checkedPath = filePath;
111+
return Promise.resolve();
112+
},
113+
});
114+
await getBinaryPath(deps);
115+
assert.strictEqual(checkedPath, path.join('/my/ext', 'bin', 'gosqlx'));
116+
});
117+
});
118+
119+
// ---------------------------------------------------------------------------
120+
// 3. PATH fallback
121+
// ---------------------------------------------------------------------------
122+
123+
suite('getBinaryPath — PATH fallback', () => {
124+
125+
test('returns "gosqlx" when no user path and no bundled binary', async () => {
126+
const deps = makeDeps({
127+
extensionPath: '/ext',
128+
getConfig: () => '',
129+
checkAccess: () => Promise.reject(new Error('ENOENT')),
130+
});
131+
const result = await getBinaryPath(deps);
132+
assert.strictEqual(result, 'gosqlx');
133+
});
134+
135+
test('returns "gosqlx" when extensionPath is undefined and no user path', async () => {
136+
const deps = makeDeps({
137+
extensionPath: undefined,
138+
getConfig: () => '',
139+
});
140+
const result = await getBinaryPath(deps);
141+
assert.strictEqual(result, 'gosqlx');
142+
});
143+
});
144+
145+
// ---------------------------------------------------------------------------
146+
// 4. Windows-specific behavior
147+
// ---------------------------------------------------------------------------
148+
149+
suite('getBinaryPath — Windows platform handling', () => {
150+
151+
test('appends .exe on win32 platform', async () => {
152+
let checkedPath = '';
153+
const deps = makeDeps({
154+
extensionPath: '/ext',
155+
platform: 'win32',
156+
checkAccess: (filePath: string) => {
157+
checkedPath = filePath;
158+
return Promise.resolve();
159+
},
160+
});
161+
const result = await getBinaryPath(deps);
162+
assert.ok(
163+
checkedPath.endsWith('gosqlx.exe'),
164+
`Expected path to end with gosqlx.exe, got: ${checkedPath}`
165+
);
166+
assert.strictEqual(result, path.join('/ext', 'bin', 'gosqlx.exe'));
167+
});
168+
169+
test('does not append .exe on non-win32 platform', async () => {
170+
let checkedPath = '';
171+
const deps = makeDeps({
172+
extensionPath: '/ext',
173+
platform: 'darwin',
174+
checkAccess: (filePath: string) => {
175+
checkedPath = filePath;
176+
return Promise.resolve();
177+
},
178+
});
179+
await getBinaryPath(deps);
180+
assert.ok(
181+
checkedPath.endsWith('gosqlx') && !checkedPath.endsWith('gosqlx.exe'),
182+
`Expected path to end with gosqlx (no .exe), got: ${checkedPath}`
183+
);
184+
});
185+
186+
test('uses F_OK access mode on Windows', async () => {
187+
let usedMode: number | undefined;
188+
const deps = makeDeps({
189+
extensionPath: '/ext',
190+
platform: 'win32',
191+
checkAccess: (_filePath: string, mode: number) => {
192+
usedMode = mode;
193+
return Promise.resolve();
194+
},
195+
});
196+
await getBinaryPath(deps);
197+
assert.strictEqual(usedMode, fs.constants.F_OK);
198+
});
199+
200+
test('uses X_OK access mode on non-Windows platforms', async () => {
201+
let usedMode: number | undefined;
202+
const deps = makeDeps({
203+
extensionPath: '/ext',
204+
platform: 'linux',
205+
checkAccess: (_filePath: string, mode: number) => {
206+
usedMode = mode;
207+
return Promise.resolve();
208+
},
209+
});
210+
await getBinaryPath(deps);
211+
assert.strictEqual(usedMode, fs.constants.X_OK);
212+
});
213+
214+
test('uses X_OK access mode on macOS (darwin)', async () => {
215+
let usedMode: number | undefined;
216+
const deps = makeDeps({
217+
extensionPath: '/ext',
218+
platform: 'darwin',
219+
checkAccess: (_filePath: string, mode: number) => {
220+
usedMode = mode;
221+
return Promise.resolve();
222+
},
223+
});
224+
await getBinaryPath(deps);
225+
assert.strictEqual(usedMode, fs.constants.X_OK);
226+
});
227+
});
228+
229+
// ---------------------------------------------------------------------------
230+
// 5. Priority / precedence
231+
// ---------------------------------------------------------------------------
232+
233+
suite('getBinaryPath — fallback chain precedence', () => {
234+
235+
test('user setting > bundled binary > PATH (full chain)', async () => {
236+
// All three options available — user setting wins
237+
const deps = makeDeps({
238+
extensionPath: '/ext',
239+
getConfig: (key: string, defaultValue: string) =>
240+
key === 'executablePath' ? '/user/bin/gosqlx' : defaultValue,
241+
checkAccess: () => Promise.resolve(),
242+
});
243+
const result = await getBinaryPath(deps);
244+
assert.strictEqual(result, '/user/bin/gosqlx');
245+
});
246+
247+
test('bundled binary > PATH when user setting is empty', async () => {
248+
const deps = makeDeps({
249+
extensionPath: '/ext',
250+
getConfig: () => '',
251+
checkAccess: () => Promise.resolve(),
252+
});
253+
const result = await getBinaryPath(deps);
254+
assert.strictEqual(result, path.join('/ext', 'bin', 'gosqlx'));
255+
});
256+
257+
test('PATH is last resort', async () => {
258+
const deps = makeDeps({
259+
extensionPath: '/ext',
260+
getConfig: () => '',
261+
checkAccess: () => Promise.reject(new Error('ENOENT')),
262+
});
263+
const result = await getBinaryPath(deps);
264+
assert.strictEqual(result, 'gosqlx');
265+
});
266+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Binary resolution logic for locating the gosqlx executable.
3+
*
4+
* Resolves via a three-step fallback chain:
5+
* 1. User-configured explicit path (gosqlx.executablePath setting)
6+
* 2. Bundled binary at <extensionPath>/bin/gosqlx[.exe]
7+
* 3. PATH lookup ("gosqlx")
8+
*
9+
* Dependencies are injected so the logic is testable without VS Code or the filesystem.
10+
*/
11+
12+
import * as path from 'path';
13+
import * as fs from 'fs';
14+
15+
/**
16+
* Dependencies that can be injected for testing.
17+
*/
18+
export interface BinaryResolverDeps {
19+
/** The extension install path, or undefined if not available. */
20+
extensionPath: string | undefined;
21+
/** The OS platform string (e.g. 'win32', 'darwin', 'linux'). */
22+
platform: string;
23+
/** Reads a configuration value by key, returning defaultValue when absent. */
24+
getConfig: (key: string, defaultValue: string) => string;
25+
/** Checks file access; rejects when the file is missing or the mode check fails. */
26+
checkAccess: (filePath: string, mode: number) => Promise<void>;
27+
}
28+
29+
/**
30+
* Resolves the gosqlx binary path using a fallback chain.
31+
*
32+
* @param deps Injected dependencies (defaults use real VS Code / Node APIs in production).
33+
* @returns The resolved binary path.
34+
*/
35+
export async function getBinaryPath(deps: BinaryResolverDeps): Promise<string> {
36+
// 1. Explicit user setting (non-empty means user override)
37+
const userPath = deps.getConfig('executablePath', '');
38+
if (userPath) {
39+
return userPath;
40+
}
41+
42+
// 2. Bundled binary
43+
if (deps.extensionPath) {
44+
const binaryName = deps.platform === 'win32' ? 'gosqlx.exe' : 'gosqlx';
45+
const bundledPath = path.join(deps.extensionPath, 'bin', binaryName);
46+
try {
47+
const mode = deps.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK;
48+
await deps.checkAccess(bundledPath, mode);
49+
return bundledPath;
50+
} catch {
51+
// Bundled binary not found or not executable, fall through
52+
}
53+
}
54+
55+
// 3. Fall back to PATH lookup
56+
return 'gosqlx';
57+
}

vscode-extension/src/utils/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ export {
4949
withTelemetry
5050
} from './telemetry';
5151

52+
// Binary resolution utilities
53+
export {
54+
BinaryResolverDeps,
55+
getBinaryPath as resolveBinaryPath
56+
} from './binaryResolver';
57+
5258
// Performance metrics utilities
5359
export {
5460
OperationType,

0 commit comments

Comments
 (0)