diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index 9bc85fdd5c..7a0eaf3e1a 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -4,19 +4,15 @@ import { electronTest } from './helpers' declare const window: { api: typeof import('../src/backend/api').default } -electronTest('renders the first page', async (app) => { - const page = await app.firstWindow() +electronTest('renders the first page', async (app, page) => { await expect(page).toHaveTitle('Heroic Games Launcher') }) -electronTest('gets heroic, legendary, and gog versions', async (app) => { - const page = await app.firstWindow() - +electronTest('gets heroic, legendary, and gog versions', async (app, page) => { await test.step('get heroic version', async () => { const heroicVersion = await page.evaluate(async () => window.api.getHeroicVersion() ) - console.log('Heroic Version: ', heroicVersion) // check that heroic version is newer or equal to 2.6.3 expect(compareVersions(heroicVersion, '2.6.3')).toBeGreaterThanOrEqual(0) }) @@ -26,7 +22,6 @@ electronTest('gets heroic, legendary, and gog versions', async (app) => { window.api.getLegendaryVersion() ) legendaryVersion = legendaryVersion.trim().split(' ')[0] - console.log('Legendary Version: ', legendaryVersion) expect(compareVersions(legendaryVersion, '0.20.32')).toBeGreaterThanOrEqual( 0 ) @@ -36,7 +31,6 @@ electronTest('gets heroic, legendary, and gog versions', async (app) => { const gogdlVersion = await page.evaluate(async () => window.api.getGogdlVersion() ) - console.log('Gogdl Version: ', gogdlVersion) expect(compareVersions(gogdlVersion, '0.7.1')).toBeGreaterThanOrEqual(0) }) }) diff --git a/e2e/helpers.ts b/e2e/helpers.ts index cf57fa7b40..4f161558bd 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -2,7 +2,8 @@ import { join } from 'path' import { test, _electron as electron, - type ElectronApplication + ElectronApplication, + Page } from '@playwright/test' const main_js = join(__dirname, '../build/main/main.js') @@ -14,15 +15,58 @@ const main_js = join(__dirname, '../build/main/main.js') */ function electronTest( name: string, - func: (app: ElectronApplication) => void | Promise + func: (app: ElectronApplication, page: Page) => void | Promise ) { test(name, async () => { - const app = await electron.launch({ + const electronApp = await electron.launch({ args: [main_js] }) - await func(app) - await app.close() + + // uncomment these lines to print electron's output + // electronApp + // .process()! + // .stdout?.on('data', (data) => console.log(`stdout: ${data}`)) + // electronApp + // .process()! + // .stderr?.on('data', (error) => console.log(`stderr: ${error}`)) + + const page = await electronApp.firstWindow() + + await func(electronApp, page) + + await resetAllStubs(electronApp) + + await electronApp.close() }) } -export { electronTest } +async function resetAllStubs(app: ElectronApplication) { + await resetLegendaryCommandStub(app) + await resetGogdlCommandStub(app) + await resetNileCommandStub(app) +} + +async function resetLegendaryCommandStub(app: ElectronApplication) { + await app.evaluate(({ ipcMain }) => { + ipcMain.emit('resetLegendaryCommandStub') + }) +} + +async function resetGogdlCommandStub(app: ElectronApplication) { + await app.evaluate(({ ipcMain }) => { + ipcMain.emit('resetGogdlCommandStub') + }) +} + +async function resetNileCommandStub(app: ElectronApplication) { + await app.evaluate(({ ipcMain }) => { + ipcMain.emit('resetNileCommandStub') + }) +} + +export { + electronTest, + resetLegendaryCommandStub, + resetGogdlCommandStub, + resetNileCommandStub +} diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 0000000000..b61d10ea7b --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from '@playwright/test' +import { electronTest } from './helpers' + +electronTest('Settings', async (app, page) => { + // stub `legendary --version` + await app.evaluate(({ ipcMain }) => { + ipcMain.emit('setLegendaryCommandStub', [ + { + commandParts: ['--version'], + stdout: 'legendary version "1.2.3", codename "Some Name"' + } + ]) + }) + + // stub `gogdl --version` + await app.evaluate(({ ipcMain }) => { + ipcMain.emit('setGogdlCommandStub', [ + { + commandParts: ['--version'], + stdout: '2.3.4' + } + ]) + }) + + // stub `nile --version` + await app.evaluate(({ ipcMain }) => { + ipcMain.emit('setNileCommandStub', [ + { + commandParts: ['--version'], + stdout: '1.1.1 JoJo' + } + ]) + }) + + await test.step('shows the Advanced settings', async () => { + await page.getByTestId('settings').click() + page.getByText('Global Settings') + await page.getByText('Advanced').click() + }) + + await test.step('shows alternative binaries inputs', async () => { + await expect( + page.getByLabel('Choose an Alternative Legendary Binary') + ).toBeVisible() + await expect( + page.getByLabel('Choose an Alternative GOGDL Binary to use') + ).toBeVisible() + await expect( + page.getByLabel('Choose an Alternative Nile Binary') + ).toBeVisible() + }) + + await test.step('shows the binaries versions from the binaries', async () => { + await expect( + page.getByText('Legendary Version: 1.2.3 Some Name') + ).toBeVisible() + await expect(page.getByText('GOGDL Version: 2.3.4')).toBeVisible() + await expect(page.getByText('Nile Version: 1.1.1 JoJo')).toBeVisible() + }) + + await test.step('shows the default experimental features', async () => { + await expect(page.getByLabel('New design')).not.toBeChecked() + await expect(page.getByLabel('Help component')).not.toBeChecked() + }) +}) diff --git a/src/backend/anticheat/utils.ts b/src/backend/anticheat/utils.ts index 23b2c89341..39321c02c4 100644 --- a/src/backend/anticheat/utils.ts +++ b/src/backend/anticheat/utils.ts @@ -6,6 +6,7 @@ import { runOnceWhenOnline } from '../online_monitor' import { axiosClient } from 'backend/utils' async function downloadAntiCheatData() { + if (process.env.CI === 'e2e') return if (isWindows) return runOnceWhenOnline(async () => { diff --git a/src/backend/main.ts b/src/backend/main.ts index c79e2ebd04..8cbc1fecfc 100644 --- a/src/backend/main.ts +++ b/src/backend/main.ts @@ -425,7 +425,7 @@ if (!gotTheLock) { handleProtocol([request.url]) return new Response('Operation initiated.', { status: 201 }) }) - if (!app.isDefaultProtocolClient('heroic')) { + if (process.env.CI !== 'e2e' && !app.isDefaultProtocolClient('heroic')) { if (app.setAsDefaultProtocolClient('heroic')) { logInfo('Registered protocol with OS.', LogPrefix.Backend) } else { diff --git a/src/backend/online_monitor.ts b/src/backend/online_monitor.ts index 0756941e15..51bcbbfd40 100644 --- a/src/backend/online_monitor.ts +++ b/src/backend/online_monitor.ts @@ -69,6 +69,11 @@ const ping = async (url: string, signal: AbortSignal) => { } const pingSites = () => { + if (process.env.CI === 'e2e') { + setStatus('online') + return + } + logInfo(`Pinging external endpoints`, LogPrefix.Connection) abortController = new AbortController() diff --git a/src/backend/storeManagers/gog/e2eMock.ts b/src/backend/storeManagers/gog/e2eMock.ts new file mode 100644 index 0000000000..9e3c5d29ff --- /dev/null +++ b/src/backend/storeManagers/gog/e2eMock.ts @@ -0,0 +1,41 @@ +import { ipcMain } from 'electron' +import { RunnerCommandStub } from 'common/types' + +/* + * Multiple parts of a command can be set for the stub to be able to stub + * similar commands + * + * The first stub for which all commandParts are included in the executed + * command will be selected. The stubs should be declared from more + * precise to less precise to avoid unreachable stubs. + * + * We can stub a Promise as a response, or stub stdout/stderr + * values as an alternative to make the stubbing easier + */ +const defaultStubs: RunnerCommandStub[] = [ + { + commandParts: ['--version'], + stdout: '0.7.1' + } +] + +let currentStubs = [...defaultStubs] + +export const runGogdlCommandStub = async (command: string[]) => { + const stub = currentStubs.find((stub) => + stub.commandParts.every((part) => command.includes(part)) + ) + + if (stub?.response) return stub.response + + return Promise.resolve({ + stdout: stub?.stdout || '', + stderr: stub?.stderr || '' + }) +} + +// Add listeners to be called from e2e tests to stub the gogdl command calls +if (process.env.CI === 'e2e') { + ipcMain.on('setGogdlCommandStub', (stubs) => (currentStubs = [...stubs])) + ipcMain.on('resetGogdlCommandStub', () => (currentStubs = [...defaultStubs])) +} diff --git a/src/backend/storeManagers/gog/library.ts b/src/backend/storeManagers/gog/library.ts index 37342be8cc..6622694d09 100644 --- a/src/backend/storeManagers/gog/library.ts +++ b/src/backend/storeManagers/gog/library.ts @@ -52,6 +52,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { unzipSync } from 'node:zlib' import { readdirSync, rmSync, writeFileSync } from 'node:fs' import { checkForRedistUpdates } from './redist' +import { runGogdlCommandStub } from './e2eMock' const library: Map = new Map() const installedGames: Map = new Map() @@ -1319,6 +1320,10 @@ export async function runRunnerCommand( commandParts: string[], options?: CallRunnerOptions ): Promise { + if (process.env.CI === 'e2e') { + return runGogdlCommandStub(commandParts) + } + const { dir, bin } = getGOGdlBin() const authConfig = join(app.getPath('userData'), 'gog_store', 'auth.json') diff --git a/src/backend/storeManagers/legendary/e2eMock.ts b/src/backend/storeManagers/legendary/e2eMock.ts new file mode 100644 index 0000000000..e80971e983 --- /dev/null +++ b/src/backend/storeManagers/legendary/e2eMock.ts @@ -0,0 +1,48 @@ +import { ipcMain } from 'electron' +import { RunnerCommandStub } from 'common/types' +import { LegendaryCommand } from './commands' + +/* + * Multiple parts of a command can be set for the stub to be able to stub + * similar commands + * + * The first stub for which all commandParts are included in the executed + * command will be selected. The stubs should be declared from more + * precise to less precise to avoid unreachable stubs. + * + * We can stub a Promise as a response, or stub stdout/stderr + * values as an alternative to make the stubbing easier + */ +const defaultStubs: RunnerCommandStub[] = [ + { + commandParts: ['--version'], + response: Promise.resolve({ + stdout: 'legendary version "0.20.33", codename "Undue Alarm"', + stderr: '' + }) + } +] + +let currentStubs = [...defaultStubs] + +export const runLegendaryCommandStub = async (command: LegendaryCommand) => { + const stub = currentStubs.find((stub) => + stub.commandParts.every((part) => command[part]) + ) + + if (stub?.response) return stub.response + + return Promise.resolve({ + stdout: stub?.stdout || '', + stderr: stub?.stderr || '' + }) +} + +// Add listeners to be called from e2e tests to stub the legendary command calls +if (process.env.CI === 'e2e') { + ipcMain.on('setLegendaryCommandStub', (stubs) => (currentStubs = [...stubs])) + ipcMain.on( + 'resetLegendaryCommandStub', + () => (currentStubs = [...defaultStubs]) + ) +} diff --git a/src/backend/storeManagers/legendary/library.ts b/src/backend/storeManagers/legendary/library.ts index 7ca7aa1faf..33247bfdc1 100644 --- a/src/backend/storeManagers/legendary/library.ts +++ b/src/backend/storeManagers/legendary/library.ts @@ -58,6 +58,7 @@ import { Path } from 'backend/schemas' import shlex from 'shlex' import thirdParty from './thirdParty' import { Entries } from 'type-fest' +import { runLegendaryCommandStub } from './e2eMock' const allGames: Set = new Set() let installedGames: Map = new Map() @@ -679,6 +680,10 @@ export async function runRunnerCommand( command: LegendaryCommand, options?: CallRunnerOptions ): Promise { + if (process.env.CI === 'e2e') { + return runLegendaryCommandStub(command) + } + const { dir, bin } = getLegendaryBin() // Set LEGENDARY_CONFIG_PATH to a custom, Heroic-specific location so user-made diff --git a/src/backend/storeManagers/nile/e2eMock.ts b/src/backend/storeManagers/nile/e2eMock.ts new file mode 100644 index 0000000000..8be2c052e5 --- /dev/null +++ b/src/backend/storeManagers/nile/e2eMock.ts @@ -0,0 +1,41 @@ +import { ipcMain } from 'electron' +import { RunnerCommandStub } from 'common/types' + +/* + * Multiple parts of a command can be set for the stub to be able to stub + * similar commands + * + * The first stub for which all commandParts are included in the executed + * command will be selected. The stubs should be declared from more + * precise to less precise to avoid unreachable stubs. + * + * We can stub a Promise as a response, or stub stdout/stderr + * values as an alternative to make the stubbing easier + */ +const defaultStubs: RunnerCommandStub[] = [ + { + commandParts: ['--version'], + stdout: '1.0.0 Jonathan Joestar' + } +] + +let currentStubs = [...defaultStubs] + +export const runNileCommandStub = async (command: string[]) => { + const stub = currentStubs.find((stub) => + stub.commandParts.every((part) => command.includes(part)) + ) + + if (stub?.response) return stub.response + + return Promise.resolve({ + stdout: stub?.stdout || '', + stderr: stub?.stderr || '' + }) +} + +// Add listeners to be called from e2e tests to stub the nile command calls +if (process.env.CI === 'e2e') { + ipcMain.on('setNileCommandStub', (stubs) => (currentStubs = [...stubs])) + ipcMain.on('resetNileCommandStub', () => (currentStubs = [...defaultStubs])) +} diff --git a/src/backend/storeManagers/nile/library.ts b/src/backend/storeManagers/nile/library.ts index bc7743df65..59d086b74d 100644 --- a/src/backend/storeManagers/nile/library.ts +++ b/src/backend/storeManagers/nile/library.ts @@ -28,6 +28,7 @@ import { dirname, join } from 'path' import { app } from 'electron' import { copySync } from 'fs-extra' import { NileUser } from './user' +import { runNileCommandStub } from './e2eMock' const installedGames: Map = new Map() const library: Map = new Map() @@ -466,6 +467,10 @@ export async function runRunnerCommand( commandParts: string[], options?: CallRunnerOptions ): Promise { + if (process.env.CI === 'e2e') { + return runNileCommandStub(commandParts) + } + const { dir, bin } = getNileBin() // Set NILE_CONFIG_PATH to a custom, Heroic-specific location so user-made diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 2dfed52c9d..e6c5194cbb 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -742,6 +742,8 @@ function detectVCRedist(mainWindow: BrowserWindow) { } const getLatestReleases = async (): Promise => { + if (process.env.CI === 'e2e') return [] + const newReleases: Release[] = [] logInfo('Checking for new Heroic Updates', LogPrefix.Backend) @@ -787,6 +789,8 @@ const getLatestReleases = async (): Promise => { } const getCurrentChangelog = async (): Promise => { + if (process.env.CI === 'e2e') return null + logInfo('Checking for current version changelog', LogPrefix.Backend) try { diff --git a/src/common/typedefs/ipcBridge.d.ts b/src/common/typedefs/ipcBridge.d.ts index 193362e327..12e1e044fa 100644 --- a/src/common/typedefs/ipcBridge.d.ts +++ b/src/common/typedefs/ipcBridge.d.ts @@ -33,7 +33,8 @@ import { DownloadManagerState, InstallInfo, WikiInfo, - UploadedLogData + UploadedLogData, + RunnerCommandStub } from 'common/types' import { GameOverride, SelectiveDownload } from 'common/types/legendary' import { GOGCloudSavesLocation } from 'common/types/gog' @@ -119,6 +120,21 @@ interface SyncIPCFunctions { ) => void } +/* + * These events should only be used during tests to stub/mock + * + * We have to handle them in another interface because these + * events don't have an IpcMainEvent first argument when handled + */ +interface TestSyncIPCFunctions { + setLegendaryCommandStub: (stubs: RunnerCommandStub[]) => void + resetLegendaryCommandStub: () => void + setGogdlCommandStub: (stubs: RunnerCommandStub[]) => void + resetGogdlCommandStub: () => void + setNileCommandStub: (stubs: RunnerCommandStub[]) => void + resetNileCommandStub: () => void +} + // ts-prune-ignore-next interface AsyncIPCFunctions { addToDMQueue: (element: DMQueueElement) => Promise @@ -312,13 +328,20 @@ interface AsyncIPCFunctions { // ts-prune-ignore-next declare namespace Electron { class IpcMain extends EventEmitter { - public on: < + public on: (< Name extends keyof SyncIPCFunctions, Definition extends SyncIPCFunctions[Name] >( name: Name, callback: (e: IpcMainEvent, ...args: Parameters) => void - ) => void + ) => void) & + (< + Name extends keyof TestSyncIPCFunctions, + Definition extends TestSyncIPCFunctions[Name] + >( + name: Name, + callback: (...args: Parameters) => void + ) => void) public handle: < Name extends keyof AsyncIPCFunctions, diff --git a/src/common/types.ts b/src/common/types.ts index 74f067d151..91fc096411 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -775,3 +775,10 @@ export interface UploadedLogData { // Time the log file was uploaded (used to know whether it expired) uploadedAt: number } + +export interface RunnerCommandStub { + commandParts: string[] + response?: Promise + stdout?: string + stderr?: string +}