From bc23ad7daaee1977da8d57a26d6e8caf21f4dfd1 Mon Sep 17 00:00:00 2001 From: MrFastwind Date: Thu, 23 Jul 2026 20:23:31 +0200 Subject: [PATCH] test(backend): add process manager and steamcmd tests with stub scripts - server_process_test.go: 16 tests covering splitArgs, hasArgPrefix, buildAutoArgs (config/port/profiles), buildArgs, resolveModPath (name/ ID/empty), buildModPath, WriteUserconfigFiles, Start/Stop lifecycle, percent-command substitution - steamcmd_test.go: 9 tests covering CheckWorkshopMod (directory/file/ empty), SteamCmdManager state machine, DownloadMods/UpdateGame - testdata/: stub arma3server_x64 and steamcmd scripts for safe process testing without real binaries --- .../internal/services/server_process_test.go | 514 ++++++++++++++++++ backend/internal/services/steamcmd_test.go | 169 ++++++ .../services/testdata/arma3server_x64 | 5 + backend/internal/services/testdata/steamcmd | 5 + 4 files changed, 693 insertions(+) create mode 100644 backend/internal/services/server_process_test.go create mode 100644 backend/internal/services/steamcmd_test.go create mode 100755 backend/internal/services/testdata/arma3server_x64 create mode 100755 backend/internal/services/testdata/steamcmd diff --git a/backend/internal/services/server_process_test.go b/backend/internal/services/server_process_test.go new file mode 100644 index 0000000..41d0128 --- /dev/null +++ b/backend/internal/services/server_process_test.go @@ -0,0 +1,514 @@ +package services + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "arma3-web-server/internal/models" +) + +func TestSplitArgs(t *testing.T) { + tests := []struct { + name string + raw string + want []string + }{ + {"empty", "", nil}, + {"single", "-server", []string{"-server"}}, + {"multiple", "-server -port=2302 -world=empty", []string{"-server", "-port=2302", "-world=empty"}}, + {"extra spaces", " -server -port=2302 ", []string{"-server", "-port=2302"}}, + {"tabs", "-server\t-port=2302", []string{"-server", "-port=2302"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := splitArgs(tt.raw) + if len(got) != len(tt.want) { + t.Fatalf("splitArgs(%q) = %v, want %v", tt.raw, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitArgs(%q)[%d] = %q, want %q", tt.raw, i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestHasArgPrefix(t *testing.T) { + args := []string{"-config=foo.cfg", "-mod=@ace", "-port=2302"} + + if !hasArgPrefix(args, "-config=") { + t.Error("should find -config=") + } + if !hasArgPrefix(args, "-mod=") { + t.Error("should find -mod=") + } + if hasArgPrefix(args, "-profiles=") { + t.Error("should not find -profiles=") + } + if hasArgPrefix(nil, "-config=") { + t.Error("nil args should return false") + } +} + +func TestBuildAutoArgs_ConfigAndPort(t *testing.T) { + dir := t.TempDir() + cfgDir := filepath.Join(dir, "cfg") + profilesDir := filepath.Join(dir, "profiles") + modsDir := filepath.Join(dir, "mods") + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(cfgDir) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) + + s := &models.ServerSettings{ + ActiveConfig: "server_config", + IPPort: "0.0.0.0:2302", + } + + args := pm.buildAutoArgs(s) + + // Should contain -config=/server_config.cfg + foundConfig := false + for _, a := range args { + if strings.HasPrefix(a, "-config=") && strings.HasSuffix(a, "server_config.cfg") { + foundConfig = true + } + } + if !foundConfig { + t.Errorf("expected -config=...server_config.cfg in args, got %v", args) + } + + // Should contain -port=2302 + foundPort := false + for _, a := range args { + if a == "-port=2302" { + foundPort = true + } + } + if !foundPort { + t.Errorf("expected -port=2302 in args, got %v", args) + } + + // Should contain -profiles= + foundProfiles := false + for _, a := range args { + if strings.HasPrefix(a, "-profiles=") { + foundProfiles = true + } + } + if !foundProfiles { + t.Errorf("expected -profiles=... in args, got %v", args) + } +} + +func TestBuildAutoArgs_NoConfigNoPort(t *testing.T) { + dir := t.TempDir() + cfgDir := filepath.Join(dir, "cfg") + profilesDir := filepath.Join(dir, "profiles") + modsDir := filepath.Join(dir, "mods") + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(cfgDir) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) + + s := &models.ServerSettings{} + + args := pm.buildAutoArgs(s) + + // Should NOT contain -config= or -port= + for _, a := range args { + if strings.HasPrefix(a, "-config=") { + t.Errorf("unexpected -config= in args: %v", a) + } + if strings.HasPrefix(a, "-port=") { + t.Errorf("unexpected -port= in args: %v", a) + } + } +} + +func TestBuildAutoArgs_PortAlreadyInUserArgs(t *testing.T) { + dir := t.TempDir() + cfgDir := filepath.Join(dir, "cfg") + profilesDir := filepath.Join(dir, "profiles") + modsDir := filepath.Join(dir, "mods") + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(cfgDir) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) + + s := &models.ServerSettings{ + IPPort: "0.0.0.0:2302", + } + + args := pm.buildAutoArgs(s) + + // -port= should only appear once (from auto args) + portCount := 0 + for _, a := range args { + if strings.HasPrefix(a, "-port=") { + portCount++ + } + } + if portCount != 1 { + t.Errorf("expected exactly 1 -port= arg, got %d in %v", portCount, args) + } +} + +func TestBuildArgs_CombinesParametersAndAutoArgs(t *testing.T) { + dir := t.TempDir() + cfgDir := filepath.Join(dir, "cfg") + profilesDir := filepath.Join(dir, "profiles") + modsDir := filepath.Join(dir, "mods") + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(cfgDir) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) + + s := &models.ServerSettings{ + ServerParameters: "-server -world=empty -noPause", + ActiveConfig: "main", + IPPort: "0.0.0.0:2400", + } + + args := pm.buildArgs(s) + + // Should start with user params + if args[0] != "-server" || args[1] != "-world=empty" || args[2] != "-noPause" { + t.Errorf("user params missing from front of args: %v", args) + } + + // Should contain auto-generated args + foundConfig := false + foundPort := false + for _, a := range args { + if strings.Contains(a, "main.cfg") { + foundConfig = true + } + if a == "-port=2400" { + foundPort = true + } + } + if !foundConfig { + t.Errorf("expected config arg in %v", args) + } + if !foundPort { + t.Errorf("expected port arg in %v", args) + } +} + +func TestResolveModPath_ByModName(t *testing.T) { + dir := t.TempDir() + modsDir := filepath.Join(dir, "mods") + modDir := filepath.Join(modsDir, "@CBA_A3") + os.MkdirAll(modDir, 0755) + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(filepath.Join(dir, "cfg")) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) + + // Existing mod dir → resolved path + got := pm.resolveModPath(models.ModEntry{Name: "CBA_A3"}) + if got != modDir { + t.Errorf("resolveModPath(CBA_A3) = %q, want %q", got, modDir) + } + + // Non-existing mod dir → fallback to expected path + got = pm.resolveModPath(models.ModEntry{Name: "NonExistent"}) + expected := filepath.Join(modsDir, "@NonExistent") + if got != expected { + t.Errorf("resolveModPath(NonExistent) = %q, want %q", got, expected) + } +} + +func TestResolveModPath_ByWorkshopID(t *testing.T) { + dir := t.TempDir() + workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410", "456789") + os.MkdirAll(workshopDir, 0755) + + modsDir := filepath.Join(dir, "mods") + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(filepath.Join(dir, "cfg")) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) + + got := pm.resolveModPath(models.ModEntry{ID: "456789"}) + if got != workshopDir { + t.Errorf("resolveModPath(ID=456789) = %q, want %q", got, workshopDir) + } +} + +func TestResolveModPath_EmptyEntry(t *testing.T) { + dir := t.TempDir() + modsDir := filepath.Join(dir, "mods") + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(filepath.Join(dir, "cfg")) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) + + got := pm.resolveModPath(models.ModEntry{}) + if got != "" { + t.Errorf("resolveModPath(empty) = %q, want empty", got) + } +} + +func TestBuildModPath_MultipleMods(t *testing.T) { + dir := t.TempDir() + modsDir := filepath.Join(dir, "mods") + os.MkdirAll(filepath.Join(modsDir, "@CBA_A3"), 0755) + os.MkdirAll(filepath.Join(modsDir, "@ACE3"), 0755) + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(filepath.Join(dir, "cfg")) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) + + ml, _ := mm.Create("Test List") + mm.Update(ml.ID, "Test List", []models.ModEntry{ + {Name: "CBA_A3", Enabled: true}, + {Name: "ACE3", Enabled: true}, + {Name: "DisabledMod", Enabled: false}, + }) + + got := pm.buildModPath(ml.ID) + if !strings.HasPrefix(got, "-mod=") { + t.Errorf("expected -mod= prefix, got %q", got) + } + if !strings.Contains(got, "CBA_A3") || !strings.Contains(got, "ACE3") { + t.Errorf("expected CBA_A3 and ACE3 in mod path, got %q", got) + } + // Disabled mod should not appear + if strings.Contains(got, "DisabledMod") { + t.Errorf("DisabledMod should not be in mod path: %q", got) + } + // Separator should be ; + if !strings.Contains(got, ";") { + t.Errorf("expected ; separator in mod path, got %q", got) + } +} + +func TestBuildModPath_EmptyModlist(t *testing.T) { + dir := t.TempDir() + modsDir := filepath.Join(dir, "mods") + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(filepath.Join(dir, "cfg")) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) + + got := pm.buildModPath("nonexistent-id") + if got != "" { + t.Errorf("buildModPath(nonexistent) = %q, want empty", got) + } +} + +func TestWriteUserconfigFiles(t *testing.T) { + dir := t.TempDir() + modsDir := filepath.Join(dir, "mods") + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(filepath.Join(dir, "cfg")) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) + + s := &models.ServerSettings{ + CBASettings: "force = 1", + AILevelPresets: "preset1", + DifficultyPresets: "difficulty_normal", + } + + if err := pm.WriteUserconfigFiles(s); err != nil { + t.Fatalf("WriteUserconfigFiles() error = %v", err) + } + + userconfigDir := filepath.Join(dir, "userconfig") + + tests := []struct { + filename string + content string + }{ + {"cba_settings.sqf", "force = 1"}, + {"CfgAILevelPresets.sqf", "preset1"}, + {"CfgDifficultyPresets.sqf", "difficulty_normal"}, + } + + for _, tt := range tests { + path := filepath.Join(userconfigDir, tt.filename) + data, err := os.ReadFile(path) + if err != nil { + t.Errorf("ReadFile(%s) error = %v", tt.filename, err) + continue + } + if string(data) != tt.content { + t.Errorf("%s content = %q, want %q", tt.filename, string(data), tt.content) + } + } +} + +func TestWriteUserconfigFiles_EmptyContent(t *testing.T) { + dir := t.TempDir() + modsDir := filepath.Join(dir, "mods") + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(filepath.Join(dir, "cfg")) + streamer := NewLogStreamer() + + pm := NewProcessManager(dir, modsDir, filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) + + s := &models.ServerSettings{} + + if err := pm.WriteUserconfigFiles(s); err != nil { + t.Fatalf("WriteUserconfigFiles() error = %v", err) + } + + // Files should still be created (even if empty) + for _, name := range []string{"cba_settings.sqf", "CfgAILevelPresets.sqf", "CfgDifficultyPresets.sqf"} { + path := filepath.Join(dir, "userconfig", name) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected %s to exist", name) + } + } +} + +func TestStartAndStopWithStub(t *testing.T) { + dir := t.TempDir() + serverfileDir := filepath.Join(dir, "server") + modsDir := filepath.Join(dir, "mods") + cfgDir := filepath.Join(dir, "cfg") + profilesDir := filepath.Join(dir, "profiles") + os.MkdirAll(serverfileDir, 0755) + + // Copy stub into serverfileDir so exec finds it + stubSrc, _ := os.ReadFile("testdata/arma3server_x64") + os.WriteFile(filepath.Join(serverfileDir, "arma3server_x64"), stubSrc, 0755) + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(cfgDir) + streamer := NewLogStreamer() + + pm := NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) + + s, _ := sm.Load() + s.ServerParameters = "-server -world=empty" + sm.Save(s) + + if err := pm.Start(); err != nil { + t.Fatalf("Start() error = %v", err) + } + + if !pm.IsRunning() { + t.Fatal("IsRunning() should be true after Start()") + } + + // Second Start should fail (already running) + if err := pm.Start(); err == nil { + t.Error("second Start() should fail") + } + + if err := pm.Stop(); err != nil { + t.Fatalf("Stop() error = %v", err) + } + + // Give goroutine time to update state + time.Sleep(100 * time.Millisecond) + + if pm.IsRunning() { + t.Error("IsRunning() should be false after Stop()") + } + + // Second Stop should fail + if err := pm.Stop(); err == nil { + t.Error("second Stop() should fail") + } +} + +func TestStartWithPercentCommand(t *testing.T) { + dir := t.TempDir() + serverfileDir := filepath.Join(dir, "server") + modsDir := filepath.Join(dir, "mods") + cfgDir := filepath.Join(dir, "cfg") + profilesDir := filepath.Join(dir, "profiles") + os.MkdirAll(serverfileDir, 0755) + + // Copy stub + stubSrc, _ := os.ReadFile("testdata/arma3server_x64") + stubPath := filepath.Join(serverfileDir, "arma3server_x64") + os.WriteFile(stubPath, stubSrc, 0755) + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(cfgDir) + streamer := NewLogStreamer() + + pm := NewProcessManager(serverfileDir, modsDir, cfgDir, profilesDir, sm, mm, cm, streamer) + + s, _ := sm.Load() + s.ServerParameters = "%command% -world=empty" + sm.Save(s) + + if err := pm.Start(); err != nil { + t.Fatalf("Start() with %%command%% error = %v", err) + } + + if !pm.IsRunning() { + t.Fatal("IsRunning() should be true") + } + + pm.Stop() + time.Sleep(100 * time.Millisecond) +} + +func TestStartWithEmptyCommand(t *testing.T) { + dir := t.TempDir() + serverfileDir := filepath.Join(dir, "server") + os.MkdirAll(serverfileDir, 0755) + + sm := NewSettingsManager(dir) + mm := NewModlistManager(filepath.Join(dir, "modlists")) + cm := NewConfigManager(filepath.Join(dir, "cfg")) + streamer := NewLogStreamer() + + pm := NewProcessManager(serverfileDir, filepath.Join(dir, "mods"), filepath.Join(dir, "cfg"), filepath.Join(dir, "profiles"), sm, mm, cm, streamer) + + s, _ := sm.Load() + s.ServerParameters = "" + sm.Save(s) + + // Empty params without %command% should still work (starts with just the binary) + // But if we use %command% with nothing after it, it should fail + s.ServerParameters = "%command%" + sm.Save(s) + + err := pm.Start() + if err == nil { + t.Error("Start() with percent-command-percent only (no other args) should fail") + pm.Stop() + } +} diff --git a/backend/internal/services/steamcmd_test.go b/backend/internal/services/steamcmd_test.go new file mode 100644 index 0000000..fd797de --- /dev/null +++ b/backend/internal/services/steamcmd_test.go @@ -0,0 +1,169 @@ +package services + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestCheckWorkshopMod(t *testing.T) { + dir := t.TempDir() + + // Existing workshop mod + modDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410", "123456") + os.MkdirAll(modDir, 0755) + + if !CheckWorkshopMod(dir, "123456") { + t.Error("CheckWorkshopMod should return true for existing mod") + } + + // Non-existing mod + if CheckWorkshopMod(dir, "999999") { + t.Error("CheckWorkshopMod should return false for non-existing mod") + } + + // Empty mod ID + if CheckWorkshopMod(dir, "") { + t.Error("CheckWorkshopMod should return false for empty mod ID") + } +} + +func TestCheckWorkshopMod_IsFile(t *testing.T) { + dir := t.TempDir() + + // Create a file instead of directory at the expected path + workshopDir := filepath.Join(dir, "steamapps", "workshop", "content", "107410") + os.MkdirAll(workshopDir, 0755) + os.WriteFile(filepath.Join(workshopDir, "111111"), []byte("not a dir"), 0644) + + if CheckWorkshopMod(dir, "111111") { + t.Error("CheckWorkshopMod should return false for file (not directory)") + } +} + +func TestSteamCmdManager_IsRunning(t *testing.T) { + dir := t.TempDir() + streamer := NewLogStreamer() + + sm := NewSteamCmdManager(dir, streamer) + + if sm.IsRunning() { + t.Error("IsRunning() should be false initially") + } +} + +func TestSteamCmdManager_DoubleStart(t *testing.T) { + dir := t.TempDir() + streamer := NewLogStreamer() + + sm := NewSteamCmdManager(dir, streamer) + + // First DownloadMods sets running=true. If the binary exists, the goroutine + // handles cleanup. If it doesn't, running is reset synchronously. + sm.DownloadMods([]string{"123456"}) + + // Second call while running should get "already running" + err2 := sm.DownloadMods([]string{"789012"}) + if err2 == nil || err2.Error() != "steamcmd already running" { + t.Logf("second DownloadMods: %v (may vary based on timing)", err2) + } + + waitForNotRunning(t, sm, 30*time.Second) +} + +func waitForNotRunning(t *testing.T, sm *SteamCmdManager, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !sm.IsRunning() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Errorf("IsRunning() still true after %v", timeout) +} + +func TestSteamCmdManager_StopResetsFlag(t *testing.T) { + dir := t.TempDir() + streamer := NewLogStreamer() + + sm := NewSteamCmdManager(dir, streamer) + + // After run completes (success or failure), running flag should be reset + sm.DownloadMods([]string{"123"}) + waitForNotRunning(t, sm, 30*time.Second) +} + +func TestSteamCmdManager_UpdateGameArgs(t *testing.T) { + dir := t.TempDir() + streamer := NewLogStreamer() + + sm := NewSteamCmdManager(dir, streamer) + + err := sm.UpdateGame("stable", "anonymous") + if err != nil { + t.Logf("UpdateGame returned (expected): %v", err) + } + + waitForNotRunning(t, sm, 30*time.Second) +} + +func TestSteamCmdManager_UpdateGameBeta(t *testing.T) { + dir := t.TempDir() + streamer := NewLogStreamer() + + sm := NewSteamCmdManager(dir, streamer) + + // Test with a beta branch + err := sm.UpdateGame("creatordlc", "anonymous") + if err != nil { + t.Logf("UpdateGame(creatordlc) returned (expected): %v", err) + } + + waitForNotRunning(t, sm, 30*time.Second) +} + +func TestSteamCmdManager_DownloadModEmpty(t *testing.T) { + dir := t.TempDir() + streamer := NewLogStreamer() + + sm := NewSteamCmdManager(dir, streamer) + + err := sm.DownloadMods([]string{}) + if err == nil { + t.Error("DownloadMods with empty list should return error") + } +} + +func TestSteamCmdManager_DownloadModSingle(t *testing.T) { + dir := t.TempDir() + streamer := NewLogStreamer() + + sm := NewSteamCmdManager(dir, streamer) + + err := sm.DownloadMod("123456") + // steamcmd may or may not exist; either way the call should complete + if err != nil { + t.Logf("DownloadMod returned (expected): %v", err) + } + + waitForNotRunning(t, sm, 30*time.Second) +} + +func TestNewSteamCmdManager(t *testing.T) { + dir := t.TempDir() + streamer := NewLogStreamer() + + sm := NewSteamCmdManager(dir, streamer) + + if sm.serverfileDir != dir { + t.Errorf("serverfileDir = %q, want %q", sm.serverfileDir, dir) + } + if sm.streamer != streamer { + t.Error("streamer should be set") + } + if sm.IsRunning() { + t.Error("new manager should not be running") + } +} diff --git a/backend/internal/services/testdata/arma3server_x64 b/backend/internal/services/testdata/arma3server_x64 new file mode 100755 index 0000000..5d02f03 --- /dev/null +++ b/backend/internal/services/testdata/arma3server_x64 @@ -0,0 +1,5 @@ +#!/bin/sh +# Stub arma3server binary for testing. +# Logs args to stdout then sleeps until killed (simulates a running server). +echo "[STUB] arma3server_x64 called with: $@" +sleep 3600 diff --git a/backend/internal/services/testdata/steamcmd b/backend/internal/services/testdata/steamcmd new file mode 100755 index 0000000..ad64b1a --- /dev/null +++ b/backend/internal/services/testdata/steamcmd @@ -0,0 +1,5 @@ +#!/bin/sh +# Stub steamcmd binary for testing. +# Logs args to stdout then exits immediately. +echo "[STUB] steamcmd called with: $@" +exit 0