Files
gen1recomp/tests/engine/update_check_tests.lua
T
Shane McGovern 93e071bcaa Fix self-update on the PortMaster SBC build
The SBC and RG34XXSP ports hand LÖVE a source *directory*
(`love <dir>`), so `love.filesystem.isFused()` is false there and
`Boot.run` bailed on its first line.  The launcher still offered
"Update vX.Y.Z" and "Restart to update", so an update downloaded,
verified, and then was silently ignored forever.

- Boot.canUpdateInPlace() replaces the fused-only gate: a packaged
  build updates whether it is fused (AppImage, Flatpak game.love) or
  unpacked, and only a dev / source checkout (engine "0.0.0-dev") is
  excluded.  Fails closed when the host cannot be established.
  Prelaunch.updateAllowed now delegates to it, so the boot gate and the
  `--update` gate cannot disagree.
- Check.hostPort() reads the release-target marker, with
  POKEPORT_HANDHELD as a legacy fallback (the SBC launcher has always
  exported it, so packs predating this change still identify
  themselves).  The marker was read from the environment but never
  exported, so a full-package fallback on a handheld resolved to a
  desktop AppImage that cannot run there.
- A handheld now fetches its own package ("Download port update",
  reusing the worker's cross-platform download_full) instead of
  offering a URL it has no browser to open, and reports "Update package
  ready" once it is in the save directory for a manual re-extract.
- Launchers export POKEPORT_PORTMASTER / POKEPORT_RG34XXSP.

An in-place update only ever mounts a payload over the running source,
so relaxing the gate is less invasive than the fused path it joins.
A runtime bump still needs a full package, which the minShell gate
already reports as needs_full.

Tests: tests/engine/update_boot_host_gate.lua (new, 10 checks) pins the
gate; update_check_tests.lua pins both port asset names and hostPort().
scripts/test.sh --quick: all tiers passed; scripts/lint.sh: 0 errors.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-19 22:15:02 +01:00

145 lines
7.1 KiB
Lua

-- Pure-surface coverage for src/update/Check.lua (the self-update release
-- check / payload download module). The network, hashing and archive-probe
-- logic lives in src/update/check_worker.lua and needs love + curl; these are
-- the love-free extraction/parsing seams the worker and UI both trust.
-- luajit tests/engine/update_check_tests.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
local Check = require("src.update.Check")
local Json = require("src.link.Json")
-- releaseUrl is the fixed public landing page the UI links on needs_full
eq(Check.releaseUrl(),
"https://github.com/bryanthaboi/gen1recomp/releases/latest",
"releaseUrl points at the repo's latest release")
-- parseRelease: a well-formed release with the .love payload and its sums
local body = Json.encode({
tag_name = "v1.4.2",
assets = {
{ name = "gen1recomp-1.4.2-macos.zip", browser_download_url = "http://x/mac", size = 10 },
{ name = "gen1recomp-1.4.2.love", browser_download_url = "http://x/love", size = 12345 },
{ name = "sha256sums.txt", browser_download_url = "http://x/sums", size = 99 },
},
})
local rel = Check.parseRelease(body)
check(rel ~= nil, "parseRelease accepts a valid release")
eq(rel.version, "1.4.2", "leading v stripped from tag_name")
eq(rel.payloadName, "gen1recomp-1.4.2.love", "payload name derived from version")
eq(rel.payload.url, "http://x/love", "payload asset url picked")
eq(rel.payload.size, 12345, "payload asset size picked")
eq(rel.sums.url, "http://x/sums", "sums asset url picked")
eq(rel.notes, "", "missing release body becomes empty notes")
-- Native package assets are target-specific. This is intentionally separate
-- from the generic .love payload, which a new shell may be unable to host.
local android = Check.parseRelease(body, nil, { os = "Android", arch = "arm64" })
eq(android.fullName, "gen1recomp-1.4.2-android.apk", "Android package name derived")
eq(android.full, nil, "missing Android package is surfaced as nil")
eq(Check.fullAssetName("1.4.2", "Android", "arm64"),
"gen1recomp-1.4.2-android.apk", "Android full asset mapping")
eq(Check.fullAssetName("1.4.2", "Linux", "aarch64"),
"gen1recomp-1.4.2-linux-arm64.AppImage", "Linux ARM package mapping")
eq(Check.fullAssetName("1.4.2", "Linux", "x64"),
"gen1recomp-1.4.2-linux-x86_64.AppImage", "Linux x86_64 AppImage mapping")
eq(Check.fullAssetName("1.4.2", "Linux", "x64", "flatpak"),
"gen1recomp-1.4.2-linux.flatpak", "Linux Flatpak package mapping")
-- Handheld ports run aarch64 Linux, so the port tag must win over the arch
-- mapping: a desktop AppImage cannot be installed on a PortMaster device.
eq(Check.fullAssetName("1.4.2", "Linux", "aarch64", "portmaster"),
"gen1recomp-1.4.2-sbc-portmaster.zip", "PortMaster SBC package mapping")
eq(Check.fullAssetName("1.4.2", "Linux", "aarch64", "rg34xxsp"),
"gen1recomp-1.4.2-rg34xxsp-stockos64-mod.zip", "RG34XXSP package mapping")
eq(Check.fullAssetName("1.4.2", "iOS", "arm64"),
"gen1recomp++-1.4.2-ios.ipa", "iOS package mapping")
eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil,
"invalid full-package version rejected")
-- The port tag comes from the launcher's environment. POKEPORT_HANDHELD is the
-- marker the SBC launcher has always exported, so a pack built before the
-- explicit POKEPORT_PORTMASTER existed is still identified as the PortMaster
-- port rather than a desktop Linux build.
do
local realGetenv = os.getenv
local function portWith(vars)
os.getenv = function(name) return vars[name] end
local port = Check.hostPort()
os.getenv = realGetenv
return port
end
eq(portWith({ POKEPORT_PORTMASTER = "1" }), "portmaster",
"PortMaster launcher marker identifies the SBC port")
eq(portWith({ POKEPORT_RG34XXSP = "1" }), "rg34xxsp",
"RG34XXSP launcher marker identifies that port")
eq(portWith({ POKEPORT_HANDHELD = "1" }), "portmaster",
"legacy handheld marker identifies the SBC port")
eq(portWith({ PORTMASTER = "1" }), nil,
"PORTMASTER alone is not the release-target marker")
eq(portWith({}), nil, "a desktop Linux build has no port tag")
end
-- A legacy Android shell needs one manual package update even when its
-- downloaded payload already reports the latest engine version.
eq(Check.androidNeedsInstallerBootstrap("Android", false), true,
"legacy Android shell requires one bootstrap package")
eq(Check.androidNeedsInstallerBootstrap("Android", true), false,
"current Android shell keeps the selective updater")
eq(Check.androidNeedsInstallerBootstrap("Windows", false), false,
"non-Android update behavior remains unchanged")
local withNotes = Check.parseRelease(Json.encode({
tag_name = "v1.4.2",
body = "## Issues closed\n\n- #1 cart padding",
assets = {},
}))
eq(withNotes.notes, "## Issues closed\n\n- #1 cart padding",
"parseRelease keeps the GitHub release body")
-- a newer release that ships no .love yet: parses, but the payload/sums are nil
-- so the worker will route to needs_full rather than an in-place update
local noPayload = Check.parseRelease(Json.encode({ tag_name = "2.0.0", assets = {} }))
check(noPayload ~= nil, "parseRelease accepts a payload-less release")
eq(noPayload.version, "2.0.0", "version parsed without assets")
eq(noPayload.payload, nil, "no payload asset -> nil")
eq(noPayload.sums, nil, "no sums asset -> nil")
-- rejects: non-semver tag, and a document with no tag at all
local bad, badErr = Check.parseRelease(Json.encode({ tag_name = "nightly" }))
eq(bad, nil, "non-X.Y.Z tag rejected")
check(badErr ~= nil, "rejection carries an error string")
eq(Check.parseRelease(Json.encode({ foo = 1 })), nil, "missing tag_name rejected")
-- Network failures can return a plain-text or HTML body instead of JSON. The
-- launcher must describe that response rather than leaking the decoder's
-- low-level "unexpected character 'E'" assertion.
do
local release, err = Check.parseRelease("Error: API rate limit exceeded")
check(release == nil and tostring(err):find("not JSON", 1, true) ~= nil,
"plain-text update failure is reported as non-JSON")
release, err = Check.parseRelease("<!DOCTYPE html><html>502 Bad Gateway</html>")
check(release == nil and tostring(err):find("HTML", 1, true) ~= nil,
"HTML update failure is identified")
check(tostring(err):find("unexpected character", 1, true) == nil,
"decoder assertion is not exposed")
end
-- parseSums: shasum -a 256 format, tolerating the '*' binary marker, a './'
-- prefix and CRLF line endings; unrelated lines are skipped
local sums =
"aaaa1111 gen1recomp-1.4.2.love\n" ..
"BBBB2222 *./sha256sums.txt\r\n" ..
"not a checksum line\n"
local map = Check.parseSums(sums)
eq(map["gen1recomp-1.4.2.love"], "aaaa1111", "bare-name sum parsed")
eq(map["sha256sums.txt"], "bbbb2222", "* marker and ./ prefix stripped, lowered")
eq(Check.parseSums(sums, "gen1recomp-1.4.2.love"), "aaaa1111", "targeted lookup returns the hash")
eq(Check.parseSums(sums, "missing.love"), nil, "targeted lookup misses cleanly")
-- pickAsset guards a non-table assets field
eq(Check.pickAsset(nil, "x"), nil, "pickAsset tolerates a nil asset list")
T.finish("update_check")