| 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); |