| 1 | //go:build windows |
| 2 | |
| 3 | package builtin |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "encoding/base64" |
| 8 | "encoding/json" |
| 9 | "fmt" |
| 10 | "net/http" |
| 11 | "os/exec" |
| 12 | "regexp" |
| 13 | "strconv" |
| 14 | "strings" |
| 15 | "testing" |
| 16 | "time" |
| 17 | |
| 18 | "reasonix/internal/event" |
| 19 | "reasonix/internal/jobs" |
| 20 | "reasonix/internal/sandbox" |
| 21 | ) |
| 22 | |
| 23 | func TestWindowsWorkspaceWriteBackgroundHTTPJobLifecycle(t *testing.T) { |
| 24 | node, err := exec.LookPath("node") |
| 25 | if err != nil { |
| 26 | t.Skip("node unavailable") |
| 27 | } |
| 28 | powerShell := "" |
| 29 | for _, name := range []string{"pwsh", "powershell"} { |
| 30 | if found, lookupErr := exec.LookPath(name); lookupErr == nil { |
| 31 | powerShell = found |
| 32 | break |
| 33 | } |
| 34 | } |
| 35 | if powerShell == "" { |
| 36 | t.Skip("PowerShell unavailable") |
| 37 | } |
| 38 | |
| 39 | workspace := t.TempDir() |
| 40 | sh := sandbox.Shell{Kind: sandbox.ShellPowerShell, Path: powerShell} |
| 41 | b := bash{ |
| 42 | name: "pwsh", |
| 43 | shell: sh, |
| 44 | workDir: workspace, |
| 45 | sb: sandbox.Spec{ |
| 46 | Mode: "enforce", |
| 47 | WriteRoots: []string{workspace}, |
| 48 | Network: true, |
| 49 | Shell: sh, |
| 50 | }, |
| 51 | } |
| 52 | manager := jobs.NewManager(event.Discard) |
| 53 | defer manager.Close() |
| 54 | ctx := jobs.WithSession(jobs.WithManager(context.Background(), manager), "windows-http") |
| 55 | ctx = sandbox.WithPermissionPreset(ctx, "workspace-write") |
| 56 | |
| 57 | js := `const http=require("http");const s=http.createServer((q,r)=>{r.end("openmaic-ok")});s.listen(0,"127.0.0.1",()=>console.log("READY "+s.address().port));` |
| 58 | encoded := base64.StdEncoding.EncodeToString([]byte(js)) |
| 59 | command := fmt.Sprintf("$code=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')); & '%s' -e $code", encoded, strings.ReplaceAll(node, "'", "''")) |
| 60 | args, _ := json.Marshal(bashParams{Command: command, Description: "Start mock OpenMAIC service", RunInBackground: true}) |
| 61 | result, err := b.ExecuteDetailed(ctx, args) |
| 62 | if err != nil { |
| 63 | t.Fatalf("start background PowerShell: %v", err) |
| 64 | } |
| 65 | jobID := regexp.MustCompile(`pwsh-\d+`).FindString(result.Output) |
| 66 | if jobID == "" { |
| 67 | t.Fatalf("background result did not return pwsh job id: %q", result.Output) |
| 68 | } |
| 69 | |
| 70 | ready := regexp.MustCompile(`READY\s+(\d+)`) |
| 71 | var output strings.Builder |
| 72 | deadline := time.Now().Add(15 * time.Second) |
| 73 | port := 0 |
| 74 | for time.Now().Before(deadline) { |
| 75 | chunk, _, found := manager.OutputForSession("windows-http", jobID) |
| 76 | if !found { |
| 77 | t.Fatalf("job %q disappeared", jobID) |
| 78 | } |
| 79 | output.WriteString(chunk) |
| 80 | if match := ready.FindStringSubmatch(output.String()); len(match) == 2 { |
| 81 | port, _ = strconv.Atoi(match[1]) |
| 82 | break |
| 83 | } |
| 84 | time.Sleep(25 * time.Millisecond) |
| 85 | } |
| 86 | if port == 0 { |
| 87 | t.Fatalf("background service never became ready: %q", output.String()) |
| 88 | } |
| 89 | url := fmt.Sprintf("http://127.0.0.1:%d/", port) |
| 90 | client := &http.Client{Timeout: 2 * time.Second} |
| 91 | response, err := client.Get(url) |
| 92 | if err != nil { |
| 93 | t.Fatalf("health check %s: %v", url, err) |
| 94 | } |
| 95 | _ = response.Body.Close() |
| 96 | if response.StatusCode != http.StatusOK { |
| 97 | t.Fatalf("health status = %s", response.Status) |
| 98 | } |
| 99 | if _, err := (jobKill{}).Execute(ctx, json.RawMessage(`{"job_id":"`+jobID+`","reason":"test complete"}`)); err != nil { |
| 100 | t.Fatalf("job_kill: %v", err) |
| 101 | } |
| 102 | if results := manager.WaitForSession(ctx, "windows-http", []string{jobID}, 10); len(results) != 1 || results[0].Status != jobs.Killed { |
| 103 | t.Fatalf("job did not settle killed: %+v", results) |
| 104 | } |
| 105 | for stopDeadline := time.Now().Add(5 * time.Second); ; { |
| 106 | if response, getErr := client.Get(url); getErr != nil { |
| 107 | break |
| 108 | } else { |
| 109 | _ = response.Body.Close() |
| 110 | } |
| 111 | if time.Now().After(stopDeadline) { |
| 112 | t.Fatal("HTTP child process survived job_kill") |
| 113 | } |
| 114 | time.Sleep(25 * time.Millisecond) |
| 115 | } |
| 116 | } |
| 117 |