ci/woodpecker/manual/woodpecker Pipeline was successful
A store no longer needs a repository of its own. The engine's built-in defaults already cover the host-to-asset mapping, the install modes, the platforms and the behaviour; what they cannot know is identity — a slug, a name and a catalog URL — and that is exactly what a registry record carries. So `storeRepositoryUrl` is optional: a record with a name and a catalog is a complete store, the id falls back from the repository name to the catalog host (`teletypegames.org` becomes `teletypegames`) to the display name, and the client writes a three-section config. Given a repository it still reads it, and that file stays the authority on how the store behaves; a repository without a config.json is treated as no repository at all. Measured end to end against a local registry serving one record with a null repository: the engine and the core downloaded, engine 1.1.0 accepted the written config, it listed the same ten titles the configured store does, and a hosted title synced into a sandbox with its menu entry written. The "Install all" button is gone, and with it the string it used. Titles are installed one at a time from their own cards. No footer. The window carried a bar at the bottom at all times — a toggle and a line of absolute paths — for something most sessions never need. The log is still there, folder buttons included, behind a quiet switch at the bottom of the side menu; it takes no room until it is opened, and an arriving line does not open it, because the store logs on every refresh and a window that unfolds panels by itself is worse than one that keeps quiet. Three faults that every automated count had passed, found by photographing the setup screen: the store badge rendered as an empty pill with no store open; the gate's picker showed as an empty dropdown stub, because an explicit `display` beats the browser's own `[hidden]` rule; and the gate went up while the empty-catalog line stayed on screen underneath it. The last was a design fault — whether the gate is up was a call on a view rather than state, so the two could disagree. The setup screen is now a field in the state store, and that one field decides which of the gate and the grid is drawn. The window test's gate assertion was wrong too: it demanded a store picker, which only appears when the registry offers more than one store, so one store — the ordinary case — failed it. CI builds the packages this machine cannot. `.woodpecker.yaml` runs the checks on every push and, on a tag or by hand, builds the Linux packages in `electronuserland/builder:22` and the Windows ones in `:22-wine`, then attaches them to the release with scripts/ci-upload.sh. The pipeline lives here rather than in the update server's `/build/config` extension, which serves game-platform pipelines publishing into the site's catalog — a different product with a different target. macOS stays a local build: Apple's toolchain and its signing exist only on a Mac. Both build steps verify what they produced, because a half-finished Wine build leaves a 162 KB stub named like the real installer and `ls` is happy with it. The Linux step was rehearsed locally in the same image (AppImage 128 MB, deb 100 MB); the Wine step cannot be rehearsed on Apple Silicon, where 16 KB host pages break Wine's 4 KB assumption, so the runner is where it is proven. The size check was tested against both outcomes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
214 lines
9.1 KiB
TypeScript
214 lines
9.1 KiB
TypeScript
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<number> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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
|
||
}
|
||
)
|