oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser && darwin
2
3package callstest
4
5import (
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.
24func 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}
33func TestNativeMacChat(t *testing.T) {
34 nativeMacCallsBrowser(t, false, true)
35}
36
37func 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.
111func 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.
168func 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
262type 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.
274type 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
284func 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
329func (n *nativeApp) sample() nativeSample {
330 n.mu.Lock()
331 defer n.mu.Unlock()
332 return n.last
333}
334
335func (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.
339func (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
356func (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
363func (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.
375func 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).
396func 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.
442func 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.
517func 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.
858func 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
872func 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
881func 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.
891func 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
900func 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.
914func 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.
942func 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