oficina / meet Public

browser gate: a real call in real Chromium, nine scenarios

The whole media half of room.js and devices.js first runs here:
getUserMedia, join, roster, perfect negotiation, trickle ICE, a decoded
frame, the resume ladder, removal, takeover and the scriptless floor.

Go starts the app on a real port, signs the two members in through the
magic-link flow, creates the meeting and restarts the process when
asked; node drives the browser. They talk over node's own stdin and
stdout, which is how a guest can sign itself in from the browser (only
Go can read the mailer) and how the restart lands mid-call.

All nine pass, none skipped:
  devices     the holding screen enumerates, previews and enables Join
  call        two members, video decoded both ways, no failed tile
  renewal     the call survives ~5 stream renewals each, counted
  guest       knocks, sees no peers, is admitted, gets video
  removed     removed through the confirm tab, told so in a sentence
  restart     both browsers take a new participation and reconnect
  takeover    the second tab is refused, confirms, the first goes terminal
  crosssite   a GET from another origin ends nothing; the stream 406s
  scriptless  both pages say so and neither pretends to be a call

stream.go gains X-Meet-Test-TTL, read only when Env is development and
clamped by the same floor as every other timing: EventSource cannot set
a header, so nothing a browser does on its own reaches it, and the
renewal scenario would otherwise take five minutes. A unit test pins
that production ignores it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev c4e55d9c8567d7c08aa9691736d293440c3c714a parent 3a209a3
7 files changed, +1192 −2
  • .gitignore +6 −0
  • Makefile +13 −1
  • internal/meet/stream.go +27 −1
  • internal/meet/stream_test.go +48 −0
  • internal/meettest/browser_test.go +411 −0
  • package.json +12 −0
  • test/browser/call.mjs +675 −0

CI passed — run details

diff --git a/.gitignore b/.gitignore
index 1740180..8283e1f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,12 @@
/meet.db-wal
/meet.db-shm
+# The browser gate's toolchain. `make browser` installs Playwright with
+# --no-save into node_modules/; none of it is source, and the app builds
+# without any of it.
+/node_modules/
+/package-lock.json
+
# The harness materialises these as character devices in the working
# directory; `git add -A` fails on them. Shared verbatim with the rest
# of the suite (docs, sheets, home, estilo) — keep the lists in step.
diff --git a/Makefile b/Makefile
index 8181a98..2d06e6e 100644
--- a/Makefile
+++ b/Makefile
@@ -16,7 +16,7 @@ RELEASE_BIN := releases/$(APP)-$(RELEASE_GOOS)-$(RELEASE_GOARCH)
# first commit, name it yourself: make release VERSION=v0.1.0
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null)
-.PHONY: build release version-check test vet fmt-check migration-check ci
+.PHONY: build release version-check test vet fmt-check migration-check ci browser
# build is the compile check: with more than one package matched, go
# build discards the output, so this catches a broken package without
@@ -86,3 +86,15 @@ migration-check:
# instead of at boot on whatever machine notices next. If the app
# declares manifest resources, also add: rastrillo generate --check
ci: vet fmt-check test migration-check
+
+# The browser gate: a real call between two real Chromium contexts, with
+# fake camera and microphone. Nine scenarios — devices, call, renewal,
+# guest, removed, restart, takeover, crosssite, scriptless.
+#
+# Deliberately NOT part of `ci`. It needs node and `npm install`, and the
+# one gate the team runs on every push must not depend on a toolchain the
+# app itself does not use. Run it before every review that touches a page
+# or the client.
+browser:
+ npm install --no-save playwright@1.62.1
+ go test -tags browser -run TestCallInARealBrowser -v ./internal/meettest/
diff --git a/internal/meet/stream.go b/internal/meet/stream.go
index 10172e4..f7c8de3 100644
--- a/internal/meet/stream.go
+++ b/internal/meet/stream.go
@@ -68,6 +68,32 @@ func acceptsEventStream(r *http.Request) bool {
return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/event-stream")
}
+// testTTLHeader shortens one stream's life, and only in development.
+// EventSource cannot set a header, so nothing a browser does on its own
+// can reach this; the browser gate rewrites it on the way past, which is
+// the only way to watch a renewal without waiting five minutes. Outside
+// development the header is not read at all: a request may not shorten
+// a stream on its own say-so.
+const testTTLHeader = "X-Meet-Test-TTL"
+
+// streamLifeFor is streamLife with that override applied, through the
+// same floor atLeast puts under every timing — a header cannot panic a
+// timer in a request goroutine any more than SetStreamTimings can.
+func (a *app) streamLifeFor(r *http.Request) time.Duration {
+ if !a.cfg.development() {
+ return a.streamLife()
+ }
+ raw := r.Header.Get(testTTLHeader)
+ if raw == "" {
+ return a.streamLife()
+ }
+ d, err := time.ParseDuration(raw)
+ if err != nil {
+ return a.streamLife()
+ }
+ return atLeast(int64(d))
+}
+
// streamJSON is every refusal this endpoint makes. The room's client is
// a script, not a page: spec §4.5 fixes the browser's behaviour per
// status and body, so a refusal answers JSON rather than the error page
@@ -280,7 +306,7 @@ func (a *app) serveStream(w http.ResponseWriter, r *http.Request, sub *hub.Sub,
}
heartbeat := time.NewTicker(a.heartbeatEvery())
defer heartbeat.Stop()
- ttl := time.NewTimer(a.streamLife())
+ ttl := time.NewTimer(a.streamLifeFor(r))
defer ttl.Stop()
for {
select {
diff --git a/internal/meet/stream_test.go b/internal/meet/stream_test.go
new file mode 100644
index 0000000..282c9bc
--- /dev/null
+++ b/internal/meet/stream_test.go
@@ -0,0 +1,48 @@
+package meet
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+// TestTheTestTTLHeaderIsDevelopmentOnly pins the one thing that makes
+// X-Meet-Test-TTL safe to have at all: outside development it is not
+// read, so a request cannot shorten its own stream and no amount of
+// header-setting turns a five-minute stream into a reconnect storm.
+func TestTheTestTTLHeaderIsDevelopmentOnly(t *testing.T) {
+ req := func(v string) *http.Request {
+ r := httptest.NewRequest(http.MethodGet, "/m/abc/stream", nil)
+ if v != "" {
+ r.Header.Set(testTTLHeader, v)
+ }
+ return r
+ }
+ newApp := func(env string) *app {
+ a := &app{cfg: resolved{Config: Config{Env: env}}}
+ a.ttl.Store(int64(5 * time.Minute))
+ return a
+ }
+
+ dev := newApp(EnvDevelopment)
+ if got := dev.streamLifeFor(req("2s")); got != 2*time.Second {
+ t.Fatalf("development: stream life %v, want 2s", got)
+ }
+ if got := dev.streamLifeFor(req("")); got != 5*time.Minute {
+ t.Fatalf("development, no header: stream life %v, want the configured 5m", got)
+ }
+ if got := dev.streamLifeFor(req("not a duration")); got != 5*time.Minute {
+ t.Fatalf("development, unparseable header: stream life %v, want the configured 5m", got)
+ }
+ // The floor is the same one every other timing goes through: a
+ // header cannot hand time.NewTimer a zero or a negative.
+ if got := dev.streamLifeFor(req("0s")); got != minStreamTiming {
+ t.Fatalf("development, zero header: stream life %v, want the %v floor", got, minStreamTiming)
+ }
+
+ prod := newApp("production")
+ if got := prod.streamLifeFor(req("2s")); got != 5*time.Minute {
+ t.Fatalf("production: stream life %v, want the configured 5m — the header must not be read", got)
+ }
+}
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go
new file mode 100644
index 0000000..16f16be
--- /dev/null
+++ b/internal/meettest/browser_test.go
@@ -0,0 +1,411 @@
+//go:build browser
+
+// The browser gate: a real call, in real Chromium, between two real
+// browser contexts with fake cameras.
+//
+// make browser
+//
+// Not part of the plain suite and not part of `make ci`: it needs node
+// and an npm install, and the gate the team runs on every push must not
+// depend on a toolchain the app itself does not use.
+//
+// Every other test in this repo lives inside one layer. The Go suite
+// asks Go what Go produced; room.js was landed with nothing but
+// `node --check` behind it. Everything in the media half —
+// getUserMedia, the offer/answer exchange, trickle ICE, a decoded frame,
+// the resume ladder, the takeover refusal — first runs here.
+//
+// Go owns the half a browser cannot reach: it starts the app on a real
+// port (the origin is load-bearing — csrf.Protect measures against it),
+// signs the two members in through the actual magic-link flow so it can
+// read the mailer, creates the meeting, and restarts the process on the
+// same port when the restart scenario asks for it. node owns the
+// browser. They talk over the node process's own stdin and stdout: node
+// writes a line beginning "CONTROL ", Go answers with one line.
+package meettest
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/http/cookiejar"
+ "net/url"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/db"
+
+ meet "amadan.net/oficina/meet/internal/meet"
+)
+
+// scenarioTimeout is per scenario, not for the run: a call that has not
+// connected in three minutes is not slow, it is broken.
+const scenarioTimeout = 3 * time.Minute
+
+// memberA and memberB are the two AllowedAddresses this gate signs in.
+// A guest signs itself in from the browser, through the emailed link,
+// with an address that is deliberately not on the list.
+const (
+ memberA = "paul@example.com"
+ memberB = "paul@rslw.com"
+)
+
+// scenarios are the nine passes, in the order they run. Each gets its
+// own node process and its own freshly restarted app, so a participation
+// one scenario left behind cannot 409 the next one's fresh stream.
+//
+// skip is the Task 8 fix round's list: a scenario that fails on the
+// client rather than on the gate stays here, written as it should be,
+// and carries the exact failure it produced. Emptying a skip is how the
+// fix round proves it fixed something.
+var scenarios = []struct {
+ name string
+ skip string
+}{
+ {name: "devices"},
+ {name: "call"},
+ {name: "renewal"},
+ {name: "guest"},
+ {name: "removed"},
+ {name: "restart"},
+ {name: "takeover"},
+ {name: "crosssite"},
+ {name: "scriptless"},
+}
+
+func TestCallInARealBrowser(t *testing.T) {
+ rig := newBrowserRig(t)
+ sqid, token := rig.createMeeting(t)
+
+ for _, sc := range scenarios {
+ t.Run(sc.name, func(t *testing.T) {
+ if sc.skip != "" {
+ t.Skip(sc.skip)
+ }
+ // A fresh hub per scenario. The session cookies and the
+ // meeting live in the database and survive; the hub's
+ // participations do not, which is exactly what is wanted —
+ // a tab that did not manage to send `leave` would otherwise
+ // hold this session's one peer slot into the next scenario.
+ rig.restart(t)
+ rig.run(t, sc.name, sqid, token)
+ })
+ }
+}
+
+// browserRig is the app under a real listener, plus the two signed-in
+// members' cookies and the mail recorder a guest's own sign-in needs.
+type browserRig struct {
+ dbPath string
+ d *db.DB
+ mail *recordingMailer
+
+ addr string
+ origin string
+
+ mu sync.Mutex
+ ln net.Listener
+ srv *http.Server
+ ctl *meet.TestHandle
+
+ cookieName string
+ cookieA string
+ cookieB string
+}
+
+func newBrowserRig(t *testing.T) *browserRig {
+ t.Helper()
+
+ // The listener first: the origin is the CSRF yardstick and the base
+ // of every emailed link, so the app cannot be built until the kernel
+ // has named the port.
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ rig := &browserRig{
+ dbPath: filepath.Join(t.TempDir(), "meet.db"),
+ mail: &recordingMailer{},
+ addr: ln.Addr().String(),
+ }
+ rig.origin = "http://" + rig.addr
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(rig.dbPath, logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ rig.d = d
+
+ rig.serve(t, ln)
+ t.Cleanup(func() { rig.stop() })
+
+ rig.cookieName, rig.cookieA = rig.signIn(t, memberA)
+ _, rig.cookieB = rig.signIn(t, memberB)
+ return rig
+}
+
+// serve builds a fresh app over the same database and puts it behind ln.
+func (rig *browserRig) serve(t *testing.T, ln net.Listener) {
+ t.Helper()
+ mux, ctl, err := meet.NewForTest(rig.d, meet.Config{
+ Env: meet.EnvDevelopment,
+ Origin: rig.origin,
+ AllowedAddresses: []string{memberA, memberB},
+ Mailer: rig.mail,
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ })
+ if err != nil {
+ t.Fatalf("meet.NewForTest: %v", err)
+ }
+ srv := &http.Server{Handler: mux}
+ go func() { _ = srv.Serve(ln) }()
+
+ rig.mu.Lock()
+ rig.ln, rig.srv, rig.ctl = ln, srv, ctl
+ rig.mu.Unlock()
+}
+
+// stop is the SIGTERM and the socket close, in that order: cancelling
+// the app's context is what lets a live stream return, and Close would
+// otherwise wait on it.
+func (rig *browserRig) stop() {
+ rig.mu.Lock()
+ srv, ctl := rig.srv, rig.ctl
+ rig.mu.Unlock()
+ if ctl != nil {
+ ctl.ShutdownForTest()
+ }
+ if srv != nil {
+ _ = srv.Close()
+ }
+}
+
+// restart is what the restart scenario asks for, and what every other
+// scenario gets for free before it starts: the process goes away and
+// comes back on the same port, with a new hub and a new boot id, over
+// the same database.
+func (rig *browserRig) restart(t *testing.T) {
+ t.Helper()
+ rig.stop()
+
+ // SO_REUSEADDR is on by default for Go listeners, so the old
+ // connections' TIME_WAIT does not block the bind — but a listener
+ // closing is not instantaneous under load, so this is patient.
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ ln, err := net.Listen("tcp", rig.addr)
+ if err == nil {
+ rig.serve(t, ln)
+ return
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("restart: could not re-listen on %s: %v", rig.addr, err)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+}
+
+// signIn drives the real sign-in against the live listener and returns
+// the session cookie's name and value. The name comes from the app, not
+// from a constant here: rastrillo/sessions picks it from the origin's
+// scheme (plain rastrillo_session on http, __Host- prefixed on https),
+// and a gate that hardcoded one would quietly stop signing anything in
+// the day this moves behind TLS.
+func (rig *browserRig) signIn(t *testing.T, address string) (name, value string) {
+ t.Helper()
+ jar, err := cookiejar.New(nil)
+ if err != nil {
+ t.Fatalf("cookiejar: %v", err)
+ }
+ client := &http.Client{
+ Jar: jar,
+ // The magic link answers 303; following it is what mints the
+ // session, so redirects are followed, but not endlessly.
+ CheckRedirect: func(r *http.Request, via []*http.Request) error {
+ if len(via) >= 5 {
+ return http.ErrUseLastResponse
+ }
+ return nil
+ },
+ }
+
+ before := rig.mail.count()
+ form := url.Values{"address": {address}}
+ req, err := http.NewRequest(http.MethodPost, rig.origin+"/signin", strings.NewReader(form.Encode()))
+ if err != nil {
+ t.Fatalf("build signin request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ // The same-origin evidence csrf.Protect wants; a browser sends it on
+ // every form POST.
+ req.Header.Set("Origin", rig.origin)
+ res, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("POST /signin: %v", err)
+ }
+ res.Body.Close()
+
+ if rig.mail.count() == before {
+ t.Fatalf("no sign-in link was mailed to %s", address)
+ }
+ to, link := rig.mail.last()
+ if to != address || link == "" {
+ t.Fatalf("last mail went to %q, not %s", to, address)
+ }
+
+ res, err = client.Get(link)
+ if err != nil {
+ t.Fatalf("follow the sign-in link: %v", err)
+ }
+ res.Body.Close()
+
+ u, _ := url.Parse(rig.origin)
+ for _, c := range jar.Cookies(u) {
+ if strings.Contains(c.Name, "rastrillo_session") {
+ return c.Name, c.Value
+ }
+ }
+ t.Fatalf("following the sign-in link minted no session cookie (jar: %v)", jar.Cookies(u))
+ return "", ""
+}
+
+var sqidOnPage = regexp.MustCompile(`/m/([23456789a-z]{10})/join`)
+var tokenOnPage = regexp.MustCompile(`/j/([A-Za-z0-9_-]{43})`)
+
+// createMeeting is member A starting a call and reading the link off the
+// page it is shown on exactly once.
+func (rig *browserRig) createMeeting(t *testing.T) (sqid, token string) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodPost, rig.origin+"/meetings",
+ strings.NewReader(url.Values{"title": {"Board meeting"}}.Encode()))
+ if err != nil {
+ t.Fatalf("build create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", rig.origin)
+ req.AddCookie(&http.Cookie{Name: rig.cookieName, Value: rig.cookieA})
+
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("POST /meetings: %v", err)
+ }
+ defer res.Body.Close()
+ body, _ := io.ReadAll(res.Body)
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("POST /meetings: %d\n%s", res.StatusCode, body)
+ }
+ s := sqidOnPage.FindSubmatch(body)
+ k := tokenOnPage.FindSubmatch(body)
+ if s == nil || k == nil {
+ t.Fatalf("no meeting id or link on the page:\n%s", body)
+ }
+ return string(s[1]), string(k[1])
+}
+
+// run drives one scenario. node's stdout is read a line at a time so a
+// CONTROL request is answered while the browser is still waiting on it;
+// everything else is logged as it arrives, which is what makes a hung
+// scenario readable rather than silent for three minutes.
+func (rig *browserRig) run(t *testing.T, scenario, sqid, token string) {
+ t.Helper()
+
+ ctx, cancel := context.WithTimeout(context.Background(), scenarioTimeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "node", "test/browser/call.mjs",
+ rig.origin, sqid, rig.cookieName, rig.cookieA, rig.cookieB, scenario, token)
+ cmd.Dir = repoRootForBrowser(t)
+
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ t.Fatalf("stdout pipe: %v", err)
+ }
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ t.Fatalf("stdin pipe: %v", err)
+ }
+ var errs bytes.Buffer
+ cmd.Stderr = &errs
+
+ if err := cmd.Start(); err != nil {
+ t.Fatalf("start node: %v", err)
+ }
+
+ scanner := bufio.NewScanner(stdout)
+ scanner.Buffer(make([]byte, 0, 64<<10), 4<<20)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "CONTROL ") {
+ t.Logf(" %s", line)
+ continue
+ }
+ verb := strings.TrimSpace(strings.TrimPrefix(line, "CONTROL "))
+ t.Logf(" [control] %s", verb)
+ fmt.Fprintln(stdin, rig.control(t, verb))
+ }
+ _ = stdin.Close()
+
+ waitErr := cmd.Wait()
+ if s := strings.TrimSpace(errs.String()); s != "" {
+ t.Logf("node stderr:\n%s", s)
+ }
+ if waitErr != nil {
+ t.Fatalf("the %s scenario failed: %v", scenario, waitErr)
+ }
+}
+
+// control answers one request from the browser side. Both verbs need
+// something only this process has: the mail recorder, and the app's own
+// lifecycle.
+func (rig *browserRig) control(t *testing.T, verb string) string {
+ t.Helper()
+ switch {
+ case verb == "restart":
+ rig.restart(t)
+ return "ok"
+ case strings.HasPrefix(verb, "guest-link "):
+ address := strings.TrimSpace(strings.TrimPrefix(verb, "guest-link "))
+ // The browser has just posted the sign-in form; the link is in
+ // the recorder by the time the POST's response came back, but a
+ // short wait costs nothing and turns a race into a wait.
+ deadline := time.Now().Add(10 * time.Second)
+ for {
+ to, link := rig.mail.last()
+ if to == address && link != "" {
+ return link
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("no sign-in link was mailed to %s (last went to %q)", address, to)
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ default:
+ t.Fatalf("unknown control verb from the browser: %q", verb)
+ return ""
+ }
+}
+
+// repoRootForBrowser finds the repository from this file's own path,
+// never from the working directory: go test runs in the package
+// directory and the node script is addressed from the root.
+func repoRootForBrowser(t *testing.T) string {
+ t.Helper()
+ _, file, _, ok := runtime.Caller(0)
+ if !ok {
+ t.Fatal("runtime.Caller failed")
+ }
+ return filepath.Dir(filepath.Dir(filepath.Dir(file)))
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e62d1f3
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "meet-browser-harness",
+ "private": true,
+ "type": "module",
+ "description": "Playwright harness for the Meet browser gate. Not built; not published. The app is one Go binary — this exists only to drive a real browser at it, because a call is the one thing no Go test can see.",
+ "devDependencies": {
+ "playwright": "1.62.1"
+ },
+ "scripts": {
+ "test:browser": "go test -tags browser -run TestCallInARealBrowser ./internal/meettest/"
+ }
+}
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
new file mode 100644
index 0000000..e4690c3
--- /dev/null
+++ b/test/browser/call.mjs
@@ -0,0 +1,675 @@
+// call.mjs — a real call, in a real browser.
+//
+// Driven by TestCallInARealBrowser, which starts the app on a real
+// port, signs the two members in through the magic-link flow, creates
+// the meeting, and hands this script the origin, the meeting id, the
+// session cookie's name and the two members' values, the scenario to
+// run, and the meeting's join link token.
+//
+// node test/browser/call.mjs <origin> <sqid> <cookieName> <cookieA> <cookieB> <scenario> <token>
+//
+// One scenario per process. Every assertion prints one line; the exit
+// code is the gate.
+//
+// Two things this script cannot do for itself, and asks Go for over its
+// own stdin and stdout: read the mail recorder (a guest signs itself in
+// from the browser, and only Go can see the link that was mailed), and
+// restart the app. Both go out as a line beginning "CONTROL " and come
+// back as one line.
+//
+// Chromium's flags are not decoration. --use-fake-device-for-media-stream
+// gives every context a camera and a microphone that always work;
+// --use-fake-ui-for-media-stream answers the permission prompt;
+// --autoplay-policy=no-user-gesture-required lets a remote <video> play
+// without a click; and --disable-features=WebRtcHideLocalIpsWithMdns
+// turns off mDNS candidate obfuscation, which in a container resolves to
+// nothing at all — with no ICE servers in this slice (spec §5.5), host
+// candidates are the only ones there are, and an obfuscated one connects
+// to nobody.
+import { chromium } from 'playwright';
+import { createInterface } from 'node:readline';
+
+const [origin, sqid, cookieName, cookieA, cookieB, scenario, token] =
+ process.argv.slice(2);
+
+if (!origin || !sqid || !cookieName || !cookieA || !cookieB || !scenario) {
+ console.error('usage: call.mjs <origin> <sqid> <cookieName> <cookieA> <cookieB> <scenario> [token]');
+ process.exit(2);
+}
+
+const GUEST = 'guest@example.com';
+
+const LAUNCH_ARGS = [
+ '--use-fake-device-for-media-stream',
+ '--use-fake-ui-for-media-stream',
+ '--autoplay-policy=no-user-gesture-required',
+ '--disable-features=WebRtcHideLocalIpsWithMdns',
+];
+
+// ── the scoreboard ───────────────────────────────────────────────────
+
+const failures = [];
+
+function line(s) {
+ // One line per assertion, and never one that Go would read as a
+ // control request.
+ process.stdout.write(String(s).replace(/\r?\n/g, ' | ') + '\n');
+}
+
+function check(cond, what) {
+ if (cond) {
+ line('ok ' + what);
+ return true;
+ }
+ failures.push(what);
+ line('FAIL ' + what);
+ return false;
+}
+
+// ── the control channel back to Go ───────────────────────────────────
+
+let rl = null;
+const inbox = [];
+const waiters = [];
+
+function control(verb) {
+ if (!rl) {
+ rl = createInterface({ input: process.stdin });
+ rl.on('line', (l) => {
+ const w = waiters.shift();
+ if (w) w(l);
+ else inbox.push(l);
+ });
+ }
+ process.stdout.write('CONTROL ' + verb + '\n');
+ return new Promise((resolve) => {
+ if (inbox.length) resolve(inbox.shift());
+ else waiters.push(resolve);
+ });
+}
+
+// ── waiting ──────────────────────────────────────────────────────────
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+// until polls rather than reading once: every state this gate asserts on
+// arrives over a network and a peer connection, and a single read is a
+// race dressed up as an assertion.
+async function until(fn, ms) {
+ const deadline = Date.now() + ms;
+ for (;;) {
+ try {
+ const got = await fn();
+ if (got) return got;
+ } catch {
+ // A page mid-navigation: try again until the deadline.
+ }
+ if (Date.now() > deadline) return null;
+ await sleep(250);
+ }
+}
+
+// ── the browser ──────────────────────────────────────────────────────
+
+const browser = await chromium.launch({ args: LAUNCH_ARGS });
+
+const consoleErrors = new Map(); // label -> string[]
+
+async function newContext(label, { cookie = null, javaScriptEnabled = true } = {}) {
+ const ctx = await browser.newContext({ javaScriptEnabled });
+ if (cookie) {
+ await ctx.addCookies([{ name: cookieName, value: cookie, url: origin }]);
+ }
+ consoleErrors.set(label, []);
+ ctx.on('page', (p) => watch(p, label));
+ return ctx;
+}
+
+function watch(page, label) {
+ const errs = consoleErrors.get(label);
+ page.on('console', (m) => {
+ if (m.type() === 'error') errs.push(m.text());
+ });
+ page.on('pageerror', (e) => errs.push(String(e && e.message ? e.message : e)));
+}
+
+function errorsFor(label) {
+ return (consoleErrors.get(label) || []).join('; ');
+}
+
+// state reads window.meet.state() over the wire. frames is a Map in the
+// page, so it is flattened here rather than trusting a serializer.
+function state(page) {
+ return page.evaluate(() => {
+ const s = window.meet.state();
+ return {
+ peer: s.peer,
+ admitted: s.admitted,
+ peers: s.peers,
+ frames: Object.fromEntries(s.frames),
+ };
+ });
+}
+
+function statusText(page) {
+ return page.evaluate(() => (document.getElementById('status') || {}).textContent || '');
+}
+
+// diagnose is what a failure prints. It is deliberately more than the
+// assertion needed: this output is the fix round's evidence, and the
+// difference between "frames stayed 0" and "frames stayed 0 while the
+// video element had a stream, 640px of width and was not paused" is the
+// whole diagnosis.
+async function diagnose(page, label) {
+ try {
+ const d = await page.evaluate(() => {
+ const s = window.meet ? window.meet.state() : null;
+ const tiles = Array.from(document.querySelectorAll('#tiles figure')).map((f) => {
+ const v = f.querySelector('video');
+ return {
+ name: (f.querySelector('figcaption span') || {}).textContent || '',
+ note: (f.querySelector('.meet-tile-note') || {}).textContent || '',
+ hasStream: !!(v && v.srcObject),
+ readyState: v ? v.readyState : null,
+ videoWidth: v ? v.videoWidth : null,
+ paused: v ? v.paused : null,
+ currentTime: v ? Number(v.currentTime.toFixed(2)) : null,
+ };
+ });
+ return {
+ url: location.pathname,
+ status: (document.getElementById('status') || {}).textContent || '',
+ roomHidden: (document.getElementById('room') || {}).hidden,
+ state: s
+ ? { peer: s.peer, admitted: s.admitted, peers: s.peers, frames: Object.fromEntries(s.frames) }
+ : null,
+ tiles,
+ queue: Array.from(document.querySelectorAll('#knocks li')).map((li) => li.textContent),
+ };
+ });
+ return label + ' ' + JSON.stringify(d) + ' console=[' + errorsFor(label) + ']';
+ } catch (e) {
+ return label + ' could not be diagnosed: ' + e.message + ' console=[' + errorsFor(label) + ']';
+ }
+}
+
+// ── the room ─────────────────────────────────────────────────────────
+
+async function openRoom(ctx, label) {
+ const page = await ctx.newPage();
+ await page.goto(origin + '/m/' + sqid, { waitUntil: 'domcontentloaded' });
+ // window.meet exists only once #room is in the document and the
+ // script has run to its end.
+ await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
+ return page;
+}
+
+// connected is admitted, with at least one live peer connection. It is
+// never asserted tighter than a couple of seconds: a pair negotiates,
+// and glare runs on nearly every one of them.
+async function waitConnected(page, ms = 25000) {
+ return until(async () => {
+ const s = await state(page);
+ return s.admitted && s.peers.length >= 1 ? s : false;
+ }, ms);
+}
+
+async function waitFrames(page, peer, ms = 25000) {
+ return until(async () => {
+ const s = await state(page);
+ return (s.frames[peer] || 0) > 0 ? s : false;
+ }, ms);
+}
+
+async function waitStatus(page, text, ms = 20000) {
+ return until(async () => ((await statusText(page)).includes(text) ? true : false), ms);
+}
+
+// A pair is asserted from both ends: a call where only one side sees
+// video is not a call.
+async function assertPair(pageA, pageB, labelA, labelB, ms = 25000) {
+ const a = await waitConnected(pageA, ms);
+ if (!check(a, labelA + ' is admitted and has a peer connection — ' + (await diagnose(pageA, labelA)))) return null;
+ const b = await waitConnected(pageB, ms);
+ if (!check(b, labelB + ' is admitted and has a peer connection — ' + (await diagnose(pageB, labelB)))) return null;
+
+ check(a.peers.includes(b.peer), labelA + ' knows ' + labelB + "'s peer id (" + a.peers + ' vs ' + b.peer + ')');
+ check(b.peers.includes(a.peer), labelB + ' knows ' + labelA + "'s peer id (" + b.peers + ' vs ' + a.peer + ')');
+
+ const fa = await waitFrames(pageA, b.peer, ms);
+ check(fa, labelA + ' decoded a video frame from ' + labelB + ' — ' + (await diagnose(pageA, labelA)));
+ const fb = await waitFrames(pageB, a.peer, ms);
+ check(fb, labelB + ' decoded a video frame from ' + labelA + ' — ' + (await diagnose(pageB, labelB)));
+
+ // The observable proxy for a healthy pair: entry.failed is what puts
+ // this note on a tile, and it is set from the connection going to
+ // "failed". window.meet.state() does not expose signalingState, so
+ // this is as close as the gate can get without touching room.js.
+ for (const [p, l] of [[pageA, labelA], [pageB, labelB]]) {
+ const note = await p.evaluate(() =>
+ Array.from(document.querySelectorAll('.meet-tile-note'))
+ .filter((n) => !n.hidden)
+ .map((n) => n.textContent)
+ .join('; '));
+ check(note === '', l + ' has no failed tile (' + note + ')');
+ }
+ return { a, b };
+}
+
+// leave is preferred over closing the context: `leave` is posted with
+// keepalive on pagehide, and a closing context may never flush it —
+// which would leave this session's one peer slot held into whatever runs
+// next.
+async function leaveRoom(page) {
+ try {
+ await page.click('#leave', { timeout: 5000 });
+ await page.waitForURL('**/m/' + sqid + '/join', { timeout: 10000 });
+ } catch {
+ // A terminal page has its controls disabled; there is nothing to
+ // leave, and the participation is already gone.
+ }
+}
+
+// ── the guest ────────────────────────────────────────────────────────
+
+// signInAsGuest is the whole outsider path from spec §3.2, driven in the
+// browser: follow the emailed link signed out, ask for a sign-in link,
+// and click the one that was actually mailed.
+async function signInAsGuest(ctx) {
+ const page = await ctx.newPage();
+ await page.goto(origin + '/j/' + token, { waitUntil: 'domcontentloaded' });
+ check(page.url().includes('/signin'), 'the link sends a signed-out outsider to sign in (at ' + page.url() + ')');
+
+ await page.fill('input[name="address"]', GUEST);
+ await Promise.all([
+ page.waitForURL('**/signin?sent=1', { timeout: 15000 }),
+ page.click('form[rst-form] button[type="submit"]'),
+ ]);
+ check(true, 'the outsider asked for a sign-in link');
+
+ const link = await control('guest-link ' + GUEST);
+ await page.goto(link, { waitUntil: 'domcontentloaded' });
+ check(page.url().endsWith('/m/' + sqid + '/join'),
+ 'the emailed link lands the guest on this meeting (at ' + page.url() + ')');
+ await page.close();
+}
+
+// ── the scenarios ────────────────────────────────────────────────────
+
+const run = {
+ // The holding screen reaches real hardware: it enumerates devices,
+ // previews one, and lets the person in.
+ async devices() {
+ const ctx = await newContext('member A', { cookie: cookieA });
+ const page = await ctx.newPage();
+ await page.goto(origin + '/m/' + sqid + '/join', { waitUntil: 'domcontentloaded' });
+
+ const revealed = await until(async () => !(await page.locator('#devices').isHidden()), 10000);
+ check(revealed, 'the holding screen reveals its devices block');
+
+ const options = await until(async () => {
+ const n = await page.locator('#pick-camera option').count();
+ return n > 0 ? n : false;
+ }, 10000);
+ check(options, 'the camera picker lists at least one camera (got ' + options + ')');
+
+ const enabled = await until(async () => await page.locator('#join').isEnabled(), 10000);
+ check(enabled, 'the Join button is enabled within ten seconds — status: ' + (await page.locator('#status').textContent()));
+
+ const preview = await page.evaluate(() => {
+ const v = document.getElementById('preview');
+ return { hasStream: !!(v && v.srcObject), width: v ? v.videoWidth : null };
+ });
+ check(preview.hasStream, 'the preview has a live stream (' + JSON.stringify(preview) + ')');
+
+ check(errorsFor('member A') === '', 'no console errors on the holding screen (' + errorsFor('member A') + ')');
+ await ctx.close();
+ },
+
+ // The whole media path in one assertion each way: getUserMedia, join,
+ // roster, perfect negotiation, trickle ICE, a decoded frame.
+ async call() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA, 'member A');
+ const pageB = await openRoom(ctxB, 'member B');
+
+ await assertPair(pageA, pageB, 'member A', 'member B');
+
+ check(errorsFor('member A') === '', 'no console errors in A (' + errorsFor('member A') + ')');
+ check(errorsFor('member B') === '', 'no console errors in B (' + errorsFor('member B') + ')');
+
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
+ // A stream lives five minutes and is then renewed by a resume. Two
+ // seconds here, through a header only development honours, so the
+ // renewal happens several times while the call is being watched.
+ async renewal() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ // Counting the attaching GETs is what stops this scenario passing
+ // vacuously: a header the server ignored would leave the call up
+ // for the whole watch on one five-minute stream, and every other
+ // assertion here would still be true.
+ let attaches = 0;
+ for (const ctx of [ctxA, ctxB]) {
+ await ctx.route('**/stream*', async (route) => {
+ const url = route.request().url();
+ if (!url.includes('probe=1')) attaches += 1;
+ await route.continue({ headers: { ...route.request().headers(), 'x-meet-test-ttl': '2s' } });
+ });
+ }
+ const pageA = await openRoom(ctxA, 'member A');
+ const pageB = await openRoom(ctxB, 'member B');
+
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) {
+ await ctxA.close();
+ await ctxB.close();
+ return;
+ }
+
+ const before = { a: await state(pageA), b: await state(pageB) };
+ // Long enough for at least four renewals at a two-second life.
+ await sleep(10000);
+ const after = { a: await state(pageA), b: await state(pageB) };
+
+ check(after.a.peer === before.a.peer,
+ 'A kept its participation across the renewals (' + before.a.peer + ' -> ' + after.a.peer + ')');
+ check(after.b.peer === before.b.peer,
+ 'B kept its participation across the renewals (' + before.b.peer + ' -> ' + after.b.peer + ')');
+ check(after.a.admitted && after.a.peers.includes(before.b.peer),
+ 'A is still in the call after the renewals — ' + (await diagnose(pageA, 'member A')));
+ check(after.b.admitted && after.b.peers.includes(before.a.peer),
+ 'B is still in the call after the renewals — ' + (await diagnose(pageB, 'member B')));
+ check((after.a.frames[before.b.peer] || 0) > (before.a.frames[before.b.peer] || 0),
+ 'A is still receiving video after the renewals (' +
+ (before.a.frames[before.b.peer] || 0) + ' -> ' + (after.a.frames[before.b.peer] || 0) + ')');
+ // Two browsers, a two-second life, ten seconds of watching: the
+ // first two attaches are the two fresh streams, and everything past
+ // that is a renewal that actually happened.
+ check(attaches >= 6, 'the streams really were renewed (' + attaches + ' attaching GETs, want at least 6)');
+
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
+ // §4.3's admission queue: a guest knocks, sees nothing of the room,
+ // and is let in by a member.
+ async guest() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxG = await newContext('guest');
+ const pageA = await openRoom(ctxA, 'member A');
+ check(await until(async () => (await state(pageA)).admitted, 20000),
+ 'the member is in the room — ' + (await diagnose(pageA, 'member A')));
+
+ await signInAsGuest(ctxG);
+ const pageG = await openRoom(ctxG, 'guest');
+
+ const knocked = await until(async () => ((await state(pageG)).peer ? true : false), 20000);
+ check(knocked, 'the guest got a peer id and knocked — ' + (await diagnose(pageG, 'guest')));
+ check(await waitStatus(pageG, 'Waiting for someone to let you in.', 10000),
+ 'the guest is told it is waiting — status: ' + (await statusText(pageG)));
+
+ const waitingState = await state(pageG);
+ check(waitingState.admitted === false, 'the guest is not admitted before anyone admits it');
+ check(waitingState.peers.length === 0,
+ 'the guest has no peer connections before admission (' + JSON.stringify(waitingState.peers) + ')');
+
+ const queued = await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000);
+ check(queued, "the member's queue shows the knock — " + (await diagnose(pageA, 'member A')));
+ if (queued) await pageA.click('#knocks li button');
+
+ await assertPair(pageA, pageG, 'member A', 'guest');
+
+ await leaveRoom(pageG);
+ await leaveRoom(pageA);
+ await ctxA.close();
+ await ctxG.close();
+ },
+
+ // §3.5: removal is a member's own confirm page, in its own tab, and
+ // the person removed is told so in a sentence.
+ async removed() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxG = await newContext('guest');
+ const pageA = await openRoom(ctxA, 'member A');
+ await until(async () => (await state(pageA)).admitted, 20000);
+
+ await signInAsGuest(ctxG);
+ const pageG = await openRoom(ctxG, 'guest');
+
+ const queued = await until(async () => (await pageA.locator('#knocks li').count()) === 1, 20000);
+ check(queued, 'the guest reached the queue — ' + (await diagnose(pageA, 'member A')));
+ if (queued) await pageA.click('#knocks li button');
+
+ const pair = await assertPair(pageA, pageG, 'member A', 'guest');
+ if (!pair) {
+ await ctxA.close();
+ await ctxG.close();
+ return;
+ }
+
+ const href = await pageA.locator('.meet-tile-remove').first().getAttribute('href');
+ check(href === '/m/' + sqid + '/remove/' + pair.b.peer,
+ "the Remove control names the guest's peer (" + href + ')');
+
+ // target="_blank": the call is holding this tab, so the confirm page
+ // arrives as a second page in the same context.
+ const [confirm] = await Promise.all([
+ ctxA.waitForEvent('page', { timeout: 15000 }),
+ pageA.click('.meet-tile-remove'),
+ ]);
+ await confirm.waitForLoadState('domcontentloaded');
+ const heading = await confirm.locator('h1').first().textContent();
+ check(/^Remove .* from the call\?$/.test((heading || '').trim()),
+ 'the confirm page asks about this person (' + heading + ')');
+
+ await Promise.all([
+ confirm.waitForURL('**/m/' + sqid + '/join', { timeout: 15000 }),
+ confirm.click('form[rst-form-actions] button[type="submit"]'),
+ ]);
+ check(true, 'the removal was confirmed on its own page');
+
+ check(await waitStatus(pageG, 'Someone removed you from this meeting.', 20000),
+ 'the guest is told it was removed — status: ' + (await statusText(pageG)));
+ const gone = await until(async () => {
+ const s = await state(pageG);
+ return s.peers.length === 0;
+ }, 10000);
+ check(gone, 'the removed guest holds no peer connections — ' + (await diagnose(pageG, 'guest')));
+
+ await leaveRoom(pageA);
+ await ctxA.close();
+ await ctxG.close();
+ },
+
+ // The process goes away and comes back. Both browsers see the stream
+ // die, find a boot they do not know, and rejoin (§4.5).
+ async restart() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const ctxB = await newContext('member B', { cookie: cookieB });
+ const pageA = await openRoom(ctxA, 'member A');
+ const pageB = await openRoom(ctxB, 'member B');
+
+ const pair = await assertPair(pageA, pageB, 'member A', 'member B');
+ if (!pair) {
+ await ctxA.close();
+ await ctxB.close();
+ return;
+ }
+
+ const answer = await control('restart');
+ check(answer === 'ok', 'the app restarted on the same port (' + answer + ')');
+
+ const back = await until(async () => {
+ const a = await state(pageA);
+ const b = await state(pageB);
+ return a.peer && b.peer && a.peer !== pair.a.peer && b.peer !== pair.b.peer ? { a, b } : false;
+ }, 60000);
+ check(back, 'both browsers took a new participation after the restart — ' +
+ (await diagnose(pageA, 'member A')) + ' ' + (await diagnose(pageB, 'member B')));
+
+ if (back) await assertPair(pageA, pageB, 'member A', 'member B', 40000);
+
+ await leaveRoom(pageA);
+ await leaveRoom(pageB);
+ await ctxA.close();
+ await ctxB.close();
+ },
+
+ // §4.2: one peer per session per room, and nothing displaces a tab
+ // automatically. The second tab is refused and offers the confirm
+ // page; confirming it is what ends the first.
+ async takeover() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const first = await openRoom(ctxA, 'member A');
+ check(await until(async () => (await state(first)).peer, 20000),
+ 'the first tab is in the room — ' + (await diagnose(first, 'member A')));
+
+ const second = await openRoom(ctxA, 'member A');
+ check(await waitStatus(second, 'This meeting is open in another tab.', 20000),
+ 'the second tab is told the meeting is open elsewhere — status: ' + (await statusText(second)));
+ const secondState = await state(second);
+ check(secondState.peer === null,
+ 'the second tab took no participation (' + secondState.peer + ')');
+ const firstStillThere = await state(first);
+ check(firstStillThere.peer !== null, 'the first tab was not displaced by the second opening');
+
+ const takeoverHref = await second.locator('#status a').getAttribute('href');
+ check(takeoverHref === '/m/' + sqid + '/takeover',
+ 'the second tab offers the takeover page (' + takeoverHref + ')');
+
+ await Promise.all([
+ second.waitForURL('**/m/' + sqid + '/takeover', { timeout: 15000 }),
+ second.click('#status a'),
+ ]);
+ await Promise.all([
+ second.waitForURL(origin + '/m/' + sqid, { timeout: 15000 }),
+ second.click('form[rst-form-actions] button[type="submit"]'),
+ ]);
+ check(true, 'the takeover was confirmed on its own page');
+
+ check(await waitStatus(first, 'This meeting is open in another tab.', 20000),
+ 'the first tab is told it lost the call — status: ' + (await statusText(first)));
+
+ // Terminal means terminal: nothing reconnects on its own.
+ await sleep(5000);
+ const after = await state(first);
+ check(after.peers.length === 0, 'the displaced tab holds no peer connections');
+ check((await statusText(first)).includes('This meeting is open in another tab.'),
+ 'the displaced tab did not reconnect — status: ' + (await statusText(first)));
+
+ await ctxA.close();
+ },
+
+ // A GET from somewhere else on the web ends nothing: the takeover is
+ // a POST behind its own confirm page (§4.2), and a top-level
+ // navigation does not even reach the stream (ruling 15).
+ async crosssite() {
+ const ctxA = await newContext('member A', { cookie: cookieA });
+ const page = await openRoom(ctxA, 'member A');
+ check(await until(async () => (await state(page)).peer, 20000),
+ 'the call is up before the cross-site visit — ' + (await diagnose(page, 'member A')));
+ const before = await state(page);
+
+ await ctxA.route('http://elsewhere.test/**', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'text/html',
+ body: '<!doctype html><a id="go" href="' + origin + '/m/' + sqid + '/takeover">go</a>' +
+ '<a id="stream" href="' + origin + '/m/' + sqid + '/stream">stream</a>',
+ }));
+
+ const other = await ctxA.newPage();
+ await other.goto('http://elsewhere.test/', { waitUntil: 'domcontentloaded' });
+ check(new URL(other.url()).origin !== origin, 'the other page really is another origin (' + other.url() + ')');
+
+ await Promise.all([
+ other.waitForURL('**/m/' + sqid + '/takeover', { timeout: 15000 }),
+ other.click('#go'),
+ ]);
+ check(true, 'the cross-site GET reached the takeover URL');
+
+ // And the navigation to the stream itself, which must not take this
+ // session's peer slot either.
+ await other.goto('http://elsewhere.test/', { waitUntil: 'domcontentloaded' });
+ await Promise.all([
+ other.waitForURL('**/m/' + sqid + '/stream', { timeout: 15000 }),
+ other.click('#stream'),
+ ]);
+ check(true, 'the cross-site GET reached the stream URL');
+ // Ruling 15, seen from a browser: a top-level navigation asks for
+ // HTML, so it is refused before anything is looked up and cannot
+ // occupy this session's one peer slot for the resume window.
+ const refusal = await other.locator('body').innerText();
+ check(refusal.includes('accept'),
+ 'the navigation was refused with the accept error (' + refusal.trim() + ')');
+
+ await sleep(3000);
+ const after = await state(page);
+ check(after.peer === before.peer, 'the call kept its participation (' + before.peer + ' -> ' + after.peer + ')');
+ check(!(await statusText(page)).includes('This meeting is open in another tab.'),
+ 'the call was not displaced by a GET — status: ' + (await statusText(page)));
+
+ await leaveRoom(page);
+ await ctxA.close();
+ },
+
+ // The floor: both pages load with JavaScript switched off, say so in
+ // plain words, and show nothing that pretends to be a call.
+ async scriptless() {
+ const ctx = await newContext('scriptless', { cookie: cookieA, javaScriptEnabled: false });
+ const sentence = 'The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.';
+
+ const holding = await ctx.newPage();
+ await holding.goto(origin + '/m/' + sqid + '/join', { waitUntil: 'domcontentloaded' });
+ check((await holding.locator('body').innerText()).includes(sentence),
+ 'the holding screen says what a scriptless browser cannot do');
+ check(await holding.locator('#devices').isHidden(),
+ 'the holding screen keeps its device controls hidden with no script');
+
+ const room = await ctx.newPage();
+ await room.goto(origin + '/m/' + sqid, { waitUntil: 'domcontentloaded' });
+ const roomText = await room.locator('body').innerText();
+ check(roomText.includes(sentence), 'the room page says the same');
+ check(roomText.includes('Back to the holding screen'), 'the room page offers the way back');
+ check(await room.locator('#room').isHidden(),
+ 'the room stays hidden with no script — nothing pretends to be a call');
+
+ await ctx.close();
+ },
+};
+
+// ── run it ───────────────────────────────────────────────────────────
+
+const chosen = run[scenario];
+if (!chosen) {
+ console.error('unknown scenario: ' + scenario);
+ await browser.close();
+ process.exit(2);
+}
+
+line('scenario: ' + scenario);
+try {
+ await chosen();
+} catch (e) {
+ failures.push(scenario + ' threw: ' + (e && e.stack ? e.stack : e));
+ line('FAIL ' + scenario + ' threw: ' + (e && e.message ? e.message : e));
+ if (e && e.stack) line(e.stack);
+}
+
+await browser.close();
+
+if (failures.length) {
+ line(failures.length + ' browser check(s) failed in ' + scenario);
+ process.exit(1);
+}
+line(scenario + ': all browser checks passed');
+process.exit(0);