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