| 1 | //go:build browser && darwin |
| 2 | |
| 3 | package callstest |
| 4 | |
| 5 | import ( |
| 6 | "bufio" |
| 7 | "context" |
| 8 | "encoding/json" |
| 9 | "io" |
| 10 | "net/http" |
| 11 | "os" |
| 12 | "os/exec" |
| 13 | "path/filepath" |
| 14 | "sort" |
| 15 | "strconv" |
| 16 | "strings" |
| 17 | "sync" |
| 18 | "testing" |
| 19 | "time" |
| 20 | ) |
| 21 | |
| 22 | // This gate uses the shipping Go bridge and WebRTC implementation with a |
| 23 | // synthetic camera. Browser and native credentials come from real magic links. |
| 24 | func TestNativeMacCallsBrowser(t *testing.T) { |
| 25 | for _, guest := range []bool{false, true} { |
| 26 | name := "member" |
| 27 | if guest { |
| 28 | name = "guest" |
| 29 | } |
| 30 | t.Run(name, func(t *testing.T) { nativeMacCallsBrowser(t, guest, false) }) |
| 31 | } |
| 32 | } |
| 33 | func TestNativeMacChat(t *testing.T) { |
| 34 | nativeMacCallsBrowser(t, false, true) |
| 35 | } |
| 36 | |
| 37 | func nativeMacCallsBrowser(t *testing.T, guest, chat bool) { |
| 38 | rig := newBrowserRig(t) |
| 39 | room, token := rig.createMeeting(t) |
| 40 | mode := "member" |
| 41 | if guest { |
| 42 | mode = "guest" |
| 43 | rig.ctl.SetAllowlist(memberA) |
| 44 | // This scenario's subject is the admission queue: a guest joins |
| 45 | // from the app and a member admits them from the browser. Access |
| 46 | // defaults ship the waiting room off, so it has to be asked for. |
| 47 | rig.enableWaitingRoom(t, room) |
| 48 | req, err := http.NewRequest("GET", rig.origin+"/j/"+token, nil) |
| 49 | if err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: rig.cookieB}) |
| 53 | res, err := http.DefaultClient.Do(req) |
| 54 | if err != nil { |
| 55 | t.Fatal(err) |
| 56 | } |
| 57 | res.Body.Close() |
| 58 | if res.StatusCode != 200 { |
| 59 | t.Fatalf("guest link: %d", res.StatusCode) |
| 60 | } |
| 61 | } |
| 62 | // Leave time for the chat exchange before exercising stream renewal. |
| 63 | // Chat is intentionally not replayed to a detached recipient. |
| 64 | if !chat { |
| 65 | rig.ctl.SetStreamTimings(time.Second, 10*time.Second) |
| 66 | } else { |
| 67 | mode = "chat" |
| 68 | } |
| 69 | root, err := filepath.Abs("../..") |
| 70 | if err != nil { |
| 71 | t.Fatal(err) |
| 72 | } |
| 73 | binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| 74 | if _, err := os.Stat(binary); err != nil { |
| 75 | t.Fatal("build native interop executable with make native-mac first") |
| 76 | } |
| 77 | ctx, cancel := context.WithTimeout(t.Context(), 160*time.Second) |
| 78 | defer cancel() |
| 79 | cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native.mjs"), rig.origin, room, binary, mode) |
| 80 | cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| 81 | fixture := map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies), "invitation": rig.origin + "/j/" + token} |
| 82 | if chat { |
| 83 | fixture["chatSnapshot"] = filepath.Join(root, "native/build/chat-incoming.png") |
| 84 | } |
| 85 | input, _ := json.Marshal(fixture) |
| 86 | // CALLS_QUALITY_INTERVAL: the app's quality reports every two seconds |
| 87 | // rather than thirty (spec §15.26), so the call holds several. |
| 88 | cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input), "CALLS_QUALITY_INTERVAL=2") |
| 89 | out, err := cmd.StdoutPipe() |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | cmd.Stderr = cmd.Stdout |
| 94 | if err := cmd.Start(); err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | scanner := bufio.NewScanner(out) |
| 98 | for scanner.Scan() { |
| 99 | t.Log(scanner.Text()) |
| 100 | } |
| 101 | if err := cmd.Wait(); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | assertNativeQualityReports(t, rig) |
| 105 | } |
| 106 | |
| 107 | // assertNativeQualityReports is spec §15.26 from the app: it sent quality |
| 108 | // reports while it was in the call, the server took them (a refused one is |
| 109 | // never logged), and they carry what the app measured of the browser's |
| 110 | // connection — its audio arriving, and the path it took. |
| 111 | func assertNativeQualityReports(t *testing.T, rig *browserRig) { |
| 112 | t.Helper() |
| 113 | var mac []map[string]any |
| 114 | for _, line := range rig.reports.lines("calls: quality") { |
| 115 | if line["app"] == "mac" { |
| 116 | mac = append(mac, line) |
| 117 | } |
| 118 | } |
| 119 | if len(mac) == 0 { |
| 120 | t.Fatalf("no quality report from the app; the report log holds:\n%s", rig.reports.raw()) |
| 121 | } |
| 122 | measured := false |
| 123 | for _, line := range mac { |
| 124 | if line["os"] != "macos" || line["osVersion"] == nil { |
| 125 | t.Fatalf("the app's report does not say what it runs on: %v", line) |
| 126 | } |
| 127 | report, _ := line["report"].(map[string]any) |
| 128 | peers, _ := report["peers"].([]any) |
| 129 | for _, p := range peers { |
| 130 | peer, _ := p.(map[string]any) |
| 131 | in, _ := peer["in"].(map[string]any) |
| 132 | audio, _ := in["audio"].(map[string]any) |
| 133 | path, _ := peer["path"].(map[string]any) |
| 134 | if packets, _ := audio["packets"].(float64); packets > 0 && path["protocol"] != nil { |
| 135 | measured = true |
| 136 | } |
| 137 | } |
| 138 | } |
| 139 | if !measured { |
| 140 | t.Fatalf("no report from the app measured the browser's audio and path: %v", mac) |
| 141 | } |
| 142 | b, _ := json.Marshal(mac[len(mac)-1]) |
| 143 | t.Logf("PASS %d quality reports from the app; the last: %s", len(mac), b) |
| 144 | } |
| 145 | |
| 146 | // TestNativeMacScreenShare is §5.4 between two apps, with no browser in |
| 147 | // the call: each side is the shipping Go bridge and WebRTC build driving a |
| 148 | // synthetic camera and a synthetic screen. The browser gate above holds |
| 149 | // the app to the browser's rules in both directions; this holds two apps |
| 150 | // to each other, which is the pairing nothing else would catch drifting — |
| 151 | // and it shares from both sides at once, which is the negotiation |
| 152 | // collision the browser's mutualshare scenario was written for. |
| 153 | // |
| 154 | // It is `make native-pair`, not part of `make native-browser`, because |
| 155 | // two apps on one Mac do not reliably connect at all, screens or no |
| 156 | // screens — measured with main's interop binary as well as this branch's, |
| 157 | // before anything was shared. About two runs in five fail with signalling |
| 158 | // complete and correctly ordered, every candidate added on both sides, and |
| 159 | // no connectivity check ever answered. What the evidence points at is the |
| 160 | // Mac rather than the code: the macOS application firewall was on, it |
| 161 | // admits signed software automatically, and the interop binary is |
| 162 | // unsigned, so inbound UDP reaches it only as a reply to its own outbound |
| 163 | // on the same address pair — which two such binaries achieve only when |
| 164 | // their first checks happen to cross in the right order. Chromium is |
| 165 | // signed, so the browser gates never meet it, and a Developer ID Calls.app |
| 166 | // would not either. Not proved: allowing the binary through the firewall |
| 167 | // needs an administrator, and was not done. |
| 168 | func TestNativeMacScreenShare(t *testing.T) { |
| 169 | rig := newBrowserRig(t) |
| 170 | room, _ := rig.createMeeting(t) |
| 171 | root, err := filepath.Abs("../..") |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| 176 | if _, err := os.Stat(binary); err != nil { |
| 177 | t.Fatal("build native interop executable with make native-mac first") |
| 178 | } |
| 179 | ctx, cancel := context.WithTimeout(t.Context(), 140*time.Second) |
| 180 | defer cancel() |
| 181 | // The person behind cookie A is paul@example.com, and the hub names |
| 182 | // a person by the local part of their address. |
| 183 | const paulsScreen = "paul\u2019s screen" |
| 184 | |
| 185 | // The first app shares before the second has joined, so the `share` |
| 186 | // message goes to nobody and is never replayed: the second app has |
| 187 | // only the m-line to go on, and has to caption the screen anyway. |
| 188 | a := startNative(ctx, t, binary, rig, room, rig.cookieA, "a") |
| 189 | defer a.leave(t) |
| 190 | waitNative(t, "the first app is in the meeting", func() bool { return a.sample().Phase == "admitted" }, a) |
| 191 | a.command(t, "screen-on") |
| 192 | waitNative(t, "the first app is sharing to an empty room", func() bool { |
| 193 | s := a.sample() |
| 194 | return s.SharingScreen != nil && *s.SharingScreen |
| 195 | }, a) |
| 196 | b := startNative(ctx, t, binary, rig, room, rig.cookieB, "b") |
| 197 | defer b.leave(t) |
| 198 | waitNative(t, "both apps decode each other's camera", func() bool { |
| 199 | return a.stat("framesDecoded") > 15 && b.stat("framesDecoded") > 15 |
| 200 | }, a, b) |
| 201 | waitNative(t, "the second app captions the first app's screen", func() bool { |
| 202 | c := b.sample().ScreenCaptions |
| 203 | return len(c) == 1 && c[0] == paulsScreen && b.stat("remoteScreens") == 1 |
| 204 | }, a, b) |
| 205 | base, camera := b.stat("screenFramesDecoded"), b.stat("framesDecoded") |
| 206 | waitNative(t, "the second app decodes the screen and the camera beside it", func() bool { |
| 207 | return b.stat("screenFramesDecoded") > base+15 && b.stat("framesDecoded") > camera+15 |
| 208 | }, a, b) |
| 209 | if b.stat("remoteTracks") != 1 { |
| 210 | t.Fatalf("the screen was taken for a second camera: %+v", b.sample()) |
| 211 | } |
| 212 | a.command(t, "screen-off") |
| 213 | waitNative(t, "the screen goes when the first app stops", func() bool { |
| 214 | return len(b.sample().ScreenCaptions) == 0 && b.stat("remoteScreens") == 0 |
| 215 | }, a, b) |
| 216 | camera = b.stat("framesDecoded") |
| 217 | waitNative(t, "the camera survives the screen stopping", func() bool { |
| 218 | return b.stat("framesDecoded") > camera+15 |
| 219 | }, a, b) |
| 220 | t.Log("PASS app to app: a screen shared before the other app joined is captioned, decoded and stopped by the button") |
| 221 | |
| 222 | // The other way, ended by the capture itself. |
| 223 | b.command(t, "screen-on") |
| 224 | waitNative(t, "the first app shows the second app's screen", func() bool { |
| 225 | return len(a.sample().ScreenCaptions) == 1 && a.stat("remoteScreens") == 1 |
| 226 | }, a, b) |
| 227 | base = a.stat("screenFramesDecoded") |
| 228 | waitNative(t, "the first app decodes it", func() bool { return a.stat("screenFramesDecoded") > base+15 }, a, b) |
| 229 | b.command(t, "screen-end") |
| 230 | waitNative(t, "a capture ending on its own ends the share", func() bool { |
| 231 | s := b.sample() |
| 232 | return s.SharingScreen != nil && !*s.SharingScreen && len(a.sample().ScreenCaptions) == 0 && a.stat("remoteScreens") == 0 |
| 233 | }, a, b) |
| 234 | t.Log("PASS app to app the other way, ended by the capture") |
| 235 | |
| 236 | // Both at once: two transceivers added in the same moment, and a |
| 237 | // collision for perfect negotiation to settle without either screen |
| 238 | // landing on the m-line the other side built for something else. |
| 239 | a.command(t, "screen-on") |
| 240 | b.command(t, "screen-on") |
| 241 | waitNative(t, "each app shows the other's screen", func() bool { |
| 242 | return a.stat("remoteScreens") == 1 && b.stat("remoteScreens") == 1 |
| 243 | }, a, b) |
| 244 | baseA, baseB := a.stat("screenFramesDecoded"), b.stat("screenFramesDecoded") |
| 245 | camA, camB := a.stat("framesDecoded"), b.stat("framesDecoded") |
| 246 | waitNative(t, "both screens and both cameras decode through the collision", func() bool { |
| 247 | return a.stat("screenFramesDecoded") > baseA+15 && b.stat("screenFramesDecoded") > baseB+15 && |
| 248 | a.stat("framesDecoded") > camA+15 && b.stat("framesDecoded") > camB+15 |
| 249 | }, a, b) |
| 250 | if a.stat("remoteTracks") != 1 || b.stat("remoteTracks") != 1 { |
| 251 | t.Fatalf("a screen was taken for a camera: a=%+v b=%+v", a.sample(), b.sample()) |
| 252 | } |
| 253 | a.command(t, "screen-off") |
| 254 | b.command(t, "screen-off") |
| 255 | waitNative(t, "both screens go", func() bool { |
| 256 | return a.stat("remoteScreens") == 0 && b.stat("remoteScreens") == 0 && |
| 257 | len(a.sample().ScreenCaptions) == 0 && len(b.sample().ScreenCaptions) == 0 |
| 258 | }, a, b) |
| 259 | t.Log("PASS app to app, both sharing at once") |
| 260 | } |
| 261 | |
| 262 | type nativeSample struct { |
| 263 | Phase string `json:"phase"` |
| 264 | Peer string `json:"peer"` |
| 265 | Stats map[string]uint64 `json:"stats"` |
| 266 | ScreenCaptions []string `json:"screenCaptions"` |
| 267 | SharingScreen *bool `json:"sharingScreen"` |
| 268 | ScreenRecovery string `json:"screenRecovery"` |
| 269 | Diagnostics string `json:"diagnostics"` |
| 270 | } |
| 271 | |
| 272 | // nativeApp is one CallsInterop process and the last observation it |
| 273 | // printed. Only numeric samples are kept; nothing else it prints is read. |
| 274 | type nativeApp struct { |
| 275 | label string |
| 276 | cmd *exec.Cmd |
| 277 | stdin io.WriteCloser |
| 278 | mu sync.Mutex |
| 279 | last nativeSample |
| 280 | diag string |
| 281 | done chan struct{} |
| 282 | } |
| 283 | |
| 284 | func startNative(ctx context.Context, t *testing.T, binary string, rig *browserRig, room, cookie, label string) *nativeApp { |
| 285 | t.Helper() |
| 286 | cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": cookie}}) |
| 287 | input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)}) |
| 288 | cmd := exec.CommandContext(ctx, binary) |
| 289 | stdin, err := cmd.StdinPipe() |
| 290 | if err != nil { |
| 291 | t.Fatal(err) |
| 292 | } |
| 293 | out, err := cmd.StdoutPipe() |
| 294 | if err != nil { |
| 295 | t.Fatal(err) |
| 296 | } |
| 297 | if err := cmd.Start(); err != nil { |
| 298 | t.Fatal(err) |
| 299 | } |
| 300 | app := &nativeApp{label: label, cmd: cmd, stdin: stdin, done: make(chan struct{})} |
| 301 | go func() { |
| 302 | defer close(app.done) |
| 303 | scanner := bufio.NewScanner(out) |
| 304 | for scanner.Scan() { |
| 305 | var s nativeSample |
| 306 | if json.Unmarshal(scanner.Bytes(), &s) != nil { |
| 307 | continue |
| 308 | } |
| 309 | if s.Diagnostics != "" { |
| 310 | app.mu.Lock() |
| 311 | app.diag = s.Diagnostics |
| 312 | app.mu.Unlock() |
| 313 | continue |
| 314 | } |
| 315 | if s.Stats == nil { |
| 316 | continue |
| 317 | } |
| 318 | app.mu.Lock() |
| 319 | app.last = s |
| 320 | app.mu.Unlock() |
| 321 | } |
| 322 | }() |
| 323 | if _, err := io.WriteString(stdin, string(input)+"\n"); err != nil { |
| 324 | t.Fatal(err) |
| 325 | } |
| 326 | return app |
| 327 | } |
| 328 | |
| 329 | func (n *nativeApp) sample() nativeSample { |
| 330 | n.mu.Lock() |
| 331 | defer n.mu.Unlock() |
| 332 | return n.last |
| 333 | } |
| 334 | |
| 335 | func (n *nativeApp) stat(name string) uint64 { return n.sample().Stats[name] } |
| 336 | |
| 337 | // diagnostics asks for "Copy call diagnostics" and returns what came |
| 338 | // back, or "" if nothing did within five seconds. |
| 339 | func (n *nativeApp) diagnostics(t *testing.T) string { |
| 340 | t.Helper() |
| 341 | n.mu.Lock() |
| 342 | n.diag = "" |
| 343 | n.mu.Unlock() |
| 344 | n.command(t, "diagnostics") |
| 345 | for until := time.Now().Add(5 * time.Second); time.Now().Before(until); time.Sleep(100 * time.Millisecond) { |
| 346 | n.mu.Lock() |
| 347 | d := n.diag |
| 348 | n.mu.Unlock() |
| 349 | if d != "" { |
| 350 | return d |
| 351 | } |
| 352 | } |
| 353 | return "" |
| 354 | } |
| 355 | |
| 356 | func (n *nativeApp) command(t *testing.T, line string) { |
| 357 | t.Helper() |
| 358 | if _, err := io.WriteString(n.stdin, line+"\n"); err != nil { |
| 359 | t.Fatalf("%s: %s: %v", n.label, line, err) |
| 360 | } |
| 361 | } |
| 362 | |
| 363 | func (n *nativeApp) leave(t *testing.T) { |
| 364 | _, _ = io.WriteString(n.stdin, "leave\n") |
| 365 | select { |
| 366 | case <-n.done: |
| 367 | case <-time.After(5 * time.Second): |
| 368 | _ = n.cmd.Process.Kill() |
| 369 | } |
| 370 | _ = n.cmd.Wait() |
| 371 | } |
| 372 | |
| 373 | // waitNative polls until ok holds, and on failure says what each app |
| 374 | // last reported, which is the only account of the failure there is. |
| 375 | func waitNative(t *testing.T, label string, ok func() bool, apps ...*nativeApp) { |
| 376 | t.Helper() |
| 377 | until := time.Now().Add(35 * time.Second) |
| 378 | for time.Now().Before(until) { |
| 379 | if ok() { |
| 380 | return |
| 381 | } |
| 382 | time.Sleep(250 * time.Millisecond) |
| 383 | } |
| 384 | for _, app := range apps { |
| 385 | t.Logf("%s: %+v", app.label, app.sample()) |
| 386 | } |
| 387 | t.Fatal(label) |
| 388 | } |
| 389 | |
| 390 | // TestNativeMacScreenMotion is the app sharing a 1080p page that scrolls |
| 391 | // at 30 fps, measured at the browser: frames a second and frame size on |
| 392 | // the received screen, for each "Optimise for" mode, on an open link and |
| 393 | // under a capped uplink. It holds motion and the balanced default to a |
| 394 | // frame rate a scroll can be followed at, and detail to full resolution |
| 395 | // (test/browser/native-motion.mjs has the thresholds). |
| 396 | func TestNativeMacScreenMotion(t *testing.T) { |
| 397 | rig := newBrowserRig(t) |
| 398 | room, _ := rig.createMeeting(t) |
| 399 | root, err := filepath.Abs("../..") |
| 400 | if err != nil { |
| 401 | t.Fatal(err) |
| 402 | } |
| 403 | binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| 404 | if _, err := os.Stat(binary); err != nil { |
| 405 | t.Fatal("build native interop executable with make native-mac first") |
| 406 | } |
| 407 | ctx, cancel := context.WithTimeout(t.Context(), 300*time.Second) |
| 408 | defer cancel() |
| 409 | cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native-motion.mjs"), rig.origin, room, binary) |
| 410 | cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| 411 | input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)}) |
| 412 | cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input)) |
| 413 | out, err := cmd.StdoutPipe() |
| 414 | if err != nil { |
| 415 | t.Fatal(err) |
| 416 | } |
| 417 | cmd.Stderr = cmd.Stdout |
| 418 | if err := cmd.Start(); err != nil { |
| 419 | t.Fatal(err) |
| 420 | } |
| 421 | scanner := bufio.NewScanner(out) |
| 422 | for scanner.Scan() { |
| 423 | t.Log(scanner.Text()) |
| 424 | } |
| 425 | if err := cmd.Wait(); err != nil { |
| 426 | t.Fatal(err) |
| 427 | } |
| 428 | } |
| 429 | |
| 430 | // TestNativeMacScreenRecovery is Paul's report of 2026-09-28 — "after a |
| 431 | // while, my screen froze on her end … I had to stop and re-share" — held |
| 432 | // as a gate: the app shares its screen to a browser while the call is |
| 433 | // disturbed underneath it, and the browser's received screen has to keep |
| 434 | // decoding, or come back within a bound, with nobody sharing again. |
| 435 | // test/browser/native-screen.mjs has the scenarios and their thresholds. |
| 436 | // |
| 437 | // renewals is the one that reproduced it: a relay configured (so /ice |
| 438 | // carries a renewAt) and the stream renewed every ten seconds, which is |
| 439 | // the production shape in miniature — every renewal answered with a |
| 440 | // roster, every roster with a fresh /ice, and up to 0.4.1 every fresh |
| 441 | // /ice with an ICE restart on every connection. |
| 442 | func TestNativeMacScreenRecovery(t *testing.T) { |
| 443 | root, err := filepath.Abs("../..") |
| 444 | if err != nil { |
| 445 | t.Fatal(err) |
| 446 | } |
| 447 | binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| 448 | if _, err := os.Stat(binary); err != nil { |
| 449 | t.Fatal("build native interop executable with make native-mac first") |
| 450 | } |
| 451 | scenarios := []string{"renewals", "resume", "capture", "encoder", "loss", "wedge", "wedge-sticky", "web-wedge", "web-wedge-sticky"} |
| 452 | if only := os.Getenv("SCREEN_SCENARIOS"); only != "" { |
| 453 | scenarios = strings.Split(only, ",") |
| 454 | } |
| 455 | for _, scenario := range scenarios { |
| 456 | t.Run(scenario, func(t *testing.T) { |
| 457 | rig := newBrowserRig(t) |
| 458 | soaking := scenario == "soak" || scenario == "web-soak" |
| 459 | if scenario == "renewals" || soaking { |
| 460 | if err := rig.ctl.SetRelay(browserRelayContract()); err != nil { |
| 461 | t.Fatal(err) |
| 462 | } |
| 463 | rig.ctl.SetIceLife(20 * time.Second) |
| 464 | } |
| 465 | if scenario == "renewals" || scenario == "resume" || soaking { |
| 466 | rig.ctl.SetStreamTimings(time.Second, 10*time.Second) |
| 467 | } |
| 468 | room, _ := rig.createMeeting(t) |
| 469 | limit := 240 * time.Second |
| 470 | if soaking { |
| 471 | // SCREEN_SOAK_SECONDS of soak, and the scenario around it. |
| 472 | seconds, _ := strconv.Atoi(envOr("SCREEN_SOAK_SECONDS", "60")) |
| 473 | limit = time.Duration(seconds)*time.Second + 180*time.Second |
| 474 | } |
| 475 | ctx, cancel := context.WithTimeout(t.Context(), limit) |
| 476 | defer cancel() |
| 477 | cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native-screen.mjs"), rig.origin, room, binary, scenario) |
| 478 | cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| 479 | input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)}) |
| 480 | cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input)) |
| 481 | out, err := cmd.StdoutPipe() |
| 482 | if err != nil { |
| 483 | t.Fatal(err) |
| 484 | } |
| 485 | cmd.Stderr = cmd.Stdout |
| 486 | if err := cmd.Start(); err != nil { |
| 487 | t.Fatal(err) |
| 488 | } |
| 489 | scanner := bufio.NewScanner(out) |
| 490 | for scanner.Scan() { |
| 491 | t.Log(scanner.Text()) |
| 492 | } |
| 493 | if err := cmd.Wait(); err != nil { |
| 494 | t.Fatal(err) |
| 495 | } |
| 496 | }) |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | // TestNativeMacScreenPairRecovery is TestNativeMacScreenRecovery between |
| 501 | // two apps, which is what the report of 2026-09-28 actually was: Paul on |
| 502 | // the Mac app sharing, Vicky watching on an app. App A shares, app B |
| 503 | // watches, and B's received screen has to keep decoding, or come back |
| 504 | // within a bound, through the same disturbances. |
| 505 | // |
| 506 | // SCREEN_PAIR_A and SCREEN_PAIR_B name other CallsInterop binaries for |
| 507 | // either side — an older build, say — so mixed versions can be held to |
| 508 | // the same rules; a scenario that needs a command the older side does |
| 509 | // not have is skipped for that pairing. SCREEN_PAIR_DISPLAY=1 shares |
| 510 | // this Mac's real display (Screen Recording permission needed) rather |
| 511 | // than the synthetic screen, which puts ScreenCaptureKit and the |
| 512 | // hardware encoder in the path. |
| 513 | // |
| 514 | // It is in `make native-pair`, and flaky for the same reason as |
| 515 | // TestNativeMacScreenShare: two unsigned apps on one Mac do not always |
| 516 | // connect at all. |
| 517 | func TestNativeMacScreenPairRecovery(t *testing.T) { |
| 518 | root, err := filepath.Abs("../..") |
| 519 | if err != nil { |
| 520 | t.Fatal(err) |
| 521 | } |
| 522 | current := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| 523 | binA, binB := current, current |
| 524 | if v := os.Getenv("SCREEN_PAIR_A"); v != "" { |
| 525 | binA = v |
| 526 | } |
| 527 | if v := os.Getenv("SCREEN_PAIR_B"); v != "" { |
| 528 | binB = v |
| 529 | } |
| 530 | for _, b := range []string{binA, binB} { |
| 531 | if _, err := os.Stat(b); err != nil { |
| 532 | t.Fatalf("no interop executable at %s: build with make native-mac first", b) |
| 533 | } |
| 534 | } |
| 535 | soak := 45 * time.Second |
| 536 | if v, err := time.ParseDuration(os.Getenv("SCREEN_PAIR_SOAK")); err == nil { |
| 537 | soak = v |
| 538 | } |
| 539 | scenarios := []string{"renewals", "capture", "encoder", "loss", "wedge", "wedge-sticky", "reshare"} |
| 540 | if only := os.Getenv("SCREEN_SCENARIOS"); only != "" { |
| 541 | scenarios = strings.Split(only, ",") |
| 542 | } |
| 543 | newA, newB := binA == current, binB == current |
| 544 | for _, scenario := range scenarios { |
| 545 | t.Run(scenario, func(t *testing.T) { |
| 546 | switch { |
| 547 | case (scenario == "capture" || scenario == "encoder") && !newA: |
| 548 | t.Skip("the sharing app predates the stall commands") |
| 549 | case scenario == "loss" && (!newA || !newB): |
| 550 | t.Skip("a stall report needs the new receiver and the new sharer") |
| 551 | case strings.HasPrefix(scenario, "wedge") || scenario == "reshare": |
| 552 | if !newA || !newB { |
| 553 | t.Skip("the escalation needs the new receiver and the new sharer") |
| 554 | } |
| 555 | // Decoders the receiver can wedge (spec §15.22). |
| 556 | t.Setenv("CALLS_DECODER_WEDGE", "1") |
| 557 | } |
| 558 | rig := newBrowserRig(t) |
| 559 | if scenario == "renewals" || scenario == "soak" { |
| 560 | if err := rig.ctl.SetRelay(browserRelayContract()); err != nil { |
| 561 | t.Fatal(err) |
| 562 | } |
| 563 | rig.ctl.SetIceLife(20 * time.Second) |
| 564 | rig.ctl.SetStreamTimings(time.Second, 10*time.Second) |
| 565 | } |
| 566 | room, _ := rig.createMeeting(t) |
| 567 | ctx, cancel := context.WithTimeout(t.Context(), soak+150*time.Second) |
| 568 | defer cancel() |
| 569 | // Two unsigned apps on one Mac fail to connect at all about two |
| 570 | // times in five (see TestNativeMacScreenShare), before anything |
| 571 | // this test is about has happened; that gets three tries. |
| 572 | var a, b *nativeApp |
| 573 | for attempt := 1; ; attempt++ { |
| 574 | a = startNative(ctx, t, binA, rig, room, rig.cookieA, "a") |
| 575 | waitNative(t, "the sharing app is in", func() bool { return a.sample().Phase == "admitted" }, a) |
| 576 | b = startNative(ctx, t, binB, rig, room, rig.cookieB, "b") |
| 577 | if pollNative(35*time.Second, func() bool { return a.stat("framesDecoded") > 15 && b.stat("framesDecoded") > 15 }) { |
| 578 | break |
| 579 | } |
| 580 | a.leave(t) |
| 581 | b.leave(t) |
| 582 | if attempt == 3 { |
| 583 | t.Fatal("the two apps never connected") |
| 584 | } |
| 585 | t.Logf("RETRY the two apps did not connect (attempt %d)", attempt) |
| 586 | } |
| 587 | defer a.leave(t) |
| 588 | defer b.leave(t) |
| 589 | a.command(t, "screen-mode "+envOr("SCREEN_MODE", "balanced")) |
| 590 | if os.Getenv("SCREEN_PAIR_DISPLAY") == "1" && newA { |
| 591 | a.command(t, "screen-display 6") |
| 592 | } else if os.Getenv("SCREEN_SOURCE") == "motion" { |
| 593 | // The 1920-wide scrolling page (CALLS_SYNTHETIC_SCREEN_HEIGHT). |
| 594 | a.command(t, "screen-motion") |
| 595 | } else { |
| 596 | a.command(t, "screen-on") |
| 597 | } |
| 598 | waitNative(t, "the watching app decodes the screen", func() bool { |
| 599 | return b.stat("remoteScreens") == 1 && b.stat("screenFramesDecoded") > 15 |
| 600 | }, a, b) |
| 601 | // The watch: B's screen frames, once a second. |
| 602 | freezes, longest := 0, time.Duration(0) |
| 603 | watch := func(d time.Duration, label string) { |
| 604 | last, moved, frozen := b.stat("screenFramesDecodedTotal"), time.Now(), false |
| 605 | camera := b.stat("framesDecoded") |
| 606 | for until := time.Now().Add(d); time.Now().Before(until); { |
| 607 | time.Sleep(time.Second) |
| 608 | now := b.stat("screenFramesDecodedTotal") |
| 609 | if now > last { |
| 610 | if frozen { |
| 611 | gap := time.Since(moved) |
| 612 | t.Logf("FREEZE %s: %v from %.3f, then frames again (b's camera decoded %d frames meanwhile)", label, gap.Round(time.Millisecond), |
| 613 | float64(moved.UnixMilli())/1000, b.stat("framesDecoded")-camera) |
| 614 | freezes++ |
| 615 | longest = max(longest, gap) |
| 616 | frozen = false |
| 617 | } |
| 618 | moved = time.Now() |
| 619 | camera = b.stat("framesDecoded") |
| 620 | } else if !frozen && time.Since(moved) > 3*time.Second { |
| 621 | frozen = true |
| 622 | s := a.sample().Stats |
| 623 | t.Logf("FROZEN %s: no screen frame at b for %v (a: captured %d encoded %d sent %d, watchdog k%d e%d r%d a%d rebuilds %d shares again %d)", label, |
| 624 | time.Since(moved).Round(time.Millisecond), s["screenCaptureDelivered"], s["screenFramesEncoded"], s["screenFramesSent"], |
| 625 | s["screenWatchdogKeyframes"], s["screenWatchdogEncoderRecoveries"], s["screenWatchdogCaptureRestarts"], s["screenWatchdogReAdds"], |
| 626 | s["screenWatchdogRebuilds"], s["screenWatchdogReshares"]) |
| 627 | } |
| 628 | last = now |
| 629 | } |
| 630 | if frozen { |
| 631 | t.Errorf("%s: b's screen froze and did not come back", label) |
| 632 | } |
| 633 | } |
| 634 | recovered := func(label string) { |
| 635 | base := b.stat("screenFramesDecoded") |
| 636 | until := time.Now().Add(15 * time.Second) |
| 637 | for time.Now().Before(until) { |
| 638 | if b.stat("screenFramesDecoded") > base+10 { |
| 639 | return |
| 640 | } |
| 641 | time.Sleep(250 * time.Millisecond) |
| 642 | } |
| 643 | t.Fatalf("%s: a=%+v b=%+v", label, a.sample(), b.sample()) |
| 644 | } |
| 645 | switch scenario { |
| 646 | case "soak": |
| 647 | // §15.22's search for the cause, between two apps: renewals |
| 648 | // and resumes as `renewals`, with A's mode cycled and its |
| 649 | // uplink capped and lifted every 45 s, for SCREEN_PAIR_SOAK. |
| 650 | modes := []string{"balanced", "motion", "detail"} |
| 651 | uplinks := []string{"0", "1500000", "600000", "0", "3000000", "900000"} |
| 652 | done := make(chan struct{}) |
| 653 | notices := map[string]bool{} |
| 654 | var mu sync.Mutex |
| 655 | go func() { |
| 656 | step := 0 |
| 657 | tick := time.NewTicker(45 * time.Second) |
| 658 | defer tick.Stop() |
| 659 | for { |
| 660 | select { |
| 661 | case <-done: |
| 662 | return |
| 663 | case <-tick.C: |
| 664 | step++ |
| 665 | a.command(t, "screen-mode "+modes[step%len(modes)]) |
| 666 | a.command(t, "uplink "+uplinks[step%len(uplinks)]) |
| 667 | t.Logf("soak step %d: mode %s, uplink %s", step, modes[step%len(modes)], uplinks[step%len(uplinks)]) |
| 668 | } |
| 669 | } |
| 670 | }() |
| 671 | go func() { |
| 672 | for { |
| 673 | select { |
| 674 | case <-done: |
| 675 | return |
| 676 | case <-time.After(500 * time.Millisecond): |
| 677 | if n := a.sample().ScreenRecovery; n != "" { |
| 678 | mu.Lock() |
| 679 | notices[n] = true |
| 680 | mu.Unlock() |
| 681 | } |
| 682 | } |
| 683 | } |
| 684 | }() |
| 685 | watch(soak, "soak") |
| 686 | close(done) |
| 687 | a.command(t, "uplink 0") |
| 688 | s := a.sample().Stats |
| 689 | t.Logf("SOAK a: ICE restarts %d (b %d), stall reports %d, keyframes %d, fresh encoders %d, re-adds %d, rebuilds %d, shares again %d, given up %d, capture restarts %d; b's pictures attached again %d", |
| 690 | s["iceRestarts"], b.stat("iceRestarts"), s["screenStallReports"], s["screenWatchdogKeyframes"], s["screenWatchdogEncoderRecoveries"], |
| 691 | s["screenWatchdogReAdds"], s["screenWatchdogRebuilds"], s["screenWatchdogReshares"], s["screenWatchdogFailures"], |
| 692 | s["screenWatchdogCaptureRestarts"], b.stat("screenReattaches")) |
| 693 | mu.Lock() |
| 694 | t.Logf("SOAK notices at a: %q; %d freezes, longest %v", keys(notices), freezes, longest) |
| 695 | mu.Unlock() |
| 696 | t.Logf("a's diagnostics:\n%s", screenSections(a.diagnostics(t))) |
| 697 | t.Logf("b's diagnostics:\n%s", screenSections(b.diagnostics(t))) |
| 698 | if s["screenWatchdogFailures"] > 0 { |
| 699 | t.Error("a gave up on b's view") |
| 700 | } |
| 701 | case "renewals": |
| 702 | watch(soak, "renewals") |
| 703 | t.Logf("ICE restarts: a started %d, b started %d", a.stat("iceRestarts"), b.stat("iceRestarts")) |
| 704 | if newA && (a.stat("iceRestarts") < 1 || a.stat("iceRestarts") > uint64(soak/(16*time.Second))+1) { |
| 705 | t.Errorf("the sharing app restarted ICE %d times; renewals fall due every ~17 s", a.stat("iceRestarts")) |
| 706 | } |
| 707 | // Bounded, not zero: between two apps an ICE restart now and |
| 708 | // then (about one in ten here) stops the screen — and only the |
| 709 | // screen, the camera keeps moving — for three or four seconds, |
| 710 | // which was not traced further on 2026-09-28. 0.4.x restarted |
| 711 | // ICE on every stream resume, every five minutes; now it is |
| 712 | // every renewal, two and a half hours apart, and the watchdogs |
| 713 | // are behind it. A freeze that outlasts them fails. |
| 714 | if longest > 6*time.Second { |
| 715 | t.Errorf("b's screen froze %d times, longest %v", freezes, longest) |
| 716 | } |
| 717 | case "capture": |
| 718 | watch(5*time.Second, "before") |
| 719 | a.command(t, "screen-stall") |
| 720 | recovered("a stalled capture is restarted") |
| 721 | if a.stat("screenWatchdogCaptureRestarts") == 0 { |
| 722 | t.Error("the watchdog did not say it restarted the capture") |
| 723 | } |
| 724 | case "encoder": |
| 725 | watch(5*time.Second, "before") |
| 726 | a.command(t, "screen-encoder-stall") |
| 727 | time.Sleep(time.Second) |
| 728 | recovered("a stalled encoder is recovered") |
| 729 | if a.stat("screenWatchdogKeyframes")+a.stat("screenWatchdogEncoderRecoveries") == 0 { |
| 730 | t.Error("the watchdog did not say it recovered the encoder") |
| 731 | } |
| 732 | case "loss": |
| 733 | watch(5*time.Second, "before") |
| 734 | before := a.stat("screenWatchdogKeyframes") |
| 735 | b.command(t, "screen-freeze-test 8000") |
| 736 | waitNative(t, "b reports a's screen stalled", func() bool { return b.stat("screenStalledHere") == 1 }, a, b) |
| 737 | waitNative(t, "a hears it and answers with a keyframe", func() bool { |
| 738 | return a.stat("screenStallReports") > 0 && a.stat("screenWatchdogKeyframes") > before |
| 739 | }, a, b) |
| 740 | waitNative(t, "b clears its report once frames move", func() bool { return b.stat("screenStalledHere") == 0 }, a, b) |
| 741 | recovered("b decodes after the keyframe") |
| 742 | case "wedge": |
| 743 | // B's screen decoder stops putting out pictures while packets |
| 744 | // keep arriving (spec §15.22): keyframes cannot mend it and a |
| 745 | // new transceiver can. Without a manual share again, B has to |
| 746 | // be decoding again within 30 s, and A's notice has to have |
| 747 | // said so honestly on the way. |
| 748 | watch(5*time.Second, "before") |
| 749 | keyframes := a.stat("screenWatchdogKeyframes") |
| 750 | wedged := time.Now() |
| 751 | b.command(t, "screen-decoder-wedge") |
| 752 | waitNative(t, "b reports a's screen stalled", func() bool { return b.stat("screenStalledHere") == 1 }, a, b) |
| 753 | notices := map[string]bool{} |
| 754 | base := b.stat("screenFramesDecodedTotal") |
| 755 | back := false |
| 756 | for until := wedged.Add(30 * time.Second); time.Now().Before(until); time.Sleep(250 * time.Millisecond) { |
| 757 | if n := a.sample().ScreenRecovery; n != "" { |
| 758 | notices[n] = true |
| 759 | } |
| 760 | if b.stat("screenFramesDecodedTotal") > base+15 { |
| 761 | back = true |
| 762 | break |
| 763 | } |
| 764 | } |
| 765 | took := time.Since(wedged) |
| 766 | t.Logf("b decoding again after %v: rebuilds %d, shares again %d, keyframes %d, b's pictures attached again %d", |
| 767 | took.Round(time.Millisecond), a.stat("screenWatchdogRebuilds"), a.stat("screenWatchdogReshares"), |
| 768 | a.stat("screenWatchdogKeyframes")-keyframes, b.stat("screenReattaches")) |
| 769 | if !back { |
| 770 | t.Logf("a:\n%s", screenSections(a.diagnostics(t))) |
| 771 | t.Logf("b:\n%s", screenSections(b.diagnostics(t))) |
| 772 | t.Fatalf("b's wedged screen was not restored within 30 s: a=%+v b=%+v", a.sample(), b.sample()) |
| 773 | } |
| 774 | if a.stat("screenWatchdogKeyframes") == keyframes { |
| 775 | t.Error("keyframes were not tried first") |
| 776 | } |
| 777 | if a.stat("screenWatchdogRebuilds") == 0 { |
| 778 | t.Error("b decoded again without a rebuild: the wedge did not hold") |
| 779 | } |
| 780 | waitNative(t, "b clears its report", func() bool { return b.stat("screenStalledHere") == 0 }, a, b) |
| 781 | waitNative(t, "a's notice clears", func() bool { |
| 782 | n := a.sample().ScreenRecovery |
| 783 | if n != "" { |
| 784 | notices[n] = true |
| 785 | } |
| 786 | return n == "" |
| 787 | }, a, b) |
| 788 | t.Logf("notices seen at a: %q", keys(notices)) |
| 789 | if !anyHas(notices, "recovering") || !anyHas(notices, "Restarting your share for") { |
| 790 | t.Errorf("the notice did not move from recovering to restarting: %q", keys(notices)) |
| 791 | } |
| 792 | watch(10*time.Second, "after the rebuild") |
| 793 | t.Logf("a's diagnostics:\n%s", screenSections(a.diagnostics(t))) |
| 794 | t.Logf("b's diagnostics:\n%s", screenSections(b.diagnostics(t))) |
| 795 | case "wedge-sticky": |
| 796 | // Every screen decoder B makes is wedged, the new ones too: |
| 797 | // nothing A does can mend it, and A has to say so rather than |
| 798 | // sit at "recovering". |
| 799 | watch(5*time.Second, "before") |
| 800 | b.command(t, "screen-decoder-wedge sticky") |
| 801 | notices := map[string]bool{} |
| 802 | gaveUp := false |
| 803 | for until := time.Now().Add(60 * time.Second); time.Now().Before(until); time.Sleep(250 * time.Millisecond) { |
| 804 | if n := a.sample().ScreenRecovery; n != "" { |
| 805 | notices[n] = true |
| 806 | } |
| 807 | if a.stat("screenWatchdogFailures") > 0 && anyHas(map[string]bool{a.sample().ScreenRecovery: true}, "Couldn\u2019t restore") { |
| 808 | gaveUp = true |
| 809 | break |
| 810 | } |
| 811 | } |
| 812 | t.Logf("notices seen at a: %q", keys(notices)) |
| 813 | if !gaveUp { |
| 814 | t.Fatalf("a never said it could not restore b's view: a=%+v", a.sample()) |
| 815 | } |
| 816 | if a.stat("screenWatchdogRebuilds") == 0 || a.stat("screenWatchdogReshares") == 0 { |
| 817 | t.Errorf("a gave up without trying a rebuild and a share again: %+v", a.sample().Stats) |
| 818 | } |
| 819 | if !anyHas(notices, "Restarting your share for") { |
| 820 | t.Errorf("the notice never said it was restarting: %q", keys(notices)) |
| 821 | } |
| 822 | // The wedge lifted, the view comes back and the notice goes. |
| 823 | b.command(t, "screen-decoder-release") |
| 824 | recoveredTotal := func() bool { |
| 825 | base := b.stat("screenFramesDecodedTotal") |
| 826 | return pollNative(20*time.Second, func() bool { return b.stat("screenFramesDecodedTotal") > base+10 }) |
| 827 | } |
| 828 | if !recoveredTotal() { |
| 829 | t.Fatalf("b did not decode again once the wedge was lifted: %+v", b.sample()) |
| 830 | } |
| 831 | waitNative(t, "a's notice clears once b's view is back", func() bool { return a.sample().ScreenRecovery == "" }, a, b) |
| 832 | t.Logf("a's diagnostics:\n%s", screenSections(a.diagnostics(t))) |
| 833 | case "reshare": |
| 834 | // The watchdog's share again, on its own: every connection's |
| 835 | // screen stopped and added again from the same capture, and |
| 836 | // the watcher decoding again within a bound, one tile. |
| 837 | watch(5*time.Second, "before") |
| 838 | base := b.stat("screenFramesDecodedTotal") |
| 839 | started := time.Now() |
| 840 | a.command(t, "screen-reshare-test") |
| 841 | if !pollNative(15*time.Second, func() bool { return b.stat("screenFramesDecodedTotal") > base+30 }) { |
| 842 | t.Fatalf("b did not decode again after the share again: a=%+v b=%+v", a.sample(), b.sample()) |
| 843 | } |
| 844 | t.Logf("b decoding again %v after the share again", time.Since(started).Round(time.Millisecond)) |
| 845 | waitNative(t, "one screen at b", func() bool { return b.stat("remoteScreens") == 1 && len(b.sample().ScreenCaptions) == 1 }, a, b) |
| 846 | if a.sample().SharingScreen == nil || !*a.sample().SharingScreen { |
| 847 | t.Error("a stopped sharing") |
| 848 | } |
| 849 | watch(10*time.Second, "after the share again") |
| 850 | } |
| 851 | t.Logf("PASS %s: %d freezes, longest %v", scenario, freezes, longest) |
| 852 | }) |
| 853 | } |
| 854 | } |
| 855 | |
| 856 | // screenSections is the screen's part of "Copy call diagnostics": the |
| 857 | // "## Screen" sections and nothing else. |
| 858 | func screenSections(diag string) string { |
| 859 | var out []string |
| 860 | in := false |
| 861 | for _, line := range strings.Split(diag, "\n") { |
| 862 | if strings.HasPrefix(line, "## ") { |
| 863 | in = strings.HasPrefix(line, "## Screen") |
| 864 | } |
| 865 | if in { |
| 866 | out = append(out, line) |
| 867 | } |
| 868 | } |
| 869 | return strings.Join(out, "\n") |
| 870 | } |
| 871 | |
| 872 | func keys(m map[string]bool) []string { |
| 873 | out := make([]string, 0, len(m)) |
| 874 | for k := range m { |
| 875 | out = append(out, k) |
| 876 | } |
| 877 | sort.Strings(out) |
| 878 | return out |
| 879 | } |
| 880 | |
| 881 | func anyHas(m map[string]bool, part string) bool { |
| 882 | for k := range m { |
| 883 | if strings.Contains(k, part) { |
| 884 | return true |
| 885 | } |
| 886 | } |
| 887 | return false |
| 888 | } |
| 889 | |
| 890 | // pollNative is waitNative without the failure: whether ok held in time. |
| 891 | func pollNative(d time.Duration, ok func() bool) bool { |
| 892 | for until := time.Now().Add(d); time.Now().Before(until); time.Sleep(250 * time.Millisecond) { |
| 893 | if ok() { |
| 894 | return true |
| 895 | } |
| 896 | } |
| 897 | return false |
| 898 | } |
| 899 | |
| 900 | func envOr(key, fallback string) string { |
| 901 | if v := os.Getenv(key); v != "" { |
| 902 | return v |
| 903 | } |
| 904 | return fallback |
| 905 | } |
| 906 | |
| 907 | // TestNativeMacBarLayout holds the full-window call's control bar whole |
| 908 | // and centred at every window size (2026-10-03: Paul's 0.4.6 window |
| 909 | // showed it off the left edge with mute and camera cut off). It needs no |
| 910 | // server: CallsInterop draws the call from fixtures in a real titled |
| 911 | // window, resizes it live, animated and below the app's minimum, and |
| 912 | // checks the layout and what was drawn (BarLayoutHarness.swift). |
| 913 | // CALLS_BAR_SNAPSHOTS keeps the pictures. |
| 914 | func TestNativeMacBarLayout(t *testing.T) { |
| 915 | root, err := filepath.Abs("../..") |
| 916 | if err != nil { |
| 917 | t.Fatal(err) |
| 918 | } |
| 919 | binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| 920 | if _, err := os.Stat(binary); err != nil { |
| 921 | t.Fatal("build native interop executable with make native-mac first") |
| 922 | } |
| 923 | folder := os.Getenv("CALLS_BAR_SNAPSHOTS") |
| 924 | if folder == "" { |
| 925 | folder = t.TempDir() |
| 926 | } |
| 927 | ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) |
| 928 | defer cancel() |
| 929 | out, err := exec.CommandContext(ctx, binary, "--bar-layout", folder).CombinedOutput() |
| 930 | t.Log(string(out)) |
| 931 | if err != nil { |
| 932 | t.Fatalf("bar layout: %v", err) |
| 933 | } |
| 934 | } |
| 935 | |
| 936 | // TestNativeMacWebKit is the Mac app in a call with Safari's engine |
| 937 | // (Playwright's WebKit, which is not iOS Safari: see native-webkit.mjs |
| 938 | // for what it can and cannot stand in for). 2026-10-03: a call between |
| 939 | // the Mac app and an iPhone in Safari "kept cutting in and out". It is |
| 940 | // `make native-webkit`, apart from native-browser, because it needs |
| 941 | // Playwright's WebKit installed. |
| 942 | func TestNativeMacWebKit(t *testing.T) { |
| 943 | rig := newBrowserRig(t) |
| 944 | room, token := rig.createMeeting(t) |
| 945 | root, err := filepath.Abs("../..") |
| 946 | if err != nil { |
| 947 | t.Fatal(err) |
| 948 | } |
| 949 | binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop") |
| 950 | if _, err := os.Stat(binary); err != nil { |
| 951 | t.Fatal("build native interop executable with make native-mac first") |
| 952 | } |
| 953 | ctx, cancel := context.WithTimeout(t.Context(), 180*time.Second) |
| 954 | defer cancel() |
| 955 | cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native-webkit.mjs"), rig.origin, room, binary) |
| 956 | cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}}) |
| 957 | input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies), "invitation": rig.origin + "/j/" + token}) |
| 958 | cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input)) |
| 959 | out, err := cmd.CombinedOutput() |
| 960 | t.Log(string(out)) |
| 961 | if err != nil { |
| 962 | t.Fatalf("native-webkit: %v", err) |
| 963 | } |
| 964 | } |
| 965 | |