diff --git a/.github/workflows/machine-test.yml b/.github/workflows/machine-test.yml new file mode 100644 index 0000000..963f843 --- /dev/null +++ b/.github/workflows/machine-test.yml @@ -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 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..9b4a672 --- /dev/null +++ b/ci/smoke/drive.mjs @@ -0,0 +1,284 @@ +// 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 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 +// 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, SLOTDROP_TEST_WEBVIEW_ARGS: args.join(" ") }, // see "Build the test copy" in the workflow + 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 }; +} + +// 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) }); + } 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. + // 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); + 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"); + 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 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"); + } + await sleep(1500); + await shot(page, "3-games-added.png"); + + // The game panel with the label on the drawn cartridge. + 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"); + 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]); + 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(); + 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()