Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .github/workflows/machine-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# Builds SlotDrop.exe once, then runs it on two different Windows machines
# against a fake RomM and a scratch SD card folder. Each machine signs in,
# adds two games, checks the game files, label and cart_shell.txt landed on
# the card, and reopens the app to check the sign-in was remembered.
# Screenshots from every machine are kept with the run.
#
# Nothing here talks to a real RomM: the address and token are made up and
# only work on the test machine itself.
name: Machine test

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: machine-test-${{ github.ref }}
cancel-in-progress: true

jobs:
build:
name: Build SlotDrop.exe
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build the app (same command as build.bat)
run: go build -trimpath -tags desktop,production -ldflags "-H windowsgui -s -w" -o SlotDrop.exe .
# Wails' WebView2 loader deliberately ignores the usual way of turning on
# WebView2's debugging port, so the test copy gets a one-line change to
# that library: it also adds any switches in SLOTDROP_TEST_WEBVIEW_ARGS.
# SlotDrop's own code is the same.
- name: Build the test copy of the app
shell: pwsh
run: |
go mod download github.com/wailsapp/go-webview2
$src = go list -m -f '{{.Dir}}' github.com/wailsapp/go-webview2
$dst = Join-Path $env:RUNNER_TEMP go-webview2
Copy-Item -Recurse $src $dst
Get-ChildItem -Recurse -File $dst | ForEach-Object { $_.IsReadOnly = $false }
$file = Join-Path $dst webviewloader\env_create_options.go
$code = Get-Content -Raw $file
$line = 'wvep.additionalBrowserArguments = args'
if (-not $code.Contains($line)) { Write-Error "go-webview2 changed: can't find '$line'"; exit 1 }
$code = $code.Replace($line, 'wvep.additionalBrowserArguments = strings.TrimSpace(args + " " + os.Getenv("SLOTDROP_TEST_WEBVIEW_ARGS"))')
$code = $code.Replace("import (", "import (`n`t`"os`"`n`t`"strings`"")
[IO.File]::WriteAllText($file, $code)
go mod edit -replace "github.com/wailsapp/go-webview2=$dst"
go build -trimpath -tags desktop,production -ldflags "-H windowsgui -s -w" -o SlotDrop-test.exe .
- name: Build the fake RomM
run: go build -o fakeromm.exe ./ci/fakeromm
- uses: actions/upload-artifact@v4
with:
name: slotdrop-build
path: |
SlotDrop-test.exe
fakeromm.exe
retention-days: 7

unit-tests:
name: Unit tests on Windows
runs-on: windows-2022
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go test ./...

machine:
name: ${{ matrix.name }}
needs: build
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
# GitHub has no plain Windows 10/11 x64 machines. Server 2025 shares
# Windows 11 24H2's core, so it stands in for most PCs.
- id: win11-x64
name: Windows 11 stand-in (Server 2025) at 150% scale
os: windows-2025
scale: "1.5"
# Real Windows 11 on ARM, like Snapdragon laptops: the x64 exe runs under emulation.
- id: win11-arm
name: Windows 11 on ARM, German, card path with accents
os: windows-11-arm
culture: de-DE
card: 'C:\SD Kärtchen\Slot Karte'
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: slotdrop-build
path: build-out
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install the test driver
working-directory: ci/smoke
run: npm ci --ignore-scripts
- name: Switch Windows to ${{ matrix.culture }}
if: matrix.culture
shell: pwsh
run: |
Set-Culture ${{ matrix.culture }}
Set-WinHomeLocation -GeoId 94
Set-TimeZone -Id "W. Europe Standard Time"
- name: Run SlotDrop
shell: pwsh
env:
SMOKE_SPEC: ${{ matrix.name }}
SMOKE_EXE: ${{ github.workspace }}\build-out\SlotDrop-test.exe
SMOKE_FAKE: ${{ github.workspace }}\build-out\fakeromm.exe
SMOKE_CARD: ${{ matrix.card || format('{0}\slot-card', runner.temp) }}
SMOKE_OUT: ${{ github.workspace }}\smoke-out
SMOKE_SCALE: ${{ matrix.scale }}
run: node ci/smoke/drive.mjs
- name: Keep screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: machine-${{ matrix.id }}
path: |
smoke-out/*.png
smoke-out/result.json
retention-days: 14

report:
name: Results
needs: machine
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
pattern: machine-*
path: results
- name: Write the summary
run: |
{
echo "## SlotDrop on $(ls results/*/result.json 2>/dev/null | wc -l) machines"
echo
echo "| Machine | Result | What went wrong |"
echo "|---|---|---|"
shopt -s nullglob
for f in results/*/result.json; do
jq -r '"| \(.spec) | \(if .passed then "✅ Passed" else "❌ Failed" end) | \([.checks[] | select(.ok | not) | .name + (if .detail != "" then " (" + .detail + ")" else "" end)] | join("; ")) |"' "$f"
done
echo
echo "Screenshots for each machine are under Artifacts on this run."
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail if any machine failed
run: test "${{ needs.machine.result }}" = success
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ settings.json
.env
*.pem
*.key

# Machine test tools
node_modules/
/smoke-out/
190 changes: 190 additions & 0 deletions ci/fakeromm/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Fakeromm is a pretend RomM server for the machine tests in
// .github/workflows/machine-test.yml. It serves a handful of made-up games
// (no real game names or artwork) with drawn cartridge photos, and only
// answers requests that carry the test token. It is not part of the app.
//
// Run: go run ./ci/fakeromm -addr 127.0.0.1:8737 -token ci-token
package main

import (
"archive/zip"
"bytes"
"encoding/json"
"flag"
"fmt"
"image"
"image/color"
"image/png"
"log"
"net/http"
"strings"

"golang.org/x/image/draw"

"slotlabelmaker/internal/platforms"
)

type game struct {
id int
platform int // 1 GBA, 2 GB, 3 GBC
name string
file string // what RomM calls the file
cart bool // has a cartridge photo
}

var games = []game{
{1, 1, "Test Quest", "Test Quest (World).gba", true},
{2, 1, "Pixel Rally", "Pixel Rally (World).gba", true},
{3, 3, "Pocket Garden", "Pocket Garden (World).zip", false}, // a zip, to check unpacking
{4, 1, "Sky Courier", "Sky Courier (World).gba", false},
{5, 2, "Block Drop", "Block Drop (World).gb", false},
{6, 1, "Lantern Knight", "Lantern Knight (World).gba", true},
}

func pngBytes(img image.Image) []byte {
var b bytes.Buffer
png.Encode(&b, img)
return b.Bytes()
}

// labelArt draws a made-up label: bands of colour with a pale title block.
func labelArt(seed int) image.Image {
img := image.NewNRGBA(image.Rect(0, 0, 640, 330))
hues := []color.NRGBA{{220, 70, 60, 255}, {60, 110, 220, 255}, {60, 170, 100, 255}, {230, 170, 40, 255}, {150, 70, 200, 255}, {40, 160, 180, 255}}
a, b := hues[seed%len(hues)], hues[(seed+2)%len(hues)]
for y := 0; y < 330; y++ {
for x := 0; x < 640; x++ {
c := a
if (x+y)/40%2 == 1 {
c = b
}
img.SetNRGBA(x, y, c)
}
}
draw.Draw(img, image.Rect(60, 40, 580, 120), image.NewUniform(color.NRGBA{250, 248, 240, 255}), image.Point{}, draw.Src)
return img
}

// cartPhoto draws a GBA cartridge shaped like ScreenScraper's photos, with
// the label where SlotDrop expects to find it.
func cartPhoto(seed int) []byte {
w, h := 700, 417
shell := color.NRGBA{58, 58, 62, 255}
img := image.NewNRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
r := 34.0
cx := max(0, max(r-float64(x), float64(x)-(float64(w)-1-r)))
cy := max(0, max(r-float64(y), float64(y)-(float64(h)-1-r)))
if cx*cx+cy*cy > r*r {
continue
}
c := shell
if y < 50 {
c = color.NRGBA{shell.R + 12, shell.G + 12, shell.B + 12, 255}
}
img.SetNRGBA(x, y, c)
}
}
f := platforms.GBA.LabelBox
box := image.Rect(int(f[0]*float64(w)), int(f[1]*float64(h)), int(f[2]*float64(w)), int(f[3]*float64(h)))
draw.Draw(img, box.Inset(-8), image.NewUniform(color.NRGBA{shell.R - 15, shell.G - 15, shell.B - 15, 255}), image.Point{}, draw.Src)
lbl := labelArt(seed)
draw.CatmullRom.Scale(img, box, lbl, lbl.Bounds(), draw.Src, nil)
return pngBytes(img)
}

func cover(seed int) []byte {
return pngBytes(labelArt(seed + 1))
}

// romFile is the download: a few bytes of "game", zipped if RomM stores a zip.
func romFile(g game) []byte {
data := bytes.Repeat([]byte{byte(g.id)}, 1<<20)
if !strings.HasSuffix(g.file, ".zip") {
return data
}
var b bytes.Buffer
zw := zip.NewWriter(&b)
w, _ := zw.Create(strings.TrimSuffix(g.file, ".zip") + ".gbc")
w.Write(data)
zw.Close()
return b.Bytes()
}

func main() {
addr := flag.String("addr", "127.0.0.1:8737", "address to listen on")
token := flag.String("token", "ci-token", "the API token SlotDrop must send")
flag.Parse()

var roms []map[string]any
for _, g := range games {
stem := g.file[:strings.LastIndex(g.file, ".")]
r := map[string]any{
"id": g.id, "platform_id": g.platform, "name": g.name,
"fs_name": g.file, "fs_name_no_ext": stem, "fs_size_bytes": len(romFile(g)),
"created_at": fmt.Sprintf("2026-0%d-1%dT10:00:00Z", 1+g.id%9, g.id),
"path_cover_small": fmt.Sprintf("/cov/%d.png", g.id), "regions": []string{"World"},
}
if g.cart {
r["ss_metadata"] = map[string]any{"physical_path": fmt.Sprintf("/cart/%d.png", g.id)}
}
roms = append(roms, r)
}
find := func(id int) *game {
for i := range games {
if games[i].id == id {
return &games[i]
}
}
return nil
}

mux := http.NewServeMux()
mux.HandleFunc("/api/platforms", func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]any{{"id": 1, "slug": "gba"}, {"id": 2, "slug": "gb"}, {"id": 3, "slug": "gbc"}})
})
mux.HandleFunc("/api/roms", func(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for _, x := range roms {
if fmt.Sprint(x["platform_id"]) == r.URL.Query().Get("platform_ids") {
items = append(items, x)
}
}
json.NewEncoder(w).Encode(map[string]any{"items": items, "total": len(items)})
})
mux.HandleFunc("/api/roms/", func(w http.ResponseWriter, r *http.Request) {
var id int
if _, err := fmt.Sscanf(r.URL.Path, "/api/roms/%d/content/", &id); err != nil || find(id) == nil {
http.NotFound(w, r)
return
}
w.Write(romFile(*find(id)))
})
mux.HandleFunc("/cov/", func(w http.ResponseWriter, r *http.Request) {
var n int
fmt.Sscanf(r.URL.Path, "/cov/%d.png", &n)
w.Write(cover(n))
})
mux.HandleFunc("/cart/", func(w http.ResponseWriter, r *http.Request) {
var n int
fmt.Sscanf(r.URL.Path, "/cart/%d.png", &n)
w.Write(cartPhoto(n))
})

authed := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" { // for the test script to know we're up
w.Write([]byte("ok"))
return
}
if r.Header.Get("Authorization") != "Bearer "+*token {
log.Printf("401 %s %s (no or wrong token)", r.Method, r.URL.Path)
http.Error(w, `{"detail":"Not authenticated"}`, http.StatusUnauthorized)
return
}
log.Printf("%s %s", r.Method, r.URL.RequestURI())
mux.ServeHTTP(w, r)
})
log.Printf("fake RomM listening on http://%s", *addr)
log.Fatal(http.ListenAndServe(*addr, authed))
}
Loading
Loading