- Add FailTask to WorkerService to update build_audit on failure path (fixes bug where audit showed "running" when task actually failed) - Add WorkServiceFailer interface to avoid circular dependency - Add VerifyExecutor with Playwright-based visual verification - Add verify domain types (VerifySpec, VerifyResult, screenshot capture) - Wire VerifyExecutor placeholder into WorkExecutor (impl in Week 2) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
334 lines
8.0 KiB
Go
334 lines
8.0 KiB
Go
package worker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
func TestVerifyExecutor_Execute_Success(t *testing.T) {
|
|
cmdExec := newMockCommandExecutor()
|
|
// Simulate capture.js JSON output
|
|
cmdExec.output = []domain.OutputLine{
|
|
{Stream: "stdout", Line: `{"screenshots":{"1920x1080":"/captures/task-1/1920_1080.png","375x667":"/captures/task-1/375_667.png"},"video":"/captures/task-1/recording.webm"}`, Timestamp: time.Now()},
|
|
}
|
|
|
|
exec := NewVerifyExecutor(cmdExec, nil, nil, nil)
|
|
|
|
task := &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "project-1",
|
|
Type: domain.WorkTaskTypeVerify,
|
|
Spec: map[string]any{
|
|
"url": "https://example.com",
|
|
"viewports": []any{"1920x1080", "375x667"},
|
|
"video": true,
|
|
},
|
|
}
|
|
|
|
result := exec.Execute(context.Background(), task)
|
|
|
|
if !result.Success {
|
|
t.Errorf("expected success, got error: %s", result.Error)
|
|
}
|
|
if result.DurationMs < 0 {
|
|
t.Errorf("expected non-negative duration, got %d", result.DurationMs)
|
|
}
|
|
// Check artifacts were populated
|
|
if result.Artifacts == nil {
|
|
t.Fatal("expected artifacts to be populated")
|
|
}
|
|
if result.Artifacts["screenshot_1920x1080"] != "/captures/task-1/1920_1080.png" {
|
|
t.Errorf("screenshot_1920x1080 = %q", result.Artifacts["screenshot_1920x1080"])
|
|
}
|
|
if result.Artifacts["video"] != "/captures/task-1/recording.webm" {
|
|
t.Errorf("video = %q", result.Artifacts["video"])
|
|
}
|
|
}
|
|
|
|
func TestVerifyExecutor_Execute_URLRequired(t *testing.T) {
|
|
cmdExec := newMockCommandExecutor()
|
|
exec := NewVerifyExecutor(cmdExec, nil, nil, nil)
|
|
|
|
task := &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "project-1",
|
|
Type: domain.WorkTaskTypeVerify,
|
|
Spec: map[string]any{}, // Missing URL
|
|
}
|
|
|
|
result := exec.Execute(context.Background(), task)
|
|
|
|
if result.Success {
|
|
t.Error("expected failure for missing URL")
|
|
}
|
|
if result.Error == "" {
|
|
t.Error("expected error message")
|
|
}
|
|
}
|
|
|
|
func TestVerifyExecutor_Execute_InvalidURL(t *testing.T) {
|
|
cmdExec := newMockCommandExecutor()
|
|
exec := NewVerifyExecutor(cmdExec, nil, nil, nil)
|
|
|
|
task := &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "project-1",
|
|
Type: domain.WorkTaskTypeVerify,
|
|
Spec: map[string]any{
|
|
"url": "ftp://invalid-scheme.com",
|
|
},
|
|
}
|
|
|
|
result := exec.Execute(context.Background(), task)
|
|
|
|
if result.Success {
|
|
t.Error("expected failure for invalid URL scheme")
|
|
}
|
|
}
|
|
|
|
func TestVerifyExecutor_Execute_CaptureFailure(t *testing.T) {
|
|
cmdExec := newMockCommandExecutor()
|
|
cmdExec.err = fmt.Errorf("kubectl exec failed: connection refused")
|
|
|
|
exec := NewVerifyExecutor(cmdExec, nil, nil, nil)
|
|
|
|
task := &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "project-1",
|
|
Type: domain.WorkTaskTypeVerify,
|
|
Spec: map[string]any{
|
|
"url": "https://example.com",
|
|
},
|
|
}
|
|
|
|
result := exec.Execute(context.Background(), task)
|
|
|
|
if result.Success {
|
|
t.Error("expected failure on capture execution error")
|
|
}
|
|
if result.Error == "" {
|
|
t.Error("expected error message")
|
|
}
|
|
}
|
|
|
|
func TestVerifyExecutor_Execute_NonZeroExitCode(t *testing.T) {
|
|
cmdExec := newMockCommandExecutor()
|
|
cmdExec.result = &domain.CommandResult{
|
|
ExitCode: 1,
|
|
DurationMs: 100,
|
|
}
|
|
|
|
exec := NewVerifyExecutor(cmdExec, nil, nil, nil)
|
|
|
|
task := &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "project-1",
|
|
Type: domain.WorkTaskTypeVerify,
|
|
Spec: map[string]any{
|
|
"url": "https://example.com",
|
|
},
|
|
}
|
|
|
|
result := exec.Execute(context.Background(), task)
|
|
|
|
if result.Success {
|
|
t.Error("expected failure on non-zero exit code")
|
|
}
|
|
}
|
|
|
|
func TestVerifyExecutor_Execute_InvalidManifestJSON(t *testing.T) {
|
|
cmdExec := newMockCommandExecutor()
|
|
cmdExec.output = []domain.OutputLine{
|
|
{Stream: "stdout", Line: "not valid json", Timestamp: time.Now()},
|
|
}
|
|
|
|
exec := NewVerifyExecutor(cmdExec, nil, nil, nil)
|
|
|
|
task := &domain.WorkTask{
|
|
ID: "task-1",
|
|
ProjectID: "project-1",
|
|
Type: domain.WorkTaskTypeVerify,
|
|
Spec: map[string]any{
|
|
"url": "https://example.com",
|
|
},
|
|
}
|
|
|
|
result := exec.Execute(context.Background(), task)
|
|
|
|
if result.Success {
|
|
t.Error("expected failure on invalid JSON manifest")
|
|
}
|
|
}
|
|
|
|
func TestVerifyExecutor_ParseSpec(t *testing.T) {
|
|
exec := NewVerifyExecutor(nil, nil, nil, nil)
|
|
|
|
t.Run("valid spec with all fields", func(t *testing.T) {
|
|
spec, err := exec.parseSpec(map[string]any{
|
|
"url": "https://example.com",
|
|
"viewports": []any{"1920x1080", "800x600"},
|
|
"wait_for": "#main",
|
|
"wait_timeout": float64(5000),
|
|
"full_page": true,
|
|
"video": true,
|
|
"evaluate": true,
|
|
"prompt": "Check for hero section",
|
|
"callback_url": "https://webhook.example.com/notify",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("parseSpec() error = %v", err)
|
|
}
|
|
if spec.URL != "https://example.com" {
|
|
t.Errorf("URL = %q", spec.URL)
|
|
}
|
|
if len(spec.Viewports) != 2 {
|
|
t.Errorf("Viewports count = %d", len(spec.Viewports))
|
|
}
|
|
if spec.WaitFor != "#main" {
|
|
t.Errorf("WaitFor = %q", spec.WaitFor)
|
|
}
|
|
if spec.WaitTimeout != 5000 {
|
|
t.Errorf("WaitTimeout = %d", spec.WaitTimeout)
|
|
}
|
|
if !spec.FullPage {
|
|
t.Error("expected FullPage = true")
|
|
}
|
|
if !spec.Video {
|
|
t.Error("expected Video = true")
|
|
}
|
|
if !spec.Evaluate {
|
|
t.Error("expected Evaluate = true")
|
|
}
|
|
if spec.Prompt != "Check for hero section" {
|
|
t.Errorf("Prompt = %q", spec.Prompt)
|
|
}
|
|
if spec.CallbackURL != "https://webhook.example.com/notify" {
|
|
t.Errorf("CallbackURL = %q", spec.CallbackURL)
|
|
}
|
|
})
|
|
|
|
t.Run("minimal spec", func(t *testing.T) {
|
|
spec, err := exec.parseSpec(map[string]any{
|
|
"url": "https://example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("parseSpec() error = %v", err)
|
|
}
|
|
if spec.URL != "https://example.com" {
|
|
t.Errorf("URL = %q", spec.URL)
|
|
}
|
|
// Other fields should be zero/empty
|
|
if len(spec.Viewports) != 0 {
|
|
t.Errorf("expected empty viewports, got %v", spec.Viewports)
|
|
}
|
|
})
|
|
|
|
t.Run("missing URL", func(t *testing.T) {
|
|
_, err := exec.parseSpec(map[string]any{
|
|
"viewports": []any{"1920x1080"},
|
|
})
|
|
if err == nil {
|
|
t.Error("expected error for missing URL")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestVerifyExecutor_BuildCaptureCommand(t *testing.T) {
|
|
exec := NewVerifyExecutor(nil, nil, nil, nil)
|
|
|
|
spec := &domain.VerifySpec{
|
|
URL: "https://example.com/page",
|
|
Viewports: []string{"1920x1080", "375x667"},
|
|
WaitFor: "#app",
|
|
FullPage: true,
|
|
Video: true,
|
|
}
|
|
|
|
args := exec.buildCaptureCommand(spec, "/captures/task-123")
|
|
|
|
// Check command structure
|
|
if args[0] != "node" {
|
|
t.Errorf("expected 'node', got %q", args[0])
|
|
}
|
|
if args[1] != "/scripts/capture.js" {
|
|
t.Errorf("expected '/scripts/capture.js', got %q", args[1])
|
|
}
|
|
|
|
// Check URL is included
|
|
found := false
|
|
for _, arg := range args {
|
|
if arg == "--url=https://example.com/page" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("URL argument not found in %v", args)
|
|
}
|
|
|
|
// Check viewports
|
|
found = false
|
|
for _, arg := range args {
|
|
if arg == "--viewports=1920x1080,375x667" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("viewports argument not found in %v", args)
|
|
}
|
|
|
|
// Check full-page flag
|
|
found = false
|
|
for _, arg := range args {
|
|
if arg == "--full-page=true" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("full-page argument not found in %v", args)
|
|
}
|
|
|
|
// Check video flag
|
|
found = false
|
|
for _, arg := range args {
|
|
if arg == "--video=true" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("video argument not found in %v", args)
|
|
}
|
|
}
|
|
|
|
func TestVerifyExecutor_Config(t *testing.T) {
|
|
t.Run("default config", func(t *testing.T) {
|
|
exec := NewVerifyExecutor(nil, nil, nil, nil)
|
|
if exec.namespace != "rdev" {
|
|
t.Errorf("namespace = %q, want 'rdev'", exec.namespace)
|
|
}
|
|
if exec.podName != "playwright-0" {
|
|
t.Errorf("podName = %q, want 'playwright-0'", exec.podName)
|
|
}
|
|
})
|
|
|
|
t.Run("custom config", func(t *testing.T) {
|
|
exec := NewVerifyExecutor(nil, nil, nil, &VerifyExecutorConfig{
|
|
Namespace: "custom-ns",
|
|
PodName: "custom-pod-0",
|
|
})
|
|
if exec.namespace != "custom-ns" {
|
|
t.Errorf("namespace = %q, want 'custom-ns'", exec.namespace)
|
|
}
|
|
if exec.podName != "custom-pod-0" {
|
|
t.Errorf("podName = %q, want 'custom-pod-0'", exec.podName)
|
|
}
|
|
})
|
|
}
|