Files
aego/app/edit/remote_launcher_gl_test.go

367 lines
10 KiB
Go

//go:build aego_gltest && linux && cgo && !aego_nonative
package edit
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"image"
"image/png"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"sync/atomic"
"testing"
"time"
"aego/core/log"
"aego/editor/shell/launcher"
"aego/engine"
"aego/platform/sdl"
"aego/project"
"aego/project/remoteexample"
)
type trackedLauncherRemote struct {
host launcherRemote
calls chan string
}
// This uses a real TLS server and the actual launcher/store/HTTP stack. Only
// the test transport accepts loopback; the production dialer rejects it.
func TestRemoteLauncherClonesOverLocalTLS(t *testing.T) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
p, err := project.Create(t.TempDir(), "TLS example", false)
if err != nil {
t.Fatal(err)
}
projectBytes, err := os.ReadFile(p.FilePath())
if err != nil {
t.Fatal(err)
}
files := map[string][]byte{
"project.aego": projectBytes,
"scenes/demo.aescene": []byte("scene version=1 {}\n"),
}
var packed bytes.Buffer
zw := zip.NewWriter(&packed)
manifestFiles := make([]remoteexample.File, 0, len(files))
for _, name := range []string{"project.aego", "scenes/demo.aescene"} {
data := files[name]
w, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write(data); err != nil {
t.Fatal(err)
}
hash := sha256.Sum256(data)
manifestFiles = append(manifestFiles, remoteexample.File{Path: name, Size: int64(len(data)), SHA256: hex.EncodeToString(hash[:])})
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
var manifest []byte
var manifestRequests, archiveRequests atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/example.json":
manifestRequests.Add(1)
_, _ = w.Write(manifest)
case "/example.zip":
archiveRequests.Add(1)
_, _ = w.Write(packed.Bytes())
default:
http.NotFound(w, r)
}
}))
defer server.Close()
source := server.URL + "/example.json"
hash := sha256.Sum256(packed.Bytes())
manifest, err = json.Marshal(remoteexample.Manifest{
Format: 1, ExampleID: "tls-example", Version: "1.0.0", Origin: server.URL,
License: "MIT", Engine: remoteexample.Compatibility{Min: "0.12.1", MaxExclusive: "0.13.0"},
EntryScene: "scenes/demo.aescene", Files: manifestFiles,
Archive: remoteexample.Archive{URL: server.URL + "/example.zip", Size: int64(packed.Len()), SHA256: hex.EncodeToString(hash[:])},
})
if err != nil {
t.Fatal(err)
}
host := launcherRemote{root: filepath.Join(t.TempDir(), "store"), clientFactory: func() (*remoteexample.Client, error) {
return remoteexample.GLTestLoopbackClient(remoteexample.Policy{EngineVersion: engine.Version}, server)
}}
ls, err := OpenLauncher(Config{Hidden: true, Recent: filepath.Join(t.TempDir(), "recent"), Logger: log.Nop()})
if err != nil {
t.Fatal(err)
}
closed := false
defer func() {
if !closed {
ls.Close()
}
}()
ls.UI = launcher.New(launcher.Options{Remote: host})
ls.UI.SetTab(launcher.TabExamples)
for range 3 {
if !ls.Step() {
t.Fatal("launcher stopped")
}
}
click := func(x, y float32) {
t.Helper()
for i := range 3 {
if !sdl.TestPointerEvent(x, y, i) || !ls.Step() {
t.Fatal("launcher pointer event failed")
}
}
}
click(300, 199)
for remaining := source; len(remaining) > 0; {
n := min(20, len(remaining))
if !sdl.TestTextInput(remaining[:n]) || !ls.Step() {
t.Fatal("launcher URL input failed")
}
remaining = remaining[n:]
}
click(400, 223)
if manifestRequests.Load() != 0 || archiveRequests.Load() != 0 {
t.Fatal("network request was sent before origin confirmation")
}
click(400, 271)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if _, ok := ls.UI.Result(); ok {
break
}
if !ls.Step() {
break
}
runtime.Gosched()
time.Sleep(time.Millisecond)
}
root, ok := ls.UI.Result()
if !ok {
t.Fatalf("TLS clone did not finish (manifest=%d archive=%d)", manifestRequests.Load(), archiveRequests.Load())
}
entry, remote := ls.UI.ResultEntry()
if !remote || entry != "scenes/demo.aescene" || manifestRequests.Load() != 1 || archiveRequests.Load() != 1 {
t.Fatalf("wrong clone result: root=%q entry=%q remote=%v manifest=%d archive=%d", root, entry, remote, manifestRequests.Load(), archiveRequests.Load())
}
entryFile := filepath.Join(root, filepath.FromSlash(entry))
if data, err := os.ReadFile(entryFile); err != nil || !bytes.Equal(data, files[entry]) {
t.Fatalf("verified entry scene differs from archive: %q %v", data, err)
}
ls.Close()
closed = true
clonedProject, err := project.Load(root)
if err != nil {
t.Fatal(err)
}
editor, err := Open(clonedProject, Config{Hidden: true, NoWatch: true, OpenPath: entry, Logger: log.Nop(), RemoteProject: remote})
if err != nil {
t.Fatal(err)
}
for range 3 {
if !editor.Step() {
editor.Close()
t.Fatal("editor stopped after TLS clone")
}
}
if doc := editor.App.Context().Docs.Active(); doc == nil || doc.Path != entry || editor.App.Play().Scene() != nil {
editor.Close()
t.Fatalf("TLS clone did not open its entry in the editor without Play: %+v", doc)
}
editor.Close()
if err := os.WriteFile(entryFile, []byte("scene version=1 { } // locally edited\n"), 0o600); err != nil {
t.Fatal(err)
}
reopened, err := host.Open(context.Background(), source, server.URL, false)
if err != nil || reopened.Directory != root {
t.Fatalf("offline reopen failed: %+v %v", reopened, err)
}
if data, err := os.ReadFile(entryFile); err != nil || string(data) != "scene version=1 { } // locally edited\n" || manifestRequests.Load() != 1 || archiveRequests.Load() != 1 {
t.Fatalf("offline reopen changed local edits or fetched again: %q %v", data, err)
}
}
func (r trackedLauncherRemote) List(ctx context.Context) ([]launcher.RemoteCopy, error) {
r.calls <- "list"
return r.host.List(ctx)
}
func (r trackedLauncherRemote) Open(ctx context.Context, url, origin string, clone bool) (launcher.RemoteCopy, error) {
r.calls <- "open"
return r.host.Open(ctx, url, origin, clone)
}
func (r trackedLauncherRemote) Reopen(ctx context.Context, id string) (launcher.RemoteCopy, error) {
r.calls <- "reopen"
return r.host.Reopen(ctx, id)
}
func (r trackedLauncherRemote) Check(ctx context.Context, id, origin string) (launcher.RemoteUpdate, error) {
r.calls <- "check"
return r.host.Check(ctx, id, origin)
}
func TestRemoteLauncherNativeConfirmation(t *testing.T) {
testRemoteLauncherNativeConfirmation(t, false)
}
func TestRemoteProtocolLauncherNativeConfirmation(t *testing.T) {
testRemoteLauncherNativeConfirmation(t, true)
}
func testRemoteLauncherNativeConfirmation(t *testing.T, protocol bool) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
host, _, source, entryFile := remoteFixture(t)
tracked := trackedLauncherRemote{host: host, calls: make(chan string, 4)}
ls, err := OpenLauncher(Config{Hidden: true, Recent: filepath.Join(t.TempDir(), "recent")})
if err != nil {
t.Fatal(err)
}
closed := false
defer func() {
if !closed {
ls.Close()
}
}()
ls.UI = launcher.New(launcher.Options{Remote: tracked})
ls.UI.SetTab(launcher.TabExamples)
if protocol {
link, err := remoteexample.MakeLink(source, "scene")
if err != nil {
t.Fatal(err)
}
if err := ls.UI.RequestExampleLink(link); err != nil {
t.Fatal(err)
}
}
for range 3 {
if !ls.Step() {
t.Fatal("launcher stopped")
}
}
capture := func(name string) {
t.Helper()
dir := os.Getenv("AEGO_REMOTE_LAUNCH_SHOTS")
if dir == "" {
return
}
if protocol {
name = "protocol-" + name
}
var shot *image.RGBA
var shotErr error
ls.Engine.CaptureFrame(func(img *image.RGBA, err error) { shot, shotErr = img, err })
ls.Step()
if shotErr != nil || shot == nil {
t.Fatalf("capture launcher: %v", shotErr)
}
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(filepath.Join(dir, name+".png"))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, shot); err != nil {
f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
}
capture("remote-launcher")
select {
case call := <-tracked.calls:
t.Fatalf("opening the tab accessed the remote service: %s", call)
default:
}
click := func(x, y float32) {
t.Helper()
for i := range 3 {
if !sdl.TestPointerEvent(x, y, i) || !ls.Step() {
t.Fatal("launcher pointer event failed")
}
}
}
if !protocol {
click(300, 199)
for len(source) > 0 {
n := min(20, len(source))
if !sdl.TestTextInput(source[:n]) || !ls.Step() {
t.Fatal("launcher text input failed")
}
source = source[n:]
}
click(400, 223)
}
select {
case call := <-tracked.calls:
t.Fatalf("service was called before the origin confirmation: %s", call)
default:
}
capture("remote-confirmation")
if protocol {
click(400, 247)
} else {
click(400, 271)
}
select {
case call := <-tracked.calls:
if call != "open" {
t.Fatalf("unexpected remote operation %s", call)
}
case <-time.After(5 * time.Second):
t.Fatal("confirming the origin did not start the remote operation")
}
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if _, ok := ls.UI.Result(); ok {
break
}
if !ls.Step() {
break
}
runtime.Gosched()
time.Sleep(time.Millisecond)
}
root, ok := ls.UI.Result()
if !ok || root != filepath.Dir(filepath.Dir(entryFile)) {
t.Fatalf("confirmed offline copy did not open: %q", root)
}
entry, remote := ls.UI.ResultEntry()
if !remote || entry != "scenes/demo.aescene" {
t.Fatalf("manifest entry scene was not selected: %q, remote=%v", entry, remote)
}
ls.Close()
closed = true
p, err := project.Load(root)
if err != nil {
t.Fatal(err)
}
editor, err := Open(p, Config{Hidden: true, NoWatch: true, OpenPath: entry, Logger: log.Nop(), RemoteProject: remote})
if err != nil {
t.Fatal(err)
}
defer editor.Close()
for range 3 {
if !editor.Step() {
t.Fatal("editor stopped after opening remote copy")
}
}
if doc := editor.App.Context().Docs.Active(); doc == nil || doc.Path != entry {
t.Fatalf("editor did not open the remote entry scene: %+v", doc)
}
if editor.App.Play().Scene() != nil {
t.Fatal("opening a remote example started the game")
}
}