Files
gbr-games/cmd/gbrgames/firstboot_test.go
T
gregandClaude Opus 5.5 709cdb51e8 fix(builder): don't count Docker's init or the Steam client's own files as lost saves
Every container the node-agent starts gets /usr/sbin/docker-init, and SteamCMD
rewrites its own config.vdf on every run. Counted as files written outside the
volumes, they made every build look like it loses saves. A folder whose only
changes were logs no longer counts either, and a folder followed by a
same-named file (worlds, worlds.bak) is no longer taken for a file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 00:42:34 -04:00

158 lines
6.4 KiB
Go

package main
import (
"regexp"
"strings"
"testing"
)
func TestParseConfigFormats(t *testing.T) {
// An Unreal-style option list
list := parseConfig("/server/Game/Saved/Config/LinuxServer/GameSettings.ini", "[/Script/Game.WorldSettings]\n"+
`OptionSettings=(Difficulty=None,ExpRate=1.000000,ServerName="Default Server",CrossplayPlatforms=(Steam,Xbox,PS5,Mac))`)
want := map[string]string{"Difficulty": "None", "ExpRate": "1.000000", "ServerName": "Default Server", "CrossplayPlatforms": "(Steam,Xbox,PS5,Mac)"}
if len(list) != len(want) {
t.Fatalf("option list: got %d settings: %+v", len(list), list)
}
for _, s := range list {
if s.Section != "OptionSettings" || want[s.Key] != s.Value {
t.Errorf("option list setting %+v", s)
}
}
// A server.properties file
mc := parseConfig("/data/server.properties", "#Server properties\nmax-players=20\nmotd=A Server\nlevel-seed=\n")
if len(mc) != 3 || mc[0].Key != "max-players" || mc[0].Value != "20" || mc[2].Value != "" {
t.Errorf("properties: %+v", mc)
}
// INI sections and comments
ini := parseConfig("/server/x.ini", "; comment\n[ServerSettings]\nRCONPort = 27020\n[Other]\nKey=\"quoted\"\n")
if len(ini) != 2 || ini[0].Section != "ServerSettings" || ini[0].Value != "27020" || ini[1].Value != "quoted" {
t.Errorf("ini: %+v", ini)
}
// JSON, one level deep
js := parseConfig("/config/settings.json", `{"name":"x","limits":{"players":8},"tags":["a"]}`)
if len(js) != 3 || js[0].Key != "players" || js[0].Section != "limits" || js[0].Value != "8" {
t.Errorf("json: %+v", js)
}
}
func TestSuggestPattern(t *testing.T) {
for line, mustMatch := range map[string]string{
`[01:32:38 INFO]: Done (24.283s)! For help, type "help"`: `[03:01:02 INFO]: Done (7.5s)! For help, type "help"`,
`[2026.10.25-03.10.36:455][ 0]Server has completed startup and is now advertising for join.`: `[2026.10.26-11.00.00:001][ 12]Server has completed startup and is now advertising for join.`,
`2026-10-25T03:10:36Z Server started on port 7777`: `2026-10-26T09:00:00Z Server started on port 8000`,
} {
p := suggestPattern(line)
if p == "" {
t.Errorf("no pattern for %q", line)
continue
}
re := regexp.MustCompile(p)
if !re.MatchString(line) || !re.MatchString(mustMatch) {
t.Errorf("pattern %q should match %q and %q", p, line, mustMatch)
}
}
if p := suggestPattern("[12:00:00] ok"); p != "" {
t.Errorf("too-short line gave pattern %q", p)
}
}
func TestAnalyzeLines(t *testing.T) {
r := &bootReport{}
analyzeLines(r, []logLine{
{At: 1, Source: "console", Text: "Starting server"},
{At: 2, Source: "console", Text: "Failed to load optional module"},
{At: 9, Source: "console", Text: "[09:00:01 INFO]: Done (8.1s)! For help, type \"help\""},
{At: 9, Source: "console", Text: "[09:00:01 INFO]: Thread RCON Listener started"},
// After the stop at 200s: the shutdown, as a wrapper script prints it
{At: 201, Source: "console", Text: "[06:01:39] [RCON Listener #1/INFO]: Thread RCON Client /0:0:0:0:0:0:0:1 started"},
{At: 202, Source: "console", Text: "2026-10-25T06:01:39.976Z\tINFO\tmc-server-runner\tDone"},
}, []logLine{{At: -1, Source: "/server/x.log", Text: "Listening on 0.0.0.0:7777"}}, 200)
if len(r.ErrorLines) != 1 {
t.Fatalf("errors %v", r.ErrorLines)
}
// The last ready line before the stop, not the shutdown's "Done"
if r.SuggestedExample != `[09:00:01 INFO]: Done (8.1s)! For help, type "help"` {
t.Errorf("example = %q", r.SuggestedExample)
}
// No console candidate: fall back to the first one in a log file
r = &bootReport{}
analyzeLines(r, []logLine{{At: 1, Source: "console", Text: "booting"}},
[]logLine{{At: -1, Source: "/server/a.log", Text: "Server has completed startup and is now advertising for join."},
{At: -1, Source: "/server/a.log", Text: "Server shutdown done"}}, 100)
if r.SuggestedExample != "Server has completed startup and is now advertising for join." {
t.Errorf("file fallback example = %q", r.SuggestedExample)
}
}
func TestConfigCandidates(t *testing.T) {
for rel, want := range map[string]bool{
"server.properties": true,
"Game/Saved/Config/LinuxServer/x.ini": true,
"Game/Saved/Logs/Server.log": true,
"steamapps/appmanifest_123.acf": false,
"Engine/Config/BaseEngine.ini": false,
"plugins/SomePlugin/config.yml": false,
"world/level.dat": false,
} {
if got := isConfigCandidate(rel, 1000); got != want {
t.Errorf("isConfigCandidate(%q) = %v, want %v", rel, got, want)
}
}
}
func TestOutsideFilesCountsEverySaveButNotSystemOrTransientFiles(t *testing.T) {
diff := `C /home
C /home/steam
A /home/steam/.config
A /home/steam/.config/example
A /home/steam/.config/example/worlds
A /home/steam/.config/example/worlds/world1.db
A /home/steam/.config/example/worlds/world1.fwl
A /home/steam/.config/example/worlds.bak
A /home/steam/.config/example/Player.log
C /home/steam/logs
A /home/steam/logs/server.log
C /home/steam/Steam
C /home/steam/Steam/config
C /home/steam/Steam/config/config.vdf
C /home/steam/steamcmd/linux64/steamclient.so
C /etc
C /etc/hostname
C /usr
C /usr/sbin
A /usr/sbin/docker-init
C /var
C /var/log
A /var/log/x
A /tmp/x.sock
A /server/should-not-count.sav
D /home/steam/old.txt
A /home/steam/server.pid`
got := outsideFiles(diff, map[string]string{"/server": "v", "/data": "v"})
// Folders whose only changes aren't the game's (logs, Docker's init) don't count either
want := []string{"A /home/steam/.config/example/worlds.bak", "A /home/steam/.config/example/worlds/world1.db",
"A /home/steam/.config/example/worlds/world1.fwl"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("got %q\nwant %q", got, want)
}
}
func TestUntouchedPayloadDeploysWithoutTemplateSettings(t *testing.T) {
p := untouchedPayload(&bootSpec{Image: "example/image", Startup: "./server", SteamAppID: "1000000", MemoryMb: 4096,
User: "1000:1000", Env: map[string]string{"EULA": "TRUE"}})
if p["installMethod"] != "STEAMCMD" || p["installConfig"].(map[string]any)["appId"] != "1000000" || p["timeoutSec"] != 2400 {
t.Errorf("steam install: %v", p)
}
if p["startupCommand"] != "./server" || p["dockerNetwork"] != bootTestNetwork || p["gameConfig"].(map[string]any)["EULA"] != "TRUE" {
t.Errorf("payload: %v", p)
}
if _, ok := untouchedPayload(&bootSpec{Image: "x"})["startupCommand"]; ok {
t.Error("no startup command means the image's own entrypoint")
}
}