The Valheim draft in #13 passed -maxplayers, an option Valheim doesn't have. The server ignores unknown options, so the draft booted and passed every boot test while its MaxPlayers setting did nothing. build now reads the pages research based the variant on, in full, and the drafter rejects any launch option in the startup command that none of them, nor the image's own scripts, mention. The model gets it back to remove or spell correctly. Options of shell tools (mkdir -p, [ -n ...]) are skipped. The check is off when there are no sources (draft run by hand, no reader). Against #13's 11 real sources it flags -maxplayers and nothing else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
6.7 KiB
Go
152 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func textSettings(keys ...string) map[string]any {
|
|
props := map[string]any{}
|
|
for _, k := range keys {
|
|
props[k] = map[string]any{"type": "string"}
|
|
}
|
|
return map[string]any{"properties": props}
|
|
}
|
|
|
|
func TestSplitSettingsCatchesTheValheimDraft(t *testing.T) {
|
|
// What the builder drafted for Valheim: quoted in the assignment, split by the unquoted $ARGS
|
|
game := map[string]any{
|
|
"defaultStartupCommand": `ARGS="-name ${SERVER_NAME:-Valheim Server} -port 2456 -world ${WORLD_NAME:-Dedicated}"; ` +
|
|
`[ -n "$PASSWORD" ] && ARGS="$ARGS -password $PASSWORD"; [ "$CROSSPLAY" = "true" ] && ARGS="$ARGS -crossplay"; ` +
|
|
`HOME=/server ./valheim_server.x86_64 $ARGS`,
|
|
"configSchema": textSettings("ServerName", "WorldName", "Password"),
|
|
}
|
|
problems := splitSettings(game)
|
|
got := strings.Join(problems, "\n")
|
|
if len(problems) != 3 {
|
|
t.Fatalf("want ServerName, WorldName and Password, got:\n%s", got)
|
|
}
|
|
for _, key := range []string{"setting Password", "setting ServerName", "setting WorldName", "inside $ARGS"} {
|
|
if !strings.Contains(got, key) {
|
|
t.Errorf("missing %q in:\n%s", key, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitSettingsAcceptsQuotedAndArrays(t *testing.T) {
|
|
for _, startup := range []string{
|
|
`./valheim_server.x86_64 -name "${SERVER_NAME}" -world "$WORLD_NAME" -port 2456`,
|
|
`args=(-name "$SERVER_NAME" -world "${WORLD_NAME:-Dedicated}"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./valheim_server.x86_64 "${args[@]}"`,
|
|
`bash -c 'exec ./server -name "$SERVER_NAME"'`,
|
|
} {
|
|
game := map[string]any{"defaultStartupCommand": startup, "configSchema": textSettings("ServerName", "WorldName", "Password")}
|
|
if p := splitSettings(game); len(p) != 0 {
|
|
t.Errorf("%s\n flagged: %v", startup, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitSettingsDirectAndArrayMisuse(t *testing.T) {
|
|
cases := map[string]string{
|
|
`./server -name ${SERVER_NAME}`: "is not in double quotes",
|
|
`bash -c 'exec ./server -name $SERVER_NAME'`: "is not in double quotes",
|
|
`args=(-name "$SERVER_NAME"); ./server ${args[@]}`: "inside $args",
|
|
`args=(-name $SERVER_NAME); ./server "${args[@]}"`: "is not in double quotes",
|
|
`export OPTS="-name ${SERVER_NAME}" && ./run $OPTS`: "inside $OPTS",
|
|
}
|
|
for startup, want := range cases {
|
|
game := map[string]any{"defaultStartupCommand": startup, "configSchema": textSettings("ServerName")}
|
|
got := strings.Join(splitSettings(game), "\n")
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("%s\n want %q, got %q", startup, want, got)
|
|
}
|
|
}
|
|
// Numbers and booleans can't contain spaces
|
|
game := map[string]any{"defaultStartupCommand": `./server -port ${GAME_PORT}`, "configSchema": map[string]any{
|
|
"properties": map[string]any{"GamePort": map[string]any{"type": "integer"}}}}
|
|
if p := splitSettings(game); len(p) != 0 {
|
|
t.Errorf("integer setting flagged: %v", p)
|
|
}
|
|
}
|
|
|
|
func TestPortSettings(t *testing.T) {
|
|
game := map[string]any{
|
|
"defaultPorts": []any{"2456/udp", "2457/udp"},
|
|
"defaultConfig": map[string]any{
|
|
"GamePort": json.Number("2456"),
|
|
"QueryPort": json.Number("27015"),
|
|
"RCONPort": json.Number("25575"),
|
|
"PublicPort": "{PUBLIC_PORT_2456}",
|
|
"Transport": "udp",
|
|
"Export": json.Number("9"),
|
|
},
|
|
"configSchema": map[string]any{"properties": map[string]any{
|
|
"GamePort": map[string]any{"type": "integer"}, "QueryPort": map[string]any{"type": "integer"},
|
|
}},
|
|
}
|
|
got := strings.Join(portSettings(game), "\n")
|
|
if !strings.Contains(got, "setting GamePort lets owners change the port") || !strings.Contains(got, "write 2456 directly") {
|
|
t.Errorf("editable published port not caught:\n%s", got)
|
|
}
|
|
if !strings.Contains(got, "setting QueryPort defaults to port 27015, which defaultPorts does not publish") {
|
|
t.Errorf("unpublished port not caught:\n%s", got)
|
|
}
|
|
for _, key := range []string{"RCONPort", "PublicPort", "Transport", "Export"} {
|
|
if strings.Contains(got, "setting "+key+" ") {
|
|
t.Errorf("%s should be left alone:\n%s", key, got)
|
|
}
|
|
}
|
|
|
|
// A published port written by the template itself, not offered to owners, is fine
|
|
delete(game["configSchema"].(map[string]any)["properties"].(map[string]any), "GamePort")
|
|
delete(game["defaultConfig"].(map[string]any), "QueryPort")
|
|
if p := portSettings(game); len(p) != 0 {
|
|
t.Errorf("got %v", p)
|
|
}
|
|
}
|
|
|
|
func TestUndocumentedOptionsCatchesMaxPlayers(t *testing.T) {
|
|
// PR #13's startup: every option is in Valheim's docs except -maxplayers
|
|
game := map[string]any{"defaultStartupCommand": `args=(-name "${SERVER_NAME:-GBRHosting - Valheim}" -port 2456 -world "${WORLD_NAME:-Dedicated}" -savedir /server); ` +
|
|
`[ -n "${SERVER_PASSWORD}" ] && args+=(-password "${SERVER_PASSWORD}"); [ "${CROSSPLAY}" = "true" ] && args+=(-crossplay); ` +
|
|
`[ -n "${MAX_PLAYERS}" ] && args+=(-maxplayers "${MAX_PLAYERS}"); ./valheim_server.x86_64 "${args[@]}"`}
|
|
docs := "Launch with `-name`, `-port`, `-world`, `-password`, `-savedir <path>` and `-crossplay` to allow consoles."
|
|
got := undocumentedOptions(game, docs, nil)
|
|
if len(got) != 1 || !strings.Contains(got[0], "passes -maxplayers,") {
|
|
t.Fatalf("want only -maxplayers flagged, got %v", got)
|
|
}
|
|
// Once removed, nothing is left to flag
|
|
game["defaultStartupCommand"] = strings.Replace(game["defaultStartupCommand"].(string), `[ -n "${MAX_PLAYERS}" ] && args+=(-maxplayers "${MAX_PLAYERS}"); `, "", 1)
|
|
if got := undocumentedOptions(game, docs, nil); len(got) != 0 {
|
|
t.Errorf("got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestUndocumentedOptionsIgnoresShellAndUsesImageScripts(t *testing.T) {
|
|
game := map[string]any{"defaultStartupCommand": `mkdir -p /server/.config; cp -f /a /b; [ -n "$X" ] && [ "$Y" -eq 1 ] && ` +
|
|
`exec ./server --listen-port=7777 -ServerPlatform=ALL -logs`}
|
|
facts := &imageFacts{Scripts: map[string]string{"/start.sh": `exec "$@" -logs`}}
|
|
got := strings.Join(undocumentedOptions(game, "Use --listen-port and -ServerPlatform=ALL for crossplay.", facts), "\n")
|
|
if got != "" {
|
|
t.Errorf("shell options, documented ones and ones the image's script uses must pass, got %q", got)
|
|
}
|
|
// Without sources the check is off (draft run by hand, or no reader configured)
|
|
game["defaultStartupCommand"] = "./server -madeup"
|
|
if p := undocumentedOptions(game, "", nil); p != nil {
|
|
t.Errorf("got %v", p)
|
|
}
|
|
if p := undocumentedOptions(game, "unrelated page", nil); len(p) != 1 {
|
|
t.Errorf("want -madeup flagged, got %v", p)
|
|
}
|
|
}
|
|
|
|
func TestUndocumentedOptionsWholeWords(t *testing.T) {
|
|
// "-port" in the docs doesn't document "-portal", and "-port" isn't documented by "-portal"
|
|
game := map[string]any{"defaultStartupCommand": "./server -port 1 -portal x"}
|
|
got := strings.Join(undocumentedOptions(game, "options: -port", nil), "")
|
|
if !strings.Contains(got, "passes -portal,") || strings.Contains(got, "-port,") {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
}
|