From 6069ebe1a5a22834d5b5a101866704003f901849 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Wed, 20 Apr 2022 09:38:02 -0700 Subject: [PATCH] feat(publisher-ers): support flavor config (#2766) --- .../electron-release-server/src/Config.ts | 8 + .../src/PublisherERS.ts | 7 +- .../test/PublisherERS_spec.ts | 207 +++++++++++++++++- 3 files changed, 219 insertions(+), 3 deletions(-) diff --git a/packages/publisher/electron-release-server/src/Config.ts b/packages/publisher/electron-release-server/src/Config.ts index 8c8314bc2c..205be88cba 100644 --- a/packages/publisher/electron-release-server/src/Config.ts +++ b/packages/publisher/electron-release-server/src/Config.ts @@ -24,4 +24,12 @@ export interface PublisherERSConfig { * Default: stable */ channel?: string; + + /** + * The "flavor" of the binary that you want to release to. + * This is useful if you want to provide multiple versions + * of the same application version (e.g. full and lite) + * to end users. + */ + flavor?: string; } diff --git a/packages/publisher/electron-release-server/src/PublisherERS.ts b/packages/publisher/electron-release-server/src/PublisherERS.ts index f6ac69fd01..9903b47373 100644 --- a/packages/publisher/electron-release-server/src/PublisherERS.ts +++ b/packages/publisher/electron-release-server/src/PublisherERS.ts @@ -15,6 +15,7 @@ const d = debug('electron-forge:publish:ers'); interface ERSVersion { name: string; assets: { name: string }[]; + flavor?: string; } const fetchAndCheckStatus = async (url: RequestInfo, init?: RequestInit): Promise => { @@ -73,12 +74,15 @@ export default class PublisherERS extends PublisherBase { fetchAndCheckStatus(api(apiPath), { ...(options || {}), headers: { ...(options || {}).headers, Authorization: `Bearer ${token}` } }); const versions: ERSVersion[] = await (await authFetch('api/version')).json(); + const flavor = config.flavor || 'default'; for (const makeResult of makeResults) { const { packageJSON } = makeResult; const artifacts = makeResult.artifacts.filter((artifactPath) => path.basename(artifactPath).toLowerCase() !== 'releases'); - const existingVersion = versions.find((version) => version.name === packageJSON.version); + const existingVersion = versions.find((version) => { + return version.name === packageJSON.version && (!version.flavor || version.flavor === flavor); + }); let channel = 'stable'; if (config.channel) { @@ -97,6 +101,7 @@ export default class PublisherERS extends PublisherBase { channel: { name: channel, }, + flavor: config.flavor, name: packageJSON.version, notes: '', }), diff --git a/packages/publisher/electron-release-server/test/PublisherERS_spec.ts b/packages/publisher/electron-release-server/test/PublisherERS_spec.ts index ef4912105a..e3e6b6d1b6 100644 --- a/packages/publisher/electron-release-server/test/PublisherERS_spec.ts +++ b/packages/publisher/electron-release-server/test/PublisherERS_spec.ts @@ -1,14 +1,217 @@ import { expect } from 'chai'; -import { ForgeConfig } from '@electron-forge/shared-types'; +import { ForgeConfig, ForgeMakeResult } from '@electron-forge/shared-types'; import fetchMock from 'fetch-mock'; import proxyquire from 'proxyquire'; describe('PublisherERS', () => { let fetch: typeof fetchMock; + beforeEach(() => { fetch = fetchMock.sandbox(); }); - it('fail if the server returns 4xx', async () => { + + describe('new version', () => { + it('can publish a new version to ERS', async () => { + const baseUrl = 'https://example.com'; + const token = 'FAKE_TOKEN'; + const flavor = 'lite'; + const version = '3.0.0'; + + // mock login + fetch.postOnce('path:/api/auth/login', { body: { token }, status: 200 }); + // mock fetch all existing versions + fetch.getOnce('path:/api/version', { body: [{ name: '2.0.0', assets: [], flavor: 'default' }], status: 200 }); + // mock creating a new version + fetch.postOnce('path:/api/version', { status: 200 }); + // mock asset upload + fetch.post('path:/api/asset', { status: 200 }); + const PublisherERS = proxyquire.noCallThru().load('../src/PublisherERS', { + 'node-fetch': fetch, + }).default; + + const publisher = new PublisherERS({ + baseUrl, + username: 'test', + password: 'test', + flavor, + }); + + const makeResults: ForgeMakeResult[] = [ + { + artifacts: ['/path/to/artifact'], + packageJSON: { + version, + }, + platform: 'linux', + arch: 'x64', + }, + ]; + + await publisher.publish({ makeResults, dir: '', forgeConfig: {} as ForgeConfig }); + + const calls = fetch.calls(); + + // creates a new version with the correct flavor, name, and channel + expect(calls[2][0]).to.equal(`${baseUrl}/api/version`); + expect(calls[2][1]?.body).to.equal(`{"channel":{"name":"stable"},"flavor":"${flavor}","name":"${version}","notes":""}`); + + // uploads asset successfully + expect(calls[3][0]).to.equal(`${baseUrl}/api/asset`); + }); + }); + + describe('existing version', () => { + it('can add new assets', async () => { + const baseUrl = 'https://example.com'; + const token = 'FAKE_TOKEN'; + const channel = 'stable'; + const flavor = 'lite'; + const version = '2.0.0'; + + // mock login + fetch.postOnce('path:/api/auth/login', { body: { token }, status: 200 }); + // mock fetch all existing versions + fetch.getOnce('path:/api/version', { body: [{ name: '2.0.0', assets: [], flavor: 'lite' }], status: 200 }); + // mock asset upload + fetch.post('path:/api/asset', { status: 200 }); + + const PublisherERS = proxyquire.noCallThru().load('../src/PublisherERS', { + 'node-fetch': fetch, + }).default; + + const publisher = new PublisherERS({ + baseUrl, + username: 'test', + password: 'test', + channel, + flavor, + }); + + const makeResults: ForgeMakeResult[] = [ + { + artifacts: ['/path/to/artifact'], + packageJSON: { + version, + }, + platform: 'linux', + arch: 'x64', + }, + ]; + + await publisher.publish({ makeResults, dir: '', forgeConfig: {} as ForgeConfig }); + + const calls = fetch.calls(); + + // uploads asset successfully + expect(calls[2][0]).to.equal(`${baseUrl}/api/asset`); + }); + + it('does not replace assets for existing version', async () => { + const baseUrl = 'https://example.com'; + const token = 'FAKE_TOKEN'; + const channel = 'stable'; + const version = '2.0.0'; + + // mock login + fetch.postOnce('path:/api/auth/login', { body: { token }, status: 200 }); + // mock fetch all existing versions + fetch.getOnce('path:/api/version', { body: [{ name: '2.0.0', assets: [{ name: 'existing-artifact' }], flavor: 'default' }], status: 200 }); + + const PublisherERS = proxyquire.noCallThru().load('../src/PublisherERS', { + 'node-fetch': fetch, + }).default; + + const publisher = new PublisherERS({ + baseUrl, + username: 'test', + password: 'test', + channel, + }); + + const makeResults: ForgeMakeResult[] = [ + { + artifacts: ['/path/to/existing-artifact'], + packageJSON: { + version, + }, + platform: 'linux', + arch: 'x64', + }, + ]; + + await publisher.publish({ makeResults, dir: '', forgeConfig: {} as ForgeConfig }); + + const calls = fetch.calls(); + expect(calls).to.have.length(2); + }); + + it('can upload a new flavor for an existing version', async () => { + const baseUrl = 'https://example.com'; + const token = 'FAKE_TOKEN'; + const version = '2.0.0'; + const flavor = 'lite'; + + // mock login + fetch.postOnce('path:/api/auth/login', { body: { token }, status: 200 }); + // mock fetch all existing versions + fetch.getOnce('path:/api/version', { body: [{ name: '2.0.0', assets: [{ name: 'existing-artifact' }], flavor: 'default' }], status: 200 }); + // mock creating a new version + fetch.postOnce('path:/api/version', { status: 200 }); + // mock asset upload + fetch.post('path:/api/asset', { status: 200 }); + + const PublisherERS = proxyquire.noCallThru().load('../src/PublisherERS', { + 'node-fetch': fetch, + }).default; + + const publisher = new PublisherERS({ + baseUrl, + username: 'test', + password: 'test', + flavor, + }); + + const makeResults: ForgeMakeResult[] = [ + { + artifacts: ['/path/to/artifact'], + packageJSON: { + version, + }, + platform: 'linux', + arch: 'x64', + }, + ]; + + await publisher.publish({ makeResults, dir: '', forgeConfig: {} as ForgeConfig }); + + const calls = fetch.calls(); + + // creates a new version with the correct flavor, name, and channel + expect(calls[2][0]).to.equal(`${baseUrl}/api/version`); + expect(calls[2][1]?.body).to.equal(`{"channel":{"name":"stable"},"flavor":"${flavor}","name":"${version}","notes":""}`); + + // uploads asset successfully + expect(calls[3][0]).to.equal(`${baseUrl}/api/asset`); + }); + + // TODO: implement edge cases + it('can read the channel from the package.json version'); + it('does not upload the RELEASES file'); + }); + + it('fails if username and password are not provided', () => { + const PublisherERS = proxyquire.noCallThru().load('../src/PublisherERS', { + 'node-fetch': fetch, + }).default; + + const publisher = new PublisherERS({}); + + expect(publisher.publish({ makeResults: [], dir: '', forgeConfig: {} as ForgeConfig })).to.eventually.be.rejectedWith( + 'In order to publish to ERS you must set the "electronReleaseServer.baseUrl", "electronReleaseServer.username" and "electronReleaseServer.password" properties in your Forge config. See the docs for more info' + ); + }); + + it('fails if the server returns 4xx', async () => { fetch.mock('begin:http://example.com', { body: {}, status: 400 }); const PublisherERS = proxyquire.noCallThru().load('../src/PublisherERS', { 'node-fetch': fetch,