Keep model compilation headless (#49)
build-binaries / build-binaries (push) Successful in 2m14s
build-binaries / build-binaries (push) Successful in 2m14s
## Summary - always wrap NWN model compilation with `xvfb-run`, even when the caller has `DISPLAY` - fail closed with an actionable error when `xvfb-run` is unavailable instead of opening the client UI - update the compiler contract and regression coverage ## Verification - focused red/green regression tests - `nix develop -c make check` - real NWN compile with `DISPLAY=:0` under transient Xvfb; binary MDL output verified Generated with Claude CodeReviewed-on: #49 Co-authored-by: vickydotbat <vickydotbat@tutamail.com>
This commit was merged in pull request #49.
This commit is contained in:
@@ -126,8 +126,9 @@ exactly as the reference does:
|
|||||||
- Beamdog install dirs.
|
- Beamdog install dirs.
|
||||||
- `--nwn <install>` overrides (path to the install root or directly to the
|
- `--nwn <install>` overrides (path to the install root or directly to the
|
||||||
binary).
|
binary).
|
||||||
- **Headless.** The engine is a GUI binary. If there is no `DISPLAY` and
|
- **Headless.** The engine is a GUI binary. Require `xvfb-run` and wrap the call
|
||||||
`xvfb-run` is present, wrap the call:
|
regardless of the caller's `DISPLAY` so compilation never opens the client
|
||||||
|
UI; fail before invoking the engine when `xvfb-run` is unavailable:
|
||||||
`xvfb-run -a --server-args=-screen 0 1024x768x24 nwmain-linux compilemodel <stem>`.
|
`xvfb-run -a --server-args=-screen 0 1024x768x24 nwmain-linux compilemodel <stem>`.
|
||||||
- **Per model (one at a time — the engine's `development/` and `modelcompiler/`
|
- **Per model (one at a time — the engine's `development/` and `modelcompiler/`
|
||||||
folders are flat and single-slot):**
|
folders are flat and single-slot):**
|
||||||
|
|||||||
@@ -40,13 +40,13 @@ func runCompile(args []string, stdout, stderr io.Writer, getenv func(string) str
|
|||||||
}
|
}
|
||||||
binDir := filepath.Dir(nwmain)
|
binDir := filepath.Dir(nwmain)
|
||||||
|
|
||||||
// Headless wrap: no DISPLAY + xvfb-run present -> run under a virtual X.
|
// Always use a virtual X so compilation never opens the client UI.
|
||||||
var wrap []string
|
xvfb := look("xvfb-run")
|
||||||
if getenv("DISPLAY") == "" {
|
if xvfb == "" {
|
||||||
if xvfb := look("xvfb-run"); xvfb != "" {
|
fmt.Fprintln(stderr, "assets compile: xvfb-run not found — install it to run the NWN model compiler headlessly")
|
||||||
wrap = []string{xvfb, "-a", "--server-args=-screen 0 1024x768x24"}
|
return exitTool
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
wrap := []string{xvfb, "-a", "--server-args=-screen 0 1024x768x24"}
|
||||||
|
|
||||||
files, err := walk(dirs, mdlExt, !*nonRecursive)
|
files, err := walk(dirs, mdlExt, !*nonRecursive)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,13 +24,19 @@ func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
|
|||||||
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
xvfbDir := t.TempDir()
|
||||||
|
xvfb := filepath.Join(xvfbDir, "xvfb-run")
|
||||||
|
if err := os.WriteFile(xvfb, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", xvfbDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
|
|
||||||
getenv := func(k string) string {
|
getenv := func(k string) string {
|
||||||
switch k {
|
switch k {
|
||||||
case "HOME":
|
case "HOME":
|
||||||
return home
|
return home
|
||||||
case "DISPLAY":
|
case "DISPLAY":
|
||||||
return ":0" // pretend a display exists so no xvfb wrap is needed
|
return ":0" // a desktop display must not make compilation interactive
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -38,7 +45,11 @@ func TestCompileDrivesEngineAndReplacesInPlace(t *testing.T) {
|
|||||||
orig := runner
|
orig := runner
|
||||||
defer func() { runner = orig }()
|
defer func() { runner = orig }()
|
||||||
runner = func(dir string, env []string, name string, args ...string) ([]byte, error) {
|
runner = func(dir string, env []string, name string, args ...string) ([]byte, error) {
|
||||||
// args: compilemodel <stem>
|
if name != xvfb || len(args) != 5 || args[0] != "-a" ||
|
||||||
|
args[1] != "--server-args=-screen 0 1024x768x24" || args[2] != nwmain ||
|
||||||
|
args[3] != "compilemodel" {
|
||||||
|
t.Fatalf("engine command = %q %q, want xvfb-run wrapping nwmain", name, args)
|
||||||
|
}
|
||||||
stem := args[len(args)-1]
|
stem := args[len(args)-1]
|
||||||
compiled := filepath.Join(mc, stem+".mdl")
|
compiled := filepath.Join(mc, stem+".mdl")
|
||||||
return nil, os.WriteFile(compiled, []byte("\x00\x00compiled"), 0o644)
|
return nil, os.WriteFile(compiled, []byte("\x00\x00compiled"), 0o644)
|
||||||
@@ -77,6 +88,11 @@ func TestCompileAbortsOnNameMismatch(t *testing.T) {
|
|||||||
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
xvfbDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(xvfbDir, "xvfb-run"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", xvfbDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||||
getenv := func(k string) string {
|
getenv := func(k string) string {
|
||||||
if k == "HOME" {
|
if k == "HOME" {
|
||||||
return home
|
return home
|
||||||
@@ -108,3 +124,48 @@ func TestCompileAbortsOnNameMismatch(t *testing.T) {
|
|||||||
t.Fatal("engine should not run for a name-mismatched model")
|
t.Fatal("engine should not run for a name-mismatched model")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCompileFailsClosedWithoutXvfb(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
userData := filepath.Join(home, ".local", "share", "Neverwinter Nights")
|
||||||
|
for _, d := range []string{filepath.Join(userData, "development"), filepath.Join(userData, "modelcompiler")} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nwmain := filepath.Join(t.TempDir(), "nwmain-linux")
|
||||||
|
if err := os.WriteFile(nwmain, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("PATH", t.TempDir())
|
||||||
|
|
||||||
|
getenv := func(k string) string {
|
||||||
|
if k == "HOME" {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
orig := runner
|
||||||
|
defer func() { runner = orig }()
|
||||||
|
engineCalled := false
|
||||||
|
runner = func(string, []string, string, ...string) ([]byte, error) {
|
||||||
|
engineCalled = true
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(srcDir, "foo.mdl"),
|
||||||
|
[]byte("newmodel foo\nbeginmodelgeom foo\n node dummy foo\n parent null\n endnode\nendmodelgeom foo\ndonemodel foo\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
if code := runCompile([]string{"--nwn", nwmain, srcDir}, &stdout, &stderr, getenv); code != exitTool {
|
||||||
|
t.Fatalf("compile exit = %d, want %d\n%s", code, exitTool, stderr.String())
|
||||||
|
}
|
||||||
|
if engineCalled {
|
||||||
|
t.Fatal("engine must not run without xvfb-run")
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "xvfb-run") {
|
||||||
|
t.Fatalf("missing actionable xvfb-run error: %s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user