diff --git a/extensions/ql-vscode/src/codeql-cli/distribution.ts b/extensions/ql-vscode/src/codeql-cli/distribution.ts index d3842fb7e..7b49ed632 100644 --- a/extensions/ql-vscode/src/codeql-cli/distribution.ts +++ b/extensions/ql-vscode/src/codeql-cli/distribution.ts @@ -1,9 +1,7 @@ -import * as fetch from "node-fetch"; import { pathExists, mkdtemp, createWriteStream, remove } from "fs-extra"; import { tmpdir } from "os"; import { delimiter, dirname, join } from "path"; import * as semver from "semver"; -import { URL } from "url"; import { ExtensionContext, Event } from "vscode"; import { DistributionConfig } from "../config"; import { extLogger } from "../common/logging/vscode"; @@ -27,8 +25,8 @@ import { } from "../common/logging"; import { unzipToDirectoryConcurrently } from "../common/unzip-concurrently"; import { reportUnzipProgress } from "../common/vscode/unzip-progress"; -import { Release, ReleaseAsset } from "./release"; -import { GithubRateLimitedError, GithubApiError } from "./github-api-error"; +import { Release } from "./release"; +import { ReleasesApiConsumer } from "./releases-api-consumer"; /** * distribution.ts @@ -590,173 +588,6 @@ class ExtensionSpecificDistributionManager { private static readonly _codeQlExtractedFolderName = "codeql"; } -export class ReleasesApiConsumer { - constructor( - ownerName: string, - repoName: string, - personalAccessToken?: string, - ) { - // Specify version of the GitHub API - this._defaultHeaders["accept"] = "application/vnd.github.v3+json"; - - if (personalAccessToken) { - this._defaultHeaders["authorization"] = `token ${personalAccessToken}`; - } - - this._ownerName = ownerName; - this._repoName = repoName; - } - - public async getLatestRelease( - versionRange: semver.Range | undefined, - orderBySemver = true, - includePrerelease = false, - additionalCompatibilityCheck?: (release: GithubRelease) => boolean, - ): Promise { - const apiPath = `/repos/${this._ownerName}/${this._repoName}/releases`; - const allReleases: GithubRelease[] = await ( - await this.makeApiCall(apiPath) - ).json(); - const compatibleReleases = allReleases.filter((release) => { - if (release.prerelease && !includePrerelease) { - return false; - } - - if (versionRange !== undefined) { - const version = semver.parse(release.tag_name); - if ( - version === null || - !semver.satisfies(version, versionRange, { includePrerelease }) - ) { - return false; - } - } - - return ( - !additionalCompatibilityCheck || additionalCompatibilityCheck(release) - ); - }); - // Tag names must all be parsable to semvers due to the previous filtering step. - const latestRelease = compatibleReleases.sort((a, b) => { - const versionComparison = orderBySemver - ? semver.compare(semver.parse(b.tag_name)!, semver.parse(a.tag_name)!) - : b.id - a.id; - if (versionComparison !== 0) { - return versionComparison; - } - return b.created_at.localeCompare(a.created_at, "en-US"); - })[0]; - if (latestRelease === undefined) { - throw new Error( - "No compatible CodeQL CLI releases were found. " + - "Please check that the CodeQL extension is up to date.", - ); - } - const assets: ReleaseAsset[] = latestRelease.assets.map((asset) => { - return { - id: asset.id, - name: asset.name, - size: asset.size, - }; - }); - - return { - assets, - createdAt: latestRelease.created_at, - id: latestRelease.id, - name: latestRelease.name, - }; - } - - public async streamBinaryContentOfAsset( - asset: ReleaseAsset, - ): Promise { - const apiPath = `/repos/${this._ownerName}/${this._repoName}/releases/assets/${asset.id}`; - - return await this.makeApiCall(apiPath, { - accept: "application/octet-stream", - }); - } - - protected async makeApiCall( - apiPath: string, - additionalHeaders: { [key: string]: string } = {}, - ): Promise { - const response = await this.makeRawRequest( - ReleasesApiConsumer._apiBase + apiPath, - Object.assign({}, this._defaultHeaders, additionalHeaders), - ); - - if (!response.ok) { - // Check for rate limiting - const rateLimitResetValue = response.headers.get("X-RateLimit-Reset"); - if (response.status === 403 && rateLimitResetValue) { - const secondsToMillisecondsFactor = 1000; - const rateLimitResetDate = new Date( - parseInt(rateLimitResetValue, 10) * secondsToMillisecondsFactor, - ); - throw new GithubRateLimitedError( - response.status, - await response.text(), - rateLimitResetDate, - ); - } - throw new GithubApiError(response.status, await response.text()); - } - return response; - } - - private async makeRawRequest( - requestUrl: string, - headers: { [key: string]: string }, - redirectCount = 0, - ): Promise { - const response = await fetch.default(requestUrl, { - headers, - redirect: "manual", - }); - - const redirectUrl = response.headers.get("location"); - if ( - isRedirectStatusCode(response.status) && - redirectUrl && - redirectCount < ReleasesApiConsumer._maxRedirects - ) { - const parsedRedirectUrl = new URL(redirectUrl); - if (parsedRedirectUrl.protocol !== "https:") { - throw new Error("Encountered a non-https redirect, rejecting"); - } - if (parsedRedirectUrl.host !== "api.github.com") { - // Remove authorization header if we are redirected outside of the GitHub API. - // - // This is necessary to stream release assets since AWS fails if more than one auth - // mechanism is provided. - delete headers["authorization"]; - } - return await this.makeRawRequest(redirectUrl, headers, redirectCount + 1); - } - - return response; - } - - private readonly _defaultHeaders: { [key: string]: string } = {}; - private readonly _ownerName: string; - private readonly _repoName: string; - - private static readonly _apiBase = "https://api.github.com"; - private static readonly _maxRedirects = 20; -} - -function isRedirectStatusCode(statusCode: number): boolean { - return ( - statusCode === 301 || - statusCode === 302 || - statusCode === 303 || - statusCode === 307 || - statusCode === 308 - ); -} - /* * Types and helper functions relating to those types. */ @@ -907,55 +738,3 @@ function warnDeprecatedLauncher() { `Please use "${codeQlLauncherName()}" instead. It is recommended to update to the latest CodeQL binaries.`, ); } - -/** - * The json returned from github for a release. - */ -export interface GithubRelease { - assets: GithubReleaseAsset[]; - - /** - * The creation date of the release on GitHub, in ISO 8601 format. - */ - created_at: string; - - /** - * The id associated with the release on GitHub. - */ - id: number; - - /** - * The name associated with the release on GitHub. - */ - name: string; - - /** - * Whether the release is a prerelease. - */ - prerelease: boolean; - - /** - * The tag name. This should be the version. - */ - tag_name: string; -} - -/** - * The json returned by github for an asset in a release. - */ -export interface GithubReleaseAsset { - /** - * The id associated with the asset on GitHub. - */ - id: number; - - /** - * The name associated with the asset on GitHub. - */ - name: string; - - /** - * The size of the asset in bytes. - */ - size: number; -} diff --git a/extensions/ql-vscode/src/codeql-cli/releases-api-consumer.ts b/extensions/ql-vscode/src/codeql-cli/releases-api-consumer.ts new file mode 100644 index 000000000..982a0bb04 --- /dev/null +++ b/extensions/ql-vscode/src/codeql-cli/releases-api-consumer.ts @@ -0,0 +1,224 @@ +import * as fetch from "node-fetch"; +import * as semver from "semver"; +import { URL } from "url"; +import { Release, ReleaseAsset } from "./release"; +import { GithubRateLimitedError, GithubApiError } from "./github-api-error"; + +export class ReleasesApiConsumer { + constructor( + ownerName: string, + repoName: string, + personalAccessToken?: string, + ) { + // Specify version of the GitHub API + this._defaultHeaders["accept"] = "application/vnd.github.v3+json"; + + if (personalAccessToken) { + this._defaultHeaders["authorization"] = `token ${personalAccessToken}`; + } + + this._ownerName = ownerName; + this._repoName = repoName; + } + + public async getLatestRelease( + versionRange: semver.Range | undefined, + orderBySemver = true, + includePrerelease = false, + additionalCompatibilityCheck?: (release: GithubRelease) => boolean, + ): Promise { + const apiPath = `/repos/${this._ownerName}/${this._repoName}/releases`; + const allReleases: GithubRelease[] = await ( + await this.makeApiCall(apiPath) + ).json(); + const compatibleReleases = allReleases.filter((release) => { + if (release.prerelease && !includePrerelease) { + return false; + } + + if (versionRange !== undefined) { + const version = semver.parse(release.tag_name); + if ( + version === null || + !semver.satisfies(version, versionRange, { includePrerelease }) + ) { + return false; + } + } + + return ( + !additionalCompatibilityCheck || additionalCompatibilityCheck(release) + ); + }); + // Tag names must all be parsable to semvers due to the previous filtering step. + const latestRelease = compatibleReleases.sort((a, b) => { + const versionComparison = orderBySemver + ? semver.compare(semver.parse(b.tag_name)!, semver.parse(a.tag_name)!) + : b.id - a.id; + if (versionComparison !== 0) { + return versionComparison; + } + return b.created_at.localeCompare(a.created_at, "en-US"); + })[0]; + if (latestRelease === undefined) { + throw new Error( + "No compatible CodeQL CLI releases were found. " + + "Please check that the CodeQL extension is up to date.", + ); + } + const assets: ReleaseAsset[] = latestRelease.assets.map((asset) => { + return { + id: asset.id, + name: asset.name, + size: asset.size, + }; + }); + + return { + assets, + createdAt: latestRelease.created_at, + id: latestRelease.id, + name: latestRelease.name, + }; + } + + public async streamBinaryContentOfAsset( + asset: ReleaseAsset, + ): Promise { + const apiPath = `/repos/${this._ownerName}/${this._repoName}/releases/assets/${asset.id}`; + + return await this.makeApiCall(apiPath, { + accept: "application/octet-stream", + }); + } + + protected async makeApiCall( + apiPath: string, + additionalHeaders: { [key: string]: string } = {}, + ): Promise { + const response = await this.makeRawRequest( + ReleasesApiConsumer._apiBase + apiPath, + Object.assign({}, this._defaultHeaders, additionalHeaders), + ); + + if (!response.ok) { + // Check for rate limiting + const rateLimitResetValue = response.headers.get("X-RateLimit-Reset"); + if (response.status === 403 && rateLimitResetValue) { + const secondsToMillisecondsFactor = 1000; + const rateLimitResetDate = new Date( + parseInt(rateLimitResetValue, 10) * secondsToMillisecondsFactor, + ); + throw new GithubRateLimitedError( + response.status, + await response.text(), + rateLimitResetDate, + ); + } + throw new GithubApiError(response.status, await response.text()); + } + return response; + } + + private async makeRawRequest( + requestUrl: string, + headers: { [key: string]: string }, + redirectCount = 0, + ): Promise { + const response = await fetch.default(requestUrl, { + headers, + redirect: "manual", + }); + + const redirectUrl = response.headers.get("location"); + if ( + isRedirectStatusCode(response.status) && + redirectUrl && + redirectCount < ReleasesApiConsumer._maxRedirects + ) { + const parsedRedirectUrl = new URL(redirectUrl); + if (parsedRedirectUrl.protocol !== "https:") { + throw new Error("Encountered a non-https redirect, rejecting"); + } + if (parsedRedirectUrl.host !== "api.github.com") { + // Remove authorization header if we are redirected outside of the GitHub API. + // + // This is necessary to stream release assets since AWS fails if more than one auth + // mechanism is provided. + delete headers["authorization"]; + } + return await this.makeRawRequest(redirectUrl, headers, redirectCount + 1); + } + + return response; + } + + private readonly _defaultHeaders: { [key: string]: string } = {}; + private readonly _ownerName: string; + private readonly _repoName: string; + + private static readonly _apiBase = "https://api.github.com"; + private static readonly _maxRedirects = 20; +} + +function isRedirectStatusCode(statusCode: number): boolean { + return ( + statusCode === 301 || + statusCode === 302 || + statusCode === 303 || + statusCode === 307 || + statusCode === 308 + ); +} + +/** + * The json returned from github for a release. + */ +interface GithubRelease { + assets: GithubReleaseAsset[]; + + /** + * The creation date of the release on GitHub, in ISO 8601 format. + */ + created_at: string; + + /** + * The id associated with the release on GitHub. + */ + id: number; + + /** + * The name associated with the release on GitHub. + */ + name: string; + + /** + * Whether the release is a prerelease. + */ + prerelease: boolean; + + /** + * The tag name. This should be the version. + */ + tag_name: string; +} + +/** + * The json returned by github for an asset in a release. + */ +interface GithubReleaseAsset { + /** + * The id associated with the asset on GitHub. + */ + id: number; + + /** + * The name associated with the asset on GitHub. + */ + name: string; + + /** + * The size of the asset in bytes. + */ + size: number; +} diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/distribution.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/distribution.test.ts index e6ddd8aec..ceb0e9352 100644 --- a/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/distribution.test.ts +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/distribution.test.ts @@ -1,6 +1,3 @@ -import * as fetch from "node-fetch"; -import { Range } from "semver"; - import * as log from "../../../../src/common/logging/notifications"; import { extLogger } from "../../../../src/common/logging/vscode"; import * as fs from "fs-extra"; @@ -11,9 +8,6 @@ import { DirectoryResult } from "tmp-promise"; import { DistributionManager, getExecutableFromDirectory, - GithubRelease, - GithubReleaseAsset, - ReleasesApiConsumer, } from "../../../../src/codeql-cli/distribution"; import { showAndLogErrorMessage, @@ -30,216 +24,6 @@ jest.mock("os", () => { const mockedOS = jest.mocked(os); -describe("Releases API consumer", () => { - const owner = "someowner"; - const repo = "somerepo"; - const unconstrainedVersionRange = new Range("*"); - - describe("picking the latest release", () => { - const sampleReleaseResponse: GithubRelease[] = [ - { - assets: [], - created_at: "2019-09-01T00:00:00Z", - id: 1, - name: "", - prerelease: false, - tag_name: "v2.1.0", - }, - { - assets: [], - created_at: "2019-08-10T00:00:00Z", - id: 2, - name: "", - prerelease: false, - tag_name: "v3.1.1", - }, - { - assets: [ - { - id: 1, - name: "exampleAsset.txt", - size: 1, - }, - ], - created_at: "2019-09-05T00:00:00Z", - id: 3, - name: "", - prerelease: false, - tag_name: "v2.0.0", - }, - { - assets: [], - created_at: "2019-08-11T00:00:00Z", - id: 4, - name: "", - prerelease: true, - tag_name: "v3.1.2-pre-1.1", - }, - // Release ID 5 is older than release ID 4 but its version has a higher precedence, so release - // ID 5 should be picked over release ID 4. - { - assets: [], - created_at: "2019-08-09T00:00:00Z", - id: 5, - name: "", - prerelease: true, - tag_name: "v3.1.2-pre-2.0", - }, - // Has a tag_name that is not valid semver - { - assets: [], - created_at: "2019-08-010T00:00:00Z", - id: 6, - name: "", - prerelease: true, - tag_name: "codeql-bundle-20231220", - }, - ]; - - class MockReleasesApiConsumer extends ReleasesApiConsumer { - protected async makeApiCall(apiPath: string): Promise { - if (apiPath === `/repos/${owner}/${repo}/releases`) { - return Promise.resolve( - new fetch.Response(JSON.stringify(sampleReleaseResponse)), - ); - } - return Promise.reject(new Error(`Unknown API path: ${apiPath}`)); - } - } - - it("picked release is non-prerelease with the highest semver", async () => { - const consumer = new MockReleasesApiConsumer(owner, repo); - - const latestRelease = await consumer.getLatestRelease( - unconstrainedVersionRange, - true, - ); - expect(latestRelease.id).toBe(2); - }); - - it("picked release is non-prerelease with highest id", async () => { - const consumer = new MockReleasesApiConsumer(owner, repo); - - const latestRelease = await consumer.getLatestRelease( - unconstrainedVersionRange, - false, - ); - expect(latestRelease.id).toBe(3); - }); - - it("version of picked release is within the version range", async () => { - const consumer = new MockReleasesApiConsumer(owner, repo); - - const latestRelease = await consumer.getLatestRelease(new Range("2.*.*")); - expect(latestRelease.id).toBe(1); - }); - - it("fails if none of the releases are within the version range", async () => { - const consumer = new MockReleasesApiConsumer(owner, repo); - - await expect( - consumer.getLatestRelease(new Range("5.*.*")), - ).rejects.toThrowError(); - }); - - it("picked release passes additional compatibility test if an additional compatibility test is specified", async () => { - const consumer = new MockReleasesApiConsumer(owner, repo); - - const latestRelease = await consumer.getLatestRelease( - new Range("2.*.*"), - true, - true, - (release) => - release.assets.some((asset) => asset.name === "exampleAsset.txt"), - ); - expect(latestRelease.id).toBe(3); - }); - - it("fails if none of the releases pass the additional compatibility test", async () => { - const consumer = new MockReleasesApiConsumer(owner, repo); - - await expect( - consumer.getLatestRelease(new Range("2.*.*"), true, true, (release) => - release.assets.some( - (asset) => asset.name === "otherExampleAsset.txt", - ), - ), - ).rejects.toThrowError(); - }); - - it("picked release is the most recent prerelease when includePrereleases is set", async () => { - const consumer = new MockReleasesApiConsumer(owner, repo); - - const latestRelease = await consumer.getLatestRelease( - unconstrainedVersionRange, - true, - true, - ); - expect(latestRelease.id).toBe(5); - }); - - it("ignores invalid semver and picks (pre-)release with highest id", async () => { - const consumer = new MockReleasesApiConsumer(owner, repo); - - const latestRelease = await consumer.getLatestRelease( - undefined, - false, - true, - ); - expect(latestRelease.id).toBe(6); - }); - }); - - it("gets correct assets for a release", async () => { - const expectedAssets: GithubReleaseAsset[] = [ - { - id: 1, - name: "firstAsset", - size: 11, - }, - { - id: 2, - name: "secondAsset", - size: 12, - }, - ]; - - class MockReleasesApiConsumer extends ReleasesApiConsumer { - protected async makeApiCall(apiPath: string): Promise { - if (apiPath === `/repos/${owner}/${repo}/releases`) { - const responseBody: GithubRelease[] = [ - { - assets: expectedAssets, - created_at: "2019-09-01T00:00:00Z", - id: 1, - name: "Release 1", - prerelease: false, - tag_name: "v2.0.0", - }, - ]; - - return Promise.resolve( - new fetch.Response(JSON.stringify(responseBody)), - ); - } - return Promise.reject(new Error(`Unknown API path: ${apiPath}`)); - } - } - - const consumer = new MockReleasesApiConsumer(owner, repo); - - const assets = (await consumer.getLatestRelease(unconstrainedVersionRange)) - .assets; - - expect(assets.length).toBe(expectedAssets.length); - expectedAssets.map((expectedAsset, index) => { - expect(assets[index].id).toBe(expectedAsset.id); - expect(assets[index].name).toBe(expectedAsset.name); - expect(assets[index].size).toBe(expectedAsset.size); - }); - }); -}); - describe("Launcher path", () => { let warnSpy: jest.SpiedFunction; let errorSpy: jest.SpiedFunction; diff --git a/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/releases-api-consumer.test.ts b/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/releases-api-consumer.test.ts new file mode 100644 index 000000000..2bed1442d --- /dev/null +++ b/extensions/ql-vscode/test/vscode-tests/no-workspace/codeql-cli/releases-api-consumer.test.ts @@ -0,0 +1,218 @@ +import * as fetch from "node-fetch"; +import { Range } from "semver"; + +import { + GithubRelease, + GithubReleaseAsset, + ReleasesApiConsumer, +} from "../../../../src/codeql-cli/releases-api-consumer"; + +describe("Releases API consumer", () => { + const owner = "someowner"; + const repo = "somerepo"; + const unconstrainedVersionRange = new Range("*"); + + describe("picking the latest release", () => { + const sampleReleaseResponse: GithubRelease[] = [ + { + assets: [], + created_at: "2019-09-01T00:00:00Z", + id: 1, + name: "", + prerelease: false, + tag_name: "v2.1.0", + }, + { + assets: [], + created_at: "2019-08-10T00:00:00Z", + id: 2, + name: "", + prerelease: false, + tag_name: "v3.1.1", + }, + { + assets: [ + { + id: 1, + name: "exampleAsset.txt", + size: 1, + }, + ], + created_at: "2019-09-05T00:00:00Z", + id: 3, + name: "", + prerelease: false, + tag_name: "v2.0.0", + }, + { + assets: [], + created_at: "2019-08-11T00:00:00Z", + id: 4, + name: "", + prerelease: true, + tag_name: "v3.1.2-pre-1.1", + }, + // Release ID 5 is older than release ID 4 but its version has a higher precedence, so release + // ID 5 should be picked over release ID 4. + { + assets: [], + created_at: "2019-08-09T00:00:00Z", + id: 5, + name: "", + prerelease: true, + tag_name: "v3.1.2-pre-2.0", + }, + // Has a tag_name that is not valid semver + { + assets: [], + created_at: "2019-08-010T00:00:00Z", + id: 6, + name: "", + prerelease: true, + tag_name: "codeql-bundle-20231220", + }, + ]; + + class MockReleasesApiConsumer extends ReleasesApiConsumer { + protected async makeApiCall(apiPath: string): Promise { + if (apiPath === `/repos/${owner}/${repo}/releases`) { + return Promise.resolve( + new fetch.Response(JSON.stringify(sampleReleaseResponse)), + ); + } + return Promise.reject(new Error(`Unknown API path: ${apiPath}`)); + } + } + + it("picked release is non-prerelease with the highest semver", async () => { + const consumer = new MockReleasesApiConsumer(owner, repo); + + const latestRelease = await consumer.getLatestRelease( + unconstrainedVersionRange, + true, + ); + expect(latestRelease.id).toBe(2); + }); + + it("picked release is non-prerelease with highest id", async () => { + const consumer = new MockReleasesApiConsumer(owner, repo); + + const latestRelease = await consumer.getLatestRelease( + unconstrainedVersionRange, + false, + ); + expect(latestRelease.id).toBe(3); + }); + + it("version of picked release is within the version range", async () => { + const consumer = new MockReleasesApiConsumer(owner, repo); + + const latestRelease = await consumer.getLatestRelease(new Range("2.*.*")); + expect(latestRelease.id).toBe(1); + }); + + it("fails if none of the releases are within the version range", async () => { + const consumer = new MockReleasesApiConsumer(owner, repo); + + await expect( + consumer.getLatestRelease(new Range("5.*.*")), + ).rejects.toThrowError(); + }); + + it("picked release passes additional compatibility test if an additional compatibility test is specified", async () => { + const consumer = new MockReleasesApiConsumer(owner, repo); + + const latestRelease = await consumer.getLatestRelease( + new Range("2.*.*"), + true, + true, + (release) => + release.assets.some((asset) => asset.name === "exampleAsset.txt"), + ); + expect(latestRelease.id).toBe(3); + }); + + it("fails if none of the releases pass the additional compatibility test", async () => { + const consumer = new MockReleasesApiConsumer(owner, repo); + + await expect( + consumer.getLatestRelease(new Range("2.*.*"), true, true, (release) => + release.assets.some( + (asset) => asset.name === "otherExampleAsset.txt", + ), + ), + ).rejects.toThrowError(); + }); + + it("picked release is the most recent prerelease when includePrereleases is set", async () => { + const consumer = new MockReleasesApiConsumer(owner, repo); + + const latestRelease = await consumer.getLatestRelease( + unconstrainedVersionRange, + true, + true, + ); + expect(latestRelease.id).toBe(5); + }); + + it("ignores invalid semver and picks (pre-)release with highest id", async () => { + const consumer = new MockReleasesApiConsumer(owner, repo); + + const latestRelease = await consumer.getLatestRelease( + undefined, + false, + true, + ); + expect(latestRelease.id).toBe(6); + }); + }); + + it("gets correct assets for a release", async () => { + const expectedAssets: GithubReleaseAsset[] = [ + { + id: 1, + name: "firstAsset", + size: 11, + }, + { + id: 2, + name: "secondAsset", + size: 12, + }, + ]; + + class MockReleasesApiConsumer extends ReleasesApiConsumer { + protected async makeApiCall(apiPath: string): Promise { + if (apiPath === `/repos/${owner}/${repo}/releases`) { + const responseBody: GithubRelease[] = [ + { + assets: expectedAssets, + created_at: "2019-09-01T00:00:00Z", + id: 1, + name: "Release 1", + prerelease: false, + tag_name: "v2.0.0", + }, + ]; + + return Promise.resolve( + new fetch.Response(JSON.stringify(responseBody)), + ); + } + return Promise.reject(new Error(`Unknown API path: ${apiPath}`)); + } + } + + const consumer = new MockReleasesApiConsumer(owner, repo); + + const assets = (await consumer.getLatestRelease(unconstrainedVersionRange)) + .assets; + + expect(assets.length).toBe(expectedAssets.length); + expectedAssets.map((expectedAsset, index) => { + expect(assets[index].id).toBe(expectedAsset.id); + expect(assets[index].name).toBe(expectedAsset.name); + expect(assets[index].size).toBe(expectedAsset.size); + }); + }); +});