Skip to content

Commit a0ea78c

Browse files
committed
tests(ssh): expand coverage for edge cases
Signed-off-by: Trae Yelovich <trae.yelovich@broadcom.com>
1 parent 81d13b6 commit a0ea78c

9 files changed

Lines changed: 318 additions & 19 deletions

packages/zowex-for-zowe-explorer/tests/NativeSshHelper.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,5 +184,85 @@ describe("NativeSshHelper", () => {
184184
expect(fetchMock).toHaveBeenCalledTimes(1);
185185
expect(String(fetchMock.mock.calls[0][0])).toContain("linux-arm-gnueabihf");
186186
});
187+
188+
it.each([
189+
["win32", "arm64", "win32-arm64-msvc"],
190+
["darwin", "x64", "darwin-x64"],
191+
["linux", "x64", "linux-x64-gnu"],
192+
["linux", "arm64", "linux-arm64-gnu"],
193+
])("should resolve the %s-%s triple (%s) when downloading", async (platform, arch, triple) => {
194+
setPlatform(platform, arch);
195+
vi.spyOn(fs, "existsSync").mockReturnValue(false);
196+
vi.spyOn(fs, "writeFileSync").mockImplementation(() => undefined as never);
197+
vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined as never);
198+
199+
handleNativeSshSettings(fakeContext);
200+
await flush();
201+
202+
expect(fetchMock).toHaveBeenCalledTimes(1);
203+
expect(String(fetchMock.mock.calls[0][0])).toContain(triple);
204+
});
205+
206+
it("should warn when the platform is supported but the architecture is not (arch lookup misses)", async () => {
207+
// Exercises the `NATIVE_TRIPLES[platform]?.[arch]` arch-miss branch (distinct from an
208+
// entirely unknown platform): win32 is known, but "mips" has no entry.
209+
setPlatform("win32", "mips");
210+
const warnSpy = vi.spyOn(vscode.window, "showWarningMessage").mockReturnValue(undefined);
211+
vi.spyOn(fs, "existsSync").mockReturnValue(false);
212+
213+
handleNativeSshSettings(fakeContext);
214+
await flush();
215+
216+
expect(warnSpy).toHaveBeenCalledTimes(1);
217+
expect(String(warnSpy.mock.calls[0][0])).toContain("No native SSH binary available for win32-mips");
218+
expect(fetchMock).not.toHaveBeenCalled();
219+
});
220+
});
221+
222+
describe("ensureNativeBinary timeout handling", () => {
223+
beforeEach(() => {
224+
mockFlag(true);
225+
vi.spyOn(imperative.Logger, "getAppLogger").mockReturnValue({ info: vi.fn(), error: vi.fn() } as any);
226+
setPlatform("darwin", "arm64");
227+
vi.spyOn(fs, "existsSync").mockReturnValue(false);
228+
vi.spyOn(fs, "writeFileSync").mockImplementation(() => undefined as never);
229+
vi.spyOn(fs, "mkdirSync").mockImplementation(() => undefined as never);
230+
});
231+
232+
it("should clear the abort timeout after a successful fetch", async () => {
233+
fetchMock.mockResolvedValue({ ok: true, status: 200, arrayBuffer: () => Promise.resolve(new ArrayBuffer(4)) });
234+
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
235+
236+
handleNativeSshSettings(fakeContext);
237+
await flush();
238+
239+
// The `finally { clearTimeout(timeoutId) }` arm must run on the success path.
240+
expect(clearTimeoutSpy).toHaveBeenCalled();
241+
});
242+
243+
it("should abort the request and surface an error when the timeout elapses", async () => {
244+
vi.useFakeTimers();
245+
try {
246+
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
247+
// fetch only settles when its AbortSignal fires, so advancing the timer triggers the reject.
248+
fetchMock.mockImplementation(
249+
(_url: string, opts: { signal: AbortSignal }) =>
250+
new Promise((_resolve, reject) => {
251+
opts.signal.addEventListener("abort", () => reject(new Error("The operation was aborted")));
252+
})
253+
);
254+
const errorSpy = vi.spyOn(vscode.window, "showErrorMessage").mockReturnValue(undefined);
255+
256+
handleNativeSshSettings(fakeContext);
257+
// SSH_TIMEOUT is 60_000ms; advancing past it fires the controller.abort() callback.
258+
await vi.advanceTimersByTimeAsync(60_000);
259+
260+
expect(abortSpy).toHaveBeenCalled();
261+
expect(errorSpy).toHaveBeenCalledTimes(1);
262+
expect(String(errorSpy.mock.calls[0][0])).toContain("Failed to download native SSH binary");
263+
} finally {
264+
vi.useRealTimers();
265+
}
266+
});
187267
});
188268
});

packages/zowex-for-zowe-explorer/tests/SSHClientCache.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,15 @@ import { deployWithProgress } from "../src/ServerDeployment";
1818

1919
vi.mock("@zowe/zowe-explorer-api", () => {
2020
class MockDeferredPromise {
21-
promise = Promise.resolve();
22-
resolve = vi.fn();
21+
public promise: Promise<unknown>;
22+
public resolve!: (value?: unknown) => void;
23+
public reject!: (reason?: unknown) => void;
24+
constructor() {
25+
this.promise = new Promise((resolve, reject) => {
26+
this.resolve = resolve;
27+
this.reject = reject;
28+
});
29+
}
2330
}
2431
return {
2532
Gui: {},
@@ -290,6 +297,45 @@ describe("SshClientCache", () => {
290297
expect(endSpy).toHaveBeenCalledWith(clientId, { restart: true, retryRequests: true });
291298
expect(ZSshClient.create).toHaveBeenCalledTimes(2);
292299
});
300+
301+
it("should reuse the cached client on a second connect without restart (cache hit)", async () => {
302+
const first = await cache.connect(mockProfile);
303+
vi.mocked(ZSshClient.create).mockClear();
304+
305+
const second = await cache.connect(mockProfile);
306+
307+
// The second connect must short-circuit on the existing session-map entry, not rebuild.
308+
expect(second).toBe(first);
309+
expect(ZSshClient.create).not.toHaveBeenCalled();
310+
});
311+
312+
it("should serialize overlapping connect() calls so exactly one client is built", async () => {
313+
let releaseCreate!: (client: unknown) => void;
314+
const createGate = new Promise((resolve) => {
315+
releaseCreate = resolve;
316+
});
317+
const builtClient = { dispose: vi.fn(), collectAllRequests: vi.fn(), serverChecksums: {} };
318+
vi.mocked(ZSshClient.create).mockReset();
319+
vi.mocked(ZSshClient.create).mockReturnValueOnce(createGate as any);
320+
vi.mocked(ZSshClient.create).mockResolvedValue(builtClient as any);
321+
322+
const firstConnect = cache.connect(mockProfile);
323+
await Promise.resolve();
324+
await Promise.resolve();
325+
expect((cache as any).mMutexMap.has(clientId)).toBe(true);
326+
327+
const secondConnect = cache.connect(mockProfile);
328+
329+
releaseCreate(builtClient);
330+
const [c1, c2] = await Promise.all([firstConnect, secondConnect]);
331+
332+
// Only the first build runs; the second observes the populated session map and reuses it.
333+
expect(ZSshClient.create).toHaveBeenCalledTimes(1);
334+
expect(c1).toBe(builtClient);
335+
expect(c2).toBe(builtClient);
336+
// The lock is released once the build completes.
337+
expect((cache as any).mMutexMap.has(clientId)).toBe(false);
338+
});
293339
});
294340

295341
describe("end()", () => {

packages/zowex-for-zowe-explorer/tests/ServerDeployment.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,5 +82,11 @@ describe("ServerDeployment", () => {
8282

8383
expect(result).toEqual(false);
8484
});
85+
86+
it("should propagate a rejection from ZSshUtils.installServer", async () => {
87+
vi.mocked(ZSshUtils.installServer).mockRejectedValue(new Error("install failed"));
88+
89+
await expect(deployWithProgress(fakeSession, "/server/path")).rejects.toThrow("install failed");
90+
});
8591
});
8692
});

packages/zowex-for-zowe-explorer/tests/SshErrorCorrelations.test.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import { ErrorCorrelator, ZoweExplorerApiType } from "@zowe/zowe-explorer-api";
1313
import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from "vitest";
14+
import { SshErrors } from "@zowe/zowex-for-zowe-sdk";
1415
import { registerSshErrorCorrelations } from "../src/SshErrorCorrelations";
1516

1617
// Mock Zowe Explorer API
@@ -49,8 +50,8 @@ describe("SshErrorCorrelations", () => {
4950

5051
expect(mockGetInstance).toHaveBeenCalled();
5152

52-
// Should register multiple correlations (connection failures, memory failures, filesystem errors, expired password)
53-
expect(mockErrorCorrelator.addCorrelation).toHaveBeenCalledTimes(17); // 1 request timeout + 6 connection (incl. FOTS1668) + 4 memory + 6 filesystem
53+
// One correlation per entry in the SDK's SshErrors map
54+
expect(mockErrorCorrelator.addCorrelation).toHaveBeenCalledTimes(Object.keys(SshErrors).length);
5455
});
5556

5657
it("should handle missing ErrorCorrelator gracefully", () => {
@@ -242,6 +243,50 @@ describe("SshErrorCorrelations", () => {
242243
});
243244
});
244245

246+
describe("request timeout & disk/password correlations", () => {
247+
beforeEach(() => {
248+
registerSshErrorCorrelations();
249+
});
250+
251+
const findCorrelation = (code: string): any =>
252+
(mockErrorCorrelator.addCorrelation as any).mock.calls.find((call: any) => call[2].errorCode === code)?.[2];
253+
254+
it("should register the REQUEST_TIMEOUT correlation with both string and RegExp matchers", () => {
255+
const correlation = findCorrelation("REQUEST_TIMEOUT");
256+
257+
expect(correlation).toBeDefined();
258+
expect(correlation.summary).toContain("exceeded the timeout limit");
259+
expect(correlation.matches).toContain("Request timed out");
260+
expect(correlation.matches).toEqual(expect.arrayContaining([expect.any(RegExp)]));
261+
expect(correlation.tips.length).toBeGreaterThan(0);
262+
// The timeout correlation intentionally ships without resource links.
263+
expect(correlation.resources).toBeUndefined();
264+
});
265+
266+
it("should register the FOTS1668 expired-password correlation", () => {
267+
const correlation = findCorrelation("FOTS1668");
268+
269+
expect(correlation).toBeDefined();
270+
expect(correlation.summary).toContain("password has expired");
271+
expect(correlation.matches).toEqual(
272+
expect.arrayContaining(["FOTS1668", "FOTS1669", "Your password has expired", "Password change required but no TTY available"])
273+
);
274+
expect(correlation.resources).toHaveLength(1);
275+
expect(new URL(correlation.resources[0].href).host).toBe("www.ibm.com");
276+
});
277+
278+
it("should register the EDC5133I out-of-space correlation", () => {
279+
const correlation = findCorrelation("EDC5133I");
280+
281+
expect(correlation).toBeDefined();
282+
expect(correlation.summary).toContain("no space left on the device");
283+
expect(correlation.matches).toEqual(["EDC5133I"]);
284+
expect(correlation.tips).toEqual(expect.arrayContaining([expect.stringContaining("free space")]));
285+
// EDC5133I has no resource links.
286+
expect(correlation.resources).toBeUndefined();
287+
});
288+
});
289+
245290
describe("correlation structure validation", () => {
246291
beforeEach(() => {
247292
registerSshErrorCorrelations();
@@ -320,8 +365,11 @@ describe("SshErrorCorrelations", () => {
320365
const errorCodes = allCalls.map((call) => call[2].errorCode);
321366

322367
const expectedCodes = [
368+
// Request timeout
369+
"REQUEST_TIMEOUT",
323370
// Connection failures
324371
"FOTS4241",
372+
"FOTS1668",
325373
"FOTS4134",
326374
"FOTS4231",
327375
"FOTS4203",
@@ -333,12 +381,16 @@ describe("SshErrorCorrelations", () => {
333381
"FOTS4311",
334382
// Filesystem errors
335383
"FSUM6260",
384+
"EDC5133I",
336385
"FOTS4152",
337386
"FOTS4154",
338387
"FOTS4150",
339388
"FOTS4312",
340389
];
341390

391+
// Sanity check: the curated list should match the SDK's full set of registered codes.
392+
expect(expectedCodes.sort()).toEqual(Object.keys(SshErrors).sort());
393+
342394
expectedCodes.forEach((expectedCode) => {
343395
expect(errorCodes).toContain(expectedCode);
344396
});

packages/zowex-for-zowe-explorer/tests/SshErrorHandler.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ describe("SshErrorHandler", () => {
184184
expect(errorHandler.isFatalError("Prefix FOTS4241 Authentication failed suffix")).toBe(true);
185185
expect(errorHandler.isFatalError("Some message with FSUM6260 in the middle")).toBe(true);
186186
});
187+
188+
it("should classify any literal SshErrors key substring as fatal (e.g. FOTS1668, EDC5133I)", () => {
189+
expect(errorHandler.isFatalError("FOTS1668 Your password has expired")).toBe(true);
190+
expect(errorHandler.isFatalError("EDC5133I no space left on device")).toBe(true);
191+
expect(errorHandler.isFatalError(new Error("EDC5133I no space left on device"))).toBe(true);
192+
});
193+
194+
it("should not treat the REQUEST_TIMEOUT key as fatal when the message is an actual timeout", () => {
195+
expect(errorHandler.isFatalError("Request timed out after 5000 ms")).toBe(false);
196+
});
187197
});
188198

189199
describe("extractErrorCode", () => {
@@ -307,5 +317,16 @@ describe("SshErrorHandler", () => {
307317

308318
expect(errorHandler.isTimeoutError(imperativeError)).toBe(true);
309319
});
320+
321+
it("should recognize the fully-formed 'after N ms' timeout message", () => {
322+
expect(errorHandler.isTimeoutError("Request timed out after 12345 ms")).toBe(true);
323+
expect(errorHandler.isTimeoutError(new Error("Request timed out after 1 ms"))).toBe(true);
324+
});
325+
326+
it("should exercise the RegExp matcher branch and return false for a non-timeout message", () => {
327+
const imperativeError = new ImperativeError({ msg: "Some failure", errorCode: "EOTHER" });
328+
expect(errorHandler.isTimeoutError(imperativeError)).toBe(false);
329+
expect(errorHandler.isTimeoutError("Connection reset by peer")).toBe(false);
330+
});
310331
});
311332
});

packages/zowex-for-zowe-explorer/tests/Utilities.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,5 +414,21 @@ describe("Utilities", () => {
414414

415415
expect(showMessageSpy).toHaveBeenCalledWith("Uninstalled Zowe Remote SSH server from myProf");
416416
});
417+
418+
it("should propagate a rejection from ZSshUtils.uninstallServer", async () => {
419+
const mockedExplorer = await vi.importMock("@zowe/zowe-explorer-api");
420+
const mockedConfig = await vi.importMock("../src/ConfigUtils");
421+
const { VscePromptApi } = await vi.importMock("../src/VscePromptApi");
422+
const profile = { name: "myProf", profile: { host: "myHost" } };
423+
VscePromptApi.mockImplementation(() => ({ promptForProfile: vi.fn().mockResolvedValue(profile) }));
424+
425+
vi.spyOn(mockedConfig.ConfigUtils, "getServerPath").mockReturnValue("/server/path");
426+
vi.spyOn(mockedConfig.ConfigUtils, "showSessionInTree").mockResolvedValue(undefined);
427+
vi.spyOn(ZSshUtils, "buildSession").mockReturnValue({ ISshSession: {} });
428+
vi.spyOn(ZSshUtils, "uninstallServer").mockRejectedValue(new Error("uninstall failed"));
429+
430+
const api = mockedExplorer.ZoweVsCodeExtension.getZoweExplorerApi().getExplorerExtenderApi();
431+
await expect((Utilities as any).uninstallCallback(api, "myProf")).rejects.toThrow("uninstall failed");
432+
});
417433
});
418434
});

packages/zowex-for-zowe-explorer/tests/api/SshJesApi.test.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,14 @@ describe("SshJesApi", () => {
210210
expect(readSpoolSpy).toHaveBeenCalledWith({ spoolId: 1, jobId: "FAKEID", encoding: "1047" });
211211
expect(response).toEqual("fakedata");
212212
});
213+
214+
it("should propagate a rejection from readSpool", async () => {
215+
const jesApi = new SshJesApi();
216+
vi.spyOn(SshJesApi.prototype, "client", "get").mockResolvedValue({
217+
jobs: { readSpool: vi.fn().mockRejectedValue(new Error("readSpool failed")) },
218+
});
219+
await expect(jesApi.getSpoolContentById("fakejob", "fakeid", 1)).rejects.toThrow("readSpool failed");
220+
});
213221
});
214222

215223
describe("getJclForJob", () => {
@@ -236,9 +244,19 @@ describe("SshJesApi", () => {
236244
jobs: { getJcl: vi.fn().mockRejectedValue(new Error("getJcl failed")) },
237245
});
238246
const fakeJob: IJob = {
239-
jobid: "fakejob", jobname: "FAKEJOB", subsystem: "JES2", owner: "USER",
240-
status: "OUTPUT", type: "JOB", class: "A", retcode: "CC 0000",
241-
url: "", "files-url": "", "job-correlator": "", phase: 20, "phase-name": "Job is on the hard copy queue",
247+
jobid: "fakejob",
248+
jobname: "FAKEJOB",
249+
subsystem: "JES2",
250+
owner: "USER",
251+
status: "OUTPUT",
252+
type: "JOB",
253+
class: "A",
254+
retcode: "CC 0000",
255+
url: "",
256+
"files-url": "",
257+
"job-correlator": "",
258+
phase: 20,
259+
"phase-name": "Job is on the hard copy queue",
242260
};
243261
await expect(jesApi.getJclForJob(fakeJob)).rejects.toThrow("getJcl failed");
244262
});
@@ -261,6 +279,14 @@ describe("SshJesApi", () => {
261279
expect(cancelJobSpy).toHaveBeenCalledWith({ jobId: "FAKEJOB" });
262280
expect(response).toEqual(true);
263281
});
282+
283+
it("should propagate a rejection from cancelJob", async () => {
284+
const jesApi = new SshJesApi();
285+
vi.spyOn(SshJesApi.prototype, "client", "get").mockResolvedValue({
286+
jobs: { cancelJob: vi.fn().mockRejectedValue(new Error("cancelJob failed")) },
287+
});
288+
await expect(jesApi.cancelJob({ jobid: "fakejob" } as any)).rejects.toThrow("cancelJob failed");
289+
});
264290
});
265291

266292
describe("submitJcl", () => {

0 commit comments

Comments
 (0)