oficina / meet Public

browser gate fix round 1: installs the browser, keeps no secrets on argv

Important: `make browser` never installed a browser. playwright 1.62.1
has no postinstall, so the npm install fetched the driver only, and the
target passed here purely because ~/.cache/ms-playwright was already
warm. `npx playwright install chromium` is now its own line; --with-deps
is deliberately not used, since it needs root. Proved by running the
target against an empty PLAYWRIGHT_BROWSERS_PATH: it downloads Chromium,
FFmpeg and the headless shell, then passes 9/9.

The rest, one line each:

2.  Failure lines print only the first path segment, never a URL that
    could carry a join or verify token (where(), and diagnose's `at`).
3.  The session cookies and the join token move from argv to the
    environment; argv is now origin, sqid and scenario.
4.  A node-side 165 s watchdog prints a FAIL line and exits, inside the
    Go driver's 180 s cap.
5.  process.exitCode plus a natural exit, and every line written with
    writeSync, so the last one cannot be lost in a pipe buffer.
6.  The five check(true, ...) no-ops become assertions on the URL just
    reached.
7.  takeover's "not displaced" and "did not reconnect" settle and then
    poll a window, through a new stayed() helper; crosssite uses it too.
8.  crosssite matches the refusal body exactly: {"error":"accept"}.
9.  `go vet -tags browser` is the browser target's first line — nothing
    else ever compiles this file.
10. The stop() comment no longer claims http.Server.Close waits; the
    dead skip field is gone; and node is reaped on every path out of
    run(), including rig.control's t.Fatalf.
11. assertPair now polls state().signaling === "stable" and
    connection === "connected" for the other peer, in place of the
    "no failed tile" proxy, keeping the frames assertions; removed
    asserts terminal === "removed" and admitted === false, and takeover
    asserts terminal === "replaced".
12. AGENTS.md says make browser needs the bash sandbox off, because the
    sandbox blocks UDP and a call with no ICE candidates reads exactly
    like a client bug.

Also -count=1 on the test line, found while proving item 1: call.mjs is
not an input to Go's test cache, so an edited browser script was being
answered with a cached pass.
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 bb11551839722e53177ea1db1dd96baaa96ef75d parent ba5919c
4 files changed, +263 −122
  • AGENTS.md +5 −0
  • Makefile +22 −1
  • internal/meettest/browser_test.go +57 −31
  • test/browser/call.mjs +179 −90

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index d6b731c..88071db 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -55,6 +55,11 @@ mechanically.
- The gate is `make ci` (vet + gofmt + tests) — the same definition
CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the
stack is cgo-free by design.
+- `make browser` is the second gate, for any change to a page or to the
+ client: a real call in real Chromium. It needs the Claude Code bash
+ sandbox **off** — the sandbox blocks UDP, so Chromium gathers no ICE
+ candidates and every call fails with `frames` stuck at 0, which reads
+ exactly like a client bug and is not one.
## Serving
diff --git a/Makefile b/Makefile
index 2d06e6e..96c6c9e 100644
--- a/Makefile
+++ b/Makefile
@@ -95,6 +95,27 @@ ci: vet fmt-check test migration-check
# 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.
+#
+# The vet runs first because the whole gate is behind a build tag, so
+# `make ci` never compiles browser_test.go: without this line a typo in
+# it is found by nobody until the day someone runs this target.
+#
+# `npm install` fetches the driver, not the browser — playwright 1.62.1
+# has no postinstall step — so `playwright install` is a separate line,
+# and without it this target passes only on a machine whose Playwright
+# cache someone has already warmed. `--with-deps` is deliberately not
+# used: it installs system packages and needs root, which is the
+# operator's call and not a test target's.
+#
+# It also needs UDP. Inside the Claude Code bash sandbox Chromium gathers
+# zero ICE candidates and every call fails with frames stuck at 0 — run
+# this target with the sandbox off.
+#
+# -count=1 because call.mjs is not an input to Go's test cache: node
+# opens it, not the test, so an edited browser script would otherwise be
+# answered with a cached pass from before the edit.
browser:
+ go vet -tags browser ./internal/meettest/
npm install --no-save playwright@1.62.1
- go test -tags browser -run TestCallInARealBrowser -v ./internal/meettest/
+ npx playwright install chromium
+ go test -tags browser -count=1 -run TestCallInARealBrowser -v ./internal/meettest/
diff --git a/internal/meettest/browser_test.go b/internal/meettest/browser_test.go
index 16f16be..d444262 100644
--- a/internal/meettest/browser_test.go
+++ b/internal/meettest/browser_test.go
@@ -35,6 +35,7 @@ import (
"net/http"
"net/http/cookiejar"
"net/url"
+ "os"
"os/exec"
"path/filepath"
"regexp"
@@ -65,41 +66,35 @@ const (
// 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"},
+// All nine pass. A scenario that starts failing on the client rather
+// than on the gate keeps its place here and its assertions as written,
+// and is skipped from inside its own subtest with t.Skip carrying the
+// exact failure — never deleted, and never quietly loosened.
+var scenarios = []string{
+ "devices",
+ "call",
+ "renewal",
+ "guest",
+ "removed",
+ "restart",
+ "takeover",
+ "crosssite",
+ "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)
- }
+ for _, name := range scenarios {
+ t.Run(name, func(t *testing.T) {
// 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)
+ rig.run(t, name, sqid, token)
})
}
}
@@ -178,9 +173,12 @@ func (rig *browserRig) serve(t *testing.T, ln net.Listener) {
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.
+// stop is the SIGTERM and the socket close, in that order. Close does
+// not wait for handlers — it shuts the listener and every connection at
+// once — but cancelling the app's context first is what a real SIGTERM
+// does, and it is the only thing that lets a live stream handler return
+// through its own `case <-a.done` instead of having its socket pulled
+// out from under it mid-write.
func (rig *browserRig) stop() {
rig.mu.Lock()
srv, ctl := rig.srv, rig.ctl
@@ -323,11 +321,20 @@ 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)
+ rig.origin, sqid, scenario)
cmd.Dir = repoRootForBrowser(t)
+ // The session cookies and the join-link token go in the environment,
+ // never on argv: a command line is readable by every process on the
+ // box, and either one of these is enough to be somebody in this
+ // meeting.
+ cmd.Env = append(os.Environ(),
+ "MEET_COOKIE_NAME="+rig.cookieName,
+ "MEET_COOKIE_A="+rig.cookieA,
+ "MEET_COOKIE_B="+rig.cookieB,
+ "MEET_JOIN_TOKEN="+token,
+ )
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -341,9 +348,28 @@ func (rig *browserRig) run(t *testing.T, scenario, sqid, token string) {
cmd.Stderr = &errs
if err := cmd.Start(); err != nil {
+ cancel()
t.Fatalf("start node: %v", err)
}
+ // Every path out of here reaps node, including the ones that do not
+ // come back: rig.control calls t.Fatalf, which unwinds this
+ // goroutine, and without this the browser would outlive the test.
+ // Cancel first, so a hung node is killed rather than waited on.
+ var waitErr error
+ waited := false
+ wait := func() error {
+ if !waited {
+ waited = true
+ waitErr = cmd.Wait()
+ }
+ return waitErr
+ }
+ defer func() {
+ cancel()
+ _ = wait()
+ }()
+
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64<<10), 4<<20)
for scanner.Scan() {
@@ -358,12 +384,12 @@ func (rig *browserRig) run(t *testing.T, scenario, sqid, token string) {
}
_ = stdin.Close()
- waitErr := cmd.Wait()
+ err = 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)
+ if err != nil {
+ t.Fatalf("the %s scenario failed: %v", scenario, err)
}
}
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index e4690c3..d3386ee 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -2,11 +2,15 @@
//
// 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.
+// the meeting, and hands this script the origin, the meeting id and the
+// scenario to run.
//
-// node test/browser/call.mjs <origin> <sqid> <cookieName> <cookieA> <cookieB> <scenario> <token>
+// node test/browser/call.mjs <origin> <sqid> <scenario>
+//
+// The secrets do not travel on argv, where every process on the box can
+// read them off /proc: the session cookies and the meeting's join-link
+// token arrive in the environment, as MEET_COOKIE_NAME, MEET_COOKIE_A,
+// MEET_COOKIE_B and MEET_JOIN_TOKEN.
//
// One scenario per process. Every assertion prints one line; the exit
// code is the gate.
@@ -26,19 +30,29 @@
// 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 { writeSync } from 'node:fs';
import { createInterface } from 'node:readline';
+import { chromium } from 'playwright';
-const [origin, sqid, cookieName, cookieA, cookieB, scenario, token] =
- process.argv.slice(2);
+const [origin, sqid, scenario] = process.argv.slice(2);
+const cookieName = process.env.MEET_COOKIE_NAME;
+const cookieA = process.env.MEET_COOKIE_A;
+const cookieB = process.env.MEET_COOKIE_B;
+const token = process.env.MEET_JOIN_TOKEN;
-if (!origin || !sqid || !cookieName || !cookieA || !cookieB || !scenario) {
- console.error('usage: call.mjs <origin> <sqid> <cookieName> <cookieA> <cookieB> <scenario> [token]');
+if (!origin || !sqid || !scenario || !cookieName || !cookieA || !cookieB) {
+ console.error('usage: call.mjs <origin> <sqid> <scenario>');
+ console.error(' with MEET_COOKIE_NAME, MEET_COOKIE_A, MEET_COOKIE_B and MEET_JOIN_TOKEN in the environment');
process.exit(2);
}
const GUEST = 'guest@example.com';
+// The Go driver gives up at three minutes. This gives up first, so a
+// stuck scenario prints why instead of being killed silently. unref'd:
+// it must not be the reason the process stays alive.
+const HARNESS_DEADLINE = 165000;
+
const LAUNCH_ARGS = [
'--use-fake-device-for-media-stream',
'--use-fake-ui-for-media-stream',
@@ -51,9 +65,11 @@ const LAUNCH_ARGS = [
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');
+ // Written synchronously to fd 1: an assertion is evidence, and
+ // evidence that is still in a pipe buffer when the process ends is
+ // evidence nobody has. Never a line Go would read as a control
+ // request, and never a newline inside one.
+ writeSync(1, String(s).replace(/\r?\n/g, ' | ') + '\n');
}
function check(cond, what) {
@@ -66,6 +82,17 @@ function check(cond, what) {
return false;
}
+// where names a page without quoting a secret. A join link and a magic
+// link both live in a URL, and these lines are written to a log that
+// outlives the run: only the first path segment goes in.
+function where(page) {
+ try {
+ return '/' + (new URL(page.url()).pathname.split('/')[1] || '');
+ } catch {
+ return '(no url)';
+ }
+}
+
// ── the control channel back to Go ───────────────────────────────────
let rl = null;
@@ -81,7 +108,7 @@ function control(verb) {
else inbox.push(l);
});
}
- process.stdout.write('CONTROL ' + verb + '\n');
+ line('CONTROL ' + verb);
return new Promise((resolve) => {
if (inbox.length) resolve(inbox.shift());
else waiters.push(resolve);
@@ -109,6 +136,20 @@ async function until(fn, ms) {
}
}
+// stayed is until's opposite, for the assertions that are about nothing
+// happening: it polls for the whole window and returns null if the
+// predicate held throughout, or the first state that broke it. A single
+// read cannot tell "did not happen" from "has not happened yet".
+async function stayed(page, ms, pred) {
+ const deadline = Date.now() + ms;
+ for (;;) {
+ const s = await state(page);
+ if (!pred(s)) return s;
+ if (Date.now() > deadline) return null;
+ await sleep(250);
+ }
+}
+
// ── the browser ──────────────────────────────────────────────────────
const browser = await chromium.launch({ args: LAUNCH_ARGS });
@@ -137,16 +178,20 @@ 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.
+// state reads window.meet.state(). frames, signaling and connection are
+// Maps in the page, so they are flattened here rather than trusting a
+// serializer to carry them.
function state(page) {
return page.evaluate(() => {
const s = window.meet.state();
return {
peer: s.peer,
admitted: s.admitted,
+ terminal: s.terminal,
peers: s.peers,
frames: Object.fromEntries(s.frames),
+ signaling: Object.fromEntries(s.signaling),
+ connection: Object.fromEntries(s.connection),
};
});
}
@@ -156,10 +201,11 @@ function statusText(page) {
}
// diagnose is what a failure prints. It is deliberately more than the
-// assertion needed: this output is the fix round's evidence, and the
+// assertion needed: this output is a 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.
+// connection said connected, the video element had a stream, 640px of
+// width and was not paused" is the whole diagnosis. Only the first path
+// segment of the URL goes in — see where().
async function diagnose(page, label) {
try {
const d = await page.evaluate(() => {
@@ -177,11 +223,19 @@ async function diagnose(page, label) {
};
});
return {
- url: location.pathname,
+ at: '/' + (location.pathname.split('/')[1] || ''),
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) }
+ ? {
+ peer: s.peer,
+ admitted: s.admitted,
+ terminal: s.terminal,
+ peers: s.peers,
+ frames: Object.fromEntries(s.frames),
+ signaling: Object.fromEntries(s.signaling),
+ connection: Object.fromEntries(s.connection),
+ }
: null,
tiles,
queue: Array.from(document.querySelectorAll('#knocks li')).map((li) => li.textContent),
@@ -195,7 +249,7 @@ async function diagnose(page, label) {
// ── the room ─────────────────────────────────────────────────────────
-async function openRoom(ctx, label) {
+async function openRoom(ctx) {
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
@@ -236,23 +290,27 @@ async function assertPair(pageA, pageB, labelA, labelB, ms = 25000) {
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 + ')');
+ // Negotiation settled and the transport came up, read straight off the
+ // RTCPeerConnection. Glare runs on nearly every pair here, so a
+ // signalingState still at have-local-offer is the shape a collision
+ // that never resolved would leave behind.
+ for (const [page, label, other, otherLabel] of [
+ [pageA, labelA, b.peer, labelB],
+ [pageB, labelB, a.peer, labelA],
+ ]) {
+ const settled = await until(async () => {
+ const s = await state(page);
+ return s.signaling[other] === 'stable' && s.connection[other] === 'connected' ? s : false;
+ }, ms);
+ check(settled, label + ' settled negotiation and connected to ' + otherLabel +
+ ' — ' + (await diagnose(page, label)));
+ }
+
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 };
}
@@ -278,19 +336,21 @@ async function leaveRoom(page) {
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() + ')');
+ check(page.url().startsWith(origin + '/signin'),
+ 'the link sends a signed-out outsider to sign in (at ' + where(page) + ')');
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');
+ check(page.url() === origin + '/signin?sent=1',
+ 'asking for a sign-in link gives the same answer every time (at ' + where(page) + ')');
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() + ')');
+ check(page.url() === origin + '/m/' + sqid + '/join',
+ 'the emailed link lands the guest on this meeting (at ' + where(page) + ')');
await page.close();
}
@@ -331,8 +391,8 @@ const run = {
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');
+ const pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
await assertPair(pageA, pageB, 'member A', 'member B');
@@ -363,8 +423,8 @@ const run = {
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 pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
const pair = await assertPair(pageA, pageB, 'member A', 'member B');
if (!pair) {
@@ -405,12 +465,12 @@ const run = {
async guest() {
const ctxA = await newContext('member A', { cookie: cookieA });
const ctxG = await newContext('guest');
- const pageA = await openRoom(ctxA, 'member A');
+ const pageA = await openRoom(ctxA);
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 pageG = await openRoom(ctxG);
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')));
@@ -439,11 +499,11 @@ const run = {
async removed() {
const ctxA = await newContext('member A', { cookie: cookieA });
const ctxG = await newContext('guest');
- const pageA = await openRoom(ctxA, 'member A');
+ const pageA = await openRoom(ctxA);
await until(async () => (await state(pageA)).admitted, 20000);
await signInAsGuest(ctxG);
- const pageG = await openRoom(ctxG, 'guest');
+ const pageG = await openRoom(ctxG);
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')));
@@ -475,15 +535,17 @@ const run = {
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(confirm.url() === origin + '/m/' + sqid + '/join',
+ 'the confirmed removal lands on the holding screen, which opens no stream (at ' + where(confirm) + ')');
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 ended = await until(async () => {
const s = await state(pageG);
- return s.peers.length === 0;
+ return s.terminal === 'removed' && s.admitted === false && s.peers.length === 0 ? s : false;
}, 10000);
- check(gone, 'the removed guest holds no peer connections — ' + (await diagnose(pageG, 'guest')));
+ check(ended, 'the removed guest is terminal on `removed`, not admitted, and holds no peer connections — ' +
+ (await diagnose(pageG, 'guest')));
await leaveRoom(pageA);
await ctxA.close();
@@ -495,8 +557,8 @@ const run = {
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 pageA = await openRoom(ctxA);
+ const pageB = await openRoom(ctxB);
const pair = await assertPair(pageA, pageB, 'member A', 'member B');
if (!pair) {
@@ -529,18 +591,24 @@ const run = {
// 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');
+ const first = await openRoom(ctxA);
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');
+ const second = await openRoom(ctxA);
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');
+
+ // Not a single read: "was not displaced" is a claim about a window
+ // of time, and the displacement it is denying would arrive over a
+ // stream a moment later if it were going to arrive at all.
+ await sleep(1000);
+ const displaced = await stayed(first, 3000, (s) => s.peer !== null && !s.terminal);
+ check(displaced === null,
+ 'the first tab was not displaced by the second opening (' + JSON.stringify(displaced) + ')');
const takeoverHref = await second.locator('#status a').getAttribute('href');
check(takeoverHref === '/m/' + sqid + '/takeover',
@@ -554,17 +622,19 @@ const run = {
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(second.url() === origin + '/m/' + sqid,
+ 'the confirmed takeover lands the second tab in the room (at ' + where(second) + ')');
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)));
+ const lost = await state(first);
+ check(lost.terminal === 'replaced' && lost.admitted === false,
+ 'the displaced tab is terminal on `replaced` and no longer admitted (' + JSON.stringify(lost) + ')');
// 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)));
+ const reconnected = await stayed(first, 5000, (s) => s.peers.length === 0 && s.terminal === 'replaced');
+ check(reconnected === null,
+ 'the displaced tab did not reconnect (' + JSON.stringify(reconnected) + ')');
await ctxA.close();
},
@@ -574,7 +644,7 @@ const run = {
// 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');
+ const page = await openRoom(ctxA);
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);
@@ -595,7 +665,8 @@ const run = {
other.waitForURL('**/m/' + sqid + '/takeover', { timeout: 15000 }),
other.click('#go'),
]);
- check(true, 'the cross-site GET reached the takeover URL');
+ check(other.url() === origin + '/m/' + sqid + '/takeover',
+ 'the cross-site GET reached the takeover URL (at ' + where(other) + ')');
// And the navigation to the stream itself, which must not take this
// session's peer slot either.
@@ -604,17 +675,21 @@ const run = {
other.waitForURL('**/m/' + sqid + '/stream', { timeout: 15000 }),
other.click('#stream'),
]);
- check(true, 'the cross-site GET reached the stream URL');
+ check(other.url() === origin + '/m/' + sqid + '/stream',
+ 'the cross-site GET reached the stream URL (at ' + where(other) + ')');
// 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 + ')');
+ // occupy this session's one peer slot for the resume window. The
+ // exact bytes, because the whole point of that refusal is that it
+ // is identical for every meeting and every person.
+ const refusal = (await other.locator('body').innerText()).trim();
+ check(refusal === '{"error":"accept"}',
+ 'the navigation was refused with exactly the accept error (' + refusal + ')');
+
+ const disturbed = await stayed(page, 3000,
+ (s) => s.peer === before.peer && !s.terminal);
+ check(disturbed === null,
+ 'the call kept its participation through the cross-site GETs (' + JSON.stringify(disturbed) + ')');
check(!(await statusText(page)).includes('This meeting is open in another tab.'),
'the call was not displaced by a GET — status: ' + (await statusText(page)));
@@ -649,27 +724,41 @@ const run = {
// ── run it ───────────────────────────────────────────────────────────
+const watchdog = setTimeout(() => {
+ line('FAIL ' + scenario + ' hit the harness deadline of ' + (HARNESS_DEADLINE / 1000) + ' seconds');
+ process.exit(1);
+}, HARNESS_DEADLINE);
+watchdog.unref();
+
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);
+ line('FAIL unknown scenario: ' + scenario);
+ process.exitCode = 2;
+} else {
+ 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);
+ }
+ if (failures.length) {
+ line(failures.length + ' browser check(s) failed in ' + scenario);
+ process.exitCode = 1;
+ } else {
+ line(scenario + ': all browser checks passed');
+ process.exitCode = 0;
+ }
}
await browser.close();
-
-if (failures.length) {
- line(failures.length + ' browser check(s) failed in ' + scenario);
- process.exit(1);
+clearTimeout(watchdog);
+// The exit code is already set; letting node end on its own is what
+// guarantees the last line above is on its way out rather than
+// discarded mid-flush. stdin is the only thing that would otherwise
+// hold the loop open, and only when the control channel was used.
+if (rl) {
+ rl.close();
+ process.stdin.pause();
}
-line(scenario + ': all browser checks passed');
-process.exit(0);