import { jest, describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from '@jest/globals'; import https from 'https'; import {HttpClient, HttpClientResponse} from '@actions/http-client'; import type {IncomingMessage} from 'http'; import {Readable} from 'stream'; import manifestData from '../data/jetbrains.json' with {type: 'json'}; import os from 'os'; // Mock @actions/core before importing source modules that depend on it jest.unstable_mockModule('@actions/core', () => ({ info: jest.fn(), warning: jest.fn(), debug: jest.fn(), error: jest.fn(), notice: jest.fn(), setFailed: jest.fn(), setOutput: jest.fn(), getInput: jest.fn(), getBooleanInput: jest.fn(), getMultilineInput: jest.fn(), addPath: jest.fn(), exportVariable: jest.fn(), saveState: jest.fn(), getState: jest.fn(), setSecret: jest.fn(), isDebug: jest.fn(() => false), startGroup: jest.fn(), endGroup: jest.fn(), group: jest.fn((_name: string, fn: () => Promise) => fn()), toPlatformPath: jest.fn((p: string) => p), toWin32Path: jest.fn((p: string) => p), toPosixPath: jest.fn((p: string) => p) })); // Dynamic imports after mocking const core = await import('@actions/core'); const {JetBrainsDistribution} = await import('../../src/distributions/jetbrains/installer.js'); const {RetryingHttpClient} = await import('../../src/retrying-http-client.js'); function response( statusCode: number, body = '', headers: IncomingMessage['headers'] = {} ): HttpClientResponse { const message = Readable.from([Buffer.from(body)]) as IncomingMessage; message.statusCode = statusCode; message.headers = headers; return new HttpClientResponse(message); } describe('getAvailableVersions', () => { let spyHttpClient: any; let spyCoreError: any; beforeEach(() => { spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); spyHttpClient.mockReturnValue({ statusCode: 200, headers: {}, result: [] }); // Mock core.error to suppress error logs spyCoreError = core.error as jest.Mock; spyCoreError.mockImplementation(() => {}); }); afterEach(() => { jest.resetAllMocks(); jest.clearAllMocks(); jest.restoreAllMocks(); }); it('load available versions', async () => { spyHttpClient = jest.spyOn(HttpClient.prototype, 'getJson'); spyHttpClient .mockReturnValueOnce({ statusCode: 200, headers: {}, result: manifestData as any }) .mockReturnValue({ statusCode: 200, headers: {}, result: [] }); const distribution = new JetBrainsDistribution({ version: '17', architecture: 'x64', packageType: 'jdk', checkLatest: false }); const availableVersions = await distribution['getAvailableVersions'](); expect(availableVersions).not.toBeNull(); const length = os.platform() === 'win32' ? manifestData.length : manifestData.length + 2; expect(availableVersions.length).toBe(length); }, 10_000); it('retries a GitHub rate limit using Retry-After', async () => { spyHttpClient.mockRestore(); const sleep = jest.fn(async () => undefined); const requestRaw = jest .spyOn(HttpClient.prototype, 'requestRaw') .mockResolvedValueOnce(response(429, '', {'retry-after': '2'})) .mockResolvedValueOnce(response(200, '[]')) .mockResolvedValueOnce(response(200)) .mockResolvedValueOnce(response(200)); const distribution = new JetBrainsDistribution({ version: '17', architecture: 'x64', packageType: 'jdk', checkLatest: false }); distribution['http'] = new RetryingHttpClient('test', { sleep, random: () => 0 }); const availableVersions = await distribution['getAvailableVersions'](); expect(availableVersions).toHaveLength(2); expect(requestRaw).toHaveBeenCalledTimes(4); expect(requestRaw.mock.calls[0][0].options.path).toBe( requestRaw.mock.calls[1][0].options.path ); expect(requestRaw.mock.calls[0][0].options.path).toContain( '/repos/JetBrains/JetBrainsRuntime/releases' ); expect(sleep).toHaveBeenCalledWith(2000); expect(core.info).toHaveBeenCalledWith( 'Request attempt 1 of 4 failed (HTTP 429); retrying in 2000 ms' ); }); }); describe('findPackageForDownload', () => { let spyHttpClientGet: any; const JETBRAINS_CHECKSUM = 'c'.repeat(128); beforeEach(() => { // Every resolved release fetches `${url}.checksum` (sha512, GNU // ` ` format); stub it so tests never reach the real // network, except the dedicated 'version %s can be downloaded' test // below which intentionally exercises real HTTPS HEAD requests. spyHttpClientGet = jest .spyOn(HttpClient.prototype, 'get') .mockResolvedValue({ message: {statusCode: 200}, readBody: async () => `${JETBRAINS_CHECKSUM} jbrsdk.tar.gz\n` } as any); }); afterEach(() => { jest.restoreAllMocks(); }); it.each([ ['17', '17.0.11+1207.24'], ['11.0', '11.0.16+2043.64'], ['11.0.11', '11.0.11+1542.1'], ['21.0.2', '21.0.2+375.1'], ['21', '21.0.3+465.3'], ['x', '21.0.3+465.3'] ])('version is resolved correctly %s -> %s', async (input, expected) => { const distribution = new JetBrainsDistribution({ version: input, architecture: 'x64', packageType: 'jdk', checkLatest: false }); distribution['getAvailableVersions'] = async () => manifestData as any; const resolvedVersion = await distribution['findPackageForDownload'](input); expect(resolvedVersion.version).toBe(expected); }); it.each(['17', '11.0', '11.0.11', '21.0.2', '21'])( 'version %s can be downloaded', async input => { const distribution = new JetBrainsDistribution({ version: input, architecture: 'x64', packageType: 'jdk', checkLatest: false }); distribution['getAvailableVersions'] = async () => manifestData as any; const resolvedVersion = await distribution['findPackageForDownload'](input); const url = resolvedVersion.url; const options = {method: 'HEAD'}; await new Promise((resolve, reject) => { const request = https.request(url, options, res => { let assertionError: unknown; try { // JetBrains uses 403 for non-existent packages expect(res.statusCode).not.toBe(403); } catch (error) { assertionError = error; } res.resume(); res.once('error', reject); res.once('end', () => assertionError ? reject(assertionError as Error) : resolve() ); }); request.on('error', reject); request.end(); }); } ); it('version is not found', async () => { const distribution = new JetBrainsDistribution({ version: '8.0.452', architecture: 'x64', packageType: 'jdk', checkLatest: false }); distribution['getAvailableVersions'] = async () => manifestData as any; await expect(distribution['findPackageForDownload']('8.x')).rejects.toThrow( /No matching version found for SemVer */ ); }); it('version list is empty', async () => { const distribution = new JetBrainsDistribution({ version: '8', architecture: 'x64', packageType: 'jdk', checkLatest: false }); distribution['getAvailableVersions'] = async () => []; await expect(distribution['findPackageForDownload']('8')).rejects.toThrow( /No matching version found for SemVer */ ); }); it('fetches the authoritative sha512 checksum only for the resolved version', async () => { const distribution = new JetBrainsDistribution({ version: '21', architecture: 'x64', packageType: 'jdk', checkLatest: false }); distribution['getAvailableVersions'] = async () => manifestData as any; const result = await distribution['findPackageForDownload']('21'); expect(result.checksum).toEqual({ algorithm: 'sha512', value: JETBRAINS_CHECKSUM, source: `${result.url}.checksum` }); // Only the single resolved/winning version's checksum is requested, // not one per candidate considered during version resolution. expect(spyHttpClientGet).toHaveBeenCalledWith(`${result.url}.checksum`); expect(spyHttpClientGet).toHaveBeenCalledTimes(1); }); it('parses only the first whitespace-delimited token from the GNU checksum payload', async () => { spyHttpClientGet.mockResolvedValue({ message: {statusCode: 200}, readBody: async () => `${JETBRAINS_CHECKSUM} jbrsdk-21.tar.gz\n` } as any); const distribution = new JetBrainsDistribution({ version: '21', architecture: 'x64', packageType: 'jdk', checkLatest: false }); distribution['getAvailableVersions'] = async () => manifestData as any; const result = await distribution['findPackageForDownload']('21'); expect(result.checksum?.value).toBe(JETBRAINS_CHECKSUM); }); it('falls back to a sha256 checksum for older JBR builds that only publish one', async () => { // Older JBR 11 builds (e.g. jbrsdk_nomod-11_0_16-*-b2043.64.tar.gz) publish // a SHA-256 digest at the generic `.checksum` sibling instead of SHA-512. const sha256Checksum = 'a'.repeat(64); spyHttpClientGet.mockResolvedValue({ message: {statusCode: 200}, readBody: async () => `${sha256Checksum} jbrsdk_nomod-11_0_16-osx-x64-b2043.64.tar.gz\n` } as any); const distribution = new JetBrainsDistribution({ version: '21', architecture: 'x64', packageType: 'jdk', checkLatest: false }); distribution['getAvailableVersions'] = async () => manifestData as any; const result = await distribution['findPackageForDownload']('21'); expect(result.checksum).toEqual({ algorithm: 'sha256', value: sha256Checksum, source: `${result.url}.checksum` }); }); });