-
Notifications
You must be signed in to change notification settings - Fork 5
gui test harness
Portable manual for the automated GUI-test harness that drives the Avalonia/Linux build of
Simple Launcher on a Linux VM and checks it against docs/manual-tests.md (the Linux checklist).
It is written for a human or an LLM resuming the work on another machine.
The harness is tracked in this repository at scripts\gui-test-harness\. Clone the repo on the
new machine, adapt the configuration values in §4 (VM IP/credentials; the script paths derive from
$PSScriptRoot), and follow §5. If the scripts are ever lost, §12 and the appendices contain enough
contracts to rebuild them.
host (Windows or any OS with PowerShell 7 + SSH)
run-suite.ps1 scenarios (one PowerShell hashtable each)
-> lib.ps1 SSH session, app lifecycle, fixture, screenshots, vision
-> SCP atspi_nav.py + fixture.py to the guest
-> SSH python3 <scenario> (heredoc) on the guest
-> pyatspi / AT-SPI drives the running app
-> prints "SL_RESULT: {json}" one line
-> gnome-screenshot (guest) -> SCP PNG to shots\
-> ask_vision.py (host) -> OpenRouter vision model -> "VERDICT: PASS|FAIL"
-> reports\run-<timestamp>.md (AT-SPI JSON + vision answer per scenario)
guest (Linux Mint 22.3 Cinnamon, X11; user `vm`)
~/SimpleLauncher/SimpleLauncher.Avalonia self-contained linux-x64 publish
~/.local/share/SimpleLauncher/settings.dat unified SQLite (systems, favorites, history, settings)
~/roms/<System>, ~/images/<System> fixture ROMs/covers
/home/vm/dummy-emulator.sh script emulator (sleeps 7 s)
/home/vm/vision/atspi_nav.py, fixture.py deployed by Deploy-VmNav
Verdict per scenario = deterministic AND vision (vision only when the scenario defines a
Prompt). Deterministic = ok == true, at least one check, no exception in extra.
-
Locate the harness:
scripts\gui-test-harness\in this repository (clone it on the new machine). Run all commands from the repository root, or use the absolute path of the harness. -
Check the environment:
If false: start the VM / re-check the IP /
. scripts\gui-test-harness\lib.ps1 Deploy-VmNav Test-VmNavHealth # must return True (app running, AT-SPI reachable)
Restart-VmApp -Fixture seeded/ re-apply 1080p (§12). Never trust a red run whileapt/dpkgis active on the guest. -
Run the target scenarios (subset while iterating, full pass at the end):
pwsh -NoProfile -File scripts\gui-test-harness\run-suite.ps1 -Only EDIT-01,HELP-01 pwsh -NoProfile -File scripts\gui-test-harness\run-suite.ps1
-
On a failure: open the newest
reports\run-*.md, read the AT-SPI JSON and vision answer, then reproduce the scenario body ad-hoc withInvoke-VmPython(§6). Fix the scenario (not the app) unless the failure is a real product bug — in that case document it as a finding. -
Author new scenarios from
docs/manual-tests.mdusing §7-§9. Keep one concern per scenario, deterministic assertions first, vision only for visual claims. -
When green: update
ManualTests.md§6 (statuses, findings, open items) and tick thedocs/manual-tests.mdcheckboxes only for items the suite actually verified. Never weaken, filter or skip a scenario to make it pass.
- PowerShell 7+ (
pwsh) with the Posh-SSH module:Install-Module Posh-SSH -Scope CurrentUser
- Python 3 (standard library only;
ask_vision.pyusesurllib) onPATH. - User environment variable
OPENROUTER_API_KEY(sk-or-...):The harness never writes the key to disk or logs.[Environment]::SetEnvironmentVariable('OPENROUTER_API_KEY', 'sk-or-...', 'User')
- Network access to
https://openrouter.aiand to the guest's SSH port.
- X11 session (Wayland works through Xwayland) with a user logged in on
DISPLAY=:0(autologin recommended so the app can run headless-of-user). - Packages:
at-spi2-core python3-pyatspi xdotool wmctrl gnome-screenshot openssh-server. On Mint 22.3 most are present;at-spi-bus-launcherstarts with the session. - An accessibility bus available to the SSH session; the harness exports
DISPLAY=:0 XAUTHORITY=/home/vm/.Xauthority XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/busfor every guest command. - The app deployed at
~/SimpleLauncher/SimpleLauncher.Avalonia(self-contained publish, see §4).
dotnet publish SimpleLauncher.Avalonia\SimpleLauncher.Avalonia.csproj -c Release `
-f net10.0 -r linux-x64 --self-contained true -o D:\payload\linux-x64-annot
tar -czf D:\payload\payload-annot.tar.gz -C D:\payload\linux-x64-annot .
# copy to the guest, extract over the existing app folder, chmod +xD:\payload is any host folder outside the repo (the publish output is not committed).
-f net10.0 is required (the project is multi-TFM; otherwise NETSDK1129).
| File | Purpose | Size |
|---|---|---|
run-suite.ps1 |
Scenario suite + report writer. Scenarios are PowerShell hashtables (Id, Name, Fixture, Restart, LaunchArgs, Py, Shot, Prompt) |
~50 KB |
lib.ps1 |
Dot-sourced by the suite: SSH session, app lifecycle, fixture application, screenshots, vision call, result parsing. Also usable interactively (. scripts\gui-test-harness\lib.ps1) |
~7 KB |
atspi_nav.py |
AT-SPI navigation library (deployed to /home/vm/vision/); Nav class + Entry; also a CLI (tree, ready, dump, selftest) |
~23 KB |
fixture.py |
Seeds/clears the guest unified DB and files (deployed to /home/vm/vision/); commands seed, arcade, empty, clean-state, dump
|
~8 KB |
ask_vision.py |
One-shot OpenRouter vision call: python ask_vision.py <png> "<prompt>" [--model M] [--max-tokens N] [--temperature T]; reads OPENROUTER_API_KEY; prints the model text plus a --- usage --- JSON line |
~3 KB |
vm-systems.json + seed-batch.py
|
First emulator-matrix fixture (36 Easy-Mode systems, manifest emulator configs); seed-batch.py <names...> keeps the base systems + the requested batch |
~30 KB |
vm-systems2.json + seed-batch2.py
|
Standalone/CD fixture (25 manifest systems: PS1/PS2/PS3/DC/Saturn/WiiU/3DS/PSP/Xbox/DOS/Model 3/...); same batch semantics, adds fix-arrays.py to normalize scalar format strings the strict deserializer skips |
~20 KB |
launch-one.py |
App-driven single-system launch (launch-one.py <System> "<Game>" [wait]): maximize, open system, letter bar, AT-SPI card right-click (coordinates fallback), DOSBox picker handling, window/process evidence, screenshot, cleanup — run one system per fresh app start |
~4 KB |
The scripts resolve their own locations from $PSScriptRoot, so the checkout can live anywhere.
Only the guest-side paths (§4) and the VM IP/credentials need adapting. shots\ and reports\ are
created on demand and are gitignored (evidence stays local).
Edit lib.ps1:
| Setting | Meaning |
|---|---|
$script:VmHostAddress |
Guest IP (DHCP; re-check after each VM boot) |
$script:VmUserName / $script:VmPassword
|
SSH credentials (vm/vm on the reference VM) |
$script:DefaultModel |
Vision model (xiaomi/mimo-v2.6-flash) |
$script:VmEnv |
Guest session exports (display, X authority, DBus) |
Derived from $PSScriptRoot (no edit needed when cloning elsewhere): $script:HarnessRoot,
$script:ShotRoot (shots\), $script:ReportRoot (reports\), $script:AskScript,
$script:NavScript, $script:FixtureScript.
Guest paths hardcoded in lib.ps1 / fixture.py / scenarios (replace vm if the user differs):
| Path | Meaning |
|---|---|
/home/vm/SimpleLauncher/SimpleLauncher.Avalonia |
app binary (start/stop identity) |
/home/vm/SimpleLauncher |
app folder (parameters.md, WhatsNew.md, samples\, emulators\) |
/home/vm/.local/share/SimpleLauncher/settings.dat |
unified SQLite (systems/favorites/history/settings) |
/home/vm/roms/<System>, /home/vm/images/<System>
|
fixture ROMs and covers |
/home/vm/dummy-emulator.sh, /tmp/dummy-emulator.log
|
fixture emulator and its launch log |
/home/vm/vision |
deployed atspi_nav.py + fixture.py
|
App identity for kill/start is the exact exe path
(pgrep -f "/home/vm/SimpleLauncher/SimpleLauncher.Avalonia$" + /proc/<pid>/exe check), so the
SSH shell never matches itself.
- SSH works with password auth (
New-SSHSession). - Session environment: the guest must have an active X session on
:0(autologin) andat-spi2-corerunning. Verify:DISPLAY=:0 XAUTHORITY=/home/vm/.Xauthority python3 -c "import pyatspi; print(pyatspi.Registry.getDesktop(0).childCount)" - App deployed (see §2) and executable.
- Console resolution: card coordinates assume 1080p. The reference VM re-applies it with an
xrandr-1080pautostart; after a manual change use:On Hyper-V this can also be made persistent withDISPLAY=:0 XAUTHORITY=/home/vm/.Xauthority xrandr --output Virtual-1 --mode 1920x1080
Set-VMVideo -VMName LinuxMint -HorizontalResolution 1920 -VerticalResolution 1080. - Deploy the harness helpers (also done automatically at every suite start):
. scripts\gui-test-harness\lib.ps1 Deploy-VmNav # creates /home/vm/vision and SCPs the helper scripts Test-VmNavHealth # -> True when the app frame is in the AT-SPI tree
- Run the app once before the first fixture seed:
fixture.pywritessettings.dat, which the app creates on its first run.Set-VmFixturewarns when the DB is missing; runRestart-VmApponce.
# all scenarios (restarts the app per fixture; ~40-60 min with vision)
pwsh -NoProfile -File scripts\gui-test-harness\run-suite.ps1
# subsets while iterating (comma-separated, also -Only A,B from -File)
pwsh -NoProfile -File scripts\gui-test-harness\run-suite.ps1 -Only EDIT-01,HELP-01
# different vision model, or skip the restart (assume the app is already in the right state)
pwsh -NoProfile -File scripts\gui-test-harness\run-suite.ps1 -Model 'google/gemini-3.8-flash'
pwsh -NoProfile -File scripts\gui-test-harness\run-suite.ps1 -SkipRestartRun these from the repository root (or pass the harness's absolute path). The suite prints
=== <Id> - <Name> [fixture=...] === and -> PASS (det=PASS vision=PASS, Ns) per scenario, then
the report path. Reports contain the AT-SPI JSON and the raw vision answer.
. scripts\gui-test-harness\lib.ps1
Deploy-VmNav
Test-VmNavHealth
Restart-VmApp -Fixture seeded # seed DB + start app (10 s settle)
$shot = Save-VmShot -Name 'adhoc'
Invoke-VisionCheck -Image $shot -Prompt 'Expected ... First line: VERDICT: PASS or VERDICT: FAIL'
# one-off AT-SPI snippet on the guest (heredoc, python3, pyatspi available)
$py = @'
import sys, json
sys.path.insert(0, "/home/vm/vision")
from atspi_nav import Nav
n = Nav()
print(json.dumps([e.to_dict() for e in n.snapshot()][:20], indent=1))
'@
Invoke-VmPython -Script $pyatspi_nav.py also runs standalone on the guest:
python3 /home/vm/vision/atspi_nav.py dump (full tree JSON), tree (indented text),
ready (activate/maximize/dismiss Welcome), selftest (API smoke test).
Runs on the guest, only while the app is stopped (SQLite WAL). Seeded data:
| System | ROM folder | Games | Emulator |
|---|---|---|---|
| Test System | /home/vm/roms/Test System |
Alpha Quest, Beta Blaster, Captain Nemo (USA), Zipped Quest (.zip with a .nes inside) | Dummy Emulator (/home/vm/dummy-emulator.sh, args "%ROM%") |
| Second System | /home/vm/roms/Second System |
Sonic | Dummy Emulator |
| Broken System |
/home/vm/roms/Does Not Exist (missing) |
- | Dummy Emulator |
Arcade (arcade command only) |
/home/vm/roms/Arcade + /home/vm/roms/Arcade BIOS
|
real MAME zips: pacman, galaga, dkong, mspacman / neogeo, 3dobios, a1200kbd_rb | MAME (the dummy emulator) |
Covers: Alpha Quest.png (blue), Beta Blaster.png (orange), Sonic.png (green) under
/home/vm/images/<System>/; Captain Nemo and Zipped Quest intentionally have no cover (dark
placeholder). corrupt.png is an invalid image, not referenced.
-
fixture.py seed— writes files + dummy emulator, upserts the three systems intoSystems(SystemName, ConfigJson), clears Favorites/PlayHistory, removes/tmp/SimpleLauncher.ConfigJsonis theSystemConfigDatashape (PascalCase, case-insensitive on read):SystemName,SystemFolders[],SystemImageFolder,FileFormatsToSearch[],FileFormatsToLaunch[],ExtractFileBeforeLaunch,GroupByFolder,DisableRecursiveSearch,Emulators[](EmulatorName,EmulatorLocation,EmulatorParameters, ...). -
fixture.py arcade—seed+ the Arcade system: two ROM folders (primaryArcade, secondaryArcade BIOS),.zipsearch/launch formats,ExtractFileBeforeLaunch=false, aMAMEemulator (the dummy script) and three covers in/home/vm/images/Arcade/. The ROM zips themselves are copied to the VM once by the harness session (from the host's real MAME set) and are not regenerated by the fixture; re-copy them if the VM is rebuilt:pacman/galaga/dkong/mspacman->~/roms/Arcade,neogeo/3dobios/a1200kbd_rb->~/roms/Arcade BIOS(see the runbook resume checklist). -
fixture.py empty— deletes all systems (first-run/Welcome state). -
fixture.py clean-state— clears favorites/history/temp, keeps systems. -
fixture.py dump— prints Systems/Favorites/PlayHistory/AppSettings/EmulatorSettings as JSON.
The dummy emulator logs epoch <args> to /tmp/dummy-emulator.log and sleeps 7 s — play
history is recorded only when play time is > 5 s
(SimpleLauncher.Avalonia/Services/GameLauncher/LauncherService.cs:675).
lib.ps1 maps the scenario value seeded to the seed command; Restart-VmApp -Fixture X
stops the app, applies the fixture, starts the app and waits for the window.
A scenario is a PowerShell hashtable in run-suite.ps1:
| Field | Meaning |
|---|---|
Id / Name
|
Report identity (AREA-NN; keep ids stable) |
Fixture |
empty, seeded (default) or arcade (real MAME ROMs). A change restarts the app with that fixture |
Restart |
$true forces an app restart first (used for persistence checks) |
LaunchArgs |
Extra app args (e.g. -debug); a change restarts the app |
Py |
Guest Python script; must print exactly one SL_RESULT: {...} line |
Shot |
Optional PNG base name (taken after the script finishes, copied to shots\) |
Prompt |
Optional vision question; omit for purely deterministic scenarios |
Py is built as $pyCommon + @'...'@ + $pyTail: $pyCommon imports and defines n, checks,
extra, db(), enabled(name), finish() and opens try:; the body is indented 4 spaces;
$pyTail closes with except + finish().
Result contract (printed by finish()):
SL_RESULT: {"ok": true, "checks": {"grid_present": true}, "extra": {"count": 4}}Rules of thumb:
- One concern per scenario; assert with
checks, put diagnostics inextra. - Prefer deterministic tree assertions (
STATE_ENABLED, item sets, labels, DB, files) over vision; use vision only for visual claims (rendering, palettes, legibility). - Start every scenario with
n.activate_window(); n.maximize_window(); n.close_dialogs(). - Use
n.open_system("Test System")— it returns to the card screen (Escape) and clicks the card, so it works from the browser, Favorites and Play History alike. - Grid scenarios must call
n.ensure_grid_view()(view mode persists in the DB). - Search triggers on Return, not the Search button.
- Leave the UI in the state the screenshot should show; close dialogs in the next scenario.
- Prompt contract (parsed by
Get-SuiteVerdict): the answer's first line must be exactlyVERDICT: PASSorVERDICT: FAIL, then a brief report. State expected values explicitly, and mention unusual styling (e.g. disabled buttons are pale/faded, not grey).
Example scenario:
@{
Id = 'FILTER-01'
Name = 'Filter bar: letter filter narrows the list, All resets it'
Fixture = 'seeded'
Py = $pyCommon + @'
n.activate_window(); n.maximize_window(); n.close_dialogs(); time.sleep(0.5)
n.open_system("Test System", timeout=40)
n.ensure_grid_view()
n.filter_letter("All", wait=3.0)
checks["all_shows_everything"] = (n.pagination_count() or 0) >= 4
n.filter_letter("B")
checks["b_filters_to_one"] = n.pagination_count() == 1
checks["b_sets_status"] = "B" in (n.status_left() or "")
'@ + $pyTail
Shot = 'FILTER-01-letter-b'
Prompt = @'
The game grid should be filtered by the letter "B": exactly one card "Beta Blaster", the "B"
button highlighted, status "Displaying files 1 to 1 out of 1 total".
First line of your answer must be exactly "VERDICT: PASS" or "VERDICT: FAIL".
Then report what is visible and any mismatch. Be brief.
'@
}All handles are re-resolved on every call (cached AT-SPI peers go stale after layout changes).
Entry fields: .acc (pyatspi Accessible), .role, .name, .id, .extents [x,y,w,h],
.depth, .to_dict().
| Method | Purpose |
|---|---|
snapshot(max_nodes=3000) |
Walk the app tree, deduped Entry list |
find_all(id=,name=,role=,contains=,entries=) / find(...) / wait_for(timeout=,**kw) / wait_gone(...)
|
Lookup; id falls back to x:Name; contains=True = substring match |
click(entry) |
Native action (click/select) else xdotool click at live extents |
right_click(entry) / double_click(entry) / click_at(x,y,button=,repeat=)
|
Mouse-only interactions (context menus, cards) |
click_card(label) |
System-selection card: finds the label, clicks the button containing it |
open_system(label) |
Escape back to the card screen, click the card; works from any main page; waits for grid/list + scan settle |
ensure_grid_view() / browser_open()
|
Toggle back to grid when the persisted mode is list |
filter_letter(letter) |
Click a letter button in the filter bar (All, #, A..Z) |
pagination_count() / status_left()
|
Parse PaginationLabel total / read StatusLeft
|
toggle / expand / set_text / read_text / read_value / set_value
|
Control interfaces (EditableText/Value), xdotool fallback |
press(keys) |
xdotool key (activates the window first) |
open_menu(name) / popup_items() / click_menu_item(name)
|
Menu bar + popup items (menu items have no AT-SPI action → extents clicks) |
click_context_item(name) |
Click an item of an open context-menu PopupRoot
|
select_combo(combo, name) |
Expand a ComboBox and click the popup entry |
context_menu_items() / dismiss_popup()
|
Inspect/close popups |
state_names(entry) / is_enabled / is_checked / checked_menu_item()
|
AT-SPI states |
close_dialogs() / close_welcome() / ensure_ready()
|
Deterministic app state |
activate_window() / maximize_window() / window_id() / frame_present(title) / frame_extents()
|
Window management |
wait_extra_frames_gone(timeout) |
Wait for toasts/dialogs to disappear |
tree_text() |
Indented tree dump (debugging) |
ask_vision.py posts one base64 PNG + prompt to OpenRouter
(https://openrouter.ai/api/v1/chat/completions) and prints the text answer plus a usage JSON.
Defaults: model xiaomi/mimo-v2.6-flash, temperature 0, --max-tokens raised by lib.ps1
(Invoke-VisionCheck, default 8000) because reasoning models spend tokens on hidden reasoning.
Cost observed: USD 0.0004-0.0007 per scenario (~2.2k prompt tokens image + up to 4k completion).
A 30-scenario pass is a few cents. If content comes back empty, raise --max-tokens.
-
Avalonia AT-SPI server starts unconditionally on X11 (Avalonia 12.1);
at-spi2-coreis enough.AutomationIdfalls back tox:Name. -
BUG-02 (upstream): dismissing the first modal (
Welcome) collapsesMainContentGrid's children from the tree while menus/dialogs stay exposed. Restarting the app restores the tree; the seeded fixture avoids the Welcome dialog entirely. -
Grid peers go stale: after the first render / a letter click, game list items lose their
names in the tree. Use
PaginationLabel,StatusLeft, the DB and the emulator log for assertions; interact with cards via fixed coordinates (1920x1080 maximized: first card after a letter filter ≈(195, 330)). -
Context menus open as a
PopupRootframe whose menu items are exposed; right-click must be xdotool at live extents. -
Two-click submenus:
Edit LinksandSound Configurationare flyout parents whose child repeats the name — click the parent, then the rightmost match. -
System switching: the status bar (
System:label) is the authoritative loaded system; theSystemComboBoxcan lag behind it.open_system()escapes back to the card screen and clicks the card. TheAllfilter means "All Games" across systems, and a system's games appear only after that system was opened once in the session. -
Persistence: theme and view mode persist in
settings.dat; scenarios must not assume the default.ensure_grid_view()fixes the view mode. - Play history needs >5 s of play time; the dummy emulator sleeps 7 s.
-
Mint Update /
aptrunning starves the app and degrades AT-SPI: wait forapt/dpkgto finish and restart the app before trusting a red run. -
Control naming: system-selection cards are code-created buttons (AT-SPI name
Avalonia.Controls.StackPanel), so the harness clicks the card's name label; the Edit System Help button is named "Open the parameters wiki" with a? Helpcontent label (find it via the label). - One assertion per scenario, tree assertions first, vision for visuals only.
- Keep every screenshot; the report references it.
| Symptom | Fix |
|---|---|
Test-VmNavHealth false |
App not running (Restart-VmApp), wrong IP ($script:VmHostAddress), or no X session on :0
|
All checks null / 0.1 s runs |
Python syntax error or SSH failure; run the scenario body via Invoke-VmPython and read the raw output |
| Screenshots at 1024x768 | Guest rebooted; re-apply xrandr ... 1920x1080 (card coordinates assume 1080p) |
| Menus/dialogs ignore clicks | A modal is open; start with n.close_dialogs(), or restart the app |
| Vision says "empty content" / provider 5xx |
Invoke-VisionCheck retries 3× automatically; raise --max-tokens if the model returns nothing |
| AT-SPI tree empty for new app starts | Known registration flake; Start-VmApp waits up to 60 s and retries (3 starts). After a restart run-suite.ps1 also checks Test-VmNavHealth, retries once and aborts with AT-SPI tree not ready before <Id> rather than recording false failures - rerun, and reboot the VM if it keeps failing. Never kill at-spi-bus-launcher/at-spi2-registryd (leaves a stale AT_SPI_BUS guid) |
| App window frozen / UI unresponsive | Triage: ps -o stat,pcpu,wchan -p <pid> (a blocked main thread sits in futex_do_wait at 0% CPU) and sudo gdb -p <pid> -batch -ex "thread apply all bt 10"; a Nav().snapshot() subprocess probe (25 s timeout) tells "slow" from "dead" (a dead UI returns a tiny/empty tree). Reusable repro: 300 xdotool clicks @15 ms on the nav aspect-ratio button. BUG-19 (2026-09-28) was exactly this - sound Stop/Dispose joined the playback thread on the UI thread; see ManualTests.md §8 |
| Fixture changes not visible | The app must be stopped while fixture.py writes the DB; use Restart-VmApp -Fixture
|
fixture.py warning about the DB |
Brand-new VM: run the app once so it creates settings.dat
|
Report says exception
|
Read extra.exception in the report; usually a stale selector or a modal |
| Posh-SSH session drops |
Close-VmSession; Invoke-VmShell retries once automatically |
Automated by the 33 scenarios (33/33 PASS in a single run, 2026-09-27,
reports\run-20260927-232933.md, on the payload built from HEAD 9f684574 including the
BUG-07..BUG-15 fixes; the previous 31/31 run was 2026-09-26, reports\run-20260926-143336.md; WPF
suite 2122/2122 and Avalonia suite 696/696 on the current tree, 2026-09-28). The 33/33 report
predates the 2026-09-28 fixes (BUG-16..19: dialog ownership, card overlay buttons, button-size
slider sync, background sound playback), so the full suite needs a fresh run on the rebuilt
payload - see the resume pointer below):
startup/load, system selection, grid/list rendering, covers, filter bar, search + empty state,
view-mode menu checkmarks, theme menu + persistence, Edit System window/help pane, Easy Mode
selection state, fuzzy threshold, dead-zone, Edit Links, Sound Configuration, About/Update History,
Support validation, Favorites add/remove, Play History, ZIP extraction + temp cleanup, file watcher,
Debug window + live log lines, ROM History message, Broken System error dialog, menu structure
(Windows-only items hidden), real-MAME machine descriptions + sort toggle (MAME-01) and a real
history.dat entry (MAME-02).
Manual/integration by design: store scanners, config injection (Windows-only), RetroAchievements
login/hashing, gamepad hardware, CHD/ISO/XISO mounting, external tools, updater, Commander Genius,
and anything needing real network/emulators. The emulator sweep session on 2026-09-27 replaced the
"real-ROM launches" gap with a full matrix: 36 systems seeded from the Easy Mode manifest, 29
distinct emulators/cores launched through the UI with real ROMs, and the RetroAchievements settings
login, profile page, per-game window and local CLI hashing verified live. A follow-up session the
same day seeded 25 more systems from the user's E:/F:/G:/I:/J: collections and launched the
standalone emulators: PS1/DuckStation, PS2/PCSX2, Dreamcast/Redream, Saturn/Ymir, WiiU/Cemu,
3DS/Azahar, PSP/PPSSPP, Xbox/xemu, DOS/DOSBox Staging and Atari ST/Hatari booted real games, the
DOSBox file picker was exercised, and the app's CHD→ISO conversion proved byte-identical to
chdman extractdvd; PS3/RPCS3, FDS/mesen, N64DD/mupen64plus-next and X68000/px68k are blocked by
image/format/data issues (see section 15 and the session notes in ManualTests.md). Easy Mode downloads were swept manually on 2026-09-26:
15/15 distinct emulator downloads and the shared RetroArch core install correctly (after fixing
BUG-03 .tar.gz, BUG-04 zip execute bits, BUG-05 .tar.xz and BUG-06 solid-7z extraction
performance) — see ManualTests.md §6 "Easy Mode emulator/core install sweep" and §8. The Easy Mode
edge cases were exercised ad-hoc on 2026-09-26 (same §6): Stop mid-download, network loss at
download start and mid-body, the custom ROM folder picker and the separate Download Image Pack
window - the session surfaced BUG-07 (cancel leaves the partial file) and BUG-08 (mid-body network
loss stalls with no retry/timeout). A continuation session the same day covered Global Search
(queries, filters, launch, no-emulator rows), View/Display options + persistence, Filename
Preferences, Favorites/Play History extras (launch, Delete, sorting, missing-file prompt), the
context-menu delete cover/game and missing-file flows, corrupt covers and the file watcher, and
fixed BUG-09 (Show Games filter never filtered), BUG-10 (Enter did not launch from the
Favorites/Play History grids) and BUG-11 (leaving Global Search did not cancel an in-flight search).
A MAME session on 2026-09-27 (real ROMs copied from the host, arcade fixture) covered the
machine-description surfaces (list view, Game Details, Global Search "MAME Description",
Favorites/Play History columns), the MAME sort toggle, a real history.dat entry, the mame.dat
missing/corrupt paths and the GroupByFolder warning; MAME-01/MAME-02 automate the descriptions/sort
and the real history entry, the rest stays ad-hoc (findings in ManualTests.md §6 open items).
Two further sessions on 2026-09-27 used the user's ROM collections: an emulator sweep (G:\, 36
systems, 29 RetroArch cores/emulators with real ROMs) and a standalone/BIOS session (E:/F:/G:/I:/J:,
25 systems seeded; PS1/PS2/Dreamcast/Saturn/WiiU/3DS/PSP/Xbox/DOS/Atari ST launched with real games,
Cemu/xemu/PCSX2 requiring their own data-dir fixes, RPCS3 rejecting the collection's ISO9660 images,
and the DOSBox file picker exercised — see ManualTests.md and §15). BUG-13..15 were found and
fixed. Update docs/manual-tests.md checkboxes only for items the suite actually verified.
To resume this work later, follow ManualTests.md → "Resume checklist (next session)": it lists
the VM/app state left behind (developer was manually testing on the Atari 2600 system, 776 ROMs
mounted from the host share; all standalone emulators + BIOS/keys installed), the console/screensaver
recovery steps, how to rebuild+deploy the Avalonia payload, the remaining work (the still-unexercised
manual items and the full 33-scenario pass on the rebuilt payload - the BUG-16..19 fixes changed
SimpleLauncher.Avalonia.dll/SimpleLauncher.Core.dll after the 33/33 report) and the working-tree
state (clean, everything committed and pushed through 04836cbd). The deployed payload is
SimpleLauncher.Core.dll md5 2d7eff75dee0bc6be36ecbf3af976135 +
SimpleLauncher.Avalonia.dll md5 ac3b2f7961b44fa197d930df4d4bf824.
-
reports\run-<yyyyMMdd-HHmmss>.md— one section per scenario: fixture, verdict, deterministic / vision results, AT-SPI JSON, raw vision answer, summary list. -
shots\<Scenario>-<state>.png— screenshot per scenario that definesShot. - Ad-hoc screenshots via
Save-VmShot -Name <name>. - Guest app log:
/tmp/simplelauncher.log; daily logs under~/.local/share/SimpleLauncher/(also checked by DEBUG-01).
scripts\gui-test-harness\vm-systems.json holds 36 system configs generated from the Linux Easy Mode
manifest (https://assets.purelogiccode.com/.../easymode_linux_x64.xml, %BASEFOLDER% ->
/home/vm/SimpleLauncher, formats normalized to arrays). seed-batch.py <names...> keeps the four
base systems plus only the requested batch, so the system-selection screen shows every card (its
AT-SPI card labels go missing when many systems are loaded - keep batches under ~19 systems).
Guest prerequisites (see the 2026-09-27 sweep in ManualTests.md for the full recipe):
- RetroArch 1.22.2 + all cores extracted to
~/SimpleLauncher/emulators/RetroArch/RetroArch-Linux-x86_64/(AppImage +.homelayout). - Real ROMs under
~/roms/<System>/and an existing image folder per system (~/images/<System>/); opening a system whose image folder is missing shows "System Image Folder path is not valid or does not exist". -
chmod +xthe bundled Linux tools after an SCP deploy (tools/SevenZip/7zz,tools/RetroAchievementsSharp/RetroAchievementsSharp) - SCP does not preserve the exec bits thatpackage-release-linux.ps1sets in the release zip.
Launch pattern (UI, deterministic): open_system -> ensure_grid_view -> filter_letter("All")
(the grid only renders after a letter filter) -> click_at(195, 330, button=3) -> context menu
Launch Game -> poll pgrep -f "RetroArch-Linux-x86_64.AppImage|bin/openmsx" -> read the window
title via wmctrl -> pkill -> check the PlayHistory row. 29 distinct emulators/cores were
exercised this way on 2026-09-27.
RetroAchievements on the VM needs the user's RA username/password/Web API key in the settings window; the local hasher CLI then resolves the game ID and the per-game window loads live data.
Standalone emulators (2026-09-27 session). seed-batch2.py + vm-systems2.json seed the 25
manifest-derived standalone/CD systems (and fix-arrays.py repairs scalar format strings that the
strict deserializer silently skips). launch-one.py <System> "<Game>" [wait] drives one app-launch
of a system: it maximizes the window, opens the system, clicks the letter bar, right-clicks the game
card (AT-SPI, falling back to card coordinates), handles the DOSBox file-selection dialog, waits for
a new window, records the emulator process/args and screenshots, then kills the emulator. Run it
once per fresh app start - the AT-SPI tree accumulates stale browser peers after a launch/exit
cycle, so card clicks silently miss (restart the app per system; if the tree becomes truncated
(snapshot() returns 0 nodes while the window is fine), app restarts do not clear it and a guest
reboot is the recovery).
Per-emulator Linux data locations used by the session (create these before launching):
| Emulator | Config / BIOS / keys |
|---|---|
| DuckStation |
portable.txt next to the AppImage; BIOS in <dir>/bios/; settings.ini from the app samples |
| PCSX2 | BIOS files in ~/.config/PCSX2/bios/ (the ini BIOS path alone is not enough); config in ~/.config/PCSX2/inis/PCSX2.ini
|
| RPCS3 |
~/.config/rpcs3/{config.yml,dev_flash} (the Windows dev_flash copy works); first run shows the Welcome wizard |
| Redream |
redream.cfg + dc_boot.bin/dc_flash.bin next to the binary |
| Ymir |
Ymir.toml (IPL override = absolute saturn_bios.bin path) + saturn_bios.bin in the emulator dir |
| Azahar |
~/.config/azahar/qt-config.ini, ~/.local/share/azahar
|
| Cemu |
portable/ dir next to Cemu; portable/keys.txt (32-hex key per line - hex-encode the per-game .key files), portable/settings.xml, portable/mlc01
|
| xemu |
~/.local/share/xemu/xemu/{xemu.toml,mcpx_1.0.bin,Complex_4627.bin,eeprom.bin,xbox_hdd.qcow2} (config is NOT under ~/.config) |
| Supermodel |
~/.supermodel/{Config/Supermodel.ini,ROMs,Assets,NVRAM,Saves}; the app runs the .bat via UseShellExecute (works on Linux) |
| DOSBox Staging | the app extracts the game zip, shows the file picker and writes _simplelauncher_dosbox.conf into the temp dir |
| openMSX |
~/.openMSX/share must resolve to the install's share (symlink it) or the machine XMLs are not found |
| Vita3K | firmware installable, but the tested collection is .pkg-based (no .vpk) so the app cannot launch a game |
The app's managed CHD→ISO conversion (DiscConverter/CHDSharp) was verified byte-identical to
chdman extractdvd on the PS3 CHD; RPCS3 rejects this collection's ISO9660 "PS3VOLUME" images
regardless, so that is an image-format limitation, not a conversion bug.
#!/usr/bin/env python3
"""Fixture manager for the SimpleLauncher GUI harness (runs on the Linux VM).
Usage: fixture.py <command>
seed Create systems/ROMs/images/dummy emulator in the unified DB
empty Remove all systems (first-run state)
clean-state Clear favorites/play history/temp artifacts (keep systems)
dump Print Systems/Favorites/PlayHistory/AppSettings rows as JSON
"""
import json
import os
import shutil
import sqlite3
import struct
import subprocess
import sys
import zipfile
import zlib
APP_DIR = "/home/vm/SimpleLauncher"
APPDATA = "/home/vm/.local/share/SimpleLauncher"
DB = os.path.join(APPDATA, "settings.dat")
ROMS = "/home/vm/roms"
IMAGES = "/home/vm/images"
EMULATOR = "/home/vm/dummy-emulator.sh"
EMULATOR_LOG = "/tmp/dummy-emulator.log"
SYSTEMS = [
{
"SystemName": "Test System",
"SystemFolders": [f"{ROMS}/Test System"],
"SystemImageFolder": f"{IMAGES}/Test System",
"FileFormatsToSearch": [".nes", ".zip"],
"FileFormatsToLaunch": [".nes", ".zip"],
"ExtractFileBeforeLaunch": True,
"GroupByFolder": False,
"DisableRecursiveSearch": False,
"Emulators": [
{
"EmulatorName": "Dummy Emulator",
"EmulatorLocation": EMULATOR,
"EmulatorParameters": '"%ROM%"',
"ReceiveANotificationOnEmulatorError": True,
}
],
},
{
"SystemName": "Second System",
"SystemFolders": [f"{ROMS}/Second System"],
"SystemImageFolder": f"{IMAGES}/Second System",
"FileFormatsToSearch": [".nes"],
"FileFormatsToLaunch": [".nes"],
"ExtractFileBeforeLaunch": False,
"GroupByFolder": False,
"DisableRecursiveSearch": False,
"Emulators": [
{
"EmulatorName": "Dummy Emulator",
"EmulatorLocation": EMULATOR,
"EmulatorParameters": '"%ROM%"',
"ReceiveANotificationOnEmulatorError": True,
}
],
},
{
"SystemName": "Broken System",
"SystemFolders": [f"{ROMS}/Does Not Exist"],
"SystemImageFolder": f"{IMAGES}/Broken System",
"FileFormatsToSearch": [".nes"],
"FileFormatsToLaunch": [".nes"],
"ExtractFileBeforeLaunch": False,
"GroupByFolder": False,
"DisableRecursiveSearch": False,
"Emulators": [
{
"EmulatorName": "Dummy Emulator",
"EmulatorLocation": EMULATOR,
"EmulatorParameters": '"%ROM%"',
"ReceiveANotificationOnEmulatorError": True,
}
],
},
]
def make_png(path, rgb=(200, 60, 60), size=64):
def chunk(tag, data):
return struct.pack(">I", len(data)) + tag + data + struct.pack(
">I", zlib.crc32(tag + data) & 0xFFFFFFFF
)
raw = b"".join(b"\x00" + bytes(rgb) * size for _ in range(size))
png = (
b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw))
+ chunk(b"IEND", b"")
)
with open(path, "wb") as handle:
handle.write(png)
def write_roms():
test_roms = f"{ROMS}/Test System"
second_roms = f"{ROMS}/Second System"
os.makedirs(test_roms, exist_ok=True)
os.makedirs(second_roms, exist_ok=True)
for name in ("Alpha Quest.nes", "Beta Blaster.nes", "Captain Nemo (USA).nes"):
with open(os.path.join(test_roms, name), "wb") as handle:
handle.write(b"NES\x1a" + name.encode() + b"\x00" * 64)
with open(os.path.join(second_roms, "Sonic.nes"), "wb") as handle:
handle.write(b"NES\x1a" + b"Sonic" + b"\x00" * 64)
zip_path = os.path.join(test_roms, "Zipped Quest.zip")
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_STORED) as archive:
archive.writestr("Zipped Quest.nes", b"NES\x1aZippedQuest" + b"\x00" * 64)
test_images = f"{IMAGES}/Test System"
os.makedirs(test_images, exist_ok=True)
make_png(os.path.join(test_images, "Alpha Quest.png"), (30, 120, 200))
make_png(os.path.join(test_images, "Beta Blaster.png"), (200, 140, 30))
os.makedirs(f"{IMAGES}/Second System", exist_ok=True)
make_png(f"{IMAGES}/Second System/Sonic.png", (30, 180, 90))
with open(os.path.join(test_images, "corrupt.png"), "wb") as handle:
handle.write(b"not a real png")
def write_emulator():
script = (
"#!/bin/bash\n"
f'echo "$(date +%s) $*" >> {EMULATOR_LOG}\n'
"sleep 7\n"
)
with open(EMULATOR, "w") as handle:
handle.write(script)
os.chmod(EMULATOR, 0o755)
def connect():
connection = sqlite3.connect(DB)
connection.execute("PRAGMA busy_timeout = 5000")
return connection
def seed():
write_roms()
write_emulator()
connection = connect()
with connection:
connection.execute("DELETE FROM Favorites")
connection.execute("DELETE FROM PlayHistory")
connection.execute("DELETE FROM Systems")
for system in SYSTEMS:
connection.execute(
"INSERT OR REPLACE INTO Systems (SystemName, ConfigJson) VALUES (?, ?)",
(system["SystemName"], json.dumps(system)),
)
connection.close()
for path in (EMULATOR_LOG,):
if os.path.exists(path):
os.remove(path)
shutil.rmtree("/tmp/SimpleLauncher", ignore_errors=True)
print(json.dumps({"seeded": [s["SystemName"] for s in SYSTEMS]}))
def empty():
connection = connect()
with connection:
connection.execute("DELETE FROM Systems")
connection.execute("DELETE FROM Favorites")
connection.execute("DELETE FROM PlayHistory")
connection.close()
print(json.dumps({"systems": []}))
def clean_state():
connection = connect()
with connection:
connection.execute("DELETE FROM Favorites")
connection.execute("DELETE FROM PlayHistory")
connection.close()
for path in (EMULATOR_LOG,):
if os.path.exists(path):
os.remove(path)
shutil.rmtree("/tmp/SimpleLauncher", ignore_errors=True)
print(json.dumps({"cleaned": True}))
def dump():
connection = connect()
result = {}
for table in ("Systems", "Favorites", "PlayHistory", "AppSettings", "EmulatorSettings"):
rows = connection.execute(f"SELECT * FROM {table}").fetchall()
columns = [d[0] for d in connection.execute(f"SELECT * FROM {table} LIMIT 0").description]
result[table] = [dict(zip(columns, row)) for row in rows]
connection.close()
print(json.dumps(result, default=str))
if __name__ == "__main__":
command = sys.argv[1] if len(sys.argv) > 1 else "dump"
{"seed": seed, "empty": empty, "clean-state": clean_state, "dump": dump}[command]()| Function | Contract |
|---|---|
Get-VmCredential / Get-VmSession / Close-VmSession
|
Cached Posh-SSH session (reconnect on failure) |
Invoke-VmShell -Command -TimeoutSec |
Run a shell command; returns {Output, ExitStatus}
|
Invoke-VmXdo -Script -TimeoutSec |
Same but exports $script:VmEnv first |
Invoke-VmPython -Script -TimeoutSec |
python3 -u - heredoc with timeout -k 5; output combined |
Deploy-VmNav |
Creates /home/vm/vision and SCPs atspi_nav.py + fixture.py
|
Stop-VmApp / Start-VmApp -Arguments -SettleSeconds
|
Kill all instances by exact exe path; setsid start; Start-VmApp waits for the AT-SPI frame and retries (3 starts) |
Set-VmFixture -Name seed|seeded|empty|clean-state|dump |
Run fixture.py on the guest (warns when the DB is missing) |
Restart-VmApp -SettleSeconds -Fixture -Arguments |
Stop → fixture → start |
Test-VmAtspiReady / Test-VmNavHealth
|
True when the app frame is in the AT-SPI tree |
Save-VmShot -Name |
gnome-screenshot in the guest, SCP to shots\; returns host path |
Invoke-VisionCheck -Image -Prompt -Model -MaxTokens |
Runs ask_vision.py (key from user env); retries provider 5xx/timeouts 3× |
Get-VmWindowList |
xwininfo top-level window list (debugging) |
Get-SuiteVerdict -Text / Get-AtspiResult -Output
|
Parse VERDICT: and SL_RESULT:
|
The live inventory (ids, coverage, fixture, pass/fail status, open items) is maintained in
ManualTests.md → "GUI testing runbook (Avalonia / Linux VM)" §6. Add new scenarios there when
extending coverage.
- Home
- 01 Overview
- 02 Projects And Solution
- 03 Quickstart
- 04 Architecture
- 05 Configuration
- 06 Systems And Launch
- 07 Core Services
- 08 Ui Layer
- 09 Retroachievements
- 10 Game Scanning
- 11 Bundled Tools
- 12 Data Formats
- 13 Logging And Debug
- 14 Testing
- 15 Development
- 16 Updater
- 17 Release Notes
- 18 Emulator Parameters
- Gui Test Harness
- Manual Tests
- Parameters