import fs from 'node:fs' import path from 'node:path' import { GameDtoMapper } from '../application/mappers/GameDtoMapper' import type { CatalogListing } from '../domain/models/CatalogListing' import type { InstalledStore } from '../domain/models/InstalledStore' import type { RegistryStore } from '../domain/models/RegistryStore' import { DESKTOP_STORE_ENGINE } from '../domain/models/StoreEngine' import { deriveStoreId } from '../domain/models/StoreIdentity' import { HttpTextClient } from '../infrastructure/http/HttpTextClient' import { PythonEngineProcessRunner } from '../infrastructure/process/PythonEngineProcessRunner' import { SystemPythonRuntimeLocator } from '../infrastructure/process/SystemPythonRuntimeLocator' import { FileSystemInstalledStoreRepository } from '../infrastructure/repositories/FileSystemInstalledStoreRepository' import { HttpStoreRegistryRepository } from '../infrastructure/repositories/HttpStoreRegistryRepository' import { PythonStoreCatalogGateway } from '../infrastructure/repositories/PythonStoreCatalogGateway' import type { GameDto } from '../shared/contracts/dto/GameDto' import { ENGLISH_MESSAGES } from '../shared/i18n/EnglishMessages' import { HUNGARIAN_MESSAGES } from '../shared/i18n/HungarianMessages' import { LOCALES } from '../shared/i18n/MessageBundle' /** * Drives the store with no window and no Electron at all. * * This is the second composition root, and the reason the layers are worth having: * the same services the window uses are assembled here against the same ports, so an * integration mistake shows up in a terminal rather than in a screenshot. * * npm run smoke the store on this machine * SMOKE_HOME=/path/to/store-home npm run smoke a sandbox store */ class SmokeTest { private failed = false private readonly stores = new FileSystemInstalledStoreRepository() private readonly pythonLocator = new SystemPythonRuntimeLocator() private readonly catalogGateway = new PythonStoreCatalogGateway( new PythonEngineProcessRunner(this.pythonLocator) ) private readonly httpClient = new HttpTextClient() private readonly registry = new HttpStoreRegistryRepository(this.httpClient) private readonly gameMapper = new GameDtoMapper() public async run (): Promise { console.log('warp-engine-client smoke test') if (!this.checkPython()) return 1 this.checkMessages() await this.checkRegistry() const store = this.findStore() if (store === null) return this.failed ? 1 : 0 await this.checkCatalog(store) return this.failed ? 1 : 0 } private checkPython (): boolean { const runtime = this.pythonLocator.findRuntime() if (runtime === null) { this.reportBad('python', 'not found — the store cannot run') return false } this.reportOk('python', runtime.version) return true } /** The bundles are typed against one key set, so this only counts them. */ private checkMessages (): void { const english = Object.keys(ENGLISH_MESSAGES).length const hungarian = Object.keys(HUNGARIAN_MESSAGES).length if (english === hungarian) this.reportOk('strings', `${String(english)} keys × ${String(LOCALES.length)} languages`) else this.reportBad('strings', `en has ${String(english)}, hu has ${String(hungarian)}`) } private async checkRegistry (): Promise { try { const stores = await this.registry.listStores() if (stores.length === 0) { this.reportBad('registry', `${this.registry.sourceUrl} returned no stores`) return } this.reportOk('registry', `${String(stores.length)} store(s) from ${this.registry.sourceUrl}`) for (const store of stores) { this.reportOk(` ${store.name}`, `${store.catalogUrl} · ${deriveStoreId(store)}`) await this.checkStoreConfig(store) } } catch (error: unknown) { this.reportBad('registry', `${this.registry.sourceUrl}: ${this.describe(error)}`) } } /** * A store needs no repository, and a repository needs no config.json: either way * the engine's defaults carry it. So both absences are reported, not failed. */ private async checkStoreConfig (store: RegistryStore): Promise { if (store.storeRepositoryUrl === null) { this.reportOk(' config', 'no repository — the engine defaults would be used') return } const url = `${store.storeRepositoryUrl.replace(/\/+$/, '')}/raw/branch/master/config.json` try { const config: unknown = JSON.parse(await this.httpClient.readText(url)) const sections = typeof config === 'object' && config !== null ? Object.keys(config).length : 0 this.reportOk(' config.json', `${String(sections)} sections`) } catch (error: unknown) { this.reportOk(' config.json', `absent (${this.describe(error)}) — defaults would be used`) } } private findStore (): InstalledStore | null { const sandbox = process.env['SMOKE_HOME'] if (sandbox !== undefined && sandbox.length > 0) { const home = path.resolve(sandbox) const store: InstalledStore = { id: path.basename(home).replace(DESKTOP_STORE_ENGINE.homeSuffix, ''), name: path.basename(home), home, scriptPath: path.join(home, DESKTOP_STORE_ENGINE.scriptFileName), configPath: path.join(home, 'config.json'), engine: DESKTOP_STORE_ENGINE.id } for (const file of [store.scriptPath, store.configPath]) { if (!fs.existsSync(file)) { this.reportBad('SMOKE_HOME', `${file} is missing`) return null } } this.reportOk('store (SMOKE_HOME)', store.home) return store } const found = this.stores.findAll()[0] if (found === undefined) { console.log(' skip no store installed — run the app once, or set SMOKE_HOME') console.log(` it would be installed in ${this.stores.resolveDefaultHome('ttg')}`) return null } this.reportOk('store found', `${found.id} in ${found.home}`) return found } private async checkCatalog (store: InstalledStore): Promise { const logLines: string[] = [] const progress = { onLog: (line: string): void => { logLines.push(line) } } const engine = this.catalogGateway.readEngineVersion(store) if (engine === null) this.reportBad('engine', 'the store did not answer --version') else if (!engine.supported) this.reportBad('engine', `${engine.text} is too old for this client`) else this.reportOk('engine', engine.text) const paths = await this.catalogGateway.readPaths(store, progress) if (paths.operatingSystem.length > 0 && paths.storeFolder.length > 0) { this.reportOk('paths', `${paths.operatingSystem} → ${paths.storeFolder}`) } else { this.reportBad('paths', JSON.stringify(paths)) } const listing: CatalogListing = await this.catalogGateway.listGames(store, progress) if (listing.games.length === 0) { this.reportBad('list', 'no games came back') return } const games = this.gameMapper.toDtoList(listing.games, listing.paths?.catalogBaseUrl ?? '') this.reportListing(games) if (logLines.length > 0) this.reportOk('stderr log', `${String(logLines.length)} lines (kept off stdout)`) } private reportListing (games: readonly GameDto[]): void { const native = games.filter((game: GameDto): boolean => game.mode === 'app').length const hosted = games.length - native this.reportOk('list', `${String(games.length)} titles (app:${String(native)}, web:${String(hosted)})`) const withoutTitle = games.filter((game: GameDto): boolean => game.name.length === 0 || game.title.length === 0 || game.platform.length === 0) if (withoutTitle.length > 0) this.reportBad('game shape', `${String(withoutTitle.length)} entries are incomplete`) else this.reportOk('game shape', 'name, title, platform, version, mode, installed, updateAvailable') const installed = games.filter((game: GameDto): boolean => game.installed) this.reportOk('installed', `${String(installed.length)} of ${String(games.length)}`) const unlaunchable = installed.filter((game: GameDto): boolean => !game.launchable) if (unlaunchable.length > 0) this.reportBad('launch targets', `${String(unlaunchable.length)} installed titles have nothing to launch`) else if (installed.length > 0) this.reportOk('launch targets', 'every installed title has one') const withArt = games.filter((game: GameDto): boolean => game.imageUrl !== null) if (withArt.length > 0) { const first = withArt[0] if (first !== undefined) this.reportOk('box art', first.imageUrl ?? '') } } private describe (error: unknown): string { return error instanceof Error ? error.message : String(error) } private reportOk (label: string, value?: string): void { console.log(` ok ${label}${value === undefined ? '' : `: ${value}`}`) } private reportBad (label: string, value: string): void { this.failed = true console.log(` FAIL ${label}: ${value}`) } } void new SmokeTest().run().then( (code: number): void => { process.exitCode = code }, (error: unknown): void => { console.log(` FAIL error: ${error instanceof Error ? error.message : String(error)}`) process.exitCode = 1 } )