package runner import ( "context" "os" "os/exec" "path/filepath" "testing" "time" ) // remotionProjectDir resolves the repo's services/remotion directory relative to // this test package (services/node-agent/internal/runner), or skips the test when // it (or npx) is unavailable — keeps the test green on CI nodes without the // Remotion project checked out. func remotionProjectDir(t *testing.T) string { t.Helper() if v := os.Getenv("REMOTION_PROJECT_DIR"); v != "" { return v } dir, err := filepath.Abs(filepath.Join("..", "..", "..", "remotion")) if err != nil { t.Fatalf("abs: %v", err) } if _, err := os.Stat(filepath.Join(dir, "package.json")); err != nil { t.Skipf("remotion project not found at %s (skipping)", dir) } return dir } func TestRemotionProps(t *testing.T) { job := &Job{Bindings: []Binding{ {Key: "logoText", Value: "HELLO"}, {Key: "accentColor", Value: "#22d3ee"}, {Key: "", Value: "ignored"}, // empty keys are dropped }} got, err := remotionProps(job) if err != nil { t.Fatalf("remotionProps: %v", err) } want := `{"accentColor":"#22d3ee","logoText":"HELLO"}` if got != want { t.Fatalf("props = %s, want %s", got, want) } } func TestRemotionProgress(t *testing.T) { cases := []struct { phase int32 cur, total, stch, stchTot int64 wantMin, wantMax int }{ {0, 0, 0, 0, 0, 5, 5}, // bundling {1, 90, 180, 0, 0, 30, 45}, // half the frames rendered {2, 90, 180, 90, 180, 80, 90}, // half stitched } for _, c := range cases { pct, _ := remotionProgress(c.phase, c.cur, c.total, c.stch, c.stchTot) if pct < c.wantMin || pct > c.wantMax { t.Errorf("phase %d: pct %d not in [%d,%d]", c.phase, pct, c.wantMin, c.wantMax) } } } // TestRunRemotion_EndToEnd renders a real composition through the engine and // asserts an MP4 lands on disk. Slow (spawns Chrome) — run with `go test -run // RunRemotion -timeout 6m`. Skipped automatically without the project or npx. func TestRunRemotion_EndToEnd(t *testing.T) { if testing.Short() { t.Skip("skipping end-to-end render in -short mode") } remDir := remotionProjectDir(t) if _, err := exec.LookPath(npxCmd()); err != nil { t.Skipf("%s not on PATH (skipping)", npxCmd()) } out := filepath.Join(t.TempDir(), "engine-out.mp4") job := &Job{ JobID: "test-remotion-e2e", Engine: EngineRemotion, CompName: "KineticQuote", Quality: "free", Resolution: "360p", // exercises the height tier mapping Bindings: []Binding{ {Key: "quote", Value: "Two engines, one output."}, {Key: "author", Value: "Engine Test"}, {Key: "accentColor", Value: "#22d3ee"}, }, } var lastPct int onProgress := func(_ context.Context, pct int, _ string) error { lastPct = pct; return nil } ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) defer cancel() got, err := RunRemotion(ctx, remDir, job, out, onProgress, nil) if err != nil { t.Fatalf("RunRemotion: %v", err) } st, err := os.Stat(got) if err != nil { t.Fatalf("stat output: %v", err) } if st.Size() == 0 { t.Fatal("output file is empty") } if lastPct < 90 { t.Errorf("final progress only reached %d%%", lastPct) } t.Logf("rendered %s (%d bytes), final progress %d%%", got, st.Size(), lastPct) } // TestRun_RemotionEngine exercises the real integration point the node-agent uses: // runner.Run() dispatching on Job.Engine. With Engine=Remotion and an empty AE path // (which would otherwise trigger the AE mock), it must route to the Remotion engine // and produce a real MP4. func TestRun_RemotionEngine(t *testing.T) { if testing.Short() { t.Skip("skipping end-to-end render in -short mode") } remDir := remotionProjectDir(t) if _, err := exec.LookPath(npxCmd()); err != nil { t.Skipf("%s not on PATH (skipping)", npxCmd()) } job := &Job{ JobID: "test-run-dispatch", Engine: EngineRemotion, RemotionDir: remDir, CompName: "KineticQuote", Quality: "free", Resolution: "360p", Bindings: []Binding{{Key: "author", Value: "Dispatch Test"}}, } noop := func(context.Context, int, string) error { return nil } ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) defer cancel() // aePath empty: an AE job would mock here; a Remotion job must still render for real. got, err := Run(ctx, "", t.TempDir(), job, noop, nil) if err != nil { t.Fatalf("Run (remotion engine): %v", err) } st, err := os.Stat(got) if err != nil || st.Size() == 0 { t.Fatalf("no output from Run: %v", err) } if string(mustRead(t, got)[:4]) == "mock" { t.Fatal("Run produced the AE mock output instead of a real Remotion render") } t.Logf("Run dispatched to Remotion → %s (%d bytes)", got, st.Size()) } func mustRead(t *testing.T, path string) []byte { t.Helper() b, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } return b }