diff --git a/README.md b/README.md
index 28ae292d..de6cd967 100644
--- a/README.md
+++ b/README.md
@@ -180,8 +180,22 @@ with tab audio enabled. Meet owns the shared tab after that, and face-presence
analysis is disabled for the session. See the
[manual check](docs/meet-tab-audio-manual-check.md) for the supported flow.
-To record a practice interview without Meet, tick "I am recording this
-interview with a screen recorder" in the media preflight, then capture the
+To use the built-in local recorder in Chrome or Chromium, select Start & record
+in the media preflight to include Jim's opening, or Start recording during the
+interview. Choose the CodeTrial tab and enable Share tab audio. The recorder
+mixes that audio with the existing interview microphone; End recording saves a
+WebM for local download. While recording, the existing self view appears
+automatically when the camera is on and ready and Meet presentation is off.
+The screen-recorder checkbox is not needed for this path.
+
+Local recording targets 1 Mbps video and 128 kbps audio, about 760 MB for
+90 minutes before container overhead. These are encoder targets, not hard
+limits; actual size and memory use vary with the browser and captured content.
+The recording stays in tab memory until saved, so shorter recordings reduce
+memory use.
+
+For an external screen recorder, tick "I am recording this interview with a
+screen recorder" in the media preflight, then capture the
screen and system audio with any screen recorder, such as OBS or Clipchamp, and
leave the recorder's own camera off. While the camera is on and ready, the
interview page then shows it below Jim, so the screen capture already includes
@@ -191,8 +205,8 @@ the right way round in the file. Turning the recorder's camera on as well is
what fails: on Windows a second application usually cannot open a camera the
browser already holds, and the preflight camera check then cannot pass. Select
the microphone in the recorder too, since the page never plays the candidate's
-voice back and system audio carries only Jim. Without the tick the interview
-page shows no self view.
+voice back and system audio carries only Jim. Without the tick or an active
+local recording, the interview page shows no self view.
The lobby offers 30, 45, and 60 minutes and the endpoints accept 10 to 90.
Recording lowers that ceiling to `CODETRIAL_RECORDING_MAX_MINUTES`, and the
diff --git a/tests/browser/avatar.test.js b/tests/browser/avatar.test.js
index 7aeb13a1..277f4652 100644
--- a/tests/browser/avatar.test.js
+++ b/tests/browser/avatar.test.js
@@ -14,6 +14,7 @@ import {
captures,
firstPartyScripts,
functionBody,
+ INTERVIEW_SOURCES,
interviewSource,
read,
root,
@@ -818,21 +819,27 @@ test("avatar analyser reads Jim and never the candidate", () => {
// have passed a slice-scoped assertion while the avatar watched the candidate.
// An allowlist, not a count. The preflight legitimately builds a source from
// the candidate's own microphone to drive the level meter, so the property
- // worth pinning is that the set does not grow: exactly two call sites, and
- // the avatar's is the remote track.
+ // worth pinning is which module may read each source. Local recording also
+ // mixes the tab and existing microphone; neither source belongs to the avatar.
//
// `mic-meter.js` is read in beside the path rather than added to
// `INTERVIEW_SOURCES`, which is the modules `interview.js` hands its bindings
// to and is pinned to exactly those. The meter is where the microphone's own
// source lives, so leaving it out would let this allowlist grow unwatched in
// the one file most likely to grow it.
- const sources = captures(
- `${script}\n${read("web/mic-meter.js")}`,
- /createMediaStreamSource\(([^)]*)\)/g,
- ).map((source) => source.trim());
+ const sources = [...INTERVIEW_SOURCES, "web/mic-meter.js"].flatMap((name) =>
+ captures(read(name), /createMediaStreamSource\(([^)]*)\)/g).map(
+ (source) => [name, source.trim()],
+ ),
+ );
assert.deepEqual(
sources,
- ["new MediaStream([track.mediaStreamTrack]", "stream"],
+ [
+ ["web/local-recorder.js", "new MediaStream(mic"],
+ ["web/local-recorder.js", "new MediaStream(capture.getAudioTracks("],
+ ["web/avatar/stage.js", "new MediaStream([track.mediaStreamTrack]"],
+ ["web/mic-meter.js", "stream"],
+ ],
"a new createMediaStreamSource call site appeared; the avatar must only ever read Jim",
);
assert.doesNotMatch(script, /createMediaStreamSource\([^)]*localUserStream/);
diff --git a/tests/browser/interview-layout.test.js b/tests/browser/interview-layout.test.js
index a375afea..b40d6430 100644
--- a/tests/browser/interview-layout.test.js
+++ b/tests/browser/interview-layout.test.js
@@ -532,3 +532,48 @@ test("the replay list marks the open recording", async (t) => {
await page.close();
}
});
+
+test("local recording controls preserve the original problem reading space", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ for (const [width, height, fontFamily] of [
+ [1280, 720, null],
+ [1280, 720, "Verdana, sans-serif"],
+ [390, 844, null],
+ [390, 844, "Verdana, sans-serif"],
+ ]) {
+ const page = await openInterview(width, height);
+ try {
+ await closeSetupCheck(page);
+ await page.evaluate((fontFamily) => {
+ document.body.style.fontFamily = fontFamily || "";
+ }, fontFamily);
+ const sizes = await page.evaluate(() => {
+ const panel = document.querySelector("#local-recording");
+ const height = () =>
+ document.querySelector("#problem-panel").getBoundingClientRect()
+ .height;
+ const idle = height();
+ panel.hidden = true;
+ const original = height();
+ panel.hidden = false;
+ panel.querySelector("[data-local-start]").hidden = true;
+ const stop = panel.querySelector("[data-local-stop]");
+ stop.hidden = false;
+ stop.textContent = "End recording 00:05";
+ return { original, idle, recording: height() };
+ });
+ assert.equal(
+ sizes.idle,
+ sizes.original,
+ JSON.stringify({ width, fontFamily, ...sizes }),
+ );
+ assert.equal(
+ sizes.recording,
+ sizes.original,
+ JSON.stringify({ width, fontFamily, ...sizes }),
+ );
+ } finally {
+ await page.close();
+ }
+ }
+});
diff --git a/tests/browser/interview-lifecycle.test.js b/tests/browser/interview-lifecycle.test.js
index 0654a854..8f28a915 100644
--- a/tests/browser/interview-lifecycle.test.js
+++ b/tests/browser/interview-lifecycle.test.js
@@ -149,8 +149,9 @@ test("the room disconnects on actual pagehide, not when beforeunload is cancelle
const pagehide = new Function(
"state",
"reportRecovery",
+ "localRecording",
`${body}\n}\nreturn finalizeRecoveryOnPageHide;`,
- )(state, reportRecovery);
+ )(state, reportRecovery, { dispose: () => calls.push("dispose") });
const guard = createBeforeUnloadGuard(target);
guard.setEnabled(true);
@@ -162,7 +163,7 @@ test("the room disconnects on actual pagehide, not when beforeunload is cancelle
// Once navigation really proceeds, pagehide performs room cleanup.
target.dispatchEvent(new Event("pagehide"));
- assert.deepEqual(calls, ["disconnect"]);
+ assert.deepEqual(calls, ["dispose", "disconnect"]);
guard.dispose();
target.removeEventListener("pagehide", pagehide);
@@ -451,6 +452,7 @@ test("pagehide finalizes recovery before disconnecting the room", () => {
const handler = new Function(
"state",
"reportRecovery",
+ "localRecording",
`${body}\n}\nreturn finalizeRecoveryOnPageHide;`,
)(
{
@@ -463,8 +465,9 @@ test("pagehide finalizes recovery before disconnecting the room", () => {
},
},
{ finish: () => calls.push("finish") },
+ { dispose: () => calls.push("dispose") },
);
handler();
- assert.deepEqual(calls, ["finish", "disconnect"]);
+ assert.deepEqual(calls, ["dispose", "finish", "disconnect"]);
});
diff --git a/tests/browser/local-recorder.test.js b/tests/browser/local-recorder.test.js
new file mode 100644
index 00000000..5f06270b
--- /dev/null
+++ b/tests/browser/local-recorder.test.js
@@ -0,0 +1,1315 @@
+import { after, before, test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ functionBody,
+ FAKE_SPEECH_CAPTURE,
+ launchChromium,
+ read,
+ startStaticServer,
+} from "./source.js";
+
+let browser, server, base;
+before(async () => {
+ browser = await launchChromium({
+ args: [
+ "--autoplay-policy=no-user-gesture-required",
+ "--use-fake-device-for-media-stream",
+ "--use-fake-ui-for-media-stream",
+ FAKE_SPEECH_CAPTURE,
+ ],
+ });
+ if (browser) ({ server, base } = await startStaticServer());
+});
+after(async () => {
+ await browser?.close();
+ server?.closeAllConnections();
+ await new Promise((resolve) => (server ? server.close(resolve) : resolve()));
+});
+
+async function setup(t, options = {}) {
+ if (!browser) {
+ t.skip("playwright chromium unavailable");
+ return;
+ }
+ const page = await browser.newPage();
+ t.after(async () => {
+ await page.evaluate(async () => {
+ window.local?.dispose();
+ window.mic?.getTracks().forEach((track) => track.stop());
+ window.capture?.getTracks().forEach((track) => track.stop());
+ clearInterval(window.drawTimer);
+ await window.probeAudio?.close();
+ });
+ await page.close();
+ });
+ const panel = read("web/interview.html").match(
+ /
/,
+ )[0];
+ await page.route("**/recorder-probe", (route) =>
+ route.fulfill({ contentType: "text/html", body: panel }),
+ );
+ await page.goto(`${base}/recorder-probe`);
+ await page.evaluate(async (options) => {
+ const { initLocalRecording } = await import("/local-recorder.js");
+ const audio = new AudioContext();
+ window.probeAudio = audio;
+ await audio.resume();
+ const tone = (frequency) => {
+ const source = audio.createOscillator();
+ source.frequency.value = frequency;
+ const gain = audio.createGain();
+ gain.gain.value = 0.1;
+ const output = audio.createMediaStreamDestination();
+ source.connect(gain).connect(output);
+ source.start();
+ return output.stream;
+ };
+ window.mic = tone(440);
+ const canvas = document.createElement("canvas");
+ const drawing = canvas.getContext("2d");
+ drawing.fillRect(0, 0, 100, 100);
+ window.drawTimer = setInterval(() => drawing.fillRect(0, 0, 100, 100), 100);
+ const makeCapture = () =>
+ new MediaStream([
+ ...canvas.captureStream(10).getVideoTracks(),
+ ...(options.noAudio ? [] : tone(880).getAudioTracks()),
+ ]);
+ window.capture = makeCapture();
+ window.captureCalls = 0;
+ navigator.mediaDevices.getUserMedia = () => {
+ throw new Error("The recorder must reuse the existing microphone");
+ };
+ navigator.mediaDevices.getDisplayMedia = async (captureOptions) => {
+ window.captureOptions = captureOptions;
+ window.captureCalls++;
+ if (options.denied || window.rejectCapture)
+ throw new DOMException("Denied", "NotAllowedError");
+ if (options.pending)
+ await new Promise((resolve) => (window.allowCapture = resolve));
+ if (window.capture.getVideoTracks()[0].readyState === "ended")
+ window.capture = makeCapture();
+ return window.capture;
+ };
+ const NativeRecorder = MediaRecorder;
+ window.recorderCalls = 0;
+ window.stopCalls = 0;
+ window.created = [];
+ window.revoked = [];
+ window.downloadClicks = 0;
+ window.recordingChanges = [];
+ document
+ .querySelector("[data-local-download]")
+ .addEventListener("click", () => window.downloadClicks++);
+ const create = URL.createObjectURL.bind(URL);
+ const revoke = URL.revokeObjectURL.bind(URL);
+ URL.createObjectURL = (blob) => {
+ const url = create(blob);
+ window.created.push(url);
+ window.result = blob;
+ return url;
+ };
+ URL.revokeObjectURL = (url) => {
+ window.revoked.push(url);
+ revoke(url);
+ };
+ window.MediaRecorder = class extends NativeRecorder {
+ constructor(stream, config) {
+ super(stream, config);
+ window.recorderCalls++;
+ window.recorded = stream;
+ window.recorderOptions = config;
+ window.nativeRecorder = this;
+ window.recordedBytes = 0;
+ this.addEventListener(
+ "dataavailable",
+ ({ data }) => (window.recordedBytes += data.size),
+ );
+ if (options.fail) throw new Error("Recorder unavailable");
+ }
+ stop() {
+ window.stopCalls++;
+ super.stop();
+ }
+ };
+ if (options.unsupported) window.MediaRecorder = undefined;
+ if (options.pendingResume) {
+ const NativeAudioContext = AudioContext;
+ window.pauseResume = true;
+ window.localTimers = 0;
+ const setInterval = window.setInterval.bind(window);
+ window.setInterval = (...args) => {
+ window.localTimers++;
+ return setInterval(...args);
+ };
+ window.AudioContext = class extends NativeAudioContext {
+ constructor(...args) {
+ super(...args);
+ window.mixContext = this;
+ }
+ createMediaStreamDestination() {
+ const destination = super.createMediaStreamDestination();
+ window.mixDestination = destination;
+ return destination;
+ }
+ async resume() {
+ if (window.pauseResume)
+ await new Promise((resolve) => (window.resumeCapture = resolve));
+ try {
+ return await super.resume();
+ } finally {
+ (window.resumedContexts ||= []).push(this);
+ }
+ }
+ };
+ }
+ window.recordingState = { localUserStream: window.mic };
+ window.local = initLocalRecording({
+ state: window.recordingState,
+ nodes: { localRecording: document.querySelector("#local-recording") },
+ recordingEnabled: options.serverRecording === true,
+ onRecordingChange: (active) => window.recordingChanges.push(active),
+ });
+ }, options);
+ return page;
+}
+
+const start = "[data-local-start]";
+const stop = "[data-local-stop]";
+const status = "[data-local-status]";
+const download = "[data-local-download]";
+
+async function record(page) {
+ await page.locator(start).click();
+ await page.waitForFunction(() =>
+ document
+ .querySelector("[data-local-status]")
+ .textContent.includes("Recording locally"),
+ );
+ await page.waitForFunction(() => window.recordedBytes > 0);
+}
+async function ready(page) {
+ await page.locator(download).waitFor({ state: "visible" });
+}
+
+test("keyboard recording controls hand focus to the next available action", async (t) => {
+ const page = await setup(t, { pending: true });
+ if (!page) return;
+ await page.locator(start).focus();
+ await page.locator(start).press("Enter");
+ await page.waitForFunction(() => window.allowCapture);
+ await page.evaluate(() => window.allowCapture());
+ await page.waitForFunction(
+ () =>
+ document.activeElement === document.querySelector("[data-local-stop]"),
+ );
+ await page.waitForFunction(() => window.recordedBytes > 0);
+ await page.locator(stop).press("Enter");
+ await ready(page);
+ await page.waitForFunction(() =>
+ document.activeElement?.matches(
+ "[data-local-start], [data-local-download]",
+ ),
+ );
+ assert.equal(await page.locator(stop).isHidden(), true);
+});
+
+test("recording ticks and browser Stop sharing preserve editor focus", async (t) => {
+ const page = await setup(t);
+ if (!page) return;
+ await record(page);
+ const elapsed = await page.locator(stop).textContent();
+ await page.evaluate(() => {
+ const editor = document.createElement("textarea");
+ editor.id = "editor";
+ document.body.append(editor);
+ editor.focus();
+ });
+ await page.waitForFunction(
+ (elapsed) =>
+ document.querySelector("[data-local-stop]").textContent !== elapsed,
+ elapsed,
+ );
+ assert.equal(
+ await page
+ .locator("#editor")
+ .evaluate((node) => node === document.activeElement),
+ true,
+ );
+ await page.evaluate(() =>
+ window.capture.getVideoTracks()[0].dispatchEvent(new Event("ended")),
+ );
+ await ready(page);
+ assert.equal(
+ await page
+ .locator("#editor")
+ .evaluate((node) => node === document.activeElement),
+ true,
+ );
+});
+
+test("local recording status is accessible before its first instruction and error", async (t) => {
+ const page = await setup(t, { pending: true });
+ if (!page) return;
+ assert.equal(await page.getByRole("status").count(), 1);
+ assert.equal(await page.locator(status).textContent(), "");
+ await page.locator(start).click();
+ await page.waitForFunction(() => window.allowCapture);
+ assert.match(
+ await page.getByRole("status").textContent(),
+ /Choose the CodeTrial tab/,
+ );
+ await page.evaluate(() => {
+ window.capture.getAudioTracks().forEach((track) => {
+ window.capture.removeTrack(track);
+ track.stop();
+ });
+ window.allowCapture();
+ });
+ await page.waitForFunction(() =>
+ document
+ .querySelector("[data-local-status]")
+ .textContent.includes("No tab audio"),
+ );
+ assert.match(await page.getByRole("status").textContent(), /No tab audio/);
+});
+
+test("Stop sharing during audio startup cancels capture and allows another recording", async (t) => {
+ const page = await setup(t, { pendingResume: true });
+ if (!page) return;
+ await page.evaluate(() => {
+ window.startResult = window.local.start();
+ });
+ await page.waitForFunction(() => window.resumeCapture);
+ await page.evaluate(() => {
+ window.cancelledMixContext = window.mixContext;
+ const video = window.capture.getVideoTracks()[0];
+ video.stop();
+ video.dispatchEvent(new Event("ended"));
+ });
+ await page.waitForFunction(
+ () =>
+ window.recorderCalls ||
+ (document
+ .querySelector("[data-local-start]")
+ .getAttribute("aria-disabled") !== "true" &&
+ window.mixContext.state === "closed"),
+ );
+ assert.deepEqual(
+ await page.evaluate(() => ({
+ recorderCalls: window.recorderCalls,
+ timerCalls: window.localTimers,
+ captureEnded: window.capture
+ .getTracks()
+ .every((track) => track.readyState === "ended"),
+ mixEnded: window.mixDestination.stream
+ .getTracks()
+ .every((track) => track.readyState === "ended"),
+ context: window.mixContext.state,
+ microphone: window.mic.getAudioTracks()[0].readyState,
+ active: window.recordingChanges.includes(true),
+ })),
+ {
+ recorderCalls: 0,
+ timerCalls: 0,
+ captureEnded: true,
+ mixEnded: true,
+ context: "closed",
+ microphone: "live",
+ active: false,
+ },
+ );
+ assert.equal(await page.evaluate(() => window.startResult), false);
+ assert.doesNotMatch(
+ await page.locator(status).textContent(),
+ /Recording locally/,
+ );
+ await page.evaluate(() => (window.pauseResume = false));
+ await record(page);
+ await page.evaluate(() => window.resumeCapture());
+ await page.waitForFunction(() =>
+ window.resumedContexts.includes(window.cancelledMixContext),
+ );
+ assert.equal(
+ await page.evaluate(() => window.nativeRecorder.state),
+ "recording",
+ );
+ assert.equal(await page.evaluate(() => window.mixContext.state), "running");
+ assert.equal(
+ await page.evaluate(() =>
+ window.capture.getTracks().every((track) => track.readyState === "live"),
+ ),
+ true,
+ );
+ await page.locator(stop).click();
+ await ready(page);
+ assert.equal(await page.evaluate(() => window.recorderCalls), 1);
+});
+
+test("explicit Start mixes tab audio and the existing mic into a downloadable WebM", async (t) => {
+ const page = await setup(t);
+ if (!page) return;
+ assert.equal(
+ await page.evaluate(() => typeof window.local.stop().then),
+ "function",
+ );
+ await page.evaluate(() => window.local.stop());
+ assert.equal(await page.evaluate(() => window.captureCalls), 0);
+ assert.equal(await page.locator(stop).isDisabled(), true);
+ await record(page);
+ assert.equal(
+ await page.evaluate(() => window.recorderOptions.videoBitsPerSecond),
+ 1_000_000,
+ );
+ assert.ok(
+ await page.evaluate(
+ () => window.nativeRecorder.videoBitsPerSecond <= 1_000_000,
+ ),
+ );
+ assert.deepEqual(await page.evaluate(() => window.captureOptions.audio), {
+ suppressLocalAudioPlayback: false,
+ restrictOwnAudio: false,
+ echoCancellation: false,
+ noiseSuppression: false,
+ autoGainControl: false,
+ });
+ assert.deepEqual(
+ await page.evaluate(() => [
+ window.captureCalls,
+ window.recorded.getVideoTracks().length,
+ window.recorded.getAudioTracks().length,
+ ]),
+ [1, 1, 1],
+ );
+ await page.waitForFunction(() =>
+ /00:01/.test(document.querySelector("[data-local-stop]").textContent),
+ );
+ assert.match(await page.locator(stop).textContent(), /End recording 00:01/);
+ assert.equal(await page.locator(start).isHidden(), true);
+ const saved = page.waitForEvent("download");
+ assert.equal(
+ await page.evaluate(() => {
+ window.stopped = window.local.stop();
+ return window.stopped === window.local.stop();
+ }),
+ true,
+ );
+ await page.evaluate(() => window.stopped);
+ assert.equal(await page.locator(download).isVisible(), true);
+ assert.match((await saved).suggestedFilename(), /^codetrial-.*\.webm$/);
+ await ready(page);
+ assert.deepEqual(await page.evaluate(() => window.recordingChanges), [
+ true,
+ false,
+ ]);
+ assert.equal(await page.evaluate(() => window.downloadClicks), 1);
+ const result = await page.evaluate(async () => {
+ const decoder = new AudioContext();
+ const audio = await decoder.decodeAudioData(
+ await window.result.arrayBuffer(),
+ );
+ const samples = audio.getChannelData(0);
+ // Both source tones must survive encoding; silence or either source alone
+ // fails this check even when the stream wiring looks correct.
+ const magnitude = (hz) => {
+ let real = 0,
+ imaginary = 0;
+ for (let i = 0; i < samples.length; i++) {
+ const phase = (2 * Math.PI * hz * i) / audio.sampleRate;
+ real += samples[i] * Math.cos(phase);
+ imaginary += samples[i] * Math.sin(phase);
+ }
+ return Math.hypot(real, imaginary) / samples.length;
+ };
+ const result = {
+ levels: [magnitude(440), magnitude(880)],
+ type: window.result.type,
+ mic: window.mic.getAudioTracks()[0].readyState,
+ capture: window.capture.getTracks().map((t) => t.readyState),
+ mixed: window.recorded.getAudioTracks()[0].readyState,
+ };
+ await decoder.close();
+ return result;
+ });
+ assert.ok(result.type.startsWith("video/webm"));
+ assert.ok(
+ result.levels.every((level) => level > 0.01),
+ JSON.stringify(result),
+ );
+ assert.equal(result.mic, "live");
+ assert.ok(result.capture.every((state) => state === "ended"));
+ assert.equal(result.mixed, "ended");
+ const old = await page.locator(download).getAttribute("href");
+ await page.evaluate(() => (window.rejectCapture = true));
+ await page.locator(start).click();
+ await page.waitForFunction(() =>
+ document
+ .querySelector("[data-local-status]")
+ .textContent.includes("permission was denied"),
+ );
+ assert.equal(await page.locator(download).getAttribute("href"), old);
+ assert.deepEqual(await page.evaluate(() => window.revoked), []);
+ await page.evaluate(() => (window.rejectCapture = false));
+ await record(page);
+ assert.deepEqual(await page.evaluate(() => window.revoked), [old]);
+ await page.locator(stop).focus();
+ await page.evaluate(() => {
+ const ending = document.createElement("section");
+ ending.hidden = true;
+ document.body.append(ending);
+ window.local.finish(ending);
+ window.local.finish(ending);
+ ending.hidden = false;
+ });
+ await ready(page);
+ await page.waitForFunction(
+ () =>
+ document.activeElement ===
+ document.querySelector("[data-local-download]"),
+ );
+ assert.deepEqual(
+ await page.evaluate(() => [
+ window.stopCalls,
+ window.created.length,
+ window.downloadClicks,
+ ]),
+ [2, 2, 2],
+ );
+ assert.equal(await page.locator(start).isDisabled(), true);
+ await page.evaluate(() => window.local.dispose());
+ assert.equal(await page.locator(download).isHidden(), true);
+});
+
+for (const boundary of ["capture selection", "audio startup"]) {
+ for (const replaced of [false, true]) {
+ test(`${boundary} ${replaced ? "records the replacement microphone" : "reports a lost microphone"}`, async (t) => {
+ const page = await setup(t, {
+ pending: boundary === "capture selection",
+ pendingResume: boundary === "audio startup",
+ });
+ if (!page) return;
+ await page.evaluate(() => {
+ window.startResult = window.local.start();
+ });
+ await page.waitForFunction(
+ (boundary) =>
+ boundary === "capture selection"
+ ? window.allowCapture
+ : window.resumeCapture,
+ boundary,
+ );
+ await page.evaluate(
+ ({ replaced, boundary }) => {
+ const old = window.mic.getAudioTracks()[0];
+ const replacement = replaced ? old.clone() : null;
+ old.stop();
+ window.mic.removeTrack(old);
+ if (replacement) window.mic.addTrack(replacement);
+ if (boundary === "capture selection") window.allowCapture();
+ else window.resumeCapture();
+ },
+ { replaced, boundary },
+ );
+ if (!replaced) {
+ await page.evaluate(() => window.startResult);
+ assert.equal(await page.evaluate(() => window.recorderCalls), 0);
+ assert.equal(await page.evaluate(() => window.startResult), false);
+ assert.match(
+ await page.locator(status).textContent(),
+ /needs the interview microphone/,
+ );
+ assert.ok(
+ await page.evaluate(() =>
+ window.capture
+ .getTracks()
+ .every((track) => track.readyState === "ended"),
+ ),
+ );
+ return;
+ }
+ await page.evaluate(() => window.startResult);
+ await page.waitForFunction(() => window.recordedBytes > 0);
+ await page.locator(stop).click();
+ await ready(page);
+ const microphoneLevel = await page.evaluate(async () => {
+ const decoder = new AudioContext();
+ const audio = await decoder.decodeAudioData(
+ await window.result.arrayBuffer(),
+ );
+ const samples = audio.getChannelData(0);
+ let real = 0,
+ imaginary = 0;
+ for (let i = 0; i < samples.length; i++) {
+ const phase = (2 * Math.PI * 440 * i) / audio.sampleRate;
+ real += samples[i] * Math.cos(phase);
+ imaginary += samples[i] * Math.sin(phase);
+ }
+ await decoder.close();
+ return Math.hypot(real, imaginary) / samples.length;
+ });
+ assert.ok(microphoneLevel > 0.01, `microphone level: ${microphoneLevel}`);
+ assert.equal(await page.evaluate(() => window.startResult), true);
+ assert.equal(
+ await page.evaluate(() => window.mic.getAudioTracks()[0].readyState),
+ "live",
+ );
+ });
+ }
+}
+
+for (const [name, options, message] of [
+ ["permission denial", { denied: true }, /permission was denied/],
+ [
+ "missing tab audio",
+ { noAudio: true },
+ /choose the CodeTrial tab.*Share tab audio/,
+ ],
+ ["unsupported capture", { unsupported: true }, /Chrome or Chromium/],
+ ["recorder failure", { fail: true }, /Recorder unavailable/],
+]) {
+ test(`${name} leaves Start usable and the interview microphone live`, async (t) => {
+ const page = await setup(t, options);
+ if (!page) return;
+ await page.locator(start).click();
+ await page.waitForFunction(
+ (pattern) =>
+ new RegExp(pattern).test(
+ document.querySelector("[data-local-status]").textContent,
+ ),
+ message.source,
+ );
+ assert.equal(
+ await page.locator(start).getAttribute("aria-disabled"),
+ "false",
+ );
+ assert.match(await page.locator(status).textContent(), message);
+ assert.equal(
+ await page.evaluate(() => typeof window.local.stop().then),
+ "function",
+ );
+ await page.evaluate(() => window.local.stop());
+ assert.equal(
+ await page.evaluate(() => window.local.needsMicrophone),
+ false,
+ );
+ assert.equal(await page.locator(stop).isDisabled(), true);
+ assert.equal(
+ await page.evaluate(() => window.mic.getAudioTracks()[0].readyState),
+ "live",
+ );
+ if (options.noAudio || options.fail)
+ assert.ok(
+ await page.evaluate(() =>
+ window.capture.getTracks().every((t) => t.readyState === "ended"),
+ ),
+ );
+ assert.equal(await page.locator(download).isHidden(), true);
+ });
+}
+
+test("ending while permission is pending releases a late grant without recording", async (t) => {
+ const page = await setup(t, { pending: true });
+ if (!page) return;
+ await page.locator(start).click();
+ await page.evaluate(() => {
+ window.local.finish(document.body);
+ window.allowCapture();
+ });
+ await page.waitForFunction(() =>
+ window.capture.getTracks().every((t) => t.readyState === "ended"),
+ );
+ assert.equal(await page.evaluate(() => window.recorderCalls), 0);
+ assert.equal(await page.evaluate(() => window.local.needsMicrophone), false);
+ assert.equal(await page.locator(download).isHidden(), true);
+});
+
+test("the browser Stop sharing action finalizes once", async (t) => {
+ const page = await setup(t);
+ if (!page) return;
+ await record(page);
+ await page.evaluate(() =>
+ window.capture.getVideoTracks()[0].dispatchEvent(new Event("ended")),
+ );
+ await ready(page);
+ await page.evaluate(() => {
+ window.local.finish();
+ window.local.finish();
+ });
+ assert.deepEqual(
+ await page.evaluate(() => [window.stopCalls, window.created.length]),
+ [1, 1],
+ );
+});
+
+test("server recording retains its existing controls", async (t) => {
+ const page = await setup(t, { serverRecording: true });
+ if (!page) return;
+ assert.equal(await page.locator("#local-recording").isHidden(), true);
+ assert.equal(await page.evaluate(() => window.captureCalls), 0);
+});
+
+test("offline recording keeps only its microphone until the interview ends", () => {
+ const tracks = ["audio", "video"].map((kind) => ({
+ kind,
+ stopped: false,
+ stop() {
+ this.stopped = true;
+ },
+ }));
+ let ringsStopped = 0;
+ let viewsDetached = 0;
+ const cleanup = new Function(
+ "stopTurnRing",
+ "detachSelfView",
+ `${functionBody(read("web/interview.js"), "stopPreflight")}\n}\nreturn stopPreflight;`,
+ )(
+ () => ringsStopped++,
+ () => viewsDetached++,
+ );
+ const preflight = { userStream: { getTracks: () => tracks } };
+ cleanup(preflight, { preserveMicrophone: true });
+ assert.deepEqual(
+ tracks.map((track) => track.stopped),
+ [false, true],
+ );
+ assert.equal(ringsStopped, 1);
+ assert.equal(viewsDetached, 1);
+ cleanup(preflight);
+ assert.deepEqual(
+ tracks.map((track) => track.stopped),
+ [true, true],
+ );
+});
+
+test("interview shutdown finalizes local recording before releasing media and preserves its download in the report", () => {
+ const source = read("web/interview.js");
+ assert.match(
+ functionBody(source, "endInterview"),
+ /localRecording\.finish\(nodes\.ending\)/,
+ );
+ const teardown = functionBody(source, "stopLocalMedia");
+ assert.notEqual(teardown.indexOf("localRecording.finish"), -1);
+ assert.notEqual(teardown.indexOf("stopPreflight"), -1);
+ assert.ok(
+ teardown.indexOf("localRecording.finish") <
+ teardown.indexOf("stopPreflight"),
+ );
+ const report = functionBody(source, "renderReport");
+ assert.notEqual(report.indexOf("localRecording.finish(nodes.report)"), -1);
+ assert.notEqual(report.indexOf("nodes.report.innerHTML"), -1);
+ assert.ok(
+ report.indexOf("localRecording.finish(nodes.report)") >
+ report.indexOf("nodes.report.innerHTML"),
+ );
+ assert.match(
+ functionBody(source, "finalizeRecoveryOnPageHide"),
+ /localRecording\.dispose\(\)/,
+ );
+});
+
+test("a recorder error finalizes available chunks without releasing the interview mic", async (t) => {
+ const page = await setup(t);
+ if (!page) return;
+ await record(page);
+ await page.evaluate(() => {
+ window.nativeRecorder.onerror();
+ });
+ assert.equal(await page.locator(status).isVisible(), true);
+ await page.evaluate(() => {
+ window.nativeRecorder.stop();
+ });
+ await ready(page);
+ assert.match(
+ await page.locator(status).textContent(),
+ /Local recording failed/,
+ );
+ assert.equal(
+ await page.evaluate(() => window.mic.getAudioTracks()[0].readyState),
+ "live",
+ );
+ assert.equal(await page.evaluate(() => window.created.length), 1);
+});
+
+test("a stranded microphone switch stops once and preserves the partial recording", async (t) => {
+ const page = await setup(t);
+ if (!page) return;
+ await record(page);
+ assert.equal(
+ await page.evaluate(() => {
+ const mic = window.mic.getAudioTracks()[0];
+ window.mic.removeTrack(mic);
+ mic.stop();
+ window.local.refreshMicrophone();
+ window.local.refreshMicrophone();
+ return window.stopCalls;
+ }),
+ 1,
+ );
+ await ready(page);
+ assert.equal(await page.evaluate(() => window.created.length), 1);
+ assert.equal(await page.evaluate(() => window.downloadClicks), 0);
+ assert.equal(await page.evaluate(() => window.local.needsMicrophone), false);
+ assert.deepEqual(await page.evaluate(() => window.recordingChanges), [
+ true,
+ false,
+ ]);
+ assert.match(
+ await page.locator(status).textContent(),
+ /needs the interview microphone.*Download/,
+ );
+ assert.equal(await page.locator(start).isVisible(), true);
+});
+
+for (const outcome of [
+ "starts before joining",
+ "denial retries before joining",
+ "denial uses plain join",
+ "lost mic stays in setup",
+ "publish failure keeps local recording available",
+ "dock recording survives disconnect",
+ "idle disconnect releases microphone",
+ "dock recording follows switched microphone",
+ "disconnect during microphone switch keeps recording audible",
+ "disconnect during microphone switch keeps recording muted",
+ "readiness loss restarts with the replacement microphone",
+]) {
+ test(`preflight recording ${outcome}`, async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await browser.newPage();
+ t.after(() => page.close());
+ await page.addInitScript((outcome) => {
+ const joinedRoom =
+ outcome.includes("disconnect") ||
+ outcome.includes("switched microphone");
+ window.recordingOrder = [];
+ window.microphoneRequests = 0;
+ window.recordedBytes = 0;
+ window.recordedStops = 0;
+ window.finishedRecordingBytes = [];
+ window.downloadClicks = 0;
+ window.denyCapture = outcome.startsWith("denial");
+ document.addEventListener("click", (event) => {
+ if (event.target.matches("[data-local-download]"))
+ window.downloadClicks++;
+ });
+ window.microphones = [];
+ if (
+ outcome === "readiness loss restarts with the replacement microphone" ||
+ joinedRoom
+ ) {
+ navigator.mediaDevices.enumerateDevices = async () => [
+ {
+ kind: "audioinput",
+ deviceId: "laptop",
+ label: "Default microphone",
+ },
+ {
+ kind: "audioinput",
+ deviceId: "usb",
+ label: "Replacement microphone",
+ },
+ ];
+ window.mixedMicrophones = [];
+ window.deferPreflightStop =
+ outcome === "readiness loss restarts with the replacement microphone";
+ const NativeAudioContext = AudioContext;
+ window.AudioContext = class extends NativeAudioContext {
+ createAnalyser() {
+ const analyser = super.createAnalyser();
+ analyser.getByteTimeDomainData = (samples) => {
+ for (let index = 0; index < samples.length; index++)
+ samples[index] = index % 2 ? 200 : 60;
+ };
+ return analyser;
+ }
+ createMediaStreamSource(stream) {
+ const source = super.createMediaStreamSource(stream);
+ const connect = source.connect.bind(source);
+ source.connect = (destination, ...args) => {
+ if (destination instanceof MediaStreamAudioDestinationNode)
+ window.mixedMicrophones.push(
+ ...stream
+ .getAudioTracks()
+ .filter((track) => window.microphones.includes(track))
+ .map((track) => track.id),
+ );
+ return connect(destination, ...args);
+ };
+ return source;
+ }
+ };
+ }
+ const getUserMedia = navigator.mediaDevices.getUserMedia.bind(
+ navigator.mediaDevices,
+ );
+ navigator.mediaDevices.getUserMedia = async (constraints) => {
+ if (constraints.audio && window.loseMicrophone)
+ throw new DOMException("Microphone disconnected", "NotFoundError");
+ let requested = constraints;
+ if (
+ (outcome ===
+ "readiness loss restarts with the replacement microphone" ||
+ joinedRoom) &&
+ constraints.audio
+ ) {
+ const audio = { ...constraints.audio };
+ delete audio.deviceId;
+ requested = { audio };
+ }
+ const stream = await getUserMedia(requested);
+ if (constraints.audio) {
+ window.microphoneRequests++;
+ window.preflightMic = stream.getAudioTracks()[0];
+ window.microphones.push(window.preflightMic);
+ if (joinedRoom) {
+ const settings = window.preflightMic.getSettings.bind(
+ window.preflightMic,
+ );
+ const deviceId = constraints.audio?.deviceId?.exact || "laptop";
+ window.preflightMic.getSettings = () => ({
+ ...settings(),
+ deviceId,
+ });
+ }
+ }
+ return stream;
+ };
+ if (
+ outcome === "publish failure keeps local recording available" ||
+ joinedRoom
+ ) {
+ window.disconnectStops = [];
+ const published = [];
+ window.LivekitClient = {
+ RoomEvent: new Proxy({}, { get: (_, name) => name }),
+ Track: { Source: { Microphone: "microphone", Camera: "camera" } },
+ Room: class {
+ constructor(options) {
+ window.testRoom = this;
+ this.options = options;
+ window.stopLocalTrackOnUnpublish =
+ options.stopLocalTrackOnUnpublish;
+ }
+ handlers = new Map();
+ remoteParticipants = new Map();
+ canPlaybackAudio = true;
+ localParticipant = {
+ publishData: async () => {},
+ getTrackPublication: () => ({ track: this.microphone }),
+ publishTrack: async (track) => {
+ published.push(track);
+ if (!joinedRoom) {
+ if (window.stopLocalTrackOnUnpublish !== false) track.stop();
+ throw new Error("Disposable publish failure");
+ }
+ const handlers = new Map();
+ this.microphone = {
+ mediaStreamTrack: track,
+ isMuted: false,
+ replaceTrack: async (replacement) => {
+ if (window.deferMicrophoneSwitch)
+ await new Promise(
+ (resolve) => (window.resumeMicrophoneSwitch = resolve),
+ );
+ this.microphone.mediaStreamTrack = replacement;
+ replacement.enabled = !this.microphone.isMuted;
+ },
+ mute: async () => {
+ this.microphone.isMuted = true;
+ this.microphone.mediaStreamTrack.enabled = false;
+ },
+ unmute: async () => {
+ this.microphone.isMuted = false;
+ this.microphone.mediaStreamTrack.enabled = true;
+ for (const fn of handlers.get("unmuted") || []) fn();
+ },
+ on(name, fn) {
+ handlers.set(name, [...(handlers.get(name) || []), fn]);
+ },
+ off(name, fn) {
+ handlers.set(
+ name,
+ (handlers.get(name) || []).filter((each) => each !== fn),
+ );
+ },
+ };
+ return { track: this.microphone };
+ },
+ };
+ on(name, fn) {
+ this.handlers.set(name, [...(this.handlers.get(name) || []), fn]);
+ return this;
+ }
+ off(name, fn) {
+ this.handlers.set(
+ name,
+ (this.handlers.get(name) || []).filter((each) => each !== fn),
+ );
+ return this;
+ }
+ async connect() {}
+ async disconnect(stopTracks = true) {
+ window.disconnectStops.push(stopTracks);
+ if (stopTracks) published.forEach((track) => track.stop());
+ for (const fn of this.handlers.get("Disconnected") || []) fn();
+ }
+ },
+ };
+ }
+ navigator.mediaDevices.getDisplayMedia = async () => {
+ window.recordingOrder.push("capture");
+ await new Promise((resolve) => (window.allowCapture = resolve));
+ if (window.denyCapture)
+ throw new DOMException("Denied", "NotAllowedError");
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+ ctx.fillRect(0, 0, 1, 1);
+ setInterval(() => ctx.fillRect(0, 0, 1, 1), 100);
+ const audio = new AudioContext();
+ const tabAudio = audio.createMediaStreamDestination();
+ const tone = audio.createOscillator();
+ tone.connect(tabAudio);
+ tone.start();
+ return new MediaStream([
+ ...canvas.captureStream(10).getVideoTracks(),
+ ...tabAudio.stream.getAudioTracks(),
+ ]);
+ };
+ const NativeRecorder = MediaRecorder;
+ window.MediaRecorder = class extends NativeRecorder {
+ constructor(...args) {
+ super(...args);
+ let bytes = 0;
+ this.addEventListener("dataavailable", ({ data }) => {
+ window.recordedBytes += data.size;
+ bytes += data.size;
+ });
+ this.addEventListener("stop", (event) => {
+ window.recordedStops++;
+ window.finishedRecordingBytes.push(bytes);
+ if (window.deferPreflightStop) {
+ event.stopImmediatePropagation();
+ window.finishPreflightStop = () => this.onstop(event);
+ }
+ });
+ }
+ start(...args) {
+ window.recordingOrder.push("record");
+ super.start(...args);
+ }
+ };
+ }, outcome);
+ const joinedRoom =
+ outcome.includes("disconnect") || outcome.includes("switched microphone");
+ if (
+ outcome === "publish failure keeps local recording available" ||
+ joinedRoom
+ )
+ await page.route("**/vendor/livekit-client.js", (route) =>
+ route.fulfill({ contentType: "application/javascript", body: "" }),
+ );
+ await page.route("**/api/token", async (route) => {
+ if (outcome !== "denial uses plain join" && !joinedRoom)
+ await page.waitForFunction(() => window.recordedBytes > 0);
+ await page.evaluate(() => window.recordingOrder.push("join"));
+ await route.fulfill({
+ status:
+ outcome === "publish failure keeps local recording available" ||
+ joinedRoom
+ ? 200
+ : 503,
+ contentType: "application/json",
+ body:
+ outcome === "publish failure keeps local recording available" ||
+ joinedRoom
+ ? '{"serverUrl":"ws://disposable.test","token":"disposable"}'
+ : '{"error":"Disposable test"}',
+ });
+ });
+ await page.goto(`${base}/interview.html?problem=permission-flag-counter`, {
+ waitUntil: "domcontentloaded",
+ });
+ await page.locator("#camera-skip").click();
+ await page.locator("#audio-heard").click();
+ await page.locator("#local-recording-join").waitFor({ state: "visible" });
+ await page.waitForFunction(
+ () => !document.querySelector("#local-recording-join").disabled,
+ );
+ assert.deepEqual(await page.evaluate(() => window.recordingOrder), []);
+ assert.equal(
+ await page
+ .locator(status)
+ .evaluate(
+ (node) => !node.closest("[hidden], [inert], [aria-hidden='true']"),
+ ),
+ true,
+ );
+ if (joinedRoom) {
+ await page.locator("#audio-check-join").click();
+ await page.waitForFunction(() => window.testRoom?.microphone);
+ await page.waitForFunction(
+ () => document.querySelector("#audio-check").hidden,
+ );
+ assert.deepEqual(await page.evaluate(() => window.recordingOrder), [
+ "join",
+ ]);
+ if (outcome !== "idle disconnect releases microphone") {
+ await page.locator(start).click();
+ await page.waitForFunction(() => window.allowCapture);
+ await page.evaluate(() => window.allowCapture());
+ await page.waitForFunction(() => window.recordedBytes > 0);
+ }
+ if (outcome.startsWith("disconnect during microphone switch")) {
+ const enabled = !outcome.endsWith("muted");
+ if (!enabled) await page.locator("#mic").click();
+ await page.evaluate(() => (window.deferMicrophoneSwitch = true));
+ await page.locator("#mic-input-select").waitFor({ state: "visible" });
+ await page.locator("#mic-input-select").selectOption("usb");
+ await page.waitForFunction(() => window.resumeMicrophoneSwitch);
+ assert.equal(
+ await page.evaluate(() => window.microphones[0].enabled),
+ false,
+ );
+ await page.evaluate(() =>
+ window.testRoom.disconnect(
+ window.testRoom.options.stopLocalTrackOnUnpublish,
+ ),
+ );
+ assert.equal(
+ await page.evaluate(() => window.microphones[0].enabled),
+ enabled,
+ );
+ await page.evaluate(() => window.resumeMicrophoneSwitch());
+ await page.waitForFunction(
+ () => window.microphones[1].readyState === "ended",
+ );
+ assert.equal(
+ await page.evaluate(() => window.microphones[0].readyState),
+ "live",
+ );
+ assert.equal(
+ await page.evaluate(() => window.microphones[0].enabled),
+ enabled,
+ );
+ assert.equal(await page.evaluate(() => window.recordedStops), 0);
+ assert.equal(await page.evaluate(() => window.downloadClicks), 0);
+ assert.equal(await page.locator(stop).isVisible(), true);
+ await page.locator(stop).click();
+ await ready(page);
+ return;
+ }
+ if (outcome === "dock recording follows switched microphone") {
+ await page.locator("#mic-input-select").waitFor({ state: "visible" });
+ await page.locator("#mic-input-select").selectOption("usb");
+ await page.waitForFunction(
+ () =>
+ window.microphones.length === 2 &&
+ window.microphones[0].readyState === "ended",
+ );
+ assert.deepEqual(
+ await page.evaluate(() => window.mixedMicrophones),
+ await page.evaluate(() =>
+ window.microphones.map((track) => track.id),
+ ),
+ );
+ } else {
+ await page.evaluate(() =>
+ window.testRoom.disconnect(
+ window.testRoom.options.stopLocalTrackOnUnpublish,
+ ),
+ );
+ if (outcome === "idle disconnect releases microphone") {
+ await page.waitForFunction(
+ () => window.preflightMic.readyState === "ended",
+ );
+ assert.equal(await page.evaluate(() => window.recordedStops), 0);
+ return;
+ }
+ }
+ assert.equal(
+ await page.evaluate(() => window.preflightMic.readyState),
+ "live",
+ );
+ assert.equal(await page.evaluate(() => window.recordedStops), 0);
+ assert.equal(await page.locator(stop).isVisible(), true);
+ await page.locator(stop).click();
+ await ready(page);
+ assert.equal(await page.evaluate(() => window.recordedStops), 1);
+ return;
+ }
+ await page.locator("#local-recording-join").click();
+ await page.waitForFunction(() => window.allowCapture);
+ assert.equal(
+ await page
+ .getByRole("status")
+ .filter({
+ hasText: "Choose the CodeTrial tab and enable Share tab audio.",
+ })
+ .count(),
+ 1,
+ );
+ await page.evaluate((outcome) => {
+ if (outcome === "lost mic stays in setup") {
+ window.loseMicrophone = true;
+ window.preflightMic.stop();
+ window.preflightMic.dispatchEvent(new Event("ended"));
+ }
+ if (outcome === "readiness loss restarts with the replacement microphone")
+ document
+ .querySelector("#preflight-output")
+ .dispatchEvent(new Event("outputchange"));
+ window.allowCapture();
+ }, outcome);
+ if (outcome.startsWith("denial")) {
+ await page.waitForFunction(() =>
+ document
+ .querySelector("[data-local-status]")
+ .textContent.includes("permission was denied"),
+ );
+ assert.equal(await page.locator("#audio-check").isVisible(), true);
+ assert.equal(
+ await page.locator("#local-recording-join").isEnabled(),
+ true,
+ );
+ assert.deepEqual(await page.evaluate(() => window.recordingOrder), [
+ "capture",
+ ]);
+ assert.equal(await page.locator(status).isVisible(), true);
+ if (outcome === "denial uses plain join") {
+ await page.locator("#audio-check-join").click();
+ } else {
+ await page.evaluate(() => (window.denyCapture = false));
+ await page.locator("#local-recording-join").click();
+ await page.waitForFunction(
+ () =>
+ window.recordingOrder.filter((step) => step === "capture")
+ .length === 2,
+ );
+ await page.evaluate(() => window.allowCapture());
+ }
+ }
+ if (outcome === "lost mic stays in setup") {
+ await page.waitForFunction(() =>
+ document
+ .querySelector("[data-local-status]")
+ .textContent.includes("needs the interview microphone"),
+ );
+ assert.equal(await page.locator("#audio-check").isVisible(), true);
+ assert.equal(
+ await page.locator("#local-recording-join").isDisabled(),
+ true,
+ );
+ assert.deepEqual(await page.evaluate(() => window.recordingOrder), [
+ "capture",
+ ]);
+ return;
+ }
+ if (outcome === "readiness loss restarts with the replacement microphone") {
+ await page.waitForFunction(() =>
+ window.recordingOrder.includes("record"),
+ );
+ await page.locator("#audio-heard").click();
+ await page.waitForFunction(
+ () =>
+ window.finishPreflightStop ||
+ !document.querySelector("#local-recording-join").disabled,
+ );
+ assert.equal(
+ await page.locator("#local-recording-join").isDisabled(),
+ true,
+ );
+ await page.evaluate(() => {
+ window.deferPreflightStop = false;
+ window.finishPreflightStop();
+ });
+ await page.waitForFunction(
+ () => !document.querySelector("#local-recording-join").disabled,
+ );
+ assert.equal(await page.evaluate(() => window.recordedStops), 1);
+ assert.equal(await page.locator(stop).isHidden(), true);
+ await page.locator("#preflight-input").selectOption("usb");
+ await page.waitForFunction(
+ () =>
+ window.microphones.length === 2 &&
+ !document.querySelector("#local-recording-join").disabled,
+ );
+ assert.equal(
+ await page.evaluate(() => window.microphones[0].readyState),
+ "ended",
+ );
+ await page.locator("#local-recording-join").click();
+ await page.waitForFunction(
+ () =>
+ window.recordingOrder.filter((step) => step === "capture").length ===
+ 2,
+ );
+ await page.evaluate(() => window.allowCapture());
+ }
+ await page.waitForFunction(() => window.recordingOrder.includes("join"));
+ assert.deepEqual(
+ await page.evaluate(() => window.recordingOrder),
+ outcome === "denial uses plain join"
+ ? ["capture", "join"]
+ : outcome === "denial retries before joining"
+ ? ["capture", "capture", "record", "join"]
+ : outcome ===
+ "readiness loss restarts with the replacement microphone"
+ ? ["capture", "record", "capture", "record", "join"]
+ : ["capture", "record", "join"],
+ );
+ await page.waitForFunction(() =>
+ document
+ .querySelector("#transcript-panel")
+ .textContent.includes("Offline mode is ready"),
+ );
+ if (outcome === "denial uses plain join") {
+ await page.waitForFunction(
+ () => window.preflightMic.readyState === "ended",
+ );
+ assert.equal(await page.locator(download).isHidden(), true);
+ assert.equal(await page.evaluate(() => window.recordedStops), 0);
+ return;
+ }
+ assert.equal(await page.locator(stop).isVisible(), true);
+ assert.equal(await page.locator(start).isHidden(), true);
+ assert.equal(
+ await page.evaluate(() => window.preflightMic.readyState),
+ "live",
+ );
+ assert.equal(
+ await page.evaluate(() => window.recordedStops),
+ outcome === "readiness loss restarts with the replacement microphone"
+ ? 1
+ : 0,
+ );
+ assert.equal(
+ await page.evaluate(() => window.downloadClicks),
+ outcome === "readiness loss restarts with the replacement microphone"
+ ? await page.evaluate(() =>
+ Number(window.finishedRecordingBytes[0] > 0),
+ )
+ : 0,
+ );
+ if (outcome === "readiness loss restarts with the replacement microphone") {
+ assert.deepEqual(
+ await page.evaluate(() => window.mixedMicrophones),
+ await page.evaluate(() => window.microphones.map((track) => track.id)),
+ );
+ }
+ if (outcome === "publish failure keeps local recording available") {
+ assert.deepEqual(await page.evaluate(() => window.disconnectStops), [
+ false,
+ ]);
+ assert.equal(
+ await page.evaluate(() => window.stopLocalTrackOnUnpublish),
+ false,
+ );
+ }
+ if (outcome === "starts before joining")
+ await page.evaluate(() => window.dispatchEvent(new Event("pagehide")));
+ else await page.locator("#end").click();
+ await page.waitForFunction(
+ () => window.preflightMic.readyState === "ended",
+ );
+ assert.equal(await page.locator(start).isHidden(), true);
+ });
+}
diff --git a/tests/browser/meet-tab-audio.test.js b/tests/browser/meet-tab-audio.test.js
index 8a9f648d..722a5cb6 100644
--- a/tests/browser/meet-tab-audio.test.js
+++ b/tests/browser/meet-tab-audio.test.js
@@ -176,15 +176,15 @@ test("sink routing", () => {
);
});
-test("no synthetic microphone", () => {
+test("capture and audio mixing are confined to local recording", () => {
const offenders = firstPartyScripts.filter((name) =>
/createMediaStreamDestination|getDisplayMedia/.test(read(name)),
);
assert.deepEqual(
offenders,
- [],
- "CodeTrial must not build an audio bridge or capture the screen for Meet",
+ ["local-recorder.js"],
+ "only local recording may mix audio or capture the screen; Meet must not",
);
});
diff --git a/tests/browser/recording-consent.test.js b/tests/browser/recording-consent.test.js
index f1cf78c2..b3dec66c 100644
--- a/tests/browser/recording-consent.test.js
+++ b/tests/browser/recording-consent.test.js
@@ -106,7 +106,7 @@ test("recording-consent the start button waits for an answer", () => {
"consent is given, or does not apply; there is no third state",
);
assert.ok(
- code.includes("const ready = state.ready && consentGiven();"),
+ code.includes("const ready = state.ready && consentGiven() && !joining;"),
"media readiness alone must not open the Start button on a recording server",
);
assert.ok(
diff --git a/tests/browser/report-recovery.test.js b/tests/browser/report-recovery.test.js
index b4949d6c..5a71ce83 100644
--- a/tests/browser/report-recovery.test.js
+++ b/tests/browser/report-recovery.test.js
@@ -290,6 +290,7 @@ function pageHarness(phase = "live") {
},
stopAvatar: () => events.push("stop-avatar"),
stopLocalMedia: () => events.push("stop-media"),
+ localRecording: { dispose() {} },
startEndingClock: () => {},
stopEndingClock: () => {},
publish: (topic, payload) => {
@@ -483,6 +484,11 @@ test("terminal room disconnect finalizes recovery before clearing the room", asy
const playbackRooms = [];
page.scope.room = page.room;
page.scope.audioPlayback = { release: (room) => playbackRooms.push(room) };
+ page.scope.preflight = { userStream: { label: "Preflight stream" } };
+ page.scope.localRecording.needsMicrophone = false;
+ const mediaCleanup = [];
+ page.scope.stopPreflight = (media, options) =>
+ mediaCleanup.push([media, options]);
const disconnect = new Function(
"scope",
`with(scope) { return () => { ${body} }; }`,
@@ -490,6 +496,9 @@ test("terminal room disconnect finalizes recovery before clearing the room", asy
disconnect();
await settle();
assert.deepEqual(playbackRooms, [page.room]);
+ assert.deepEqual(mediaCleanup, [
+ [page.scope.preflight, { preserveMicrophone: false }],
+ ]);
assert.equal(page.scope.state.room, null);
assert.equal(page.scope.state.connected, false);
assert.equal(page.scope.state.phase, "report");
diff --git a/tests/browser/self-view.test.js b/tests/browser/self-view.test.js
index 63a71b2f..7de36402 100644
--- a/tests/browser/self-view.test.js
+++ b/tests/browser/self-view.test.js
@@ -179,7 +179,7 @@ test("stopping the camera takes the self view down first", () => {
);
assertIncludesCompact(
functionBody(script, "connect"),
- "stopPreflight(preflight);",
+ "stopPreflight(preflight, { preserveMicrophone });",
"the offline fallback after a failed connect must stop the camera through stopPreflight",
);
});
diff --git a/tests/browser/source.js b/tests/browser/source.js
index 4e443805..c1d10b08 100644
--- a/tests/browser/source.js
+++ b/tests/browser/source.js
@@ -53,6 +53,7 @@ export const INTERVIEW_SOURCES = [
"web/captions.js",
"web/integrity.js",
"web/recording-state.js",
+ "web/local-recorder.js",
"web/replay-feed.js",
"web/avatar/stage.js",
];
@@ -103,7 +104,9 @@ export function loadInterview(names, scope, { stubMissing = false } = {}) {
/// ones a hand-written list would have missed.
export function initialisedModules() {
const page = read("web/interview.js");
- const called = new Set(captures(page, /^(init[A-Z]\w*)\(/gm));
+ const called = new Set(
+ captures(page, /^(?:const \w+ = )?(init[A-Z]\w*)\(/gm),
+ );
return firstPartyScripts()
.map((name) => `web/${name}`)
.filter((path) => {
diff --git a/tests/browser/start-language.test.js b/tests/browser/start-language.test.js
index ffc1dcc4..d6a439ab 100644
--- a/tests/browser/start-language.test.js
+++ b/tests/browser/start-language.test.js
@@ -63,7 +63,8 @@ async function joinAndCaptureToken(page, { granted = false } = {}) {
const sent = page.waitForRequest((request) =>
request.url().endsWith("/api/token"),
);
- await page.locator("#audio-check-join").click();
+ // The judge may be held; the token, rather than page load, marks completion.
+ await page.locator("#audio-check-join").click({ noWaitAfter: true });
return sent;
}
@@ -104,8 +105,10 @@ test("the token names a requested C whose judge loads after Start", async (t) =>
releaseJudge();
assert.equal((await request).postDataJSON().language, "c");
// The token was refused, so no interview started in C.
- await page.waitForFunction(
- () => !document.querySelector("#audio-check-join").disabled,
+ await page.waitForFunction(() =>
+ document
+ .querySelector("#transcript-panel")
+ .textContent.includes("Offline mode is ready"),
);
assert.equal(
await page.evaluate((key) => localStorage.getItem(key), LANGUAGE_KEY),
diff --git a/web/interview.html b/web/interview.html
index dc53875a..d31e532d 100644
--- a/web/interview.html
+++ b/web/interview.html
@@ -71,15 +71,42 @@
Loading interview...
action used most, so it leads; ending the interview sits apart at
the far end of the row below, where it is not hit by mistake. -->
-
- Your turn is done
-
+
+
+ Your turn is done
+
+
+
+ Start recording
+
+
+ End recording
+
+
+
Download
+
+
Mic on