Files
warp-engine-client/src/scripts/SmokeTest.ts
T
mr.zeroandClaude Opus 5 526c67b069
ci/woodpecker/manual/woodpecker Pipeline was successful
Registry-only stores, a quieter window, and a CI that builds
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>
2026-08-18 16:52:59 +02:00

214 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
)