361 lines
11 KiB
Go
361 lines
11 KiB
Go
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"debug/pe"
|
|
"image"
|
|
"image/color"
|
|
"image/png"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"aego/app/export"
|
|
"aego/app/export/winres"
|
|
"aego/app/projects"
|
|
"aego/asset"
|
|
"aego/core/ids"
|
|
"aego/project"
|
|
"aego/runtime/bundle"
|
|
"aego/runtime/player"
|
|
)
|
|
|
|
var assetRef = regexp.MustCompile(`asset\("([0-9a-f]{32})"\)`)
|
|
|
|
func exportEngine(t *testing.T) string {
|
|
t.Helper()
|
|
root, err := projects.EngineRoot("")
|
|
if err != nil {
|
|
t.Skip(err)
|
|
}
|
|
return root
|
|
}
|
|
|
|
func sdlBuilt(root, goos, arch string) bool {
|
|
dir := filepath.Join(root, ".cache", "native", goos+"_"+arch, "lib")
|
|
for _, n := range []string{"libSDL3.a", "SDL3.lib"} {
|
|
if _, err := os.Stat(filepath.Join(dir, n)); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func exportReady(t *testing.T, root string) {
|
|
t.Helper()
|
|
if testing.Short() {
|
|
t.Skip("export builds a binary")
|
|
}
|
|
if !sdlBuilt(root, runtime.GOOS, runtime.GOARCH) {
|
|
t.Skip("SDL is not bootstrapped, run make bootstrap")
|
|
}
|
|
}
|
|
|
|
func exportTemplate(t *testing.T, tmpl string) *project.Project {
|
|
t.Helper()
|
|
p, err := projects.Create(projects.Options{Dir: filepath.Join(t.TempDir(), "game"), Template: tmpl})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func hasIssue(rep *export.Report, code, sev string) bool {
|
|
for _, is := range rep.Issues {
|
|
if is.Code == code && is.Severity == sev {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func withIcon(t *testing.T, p *project.Project) {
|
|
t.Helper()
|
|
img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
|
|
for y := 0; y < 64; y++ {
|
|
for x := 0; x < 64; x++ {
|
|
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 4), G: uint8(y * 4), B: 160, A: 255})
|
|
}
|
|
}
|
|
f, err := os.Create(filepath.Join(p.Root, "icon.png"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := png.Encode(f, img); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.Close()
|
|
p.Icon = "icon.png"
|
|
p.GameVersion = "1.2.3"
|
|
if err := p.Save(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func dataOf(bin string) string {
|
|
if runtime.GOOS == "darwin" {
|
|
return filepath.Join(filepath.Dir(bin), "..", "Resources", bundle.DataDir)
|
|
}
|
|
return filepath.Join(filepath.Dir(bin), bundle.DataDir)
|
|
}
|
|
|
|
func cleanEnv(home string) []string {
|
|
if runtime.GOOS == "windows" {
|
|
sys := os.Getenv("SystemRoot")
|
|
return []string{"PATH=" + filepath.Join(sys, "System32"), "SystemRoot=" + sys,
|
|
"APPDATA=" + home, "LOCALAPPDATA=" + home, "USERPROFILE=" + home}
|
|
}
|
|
return []string{"PATH=/usr/bin:/bin", "HOME=" + home, "XDG_CONFIG_HOME=" + home}
|
|
}
|
|
|
|
func assertNoSources(t *testing.T, dir string) {
|
|
t.Helper()
|
|
bad := []string{".aes", ".aescene", ".aeprefab", ".aedata", ".aetable", ".meta", ".go"}
|
|
filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
if slices.Contains(bad, filepath.Ext(path)) || d.Name() == project.FileName {
|
|
t.Errorf("the export contains a source file: %s", path)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func resolvesIn(idx *asset.Index, id ids.AssetID) bool {
|
|
if _, ok := asset.LookupBuiltin(id); ok {
|
|
return true
|
|
}
|
|
e, ok := idx.Lookup(id)
|
|
return ok && !e.Missing
|
|
}
|
|
|
|
func assertRefsResolve(t *testing.T, data string) {
|
|
t.Helper()
|
|
d, _, err := player.Mount(data)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !resolvesIn(d.Index, d.Game.MainScene) {
|
|
t.Fatal("the start scene is not in the manifest")
|
|
}
|
|
d.Index.Each(func(e *asset.Entry) {
|
|
for _, dep := range e.Deps {
|
|
if !resolvesIn(d.Index, dep) {
|
|
t.Errorf("%s depends on %s, which is not exported", e.Source, dep)
|
|
}
|
|
}
|
|
if e.Kind != asset.ArtScene && e.Kind != asset.ArtPrefab {
|
|
return
|
|
}
|
|
a, ok := e.Artifact(e.Kind)
|
|
if !ok {
|
|
t.Errorf("%s has no artifact", e.Source)
|
|
return
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(data, filepath.FromSlash(strings.TrimPrefix(a.Path, bundle.Scheme+"://"))))
|
|
if err != nil {
|
|
t.Error(err)
|
|
return
|
|
}
|
|
for _, m := range assetRef.FindAllSubmatch(raw, -1) {
|
|
id, err := ids.ParseAssetID(string(m[1]))
|
|
if err != nil || !resolvesIn(d.Index, id) {
|
|
t.Errorf("%s references %s, which does not resolve", e.Source, m[1])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func runExported(t *testing.T, bin string, frames string) {
|
|
t.Helper()
|
|
home := t.TempDir()
|
|
cmd := exec.Command(bin, "--headless", "--frames", frames)
|
|
cmd.Dir = t.TempDir()
|
|
cmd.Env = cleanEnv(home)
|
|
out, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("the exported game failed: %v\n%s", err, out)
|
|
}
|
|
}
|
|
|
|
func TestExportPlatformerRunsWithoutSources(t *testing.T) {
|
|
root := exportEngine(t)
|
|
exportReady(t, root)
|
|
p, err := projects.Create(projects.Options{Dir: filepath.Join(t.TempDir(), "plat"), Example: "platformer", Engine: root})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withIcon(t, p)
|
|
dev := filepath.Join(t.TempDir(), "dev")
|
|
drep, err := export.Export(export.Options{Project: p, Engine: root, Profile: bundle.Development, Out: dev})
|
|
if err != nil {
|
|
t.Fatalf("%v\n%+v", err, drep.Issues)
|
|
}
|
|
rel := filepath.Join(t.TempDir(), "rel")
|
|
rrep, err := export.Export(export.Options{Project: p, Engine: root, Profile: bundle.Release, Out: rel})
|
|
if err != nil {
|
|
t.Fatalf("%v\n%+v", err, rrep.Issues)
|
|
}
|
|
if rrep.BinarySize > drep.BinarySize {
|
|
t.Errorf("release binary %d is larger than development %d", rrep.BinarySize, drep.BinarySize)
|
|
}
|
|
if _, err := bundle.DecodeExport(mustRead(t, filepath.Join(dev, bundle.ExportFile)), bundle.ExportFile); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, out := range []string{dev, rel} {
|
|
assertNoSources(t, out)
|
|
}
|
|
assertRefsResolve(t, dataOf(filepath.Join(dev, filepath.FromSlash(drep.Binary))))
|
|
moved := filepath.Join(t.TempDir(), "moved")
|
|
if err := os.Rename(dev, moved); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.RemoveAll(p.Root); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
bin := filepath.Join(moved, filepath.FromSlash(drep.Binary))
|
|
runExported(t, bin, "120")
|
|
runExported(t, filepath.Join(rel, filepath.FromSlash(rrep.Binary)), "120")
|
|
if entries, _ := os.ReadDir(filepath.Join(moved, "profiles")); len(entries) > 0 {
|
|
t.Error("player settings were written into the game directory")
|
|
}
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
types, err := winres.Types(mustRead(t, bin))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !slices.Contains(types, 14) || !slices.Contains(types, 16) {
|
|
t.Errorf("the exe has resource types %v, want an icon group and version info", types)
|
|
}
|
|
f, err := pe.NewFile(bytes.NewReader(mustRead(t, bin)))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
libs, err := f.ImportedLibraries()
|
|
f.Close()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, l := range libs {
|
|
low := strings.ToLower(l)
|
|
if strings.HasPrefix(low, "libgcc") || strings.HasPrefix(low, "libwinpthread") || strings.HasPrefix(low, "libstdc++") {
|
|
t.Errorf("the exe needs %s, which a clean Windows does not have", l)
|
|
}
|
|
}
|
|
case "darwin":
|
|
app := filepath.Join(filepath.Dir(bin), "..", "..")
|
|
if pl, err := exec.LookPath("plutil"); err == nil {
|
|
if out, err := exec.Command(pl, "-lint", filepath.Join(app, "Contents", "Info.plist")).CombinedOutput(); err != nil {
|
|
t.Errorf("Info.plist: %s", out)
|
|
}
|
|
}
|
|
if cs, err := exec.LookPath("codesign"); err == nil {
|
|
if out, err := exec.Command(cs, "--verify", "--deep", app).CombinedOutput(); err != nil {
|
|
t.Errorf("codesign: %s", out)
|
|
}
|
|
}
|
|
case "linux":
|
|
if _, err := os.Stat(filepath.Join(moved, "icon.png")); err != nil {
|
|
t.Error("the linux export has no icon.png")
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustRead(t *testing.T, path string) []byte {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return data
|
|
}
|
|
|
|
func TestExportBlocksBrokenScripts(t *testing.T) {
|
|
p := exportTemplate(t, projects.Template2D)
|
|
dir := filepath.Join(p.ScriptsDir(), "game")
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
src := "package game\n\nscript Broken {\n\tfunc Ready() {\n\t\tmissing()\n\t}\n}\n"
|
|
if err := os.WriteFile(filepath.Join(dir, "broken.aes"), []byte(src), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rep, err := export.Export(export.Options{Project: p, Profile: bundle.Release, CheckOnly: true})
|
|
if err == nil || rep == nil || !rep.Blocked || !hasIssue(rep, export.CodeScripts, "error") {
|
|
t.Fatalf("a broken script did not block the export: %v %+v", err, rep)
|
|
}
|
|
}
|
|
|
|
func TestExportMissingReferenceDependsOnProfile(t *testing.T) {
|
|
p := exportTemplate(t, projects.Template2D)
|
|
path := filepath.Join(p.ScenesDir(), "main.aescene")
|
|
data := mustRead(t, path)
|
|
ghost := ids.NewAssetID().String()
|
|
next := strings.Replace(string(data), asset.BuiltinWhite.String(), ghost, 1)
|
|
if next == string(data) {
|
|
t.Skip("the 2d template references no builtin texture")
|
|
}
|
|
if err := os.WriteFile(path, []byte(next), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rel, err := export.Export(export.Options{Project: p, Profile: bundle.Release, CheckOnly: true})
|
|
if err == nil || !rel.Blocked || !hasIssue(rel, export.CodeMissing, "error") {
|
|
t.Fatalf("release did not block a missing reference: %+v", rel.Issues)
|
|
}
|
|
dev, err := export.Export(export.Options{Project: p, Profile: bundle.Development, CheckOnly: true})
|
|
if err != nil || dev.Blocked || !hasIssue(dev, export.CodeMissing, "warning") {
|
|
t.Fatalf("development should only warn: %v %+v", err, dev.Issues)
|
|
}
|
|
}
|
|
|
|
func TestExportCrossWithoutToolchain(t *testing.T) {
|
|
root := exportEngine(t)
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("host is windows")
|
|
}
|
|
t.Setenv("CC_WINDOWS_AMD64", "")
|
|
p := exportTemplate(t, projects.Template2D)
|
|
_, err := export.Export(export.Options{Project: p, Engine: root, Target: "windows/amd64", Profile: bundle.Release, Out: filepath.Join(t.TempDir(), "w")})
|
|
if err == nil || !strings.Contains(err.Error(), "C compiler") {
|
|
t.Fatalf("want a toolchain error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestExportWindowsUnderWine(t *testing.T) {
|
|
root := exportEngine(t)
|
|
if testing.Short() || runtime.GOOS != "linux" {
|
|
t.Skip("linux host only")
|
|
}
|
|
if os.Getenv("CC_WINDOWS_AMD64") == "" || !sdlBuilt(root, "windows", "amd64") {
|
|
t.Skip("set CC_WINDOWS_AMD64 and run GOOS=windows make bootstrap")
|
|
}
|
|
p := exportTemplate(t, projects.Template2D)
|
|
withIcon(t, p)
|
|
out := filepath.Join(t.TempDir(), "win")
|
|
rep, err := export.Export(export.Options{Project: p, Engine: root, Target: "windows/amd64", Profile: bundle.Release, Out: out})
|
|
if err != nil {
|
|
t.Fatalf("%v\n%+v", err, rep.Issues)
|
|
}
|
|
bin := filepath.Join(out, filepath.FromSlash(rep.Binary))
|
|
types, err := winres.Types(mustRead(t, bin))
|
|
if err != nil || !slices.Contains(types, 14) {
|
|
t.Fatalf("resource types %v, %v", types, err)
|
|
}
|
|
wine, err := exec.LookPath("wine")
|
|
if err != nil {
|
|
t.Skip("wine is not installed, the build was checked")
|
|
}
|
|
cmd := exec.Command(wine, bin, "--headless", "--frames", "60")
|
|
cmd.Env = append(os.Environ(), "WINEDEBUG=-all")
|
|
if b, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("wine: %v\n%s", err, b)
|
|
}
|
|
}
|