Files
aego/app/docsview/component_browser_gl_test.go

337 lines
11 KiB
Go

//go:build aego_editor && aego_gltest && linux && cgo && !aego_nonative
package docsview
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
"aego/app/playground"
"aego/engine/docs/democheck"
)
type browserProcess struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout io.ReadCloser
encode *json.Encoder
decode *json.Decoder
next int
}
func startBrowserProcess(ctx context.Context, site, script string) (*browserProcess, error) {
cmd := exec.CommandContext(ctx, "node", script, "--site="+site)
cmd.Stderr = os.Stderr
in, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
out, err := cmd.StdoutPipe()
if err != nil {
in.Close()
return nil, err
}
if err := cmd.Start(); err != nil {
in.Close()
return nil, err
}
return &browserProcess{cmd: cmd, stdin: in, stdout: out, encode: json.NewEncoder(in), decode: json.NewDecoder(out)}, nil
}
func (p *browserProcess) call(ctx context.Context, method string, values map[string]any, result any) error {
// A single language may need up to 120 seconds to boot; an open request
// checks both languages. A wedged subprocess must not hang the entire gate.
timeout := 5 * time.Minute
if method == "close" {
timeout = 30 * time.Second
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := ctx.Err(); err != nil {
return err
}
finished := make(chan struct{})
stop := context.AfterFunc(ctx, func() {
// Close both pipes as well as the process: descendants could otherwise
// keep an inherited stdout open and leave Decode blocked indefinitely.
_ = p.cmd.Process.Kill()
_ = p.stdin.Close()
_ = p.stdout.Close()
close(finished)
})
defer func() {
if !stop() {
<-finished
}
}()
if values == nil {
values = map[string]any{}
}
p.next++
values["id"], values["method"] = p.next, method
if err := p.encode.Encode(values); err != nil {
if ctx.Err() != nil {
return fmt.Errorf("browser %s request: %w", method, ctx.Err())
}
return fmt.Errorf("browser %s request: %w", method, err)
}
var reply struct {
ID int `json:"id"`
Error string `json:"error"`
Result json.RawMessage `json:"result"`
}
if err := p.decode.Decode(&reply); err != nil {
if ctx.Err() != nil {
return fmt.Errorf("browser %s reply: %w", method, ctx.Err())
}
return fmt.Errorf("browser %s reply: %w", method, err)
}
if err := ctx.Err(); err != nil {
return fmt.Errorf("browser %s reply: %w", method, err)
}
if reply.ID != p.next {
return fmt.Errorf("browser protocol response ID mismatch: %d != %d", reply.ID, p.next)
}
if reply.Error != "" {
return fmt.Errorf("browser %s: %s", method, reply.Error)
}
if result != nil {
return json.Unmarshal(reply.Result, result)
}
return nil
}
func (p *browserProcess) close() error {
err := p.stdin.Close()
return errors.Join(err, p.cmd.Wait())
}
type browserTryRunner struct {
p *playground.Playground
process *browserProcess
}
func (r *browserTryRunner) payload(c democheck.Contract) (map[string]any, error) {
e, ok := r.p.Docs().Get(c.ComponentID)
if !ok || e.Try == "" || e.TrySelect != c.ComponentID {
return nil, fmt.Errorf("component Try is missing or selects a different component")
}
matched := false
for _, component := range r.p.Docs().ComponentDemos() {
if component.ID == c.ComponentID && component.Identity == c.Identity {
matched = true
break
}
}
if !matched {
return nil, fmt.Errorf("Try payload identity mismatch")
}
code := ""
var err error
if e.TryScript != "" {
code, err = r.p.TryCode(e.TryScript)
if err != nil {
return nil, err
}
}
return map[string]any{"scene": e.Try, "select": e.TrySelect, "code": code, "prefabs": e.TryPrefabs, "artifacts": e.TryArtifacts}, nil
}
func (r *browserTryRunner) Open(ctx context.Context, c democheck.Contract, env string) (democheck.Instance, error) {
if (env != "chromium" && env != "firefox") || c.Environments[env].Mode != "runtime" {
return nil, fmt.Errorf("%s runner unavailable: NOT RUN", env)
}
payload, err := r.payload(c)
if err != nil {
return nil, err
}
if err := r.process.call(ctx, "open", map[string]any{"contract": c, "payload": payload, "environment": env}, nil); err != nil {
return nil, err
}
return &browserTryInstance{process: r.process, identity: c.Identity}, nil
}
type browserTryInstance struct {
process *browserProcess
identity string
}
func (p *browserTryInstance) Observe(ctx context.Context) (democheck.Observation, error) {
var observation democheck.Observation
if err := p.process.call(ctx, "observe", nil, &observation); err != nil {
return observation, err
}
// Open verified the actual payload on both localized pages against the exact
// registry entry before the runtime loaded it. Never trust an HTML label.
observation.Identity = p.identity
return observation, nil
}
func (p *browserTryInstance) Action(ctx context.Context, action democheck.Action) error {
return p.process.call(ctx, "action", map[string]any{"action": action}, nil)
}
func (p *browserTryInstance) Reset(ctx context.Context, action string) error {
return p.process.call(ctx, "reset", map[string]any{"action": action}, nil)
}
func (p *browserTryInstance) Close() error {
return p.process.call(context.Background(), "close", nil, nil)
}
type combinedTryRunner struct {
native *nativeTryRunner
browser *browserTryRunner
}
func (r combinedTryRunner) Open(ctx context.Context, c democheck.Contract, env string) (democheck.Instance, error) {
if env == "native" {
return r.native.Open(ctx, c, env)
}
return r.browser.Open(ctx, c, env)
}
// AEGO_TRY_SITE must name a freshly built docs site. Without it this test does
// not run and does not claim coverage. When provided, the entire public
// registry must pass all declared environments, not just a selected subset.
func TestComponentContractsAcrossNativeAndChromium(t *testing.T) {
initializeTryEvidence(t)
site := os.Getenv("AEGO_TRY_SITE")
if site == "" {
if os.Getenv("AEGO_DEMOCHECK_ENFORCE") == "1" {
t.Fatal("AEGO_TRY_SITE is required for the enforced native+browser component gate")
}
t.Skip("AEGO_TRY_SITE is unset: native+browser component gate NOT RUN")
}
site, err := filepath.Abs(site)
if err != nil {
t.Fatal(err)
}
script := os.Getenv("AEGO_TRY_BROWSER_RUNNER")
if script == "" {
script = filepath.Join("..", "..", "tools", "websmoke", "try-contract.mjs")
}
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Minute)
defer cancel()
process, err := startBrowserProcess(ctx, site, script)
if err != nil {
t.Fatal(err)
}
defer func() {
if err := process.close(); err != nil {
t.Errorf("browser runner shutdown: %v", err)
}
}()
runtime.LockOSThread()
defer runtime.UnlockOSThread()
p, err := playground.New()
if err != nil {
t.Fatal(err)
}
writeComponentRegistryEvidence(t, p, site)
browser := &browserTryRunner{p: p, process: process}
runner := combinedTryRunner{native: &nativeTryRunner{p: p}, browser: browser}
report := p.Docs().CheckComponentDemos(ctx, runner)
var versions map[string]any
if err := process.call(ctx, "versions", nil, &versions); err != nil {
t.Errorf("runtime versions: %v", err)
}
if err := writeTryEvidence("versions.json", versions); err != nil {
t.Errorf("version evidence: %v", err)
}
if name := os.Getenv("AEGO_DEMOCHECK_REPORT"); name != "" {
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, append(data, '\n'), 0o644); err != nil {
t.Fatal(err)
}
}
verified := map[string]bool{}
for _, result := range report.Results {
if len(result.Errors) == 0 && slices.Contains(result.Environments, "native") && slices.Contains(result.Environments, "chromium") && slices.Contains(result.Environments, "firefox") {
verified[result.ComponentID] = true
}
if result.DemoID != "" {
t.Logf("%s environments=%v errors=%v", result.ComponentID, result.Environments, result.Errors)
}
}
for _, id := range []string{"animation.AnimationPlayer", "animation.Animator", "audio.AudioListener2D", "audio.AudioSource2D", "audio.AudioZone2D", "camera.CameraBrain2D", "camera.CameraFollow2D", "camera.CameraShake2D", "camera.CameraZone2D", "camera.VirtualCamera2D", "engine.Camera2D", "engine.Camera3D", "engine.Light2D", "engine.LightingEnvironment2D", "engine.LightOccluder2D", "engine.Mesh2D", "engine.MeshRenderer", "engine.SpriteRenderer", "gameplay.Lifetime", "gameplay.StateMachine", "gameplay.Motion2D", "gameplay.Hitbox2D", "gameplay.Hurtbox2D", "gameplay.KillZone2D", "gameplay.Pickup2D", "gameplay.Checkpoint2D", "gameplay.Respawn", "particles.ParticleSystem2D", "physics.Body2D", "physics.BoxCollider2D", "physics.CircleCollider2D", "physics.CapsuleCollider2D", "physics.PolygonCollider2D", "physics.EdgeCollider2D", "physics.RevoluteJoint2D", "physics.DistanceJoint2D", "render2d.PostProcess", "save.Persist"} {
if !verified[id] {
t.Errorf("%s did not pass native, Chromium and Firefox", id)
}
}
for _, component := range p.Docs().ComponentDemos() {
if !verified[component.ID] {
t.Errorf("%s did not pass native, Chromium and Firefox", component.ID)
}
}
if !verified["engine.Tags"] {
t.Error("engine.Tags did not pass native and Chromium")
}
if !verified["engine.Script"] {
t.Error("engine.Script did not pass native and Chromium")
}
if !verified["engine.MaterialOverride"] {
t.Error("engine.MaterialOverride did not pass native and Chromium")
}
if !verified["engine.PrefabInstance"] {
t.Error("engine.PrefabInstance did not pass native and Chromium")
}
if t.Failed() {
t.Fatalf("full component registry did not pass native, Chromium and Firefox: %v", verified)
}
t.Logf("actual component Try coverage: %d/%d", report.VerifiedComponents, report.PublicComponents)
entry, _ := p.Docs().Get("engine.Mesh2D")
var component democheck.Component
for _, c := range p.Docs().ComponentDemos() {
if c.ID == entry.ID {
component = c
}
}
wrong := *entry.Demo
wrong.DemoID += ".negative-observable"
wrong.Actions = slices.Clone(wrong.Actions)
wrong.Actions[0].Expected = slices.Clone(wrong.Actions[0].Expected)
wrong.Actions[0].Expected[0].Equals = json.RawMessage("999")
bad := democheck.Check(ctx, []democheck.Component{component}, []democheck.Contract{wrong}, runner)
if err := saveNegativeTryReport(bad); err != nil {
t.Fatalf("negative control evidence: %v", err)
}
if bad.Passed() || len(bad.Results) != 1 || len(bad.Results[0].Environments) != 0 {
t.Fatalf("false expectations were accepted: %+v", bad)
}
// A crashed or unavailable browser is not evidence that the negative
// assertion worked. Every environment must reject the wrong observation.
for _, env := range []string{"native", "chromium", "firefox"} {
if !slices.ContainsFunc(bad.Results[0].Errors, func(message string) bool {
return strings.HasPrefix(message, env+": ") && strings.Contains(message, "unexpected observable")
}) {
t.Errorf("%s did not reject the deliberately false observation: %+v", env, bad.Results[0])
}
}
badPayload, err := browser.payload(*entry.Demo)
if err != nil {
t.Fatal(err)
}
badPayload["scene"] = "different scene"
if err := process.call(ctx, "open", map[string]any{"contract": *entry.Demo, "payload": badPayload}, nil); err == nil || !strings.Contains(err.Error(), "payload does not match") {
t.Fatalf("stale published Try payload was accepted: %v", err)
}
if !report.Passed() {
t.Fatalf("full component gate failed: %v", report.Error())
}
}