From 9787273f431cfd0e1081d33989e5de1a99db1dee Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:56:30 +0000 Subject: [PATCH 1/6] Add a machine test that runs SlotDrop on five Windows setups Builds SlotDrop.exe once, then on each machine starts a fake RomM, opens the real app with WebView2's debugging port on, picks a scratch card folder, signs in with a token, adds a GBA game and a zipped GBC game, checks the game files, label and cart_shell.txt on the card, and reopens the app to check the sign-in was remembered. Screenshots and a pass/fail table are kept with each run. Test-only code lives in ci/. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017tvaMaK2pCRsYnhkC57qDm --- .github/workflows/machine-test.yml | 145 ++++++++++++++++ .gitignore | 4 + ci/fakeromm/main.go | 190 ++++++++++++++++++++ ci/smoke/drive.mjs | 270 +++++++++++++++++++++++++++++ ci/smoke/package-lock.json | 25 +++ ci/smoke/package.json | 9 + ci/smoke/screenshot.ps1 | 9 + 7 files changed, 652 insertions(+) create mode 100644 .github/workflows/machine-test.yml create mode 100644 ci/fakeromm/main.go create mode 100644 ci/smoke/drive.mjs create mode 100644 ci/smoke/package-lock.json create mode 100644 ci/smoke/package.json create mode 100644 ci/smoke/screenshot.ps1 diff --git a/.github/workflows/machine-test.yml b/.github/workflows/machine-test.yml new file mode 100644 index 0000000..4991e34 --- /dev/null +++ b/.github/workflows/machine-test.yml @@ -0,0 +1,145 @@ +# Builds SlotDrop.exe once, then runs it on five 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 . + - name: Build the fake RomM + run: go build -o fakeromm.exe ./ci/fakeromm + - uses: actions/upload-artifact@v4 + with: + name: slotdrop-build + path: | + SlotDrop.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: + - id: server-2022 + name: Windows Server 2022 + os: windows-2022 + - id: server-2025 + name: Windows Server 2025 + os: windows-2025 + - id: win11-arm + name: Windows 11 on ARM (x64 app emulated) + os: windows-11-arm + - id: scale-150 + name: Windows Server 2025 at 150% scale + os: windows-2025 + scale: "1.5" + - id: german-accents + name: German Windows, card folder with spaces and accents + os: windows-2022 + 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.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: always() + 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 "|---|---|---|" + 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 diff --git a/.gitignore b/.gitignore index 11894b3..abf67a9 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ settings.json .env *.pem *.key + +# Machine test tools +node_modules/ +/smoke-out/ diff --git a/ci/fakeromm/main.go b/ci/fakeromm/main.go new file mode 100644 index 0000000..9fc6688 --- /dev/null +++ b/ci/fakeromm/main.go @@ -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)) +} diff --git a/ci/smoke/drive.mjs b/ci/smoke/drive.mjs new file mode 100644 index 0000000..e891c05 --- /dev/null +++ b/ci/smoke/drive.mjs @@ -0,0 +1,270 @@ +// Drives the real SlotDrop.exe the way a person would, for the machine tests +// in .github/workflows/machine-test.yml. It starts a fake RomM, opens the app +// with WebView2's debugging port on, picks a scratch card folder, signs in +// with an API token, adds two games, checks what landed on the card, then +// reopens the app to check the sign-in was remembered. +// +// Writes screenshots and result.json to SMOKE_OUT, and a pass/fail table to +// the job summary. Not part of the app. +// +// Environment: +// SMOKE_SPEC name of this machine, for the report +// SMOKE_EXE path to SlotDrop.exe +// SMOKE_FAKE path to fakeromm.exe +// SMOKE_CARD scratch folder to use as the SD card (made if missing) +// SMOKE_OUT folder for screenshots and result.json +// SMOKE_SCALE optional WebView2 device scale factor, e.g. 1.5 +// SMOKE_PAGE_URL dry run only: drive this page in a browser instead of the exe +import { chromium } from "playwright-core"; +import { spawn, execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const env = process.env; +const spec = env.SMOKE_SPEC || "local"; +const out = path.resolve(env.SMOKE_OUT || "smoke-out"); +const card = path.resolve(env.SMOKE_CARD || path.join(out, "card")); +const rommAddr = "127.0.0.1:8737"; +const rommURL = `http://${rommAddr}`; +const token = "ci-token"; +const cdpPort = 9222; +const dryRun = !!env.SMOKE_PAGE_URL; + +fs.mkdirSync(out, { recursive: true }); +const checks = []; +const children = []; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +function check(name, ok, detail = "") { + checks.push({ name, ok: !!ok, detail }); + console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? " (" + detail + ")" : ""}`); + return !!ok; +} + +async function waitFor(what, fn, timeoutMs) { + const end = Date.now() + timeoutMs; + let last; + while (Date.now() < end) { + try { + const v = await fn(); + if (v) return v; + } catch (e) { + last = e; + } + await sleep(500); + } + throw new Error(`timed out waiting for ${what}${last ? ": " + last.message : ""}`); +} + +// desktopShot saves the whole Windows screen, so a crash dialog or a +// "WebView2 is missing" box shows up too. +function desktopShot(name) { + if (process.platform !== "win32") return; + try { + execFileSync("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", + path.join(import.meta.dirname, "screenshot.ps1"), path.join(out, name)], { stdio: "inherit" }); + } catch (e) { + console.log("desktop screenshot failed:", e.message); + } +} + +// machineInfo notes the Windows version, WebView2 version and region, for the report. +function machineInfo() { + if (process.platform !== "win32") return {}; + const ps = ` + $os = Get-CimInstance Win32_OperatingSystem + $wv = $null + foreach ($k in 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', + 'HKLM:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', + 'HKCU:\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}') { + if (-not $wv) { $wv = (Get-ItemProperty -Path $k -Name pv -ErrorAction SilentlyContinue).pv } + } + @{ windows = "$($os.Caption) $($os.Version)"; cpu = $env:PROCESSOR_ARCHITECTURE; + webview2 = $(if ($wv) { $wv } else { 'not found' }); region = (Get-Culture).Name } | ConvertTo-Json -Compress`; + try { + return JSON.parse(execFileSync("powershell", ["-NoProfile", "-Command", ps], { encoding: "utf8" })); + } catch (e) { + return { error: e.message }; + } +} + +function makeCard() { + for (const d of ["System", "Games/GBA", "Games/GB", "Games/GBC", "Labels/GBA", "BIOS"]) { + fs.mkdirSync(path.join(card, d), { recursive: true }); + } + fs.writeFileSync(path.join(card, "System", "slot"), "not really slot"); +} + +function files(dir) { + try { return fs.readdirSync(path.join(card, dir)); } catch { return []; } +} + +async function startFakeRomm() { + if (dryRun) return; // the dry run starts it itself + const p = spawn(env.SMOKE_FAKE, ["-addr", rommAddr, "-token", token], { stdio: ["ignore", "inherit", "inherit"] }); + children.push(p); + await waitFor("the fake RomM", async () => (await fetch(rommURL + "/health")).ok, 30000); +} + +let app = null; +async function launchApp() { + const args = [`--remote-debugging-port=${cdpPort}`]; + if (env.SMOKE_SCALE) args.push(`--force-device-scale-factor=${env.SMOKE_SCALE}`); + app = spawn(env.SMOKE_EXE, [], { + env: { ...env, WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: args.join(" ") }, + stdio: "ignore", + }); + children.push(app); + const started = Date.now(); + await waitFor("WebView2 to start inside SlotDrop", async () => { + if (app.exitCode !== null) throw new Error(`SlotDrop quit with code ${app.exitCode}`); + return (await fetch(`http://127.0.0.1:${cdpPort}/json/version`)).ok; + }, 90000); + return Date.now() - started; +} + +function stopApp() { + if (app && app.exitCode === null) { + try { execFileSync("taskkill", ["/PID", String(app.pid), "/T", "/F"], { stdio: "ignore" }); } catch {} + } + app = null; +} + +// connect returns the SlotDrop page, either inside the real app or (dry run) in a browser. +async function connect() { + if (dryRun) { + const browser = await chromium.launch(); + const page = await browser.newPage({ viewport: { width: 1180, height: 780 } }); + await page.goto(env.SMOKE_PAGE_URL); + return { browser, page }; + } + const browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`); + const page = await waitFor("the SlotDrop window", async () => browser.contexts().flatMap((c) => c.pages())[0], 30000); + return { browser, page }; +} + +async function shot(page, name) { + try { + await page.screenshot({ path: path.join(out, name) }); + } catch (e) { + console.log(`screenshot ${name} failed: ${e.message}`); + } +} + +async function firstRun() { + const startMs = await (dryRun ? 0 : launchApp()); + check("SlotDrop opens", true, dryRun ? "dry run" : `window ready in ${(startMs / 1000).toFixed(1)}s`); + const { browser, page } = await connect(); + page.on("pageerror", (e) => check("No script errors in the window", false, e.message)); + await page.waitForSelector(".setup, #search", { timeout: 30000 }); + await shot(page, "1-first-screen.png"); + + // Step 1: the card. "Choose folder…" opens Windows' folder picker, which a + // script can't click through, so answer it with the scratch card's path. + // Everything after the picker is the app's own code. + if (await page.locator("text=Choose folder…").count()) { + await page.evaluate((p) => { window.go.main.App.BrowseForCard = async () => p; }, card); + await page.click("text=Choose folder…"); + // The scratch folder isn't a removable drive, so the app asks first. + const anyway = page.locator("button", { hasText: "Use it anyway" }); + if (await anyway.waitFor({ timeout: 10000 }).then(() => true, () => false)) await anyway.click(); + await page.waitForSelector(".step.done", { timeout: 15000 }); + } + check("Picks the SD card folder", await page.locator(".step.done").count() >= 1 || dryRun); + + // Step 2: RomM, with an API token. + await page.click("button:has-text('API token')"); + await page.fill("input[placeholder='romm.example.com']", rommURL); + await page.fill("input[type=password]", token); + await page.click("button[type=submit]"); + const signedIn = await page.waitForSelector("#search", { timeout: 60000 }).then(() => true, () => false); + if (!check("Signs in to RomM with a token", signedIn)) { + await shot(page, "2-sign-in-failed.png"); + return browser; + } + await page.waitForSelector(".tile[data-rom]", { timeout: 30000 }).catch(() => {}); + const tiles = await page.locator(".tile[data-rom]").count(); + check("Lists the RomM games", tiles >= 6, `${tiles} games shown`); + await sleep(1500); // let the pictures load + await shot(page, "2-library.png"); + + // Add a GBA game with a cartridge photo: the game, its label and a shell colour. + await page.locator(`.tile[data-rom="1"]`).first().hover(); + await page.locator(`.tile[data-rom="1"] button.add`).first().click({ force: true }); + const toast = await page.waitForSelector("text=is on your card, label and all", { timeout: 60000 }).then(() => true, () => false); + check("Says the game was added with its label", toast); + if (!dryRun) { + const stem = "Test Quest (World)"; + check("Copies the game to Games/GBA", files("Games/GBA").includes(stem + ".gba"), files("Games/GBA").join(", ") || "folder empty"); + const lbl = path.join(card, "Labels", "GBA", stem + ".png"); + const lblOK = fs.existsSync(lbl) && fs.readFileSync(lbl).subarray(1, 4).toString() === "PNG"; + check("Makes the label in Labels/GBA", lblOK, lblOK ? `${Math.round(fs.statSync(lbl).size / 1024)} KB` : "missing"); + let shells = ""; + try { shells = fs.readFileSync(path.join(card, "Labels", "cart_shell.txt"), "utf8"); } catch {} + check("Writes the shell colour to Labels/cart_shell.txt", shells.includes(stem + " ="), shells.trim().split("\n").find((l) => l.startsWith(stem)) || "no line for the game"); + } + + // Add a Game Boy Color game that RomM stores as a zip. + await page.locator(`.tile[data-rom="3"]`).first().hover(); + await page.locator(`.tile[data-rom="3"] button.add`).first().click({ force: true }); + if (!dryRun) { + const unzipped = await waitFor("the GBC game", async () => files("Games/GBC").includes("Pocket Garden (World).gbc"), 60000).catch(() => false); + check("Unzips a zipped game into Games/GBC", unzipped, files("Games/GBC").join(", ") || "folder empty"); + } + await sleep(1500); + await shot(page, "3-games-added.png"); + + // The game panel with the label on the drawn cartridge. + await page.locator(".tile", { hasText: "Test Quest" }).first().click(); + await page.waitForSelector(".drawer.open", { timeout: 10000 }).catch(() => {}); + await sleep(1000); + await shot(page, "4-game-panel.png"); + desktopShot("desktop.png"); + return browser; +} + +// secondRun reopens the app: the saved token should sign in by itself. +async function secondRun() { + await launchApp(); + const { browser, page } = await connect(); + const ok = await page.waitForFunction((url) => [...document.querySelectorAll(".step.done .step-summary")] + .some((e) => e.textContent.includes(url)), rommAddr, { timeout: 60000 }).then(() => true, () => false); + check("Remembers the sign-in after reopening", ok); + await shot(page, "5-reopened.png"); + return browser; +} + +function report() { + const passed = checks.length > 0 && checks.every((c) => c.ok); + fs.writeFileSync(path.join(out, "result.json"), JSON.stringify({ spec, passed, info, checks }, null, 2)); + if (env.GITHUB_STEP_SUMMARY) { + const about = Object.entries(info).map(([k, v]) => `${k}: ${v}`).join(" · "); + const rows = checks.map((c) => `| ${c.ok ? "✅" : "❌"} | ${c.name} | ${c.detail.replace(/\|/g, "/")} |`); + fs.appendFileSync(env.GITHUB_STEP_SUMMARY, `### ${passed ? "✅" : "❌"} ${spec}\n\n${about}\n\n| | Check | Detail |\n|---|---|---|\n${rows.join("\n")}\n\n`); + } + return passed; +} + +let passed = false; +const info = machineInfo(); +console.log("machine:", JSON.stringify(info)); +try { + makeCard(); + await startFakeRomm(); + let browser = await firstRun(); + await browser.close().catch(() => {}); + if (!dryRun && checks.every((c) => c.ok)) { + stopApp(); + await sleep(2000); + browser = await secondRun(); + await browser.close().catch(() => {}); + } +} catch (e) { + check("Test ran to the end", false, e.message.split("\n")[0]); + desktopShot("desktop-at-failure.png"); +} finally { + stopApp(); + for (const c of children) if (c.exitCode === null) c.kill(); + passed = report(); +} +process.exit(passed ? 0 : 1); diff --git a/ci/smoke/package-lock.json b/ci/smoke/package-lock.json new file mode 100644 index 0000000..db0820d --- /dev/null +++ b/ci/smoke/package-lock.json @@ -0,0 +1,25 @@ +{ + "name": "slotdrop-machine-test", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "slotdrop-machine-test", + "dependencies": { + "playwright-core": "1.56.1" + } + }, + "node_modules/playwright-core": { + "version": "1.56.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", + "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/ci/smoke/package.json b/ci/smoke/package.json new file mode 100644 index 0000000..2d874fb --- /dev/null +++ b/ci/smoke/package.json @@ -0,0 +1,9 @@ +{ + "name": "slotdrop-machine-test", + "private": true, + "description": "Drives SlotDrop.exe on CI machines. Not part of the app.", + "type": "module", + "dependencies": { + "playwright-core": "1.56.1" + } +} diff --git a/ci/smoke/screenshot.ps1 b/ci/smoke/screenshot.ps1 new file mode 100644 index 0000000..589890a --- /dev/null +++ b/ci/smoke/screenshot.ps1 @@ -0,0 +1,9 @@ +# Saves a PNG of the whole screen, for the machine tests. Not part of the app. +param([Parameter(Mandatory)][string]$Path) +Add-Type -AssemblyName System.Windows.Forms, System.Drawing +$b = [System.Windows.Forms.SystemInformation]::VirtualScreen +$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height +$g = [System.Drawing.Graphics]::FromImage($bmp) +$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size) +$bmp.Save($Path, [System.Drawing.Imaging.ImageFormat]::Png) +$g.Dispose(); $bmp.Dispose() From a4ba18851ead44414fef531a83fb3c3d3a862ba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 16:58:57 +0000 Subject: [PATCH 2/6] Trim the machine test to two Windows setups Server 2025 (closest to Windows 11 24H2) at 150% scale, and Windows 11 on ARM with German settings and an accented card path. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017tvaMaK2pCRsYnhkC57qDm --- .github/workflows/machine-test.yml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.github/workflows/machine-test.yml b/.github/workflows/machine-test.yml index 4991e34..86cf797 100644 --- a/.github/workflows/machine-test.yml +++ b/.github/workflows/machine-test.yml @@ -1,4 +1,4 @@ -# Builds SlotDrop.exe once, then runs it on five different Windows machines +# 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. @@ -61,22 +61,16 @@ jobs: fail-fast: false matrix: include: - - id: server-2022 - name: Windows Server 2022 - os: windows-2022 - - id: server-2025 - name: Windows Server 2025 + # 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 (x64 app emulated) + name: Windows 11 on ARM, German, card path with accents os: windows-11-arm - - id: scale-150 - name: Windows Server 2025 at 150% scale - os: windows-2025 - scale: "1.5" - - id: german-accents - name: German Windows, card folder with spaces and accents - os: windows-2022 culture: de-DE card: 'C:\SD Kärtchen\Slot Karte' steps: From 6a39e06f8d9ead5a8aeced57327cc47963b7641d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:06:36 +0000 Subject: [PATCH 3/6] Turn on WebView2's debugging port in a test build of the app Wails' WebView2 loader clears WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS on purpose, so the test never reached the window. The build job now makes SlotDrop-test.exe with a one-line patch to that library which adds switches from SLOTDROP_TEST_WEBVIEW_ARGS; SlotDrop's own code and the shipped build are unchanged. Also: skip the results job on cancelled runs, cope with missing results, and log WebView2's processes when the window never appears. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017tvaMaK2pCRsYnhkC57qDm --- .github/workflows/machine-test.yml | 24 +++++++++++++++++++++--- ci/smoke/drive.mjs | 11 +++++++++-- ci/webview2-test-args.patch | 21 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 ci/webview2-test-args.patch diff --git a/.github/workflows/machine-test.yml b/.github/workflows/machine-test.yml index 86cf797..09afa8b 100644 --- a/.github/workflows/machine-test.yml +++ b/.github/workflows/machine-test.yml @@ -32,13 +32,30 @@ jobs: 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 patch to + # that library (ci/webview2-test-args.patch). 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 } + Push-Location $dst + git apply --verbose "$env:GITHUB_WORKSPACE\ci\webview2-test-args.patch" + if ($LASTEXITCODE) { exit 1 } + Pop-Location + 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.exe + SlotDrop-test.exe fakeromm.exe retention-days: 7 @@ -96,7 +113,7 @@ jobs: shell: pwsh env: SMOKE_SPEC: ${{ matrix.name }} - SMOKE_EXE: ${{ github.workspace }}\build-out\SlotDrop.exe + 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 @@ -115,7 +132,7 @@ jobs: report: name: Results needs: machine - if: always() + if: ${{ !cancelled() }} runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 @@ -129,6 +146,7 @@ jobs: 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 diff --git a/ci/smoke/drive.mjs b/ci/smoke/drive.mjs index e891c05..cbacb95 100644 --- a/ci/smoke/drive.mjs +++ b/ci/smoke/drive.mjs @@ -9,7 +9,7 @@ // // Environment: // SMOKE_SPEC name of this machine, for the report -// SMOKE_EXE path to SlotDrop.exe +// SMOKE_EXE path to the test build of SlotDrop.exe (see the workflow) // SMOKE_FAKE path to fakeromm.exe // SMOKE_CARD scratch folder to use as the SD card (made if missing) // SMOKE_OUT folder for screenshots and result.json @@ -111,7 +111,7 @@ async function launchApp() { const args = [`--remote-debugging-port=${cdpPort}`]; if (env.SMOKE_SCALE) args.push(`--force-device-scale-factor=${env.SMOKE_SCALE}`); app = spawn(env.SMOKE_EXE, [], { - env: { ...env, WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: args.join(" ") }, + env: { ...env, SLOTDROP_TEST_WEBVIEW_ARGS: args.join(" ") }, // see ci/webview2-test-args.patch stdio: "ignore", }); children.push(app); @@ -261,6 +261,13 @@ try { } } catch (e) { check("Test ran to the end", false, e.message.split("\n")[0]); + if (process.platform === "win32") { // what WebView2 was started with, and what windows are open + try { + console.log(execFileSync("powershell", ["-NoProfile", "-Command", + "Get-CimInstance Win32_Process -Filter \"name='msedgewebview2.exe' or name='SlotDrop-test.exe'\" | Select-Object -First 3 Name, CommandLine | Format-List | Out-String -Width 400;" + + "Get-Process | Where-Object MainWindowTitle | Format-Table Name, MainWindowTitle -AutoSize | Out-String"], { encoding: "utf8" })); + } catch {} + } desktopShot("desktop-at-failure.png"); } finally { stopApp(); diff --git a/ci/webview2-test-args.patch b/ci/webview2-test-args.patch new file mode 100644 index 0000000..3a338ea --- /dev/null +++ b/ci/webview2-test-args.patch @@ -0,0 +1,21 @@ +--- a/webviewloader/env_create_options.go ++++ b/webviewloader/env_create_options.go +@@ -3,6 +3,8 @@ + package webviewloader + + import ( ++ "os" ++ "strings" + "unicode/utf16" + "unsafe" + +@@ -71,7 +73,8 @@ + // [ChromiumDevelopersHowTosRunWithFlags]: https://www.chromium.org/developers/how-tos/run-chromium-with-flags "Run Chromium with flags | The Chromium Projects" + func WithAdditionalBrowserArguments(args string) option { + return func(wvep *environmentOptions) { +- wvep.additionalBrowserArguments = args ++ // SlotDrop machine test only: let the test add switches (a debugging port). ++ wvep.additionalBrowserArguments = strings.TrimSpace(args + " " + os.Getenv("SLOTDROP_TEST_WEBVIEW_ARGS")) + } + } + From 775d056c309e3f6761802bddfebbf17e14939598 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:09:31 +0000 Subject: [PATCH 4/6] Make the test-copy change in the workflow instead of a patch file The patch file got Windows line endings on checkout and no longer applied. The workflow now edits the one line directly and stops with a clear error if go-webview2 ever changes it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017tvaMaK2pCRsYnhkC57qDm --- .github/workflows/machine-test.yml | 16 ++++++++++------ ci/smoke/drive.mjs | 2 +- ci/webview2-test-args.patch | 21 --------------------- 3 files changed, 11 insertions(+), 28 deletions(-) delete mode 100644 ci/webview2-test-args.patch diff --git a/.github/workflows/machine-test.yml b/.github/workflows/machine-test.yml index 09afa8b..5fd1e83 100644 --- a/.github/workflows/machine-test.yml +++ b/.github/workflows/machine-test.yml @@ -33,8 +33,9 @@ jobs: - 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 patch to - # that library (ci/webview2-test-args.patch). SlotDrop's own code is the same. + # 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: | @@ -43,10 +44,13 @@ jobs: $dst = Join-Path $env:RUNNER_TEMP go-webview2 Copy-Item -Recurse $src $dst Get-ChildItem -Recurse -File $dst | ForEach-Object { $_.IsReadOnly = $false } - Push-Location $dst - git apply --verbose "$env:GITHUB_WORKSPACE\ci\webview2-test-args.patch" - if ($LASTEXITCODE) { exit 1 } - Pop-Location + $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`"") + Set-Content -NoNewline $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 diff --git a/ci/smoke/drive.mjs b/ci/smoke/drive.mjs index cbacb95..d1e2191 100644 --- a/ci/smoke/drive.mjs +++ b/ci/smoke/drive.mjs @@ -111,7 +111,7 @@ async function launchApp() { const args = [`--remote-debugging-port=${cdpPort}`]; if (env.SMOKE_SCALE) args.push(`--force-device-scale-factor=${env.SMOKE_SCALE}`); app = spawn(env.SMOKE_EXE, [], { - env: { ...env, SLOTDROP_TEST_WEBVIEW_ARGS: args.join(" ") }, // see ci/webview2-test-args.patch + env: { ...env, SLOTDROP_TEST_WEBVIEW_ARGS: args.join(" ") }, // see "Build the test copy" in the workflow stdio: "ignore", }); children.push(app); diff --git a/ci/webview2-test-args.patch b/ci/webview2-test-args.patch deleted file mode 100644 index 3a338ea..0000000 --- a/ci/webview2-test-args.patch +++ /dev/null @@ -1,21 +0,0 @@ ---- a/webviewloader/env_create_options.go -+++ b/webviewloader/env_create_options.go -@@ -3,6 +3,8 @@ - package webviewloader - - import ( -+ "os" -+ "strings" - "unicode/utf16" - "unsafe" - -@@ -71,7 +73,8 @@ - // [ChromiumDevelopersHowTosRunWithFlags]: https://www.chromium.org/developers/how-tos/run-chromium-with-flags "Run Chromium with flags | The Chromium Projects" - func WithAdditionalBrowserArguments(args string) option { - return func(wvep *environmentOptions) { -- wvep.additionalBrowserArguments = args -+ // SlotDrop machine test only: let the test add switches (a debugging port). -+ wvep.additionalBrowserArguments = strings.TrimSpace(args + " " + os.Getenv("SLOTDROP_TEST_WEBVIEW_ARGS")) - } - } - From 4305f8f80bda99c6160d705c048612b98603eb89 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:11:01 +0000 Subject: [PATCH 5/6] Write the test copy's edited file with .NET, not Set-Content Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017tvaMaK2pCRsYnhkC57qDm --- .github/workflows/machine-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/machine-test.yml b/.github/workflows/machine-test.yml index 5fd1e83..963f843 100644 --- a/.github/workflows/machine-test.yml +++ b/.github/workflows/machine-test.yml @@ -50,7 +50,7 @@ jobs: 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`"") - Set-Content -NoNewline $file $code + [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 From a5d004d84daa174bafb017a67254a192ea22bf34 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 17:15:48 +0000 Subject: [PATCH 6/6] Press the add buttons through the page, not with the mouse At 150% scale the + button (shown on hover) was off screen, so the mouse-based click never reached it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_017tvaMaK2pCRsYnhkC57qDm --- ci/smoke/drive.mjs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/ci/smoke/drive.mjs b/ci/smoke/drive.mjs index d1e2191..9b4a672 100644 --- a/ci/smoke/drive.mjs +++ b/ci/smoke/drive.mjs @@ -143,6 +143,13 @@ async function connect() { return { browser, page }; } +// press clicks an element through the page itself (the app's own click handler runs). +async function press(page, selector) { + const el = page.locator(selector).first(); + await el.waitFor({ state: "attached", timeout: 15000 }); + await el.evaluate((e) => { e.scrollIntoView({ block: "center" }); e.click(); }); +} + async function shot(page, name) { try { await page.screenshot({ path: path.join(out, name) }); @@ -189,10 +196,11 @@ async function firstRun() { await shot(page, "2-library.png"); // Add a GBA game with a cartridge photo: the game, its label and a shell colour. - await page.locator(`.tile[data-rom="1"]`).first().hover(); - await page.locator(`.tile[data-rom="1"] button.add`).first().click({ force: true }); + // The + button only shows on hover, and on a small or scaled screen the + // tile can be off to the side, so press it directly rather than by mouse. + await press(page, `.tile[data-rom="1"] button.add`); const toast = await page.waitForSelector("text=is on your card, label and all", { timeout: 60000 }).then(() => true, () => false); - check("Says the game was added with its label", toast); + if (!check("Says the game was added with its label", toast)) await shot(page, "3-add-failed.png"); if (!dryRun) { const stem = "Test Quest (World)"; check("Copies the game to Games/GBA", files("Games/GBA").includes(stem + ".gba"), files("Games/GBA").join(", ") || "folder empty"); @@ -205,8 +213,7 @@ async function firstRun() { } // Add a Game Boy Color game that RomM stores as a zip. - await page.locator(`.tile[data-rom="3"]`).first().hover(); - await page.locator(`.tile[data-rom="3"] button.add`).first().click({ force: true }); + await press(page, `.tile[data-rom="3"] button.add`); if (!dryRun) { const unzipped = await waitFor("the GBC game", async () => files("Games/GBC").includes("Pocket Garden (World).gbc"), 60000).catch(() => false); check("Unzips a zipped game into Games/GBC", unzipped, files("Games/GBC").join(", ") || "folder empty"); @@ -215,7 +222,7 @@ async function firstRun() { await shot(page, "3-games-added.png"); // The game panel with the label on the drawn cartridge. - await page.locator(".tile", { hasText: "Test Quest" }).first().click(); + await press(page, `.tile[data-rom="1"]`); await page.waitForSelector(".drawer.open", { timeout: 10000 }).catch(() => {}); await sleep(1000); await shot(page, "4-game-panel.png");