Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 14 additions & 7 deletions tests/browser/avatar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
captures,
firstPartyScripts,
functionBody,
INTERVIEW_SOURCES,
interviewSource,
read,
root,
Expand Down Expand Up @@ -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/);
Expand Down
45 changes: 45 additions & 0 deletions tests/browser/interview-layout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
});
9 changes: 6 additions & 3 deletions tests/browser/interview-lifecycle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -451,6 +452,7 @@ test("pagehide finalizes recovery before disconnecting the room", () => {
const handler = new Function(
"state",
"reportRecovery",
"localRecording",
`${body}\n}\nreturn finalizeRecoveryOnPageHide;`,
)(
{
Expand All @@ -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"]);
});
Loading
Loading