diff --git a/README.md b/README.md index 4cf21f971..6e4216cc8 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ The common ones: | Variable | Default | Purpose | |---|---|---| | `CODETRIAL_WEB_ADDR` | `127.0.0.1:3000` | Listen address | -| `CODETRIAL_DURATION_MIN` | `45` | Interview length preselected in the lobby (10–90); see [interview length](docs/interview-length.md) | +| `CODETRIAL_DURATION_MIN` | `45` | Interview length preselected in the lobby, snapped to the nearest enabled button; clamped to 10–90; where recording is on, startup refuses a `CODETRIAL_RECORDING_MAX_MINUTES` below the clamped value, see [interview length](docs/interview-length.md) | | `GEMINI_LIVE_MODEL` | `gemini-3.1-flash-live-preview` | Realtime interviewer model | | `GEMINI_REPORT_MODEL` | `gemini-3.1-flash-lite` | Report model | | `CODETRIAL_MAX_INTERIM_REVIEWS` | `6` | Quiet-pause report-model reviews per interview; `0` disables them and `72` is the maximum | diff --git a/docs/interview-length.md b/docs/interview-length.md index 327e69354..efdd56d07 100644 --- a/docs/interview-length.md +++ b/docs/interview-length.md @@ -5,9 +5,19 @@ both. One rule matters more than the rest: the length offered and the length enforced come from one function, so they cannot drift apart. The lobby offers 30, 45, and 60 minutes, and the candidate picks one before -starting. `CODETRIAL_DURATION_MIN` is only the length preselected for a -candidate who does not choose; the endpoint accepts 10 to 90 either way, so a -deployment that lowers the default has not lowered the ceiling. +starting. `CODETRIAL_DURATION_MIN` is the length preselected for a candidate who +does not choose; the endpoint accepts 10 to 90 either way, so a deployment that +lowers the default has not lowered the ceiling. + +`/api/session` reports the default as `defaultDurationMin`, clamped into 10 to +90; where recording is on, startup refuses a cap below that clamped length, as +the last paragraph says. The lobby preselects it for Medium and Hard, and +for Easy at most 30. A default that is not one of the three buttons selects the +nearest enabled one, and a length the candidate chose is never replaced by the +default. The Easy cap and the snapping are the lobby's suggestion only. +`/api/token` does not look at the problem: a request that names no length, such +as a link that skips the lobby, is granted `defaultDurationMin` itself, not +snapped, whatever the difficulty. Recording lowers it. `CODETRIAL_RECORDING_MAX_MINUTES` bounds a single recording, and the sweeper fails one still running five minutes past it, so an @@ -24,8 +34,9 @@ a request that skips the lobby is bounded too, and both endpoints read it from one function so the length that is offered and the length that is enforced cannot drift apart. -Startup refuses a cap below `CODETRIAL_DURATION_MIN`, so the length this -deployment preselects is always one it can record; the pairing is a -configuration error rather than a quietly shortened interview. Should a cap ever -sit below every length the row offers, the lobby leaves the row alone rather -than rendering a page with nothing to press. +Where recording is on, startup refuses a cap below `CODETRIAL_DURATION_MIN`, +held to 10 to 90 like the default itself, so the length this deployment +preselects is always one it can record; the pairing is a configuration error +rather than a quietly shortened interview. Should a cap ever sit below every +length the row offers, the lobby leaves the row alone rather than rendering a +page with nothing to press. diff --git a/scripts/rust-style-ceilings.json b/scripts/rust-style-ceilings.json index 9c5b87bba..368ed2359 100644 --- a/scripts/rust-style-ceilings.json +++ b/scripts/rust-style-ceilings.json @@ -26,5 +26,5 @@ "tests/unit/tasks/session.rs": 1861, "tests/web/accounts.rs": 1808, "tests/web/recording.rs": 2496, - "tests/web/token.rs": 2151 + "tests/web/token.rs": 2143 } diff --git a/src/config.rs b/src/config.rs index a86796230..564986bff 100644 --- a/src/config.rs +++ b/src/config.rs @@ -630,6 +630,33 @@ pub fn max_concurrent_interviews( } } +/// The length `CODETRIAL_DURATION_MIN` stands for, held to the range the +/// endpoints offer, and what to say when it is not used as written: unreadable, +/// so the built-in length applies, or outside the range, so it is clamped. +/// One function for both, so the number in the message is the one the server +/// runs on and the operator who mistyped is not left guessing. +pub fn configured_duration(value: Option<&str>) -> (u32, Option) { + let value = value.map(str::trim).unwrap_or_default(); + if value.is_empty() { + return (DEFAULT_DURATION_MIN, None); + } + let (reason, used) = match value.parse::() { + Ok(minutes) if (MIN_DURATION_MIN..=MAX_DURATION_MIN).contains(&minutes) => { + return (minutes, None); + } + Ok(minutes) => ( + format!("is outside {MIN_DURATION_MIN} to {MAX_DURATION_MIN}"), + minutes.clamp(MIN_DURATION_MIN, MAX_DURATION_MIN), + ), + Err(_) => ( + "is not a usable number of minutes".to_string(), + DEFAULT_DURATION_MIN, + ), + }; + let warning = format!("codetrial: CODETRIAL_DURATION_MIN {value:?} {reason}; read as {used}"); + (used, Some(warning)) +} + fn optional_u32(values: &BTreeMap, key: &str, default: u32) -> u32 { values .get(key) diff --git a/src/main.rs b/src/main.rs index f6fcc067c..1ebf34d52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,7 @@ use std::collections::BTreeMap; use std::path::PathBuf; use std::sync::Arc; -use codetrial::config::{ - AgentConfig, DEFAULT_DURATION_MIN, DEFAULT_ROOM_PREFIX, DEFAULT_WEB_ADDR, DEFAULT_WEB_DIR, -}; +use codetrial::config::{AgentConfig, DEFAULT_ROOM_PREFIX, DEFAULT_WEB_ADDR, DEFAULT_WEB_DIR}; use codetrial::web::{RoomDispatcher, WebServerConfig}; /// One installation keeps its config file and its account database together in @@ -355,6 +353,7 @@ fn web_server_config( allowed_hosts: allowed_hosts(values)?, compiler_explorer_enabled: compiler_explorer_enabled(values), guest_mode_enabled: guest_mode_enabled(values), + default_duration_min: interview_duration_min(values), recording, pool, probe_provider_quota: true, @@ -400,6 +399,10 @@ fn run_web(options: CliOptions) -> Result<(), String> { let max_concurrent = read_max_concurrent(&values); let production = is_production(&values); + + if let Some(warning) = configured_duration(&values).1 { + eprintln!("{warning}"); + } let pool = web_provider_pool(&values, &options, production)?; let mut config = web_server_config(&values, pool, production, &options)?; @@ -1232,13 +1235,17 @@ fn recording_needs_verified_identity(recording: bool, oauth: bool) -> Result<(), Ok(()) } -/// The configured interview length, which is what a recording's maximum -/// duration has to clear. Read here rather than through `load_from_pairs` -/// because `codetrial web` deliberately runs without a full agent config. +/// The configured interview length, held to the range the endpoints offer, +/// which is what a recording's maximum duration has to clear. Read here rather +/// than through `load_from_pairs`, because `codetrial web` deliberately runs +/// without a full agent config. fn interview_duration_min(values: &BTreeMap) -> u32 { - nonempty(values, "CODETRIAL_DURATION_MIN") - .and_then(|value| value.parse().ok()) - .unwrap_or(DEFAULT_DURATION_MIN) + configured_duration(values).0 +} + +/// [`codetrial::config::configured_duration`] over the config values. +fn configured_duration(values: &BTreeMap) -> (u32, Option) { + codetrial::config::configured_duration(values.get("CODETRIAL_DURATION_MIN").map(String::as_str)) } /// `CODETRIAL_ALLOWED_HOSTS`, a comma-separated list of `Host` values. diff --git a/src/web/auth.rs b/src/web/auth.rs index a28f9b0ad..e9558ce72 100644 --- a/src/web/auth.rs +++ b/src/web/auth.rs @@ -540,8 +540,10 @@ pub(crate) async fn session_handler( // The lobby offers lengths this deployment may not be able to record, and // only the server knows the recording cap. Answered on both branches // because the duration row is live before anyone signs in. - let max_duration_min = - super::token::duration_ceiling(state.config.recording.as_ref().map(|it| it.max_minutes)); + let recording_max_minutes = state.config.recording.as_ref().map(|it| it.max_minutes); + let max_duration_min = super::token::duration_ceiling(recording_max_minutes); + let default_duration_min = + super::token::default_duration(state.config.default_duration_min, recording_max_minutes); // Null on the web half of a split deployment, where the lobby keeps its // billing notice hidden rather than describe an agent it cannot see. @@ -565,6 +567,7 @@ pub(crate) async fn session_handler( "guestModeAvailable": state.guest_mode_available(), "deviceLogin": device_client_id(&state.config).is_some(), "maxDurationMin": max_duration_min, + "defaultDurationMin": default_duration_min, "candidateVideo": candidate_video, "user": { "login": user.login, @@ -583,6 +586,7 @@ pub(crate) async fn session_handler( "guestModeAvailable": state.guest_mode_available(), "deviceLogin": device_client_id(&state.config).is_some(), "maxDurationMin": max_duration_min, + "defaultDurationMin": default_duration_min, "candidateVideo": candidate_video, }), ), diff --git a/src/web/mod.rs b/src/web/mod.rs index e61550f5a..dbf8f92e5 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -80,6 +80,9 @@ pub struct WebServerConfig { pub compiler_explorer_enabled: bool, /// Guest interviews require explicit operator opt-in. pub guest_mode_enabled: bool, + /// `CODETRIAL_DURATION_MIN`: the lobby's preselected length, and the one + /// a token request that names none gets. + pub default_duration_min: u32, /// Number of trusted reverse proxies in front of this server. Zero, the /// default, keys rate limiting on the TCP peer. Anything higher reads the /// client from `X-Forwarded-For`, which is only safe once every request is @@ -165,6 +168,7 @@ impl fmt::Debug for WebServerConfig { production, compiler_explorer_enabled, guest_mode_enabled, + default_duration_min, trusted_proxy_hops, allowed_hosts, recording, @@ -195,6 +199,7 @@ impl fmt::Debug for WebServerConfig { .field("production", production) .field("compiler_explorer_enabled", compiler_explorer_enabled) .field("guest_mode_enabled", guest_mode_enabled) + .field("default_duration_min", default_duration_min) .field("trusted_proxy_hops", trusted_proxy_hops) .field("allowed_hosts", allowed_hosts) .field("recording", recording) diff --git a/src/web/token.rs b/src/web/token.rs index 10c197653..17394ab60 100644 --- a/src/web/token.rs +++ b/src/web/token.rs @@ -14,7 +14,7 @@ use axum::response::{IntoResponse, Response}; use ring::rand::SecureRandom; use serde_json::{Value, json}; -use crate::config::{DEFAULT_DURATION_MIN, MAX_DURATION_MIN, MIN_DURATION_MIN}; +use crate::config::{MAX_DURATION_MIN, MIN_DURATION_MIN}; use crate::current_epoch_seconds; use crate::token::{LivekitTokenInput, TOKEN_TTL_SECONDS, livekit_observer_token, livekit_token}; @@ -39,6 +39,8 @@ pub struct TokenConfig<'a> { /// recording/sweeper.rs reaps one still running past this many minutes, so /// a longer interview loses exactly the stretch it was recorded for. pub recording_max_min: Option, + /// `CODETRIAL_DURATION_MIN`: the length of a request that names none. + pub default_duration_min: u32, } /// Same reason, and the sharpest case of it: `token` is a minted LiveKit @@ -300,7 +302,11 @@ pub fn token_response( .get("problemId") .and_then(Value::as_str) .unwrap_or(crate::agent::DEFAULT_PROBLEM_ID); - let duration_min = token_duration_min(request.get("durationMin"), config.recording_max_min); + let duration_min = token_duration_min( + request.get("durationMin"), + config.default_duration_min, + config.recording_max_min, + ); let interview_loop = crate::agent::InterviewLoop::parse(request.get("interviewLoop").and_then(Value::as_str)); let interview_mode = @@ -422,14 +428,28 @@ pub(crate) fn duration_ceiling(recording_max_min: Option) -> u32 { }) } -pub(crate) fn token_duration_min(value: Option<&Value>, recording_max_min: Option) -> Value { +/// The operator's `CODETRIAL_DURATION_MIN` as the lobby and `/api/token` both +/// use it: inside the range the endpoint offers and no longer than the +/// deployment can record. One function for the same reason as +/// [`duration_ceiling`]. +pub(crate) fn default_duration(configured_min: u32, recording_max_min: Option) -> u32 { + configured_min + .clamp(MIN_DURATION_MIN, MAX_DURATION_MIN) + .min(duration_ceiling(recording_max_min)) +} + +pub(crate) fn token_duration_min( + value: Option<&Value>, + default_duration_min: u32, + recording_max_min: Option, +) -> Value { let ceiling = duration_ceiling(recording_max_min); let duration = value.and_then(crate::agent::json_number).unwrap_or(0.0); if duration == 0.0 || duration.is_nan() { // The default is a request like any other where the cap is shorter than // it. Handing back a length this server cannot record, for a caller who // named no length at all, is the same lost stretch of interview. - json!(DEFAULT_DURATION_MIN.min(ceiling)) + json!(default_duration(default_duration_min, recording_max_min)) } else { // Fractional minutes stay fractional: the metadata is echoed back to // the frontend, which renders whatever the lobby asked for. @@ -714,6 +734,7 @@ fn minted_token( api_secret: &provider.api_secret, server_url: &provider.url, recording_max_min: state.config.recording.as_ref().map(|it| it.max_minutes), + default_duration_min: state.config.default_duration_min, }, body, room_name, diff --git a/tests/browser/account.test.js b/tests/browser/account.test.js index 39a257e3f..bd20e99b0 100644 --- a/tests/browser/account.test.js +++ b/tests/browser/account.test.js @@ -225,7 +225,7 @@ test("the lobby offers an editor or a whiteboard and carries the choice into the ); assertIncludesCompact( interview, - "durationMin, interviewId, interviewLoop, interviewMode: mode, ...(!whiteboard ? { language: state.language } : {}), interviewProfile, ...(interviewGrounding", + "...(namedDuration ? { durationMin } : {}), interviewId, interviewLoop, interviewMode: mode, ...(!whiteboard ? { language: state.language } : {}), interviewProfile, ...(interviewGrounding", ); assertIncludesCompact( interview, diff --git a/tests/browser/interviewer-voice.test.js b/tests/browser/interviewer-voice.test.js index eac3ec58a..e17b38828 100644 --- a/tests/browser/interviewer-voice.test.js +++ b/tests/browser/interviewer-voice.test.js @@ -170,3 +170,104 @@ test("the interviewer controls fit narrow and desktop preflights", async (t) => await page.close(); } }); + +// A URL that skips the lobby names no length, so the server's configured +// default decides; sending the provisional 45 would override it. +for (const [query, sent] of [ + ["", false], + // Neither names a usable length, so neither may override the server default. + ["&duration=", false], + ["&duration=abc", false], + ["&duration=60", true], +]) { + test(`the token request ${sent ? "names" : "omits"} the length for ${query || "a URL without duration"}`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage(); + try { + await page.route("**/api/token", (route) => + route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ + error: "Token request captured by the test.", + }), + }), + ); + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match${query}`, + { waitUntil: "domcontentloaded" }, + ); + await page.locator("#audio-heard").click(); + await page.locator("#camera-skip").click(); + await page.waitForFunction( + () => !document.querySelector("#audio-check-join").disabled, + ); + const request = page.waitForRequest((r) => + r.url().endsWith("/api/token"), + ); + await page.locator("#audio-check-join").click(); + const body = (await request).postDataJSON(); + assert.equal(Object.hasOwn(body, "durationMin"), sent); + if (sent) assert.equal(body.durationMin, 60); + } finally { + await page.close(); + } + }); +} + +// The round plan is drawn from the length, so a URL with no `duration` waits +// for the server's default instead of showing 45 minutes and jumping. +for (const query of ["", "&duration=", "&duration=abc"]) { + test(`the round plan for ?${query || "no duration"} waits for the server default`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage(); + try { + let release; + const held = new Promise((resolve) => (release = resolve)); + await page.route("**/api/session", async (route) => { + await held; + await route.fulfill({ + json: { signedIn: false, defaultDurationMin: 30, maxDurationMin: 90 }, + }); + }); + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&loop=coding_only${query}`, + { waitUntil: "domcontentloaded" }, + ); + const plan = page.locator("#round-plan-summary"); + await page.waitForFunction(() => document.querySelector("#audio-check")); + assert.equal(await plan.textContent(), ""); + release(); + await page.waitForFunction(() => + /30 minute coding budget/.test( + document.querySelector("#round-plan-summary").textContent, + ), + ); + } finally { + await page.close(); + } + }); +} + +test("the round plan for a URL with a duration is drawn at once", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const page = await browser.newPage(); + try { + await page.route("**/api/session", (route) => + route.fulfill({ + json: { signedIn: false, defaultDurationMin: 30, maxDurationMin: 90 }, + }), + ); + await page.goto( + `${base}/interview.html?problem=chargeback-pair-match&loop=coding_only&duration=60`, + { waitUntil: "domcontentloaded" }, + ); + await page.waitForFunction(() => + /60 minute coding budget/.test( + document.querySelector("#round-plan-summary").textContent, + ), + ); + } finally { + await page.close(); + } +}); diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 3d087cbcf..e20106626 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -3277,6 +3277,146 @@ lobbyTest( }, ); +for (const [configured, medium, easy] of [ + // The operator's own length replaces the built-in forty-five. + [30, "30", "30"], + [60, "60", "30"], + // Not a button: the nearest one is selected rather than none. + [20, "30", "30"], + [75, "60", "30"], +]) { + lobbyTest( + `an operator default of ${configured} minutes preselects ${medium}, or ${easy} for Easy`, + async (page) => { + session.defaultDurationMin = configured; + const state = await lobby(page); + assert.equal(state.duration, medium); + + await setLevel(page, "Easy", true); + await setLevel(page, "Medium", false); + assert.equal((await snapshot(page)).duration, easy); + }, + ); +} + +lobbyTest( + "a restored lobby suggests again after a level was ticked by hand", + async (page) => { + session.defaultDurationMin = 40; + session.maxDurationMin = 40; + await lobby(page); + // Ticking a level by hand skips the history-based suggestion on restore, + // so only the session can move the length now. + await setLevel(page, "Hard", true); + await setLevel(page, "Medium", false); + assert.equal((await snapshot(page)).duration, "30"); + + session.maxDurationMin = 60; + await page.evaluate(() => + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }), + ), + ); + await page.waitForFunction( + () => + document.querySelector("[data-duration].selected")?.dataset.duration === + "45", + ); + }, +); + +lobbyTest( + "a restored lobby leaves a length the candidate chose when the ceiling moves", + async (page) => { + session.maxDurationMin = 45; + await lobby(page); + await page.click('[data-duration="30"]'); + + session.maxDurationMin = 90; + await page.evaluate(() => + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }), + ), + ); + // The 60 button comes back only once the restored lobby has read the new + // ceiling, so the length below is checked after the session answered. + await page.waitForFunction( + () => !document.querySelector('[data-duration="60"]').disabled, + ); + assert.equal((await snapshot(page)).duration, "30"); + }, +); + +/// The session answers after the candidate has already acted, which is where +/// "left alone" has to hold: the latch is set before the default arrives. +const heldSession = async (page) => { + let release; + holdSession = new Promise((resolve) => (release = resolve)); + const requested = page.waitForRequest( + (request) => new URL(request.url()).pathname === "/api/session", + ); + await page.goto(`${base}/`, { waitUntil: "domcontentloaded" }); + await requested; + return async () => { + release(); + await page.waitForFunction( + () => document.querySelector("#recommendation").textContent !== "", + ); + }; +}; + +lobbyTest( + "an operator default does not overwrite a length chosen before the session answers", + async (page) => { + session.defaultDurationMin = 30; + const answer = await heldSession(page); + await page.click('[data-duration="60"]'); + await answer(); + assert.equal((await snapshot(page)).duration, "60"); + }, +); + +lobbyTest( + "an operator default does not replace a remembered length", + async (page) => { + session.defaultDurationMin = 60; + await page.addInitScript( + ([key, value]) => localStorage.setItem(key, value), + ["codetrial.interviewSettings", JSON.stringify({ durationMin: 45 })], + ); + assert.equal((await lobby(page)).duration, "45"); + }, +); + +lobbyTest( + "an operator default above the recording cap is moved down to the cap", + async (page) => { + // The server never sends this pair, since it holds the default to the cap + // itself; this is the lobby's own guard. + session.defaultDurationMin = 60; + session.maxDurationMin = 45; + assert.equal((await lobby(page)).duration, "45"); + }, +); + +lobbyTest( + "an operator default does not overrule a problem picked before the session answers", + async (page) => { + session.defaultDurationMin = 60; + const answer = await heldSession(page); + // Programmatic, because the picker is closed and the card is hidden. + await page.evaluate(() => + document.querySelector('[data-problem][data-difficulty="Easy"]').click(), + ); + await answer(); + assert.equal( + (await snapshot(page)).duration, + "30", + "the checkboxes still say Medium, which would suggest the operator's 60", + ); + }, +); + lobbyTest( "the recommendation does not move once it is on screen", async (page) => { diff --git a/tests/unit/bin.rs b/tests/unit/bin.rs index 9f7b019ea..eb4cb3785 100644 --- a/tests/unit/bin.rs +++ b/tests/unit/bin.rs @@ -660,6 +660,71 @@ fn a_data_dir_decides_every_path_a_launch_reads_or_writes() { ); } +/// The operator's `CODETRIAL_DURATION_MIN` is what the lobby and `/api/token` +/// default to, so it has to reach the web config. +#[test] +fn web_server_config_carries_the_configured_default_length() { + let options = super::CliOptions::default(); + let pool = codetrial::config::ProviderPool { + providers: vec![provider(codetrial::config::PRIMARY_PROVIDER_ID)], + }; + let configured = |values: &super::BTreeMap| { + super::web_server_config(values, pool.clone(), false, &options) + .expect("a plain local config should be accepted") + .default_duration_min + }; + assert_eq!( + configured(&values(&[])), + codetrial::config::DEFAULT_DURATION_MIN + ); + assert_eq!(configured(&values(&[("CODETRIAL_DURATION_MIN", "30")])), 30); + assert_eq!( + configured(&values(&[("CODETRIAL_DURATION_MIN", " 30 ")])), + 30 + ); +} + +/// The length a launch runs on and what the operator is told about it come from +/// one function, so the number in a warning is the one the server uses, and the +/// recording check sees the same clamped length the endpoints grant. +#[test] +fn a_duration_setting_is_read_once_and_named_when_not_used_as_written() { + let read = |value: &str| { + let pairs = values(&[("CODETRIAL_DURATION_MIN", value)]); + ( + super::interview_duration_min(&pairs), + super::configured_duration(&pairs).1, + ) + }; + let unset = values(&[]); + assert_eq!( + super::interview_duration_min(&unset), + codetrial::config::DEFAULT_DURATION_MIN + ); + assert_eq!(super::configured_duration(&unset).1, None); + for (value, used) in [("", 45), (" ", 45), ("10", 10), (" 60 ", 60), ("90", 90)] { + assert_eq!(read(value), (used, None), "{value:?}"); + } + for (value, used) in [ + ("30min", 45), + ("45.5", 45), + ("-30", 45), + ("abc", 45), + ("4294967296", 45), + ("0", 10), + ("5", 10), + ("91", 90), + ("120", 90), + ("500", 90), + ] { + let (length, said) = read(value); + assert_eq!(length, used, "{value:?}"); + let said = said.unwrap_or_else(|| panic!("{value:?} was accepted silently")); + assert!(said.contains(value), "{said}"); + assert!(said.ends_with(&format!("read as {used}")), "{said}"); + } +} + /// Made once and kept, so a relaunch keeps everyone signed in, and owner-only /// because any program on the machine can reach a loopback port. #[test] diff --git a/tests/unit/dispatch_http.rs b/tests/unit/dispatch_http.rs index 1639eab42..4abe3f0e1 100644 --- a/tests/unit/dispatch_http.rs +++ b/tests/unit/dispatch_http.rs @@ -239,6 +239,7 @@ impl Probe { production: !pinned, compiler_explorer_enabled: false, guest_mode_enabled: true, + default_duration_min: crate::config::DEFAULT_DURATION_MIN, trusted_proxy_hops: 1, allowed_hosts: Vec::new(), recording: None, diff --git a/tests/unit/livekit/recovery_live_parts/launcher_and_scenario_judging.rs b/tests/unit/livekit/recovery_live_parts/launcher_and_scenario_judging.rs index 580d72144..cd0c81873 100644 --- a/tests/unit/livekit/recovery_live_parts/launcher_and_scenario_judging.rs +++ b/tests/unit/livekit/recovery_live_parts/launcher_and_scenario_judging.rs @@ -24,6 +24,7 @@ impl<'a> LiveEffects<'a> { api_secret: &config.livekit_api_secret, server_url: &config.livekit_url, recording_max_min: recording_cap, + default_duration_min: crate::config::DEFAULT_DURATION_MIN, }, body.as_bytes(), &room_name, diff --git a/tests/unit/web/policy.rs b/tests/unit/web/policy.rs index 6ea446686..179339c54 100644 --- a/tests/unit/web/policy.rs +++ b/tests/unit/web/policy.rs @@ -25,6 +25,7 @@ fn policy_for(url: &str) -> String { production: true, compiler_explorer_enabled: false, guest_mode_enabled: false, + default_duration_min: crate::config::DEFAULT_DURATION_MIN, trusted_proxy_hops: 0, allowed_hosts: Vec::new(), recording: None, diff --git a/tests/unit/web/pool.rs b/tests/unit/web/pool.rs index b5c1e7af1..46e06ddbc 100644 --- a/tests/unit/web/pool.rs +++ b/tests/unit/web/pool.rs @@ -32,6 +32,7 @@ fn config_with(ids: &[&str]) -> WebServerConfig { production: false, compiler_explorer_enabled: false, guest_mode_enabled: false, + default_duration_min: crate::config::DEFAULT_DURATION_MIN, trusted_proxy_hops: 0, allowed_hosts: Vec::new(), recording: None, diff --git a/tests/unit/web/setup_edit.rs b/tests/unit/web/setup_edit.rs index 938859ca1..422099fed 100644 --- a/tests/unit/web/setup_edit.rs +++ b/tests/unit/web/setup_edit.rs @@ -217,6 +217,7 @@ impl Server { production: false, compiler_explorer_enabled: false, guest_mode_enabled: true, + default_duration_min: crate::config::DEFAULT_DURATION_MIN, trusted_proxy_hops: 0, allowed_hosts: Vec::new(), recording: None, diff --git a/tests/web.rs b/tests/web.rs index 283e4a024..027cdf05d 100644 --- a/tests/web.rs +++ b/tests/web.rs @@ -143,6 +143,7 @@ fn web_config() -> WebServerConfig { production: false, compiler_explorer_enabled: true, guest_mode_enabled: true, + default_duration_min: DEFAULT_DURATION_MIN, trusted_proxy_hops: 0, allowed_hosts: Vec::new(), recording: None, @@ -1383,6 +1384,9 @@ async fn spawn_counting_quota_stub( #[path = "web/token.rs"] mod token; +#[path = "web/token_default.rs"] +mod token_default; + #[path = "web/policy.rs"] mod policy; diff --git a/tests/web/accounts.rs b/tests/web/accounts.rs index 0fd125b44..75950e214 100644 --- a/tests/web/accounts.rs +++ b/tests/web/accounts.rs @@ -129,8 +129,7 @@ async fn account_routes_record_interviewee_github_login() { assert_eq!( session.json::().await.unwrap(), json!({"signedIn": false, "loginRequired": true, - "guestModeAvailable": true, "deviceLogin": false, "maxDurationMin": MAX_DURATION_MIN, - "candidateVideo": null}) + "guestModeAvailable": true, "deviceLogin": false, "maxDurationMin": MAX_DURATION_MIN, "defaultDurationMin": DEFAULT_DURATION_MIN, "candidateVideo": null}) ); let signed_in = client @@ -145,6 +144,7 @@ async fn account_routes_record_interviewee_github_login() { assert_eq!(signed_in["signedIn"], true); assert_eq!(signed_in["user"]["login"], "octo-cat"); assert_eq!(signed_in["githubSignedIn"], false); + assert_eq!(signed_in["defaultDurationMin"], DEFAULT_DURATION_MIN); let reports = client .get(format!("{base}/api/reports")) @@ -329,7 +329,7 @@ async fn an_unmigratable_account_database_refuses_rather_than_disabling_login() assert_eq!( session, json!({"signedIn": false, "loginRequired": true, - "guestModeAvailable": false, "deviceLogin": false, "maxDurationMin": MAX_DURATION_MIN, + "guestModeAvailable": false, "deviceLogin": false, "maxDurationMin": MAX_DURATION_MIN, "defaultDurationMin": DEFAULT_DURATION_MIN, "candidateVideo": null}), "a database this binary cannot migrate still requires a login" ); @@ -371,7 +371,7 @@ async fn an_unopenable_account_database_refuses_rather_than_disabling_login() { assert_eq!( session, json!({"signedIn": false, "loginRequired": true, - "guestModeAvailable": false, "deviceLogin": false, "maxDurationMin": MAX_DURATION_MIN, + "guestModeAvailable": false, "deviceLogin": false, "maxDurationMin": MAX_DURATION_MIN, "defaultDurationMin": DEFAULT_DURATION_MIN, "candidateVideo": null}), "a broken database still requires a login; it cannot serve one" ); @@ -428,7 +428,7 @@ async fn an_unopenable_account_database_refuses_rather_than_disabling_login() { "signedIn": false, "loginRequired": false, "guestModeAvailable": true, "deviceLogin": false, - "maxDurationMin": MAX_DURATION_MIN, + "maxDurationMin": MAX_DURATION_MIN, "defaultDurationMin": DEFAULT_DURATION_MIN, "candidateVideo": null }), "nothing to sign in to, and the lobby has to be told so" @@ -1084,7 +1084,7 @@ async fn account_reports_are_scoped_to_the_signed_in_user() { assert_eq!( signed_out, json!({"signedIn": false, "loginRequired": true, - "guestModeAvailable": true, "deviceLogin": false, "maxDurationMin": MAX_DURATION_MIN, + "guestModeAvailable": true, "deviceLogin": false, "maxDurationMin": MAX_DURATION_MIN, "defaultDurationMin": DEFAULT_DURATION_MIN, "candidateVideo": null}), "accounts exist here, so the browser has to know to demand a sign-in" ); diff --git a/tests/web/contract.rs b/tests/web/contract.rs index b30ff483a..6c816f5d1 100644 --- a/tests/web/contract.rs +++ b/tests/web/contract.rs @@ -59,6 +59,7 @@ async fn static_home_markup_matches_frontend_contract() { production: false, compiler_explorer_enabled: true, guest_mode_enabled: false, + default_duration_min: DEFAULT_DURATION_MIN, trusted_proxy_hops: 0, allowed_hosts: Vec::new(), recording: None, @@ -119,6 +120,7 @@ async fn static_interview_markup_exposes_offline_surface() { production: false, compiler_explorer_enabled: true, guest_mode_enabled: false, + default_duration_min: DEFAULT_DURATION_MIN, trusted_proxy_hops: 0, allowed_hosts: Vec::new(), recording: None, @@ -295,7 +297,7 @@ fn static_interview_script_leaves_candidate_identity_to_the_server() { // The shared request fields follow the mode-specific problem selection. // Strict assignment must retain the optional execution restriction too. assert!(compact(&source).contains( - "durationMin,interviewId,interviewLoop,interviewMode:mode,...(!whiteboard?{language:state.language}:{}),interviewProfile,...(interviewGrounding?{interviewGrounding}:{}),...(nodes.interviewerAccent.value?{interviewerAccent:nodes.interviewerAccent.value}:{}),...(nodes.interviewerVoice.value?{interviewerVoice:nodes.interviewerVoice.value}:{}),...(nodes.hideExamples.checked?{hideExamples:true}:{}),...(exampleDrawingEnabled?{exampleDrawing:true}:{}),...(candidateLedEnabled?{candidateLed:true}:{}),...(!editorOptions.execution?{codeExecution:false}:{})})" + "...(namedDuration?{durationMin}:{}),interviewId,interviewLoop,interviewMode:mode,...(!whiteboard?{language:state.language}:{}),interviewProfile,...(interviewGrounding?{interviewGrounding}:{}),...(nodes.interviewerAccent.value?{interviewerAccent:nodes.interviewerAccent.value}:{}),...(nodes.interviewerVoice.value?{interviewerVoice:nodes.interviewerVoice.value}:{}),...(nodes.hideExamples.checked?{hideExamples:true}:{}),...(exampleDrawingEnabled?{exampleDrawing:true}:{}),...(candidateLedEnabled?{candidateLed:true}:{}),...(!editorOptions.execution?{codeExecution:false}:{})})" )); assert!(!source.contains("candidateIdentity")); } diff --git a/tests/web/routes.rs b/tests/web/routes.rs index d9d301287..ccbae9cf7 100644 --- a/tests/web/routes.rs +++ b/tests/web/routes.rs @@ -259,6 +259,44 @@ async fn the_oauth_callback_requires_the_state_it_minted() { remove_database(path).await; } +/// The lobby preselects the operator's length, so the server has to say what +/// it is; the page cannot read `CODETRIAL_DURATION_MIN` itself. What it says is +/// the length `/api/token` would grant: inside the range, and no longer than +/// the recording cap. +#[tokio::test] +async fn the_session_reports_the_default_length_it_would_grant() { + for (configured, recording, expected) in [ + (30, None, 30), + (5, None, MIN_DURATION_MIN), + (120, None, MAX_DURATION_MIN), + ( + 120, + Some(recording_config()), + recording_config().max_minutes, + ), + ] { + let mut config = web_config(); + config.default_duration_min = configured; + config.recording = recording; + let (base, server) = spawn_web_server(config).await; + + let session = http_client() + .get(format!("{base}/api/session")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + + assert_eq!( + session["defaultDurationMin"], expected, + "configured {configured}" + ); + server.shutdown().await; + } +} + /// The same endpoint on a server that records nothing, so the cap is the range /// the endpoint has always offered rather than a number the lobby invents. #[tokio::test] diff --git a/tests/web/token.rs b/tests/web/token.rs index 895f040e7..7ba4c5146 100644 --- a/tests/web/token.rs +++ b/tests/web/token.rs @@ -103,6 +103,7 @@ fn token_response_matches_frontend_contract() { api_secret: "devsecret", server_url: "wss://example.livekit.cloud", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }, br#"{"problemId":"merge-intervals","durationMin":120}"#, "interview-fixed", @@ -136,6 +137,7 @@ fn token_ignores_a_mode_a_stale_client_still_sends() { api_secret: "devsecret", server_url: "wss://example.livekit.cloud", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; for body in [ br#"{"mode":"practice"}"#.as_slice(), @@ -163,6 +165,7 @@ fn token_interview_mode_is_allowlisted_and_defaults_to_the_editor() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; for (body, expected) in [ ( @@ -192,6 +195,7 @@ fn token_interview_loop_is_allowlisted_and_defaults_to_combined() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; for (body, expected) in [ ( @@ -218,6 +222,7 @@ fn only_explicit_false_disables_execution_in_signed_metadata() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; for value in [ json!(false), @@ -263,6 +268,7 @@ fn token_profile_is_bounded_and_enum_validated_before_signed_metadata() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }, serde_json::to_string(&json!({"interviewProfile": { "role": format!(" {}\n", "r".repeat(100)), @@ -300,6 +306,7 @@ fn token_profile_is_bounded_and_enum_validated_before_signed_metadata() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }, br#"{"interviewProfile":{"seniority":"founder","role":4}}"#, "room", @@ -320,6 +327,7 @@ fn token_grounding_is_signed_only_after_valid_consent_and_shape() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; let signed = token_response(&config, br#"{"interviewGrounding":{"consentVersion":1,"requirements":["Must know Rust"],"skills":["Rust"],"anchors":["Built a parser"]}}"#, "room", "candidate", 2_000).unwrap(); let metadata: Value = @@ -350,6 +358,7 @@ fn token_carries_hidden_examples_only_when_true() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; let metadata = |body: &[u8]| -> Value { let response = token_response(&config, body, "room", "candidate", 2_000).unwrap(); @@ -393,6 +402,7 @@ fn token_carries_candidate_led_only_when_true() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; let metadata = |body: &[u8]| -> Value { let response = token_response(&config, body, "room", "candidate", 2_000).unwrap(); @@ -428,6 +438,7 @@ fn token_carries_only_an_offered_language() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; let metadata = |body: &[u8]| -> Value { let response = token_response(&config, body, "room", "candidate", 2_000).unwrap(); @@ -466,6 +477,7 @@ fn token_response_mints_the_candidate_identity() { api_secret: "devsecret", server_url: "wss://example.livekit.cloud", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }, br#"{"candidateIdentity":"candidate-a1b2c3"}"#, "interview-fixed", @@ -501,6 +513,7 @@ fn token_duration_matches_current_frontend_clamp() { api_secret: "devsecret", server_url: "wss://example.livekit.cloud", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }, &body, "interview-fixed", @@ -525,6 +538,7 @@ fn token_duration_preserves_fractional_frontend_metadata() { api_secret: "devsecret", server_url: "wss://example.livekit.cloud", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }, br#"{"durationMin":42.8}"#, "interview-fixed", @@ -561,6 +575,7 @@ fn token_duration_stops_at_the_recording_cap() { api_secret: "devsecret", server_url: "wss://example.livekit.cloud", recording_max_min, + default_duration_min: DEFAULT_DURATION_MIN, }, &body, "interview-fixed", @@ -600,6 +615,7 @@ fn token_response_reports_the_length_it_granted() { api_secret: "devsecret", server_url: "wss://example.livekit.cloud", recording_max_min, + default_duration_min: DEFAULT_DURATION_MIN, }, &body, "interview-fixed", @@ -631,32 +647,6 @@ fn token_response_reports_the_length_it_granted() { } } -#[test] -fn token_duration_default_stops_at_the_recording_cap() { - // A caller who names no length gets the default, and the default is a - // length like any other. Handing back forty-five to a deployment that can - // only record thirty loses the same stretch, for the want of a request. - let response = token_response( - &TokenConfig { - api_key: "devkey", - api_secret: "devsecret", - server_url: "wss://example.livekit.cloud", - recording_max_min: Some(30), - }, - b"{}", - "interview-fixed", - "candidate-fixed", - 2000, - ) - .unwrap(); - let claims = claims(&response.token); - - assert_eq!( - serde_json::from_str::(claims["metadata"].as_str().unwrap()).unwrap()["durationMin"], - 30 - ); -} - #[test] fn token_duration_survives_a_recording_cap_under_the_floor() { // `f64::clamp` panics when its bounds cross, so a cap below the shortest @@ -667,6 +657,7 @@ fn token_duration_survives_a_recording_cap_under_the_floor() { api_secret: "devsecret", server_url: "wss://example.livekit.cloud", recording_max_min: Some(1), + default_duration_min: DEFAULT_DURATION_MIN, }, br#"{"durationMin":45}"#, "interview-fixed", @@ -1561,6 +1552,7 @@ fn token_resolves_voice_and_accent_independently() { api_secret: "secret", server_url: "wss://example.test", recording_max_min: None, + default_duration_min: DEFAULT_DURATION_MIN, }; for voice in ["", "Default", "Random"] .into_iter() diff --git a/tests/web/token_default.rs b/tests/web/token_default.rs new file mode 100644 index 000000000..24892cca6 --- /dev/null +++ b/tests/web/token_default.rs @@ -0,0 +1,81 @@ +//! The length a token request that names none is granted: the operator's +//! `CODETRIAL_DURATION_MIN`, held to the endpoint's range and the recording +//! cap. +//! +//! Split out of `token.rs`, which is at its size ceiling. + +use super::*; + +/// A request that names no length gets the operator's `CODETRIAL_DURATION_MIN`, +/// held to the same range and recording cap as a length the lobby asked for. +#[test] +fn token_without_a_length_gets_the_configured_default() { + for (default_duration_min, recording_max_min, expected) in [ + (30, None, json!(30)), + (DEFAULT_DURATION_MIN, None, json!(DEFAULT_DURATION_MIN)), + // Inside the range the endpoint offers, whatever the file says. + (5, None, json!(MIN_DURATION_MIN)), + (120, None, json!(MAX_DURATION_MIN)), + // A default the cap cannot record is shortened like any other length. + (60, Some(45), json!(45)), + (30, Some(45), json!(30)), + // The built-in default on a deployment that can only record thirty + // minutes: handing back forty-five loses the same stretch. + (DEFAULT_DURATION_MIN, Some(30), json!(30)), + ] { + let response = token_response( + &TokenConfig { + api_key: "devkey", + api_secret: "devsecret", + server_url: "wss://example.livekit.cloud", + recording_max_min, + default_duration_min, + }, + b"{}", + "interview-fixed", + "candidate-fixed", + 2000, + ) + .unwrap(); + + assert_eq!( + response.duration_min, expected, + "default {default_duration_min} under a cap of {recording_max_min:?}" + ); + // The agent is handed the length in the signed room metadata. + let metadata = claims(&response.token)["metadata"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + serde_json::from_str::(&metadata).unwrap()["durationMin"], + expected, + "metadata for default {default_duration_min} under a cap of {recording_max_min:?}" + ); + } +} + +/// The route hands the operator's length to the token, not the built-in one: +/// `token_response` is tested above with the field already set, so this is the +/// test that notices `/api/token` going back to the constant. +#[tokio::test] +async fn the_token_route_grants_the_configured_default_to_a_request_naming_none() { + for (configured, expected) in [(30, 30), (60, 60), (120, MAX_DURATION_MIN)] { + let mut config = web_config(); + config.guest_mode_enabled = true; + config.default_duration_min = configured; + let (base, server) = spawn_web_server_with_guest_dispatcher(config).await; + + let response = http_client() + .post(format!("{base}/api/token")) + .json(&json!({"problemId": "two-sum"})) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), 200, "configured {configured}"); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["durationMin"], expected, "configured {configured}"); + server.shutdown().await; + } +} diff --git a/web/app.js b/web/app.js index bb6b55647..9f5059f8d 100644 --- a/web/app.js +++ b/web/app.js @@ -123,6 +123,9 @@ let deletingReports = false; /// file is still in its temporal dead zone, and reading one there throws /// before the lobby has rendered anything at all. let durationCeiling = Infinity; +/// The length a Medium or Hard problem suggests: the preselected button until +/// `/api/session` reports the operator's own `CODETRIAL_DURATION_MIN`. +let defaultDuration; /// Whether the interviewer sends candidate video, from `/api/session`. Null /// until it answers, when it fails, and when this server does not host the /// interviewer itself; the billing notice above Start stays hidden while null. @@ -678,6 +681,9 @@ nodes.attemptDownloadSelected.addEventListener("click", async () => { }); const durations = [...document.querySelectorAll("[data-duration]")]; +defaultDuration = Number( + document.querySelector("[data-duration].selected")?.dataset.duration, +); for (const button of durations) { button.addEventListener("click", () => { if (starting) return; @@ -1105,7 +1111,30 @@ async function loadAccount(generation) { throw new Error("Invalid account response"); } accountHistory = false; + // Re-suggest only when the operator's length differs from the markup's, so + // a lobby on the built-in default keeps a problem the candidate picked, or + // when a restored lobby learns a ceiling other than the one it settled + // under (a suggestion made by hand-ticking a level is not a choice). + const operatorDuration = + Number.isFinite(session.defaultDurationMin) && + session.defaultDurationMin !== defaultDuration; + const ceilingMoved = + Number.isFinite(durationCeiling) && + Number.isFinite(session.maxDurationMin) && + session.maxDurationMin !== durationCeiling; + if (operatorDuration) defaultDuration = session.defaultDurationMin; applyDurationCeiling(session.maxDurationMin); + if (operatorDuration || ceilingMoved) { + // A problem picked by hand before the session answered suggested its own + // length from its own difficulty; the checkboxes would overrule it. + setDuration( + suggestedDuration( + manualProblem && problem + ? new Set([problem.difficulty]) + : selectedDifficulties(), + ), + ); + } candidateVideo = typeof session.candidateVideo === "boolean" ? session.candidateVideo @@ -1682,13 +1711,16 @@ function mark(button, on) { button.setAttribute("aria-pressed", String(on)); } -/// Capped at the server's own default length, which src/config.rs holds at or -/// under the recording cap, so nothing the lobby picks on its own outlives its -/// recording. Only the suggestion is capped: the sixty minute button is one -/// click away and a deployment can set the two environment variables apart, so -/// this narrows the default rather than guaranteeing anything. +/// The operator's default length for Medium and Hard, and no more than thirty +/// for Easy. Snapped to a button the row offers, so a default of 20 or 75 still +/// leaves one selected; the ceiling is applied afterwards by `setDuration`. function suggestedDuration(difficulties) { - return difficulties.has("Medium") || difficulties.has("Hard") ? 45 : 30; + const hard = difficulties.has("Medium") || difficulties.has("Hard"); + const wanted = hard ? defaultDuration : Math.min(30, defaultDuration); + const offered = durations.map((button) => Number(button.dataset.duration)); + return offered.reduce((best, value) => + Math.abs(value - wanted) < Math.abs(best - wanted) ? value : best, + ); } function setInterviewLoop(value) { diff --git a/web/interview.js b/web/interview.js index 5089c6322..40da18630 100644 --- a/web/interview.js +++ b/web/interview.js @@ -352,14 +352,19 @@ let languages = []; // paint. Do not switch to it after the candidate has chosen a tab or edited. let languageChoiceCommitted = false; /// What the lobby asked for, until `/api/token` says what it got. The range -/// here mirrors the server's own and is the fallback for a URL that arrives -/// without passing through the lobby; the answer below is what the interview -/// actually runs on. +/// here mirrors the server's own, and 45 is only the length shown until the +/// answer lands for a URL that arrives without passing through the lobby: such +/// a request names no length, so the server's configured default decides. The +/// answer below is what the interview actually runs on. let durationMin = clamp( Number.parseInt(params.get("duration") || "45", 10) || 45, 10, 90, ); +/// Whether the URL named a usable length, which `?duration=` and +/// `?duration=abc` do not: decided once, here, for the token request and for the +/// round plan alike. +const namedDuration = Number.parseInt(params.get("duration") ?? "", 10) > 0; const interviewLoop = codingLoop(params.get("loop")); /// Whether this interview is held at a whiteboard rather than in the editor. /// @@ -451,8 +456,8 @@ let codingMinutes = durationMin - behavioralMinutes; /// round split and the saved report describing an interview nobody is having. /// /// An answer that is missing or unusable leaves the request in place: the page -/// still runs, on the length the URL named, exactly as it did before the -/// server sent this. +/// still runs, on the length shown so far (the URL's, else the provisional 45), +/// exactly as it did before the server sent this. function applyGrantedDuration(granted) { if (typeof granted !== "number" || !Number.isFinite(granted) || granted <= 0) return; @@ -461,7 +466,7 @@ function applyGrantedDuration(granted) { interviewLoop === "coding_behavioral" ? Math.min(8, durationMin) : 0; codingMinutes = durationMin - behavioralMinutes; // The budget is on screen by now: `bindEvents` wrote it during setup, from - // the length the URL asked for. Leaving it there would put the old number in + // the length shown so far. Leaving it there would put the old number in // front of the candidate for the whole interview. renderRoundPlan(); } @@ -1182,7 +1187,14 @@ function bindEvents() { nodes.jimMute.addEventListener("click", () => jimVolume.toggleMute()); nodes.jimVolumeReset.addEventListener("click", () => jimVolume.reset()); renderJimVolume(); - renderRoundPlan(); + // A URL that names no length gets the server's default, so the plan waits for + // `/api/session` rather than show 45 minutes and then jump to the real one. + if (namedDuration) renderRoundPlan(); + else + void sessionPromise.then((session) => { + applyGrantedDuration(session?.defaultDurationMin); + renderRoundPlan(); + }); nodes.problemTab.addEventListener("click", () => selectTab("problem")); nodes.transcriptTab.addEventListener("click", () => selectTab("transcript")); nodes.mic.addEventListener("click", toggleMicrophone); @@ -2210,7 +2222,7 @@ async function connect(preflight, presenting = false) { ...(previous ? { previousAssignment: previous } : {}), } : { problemId: problem.page }), - durationMin, + ...(namedDuration ? { durationMin } : {}), interviewId, interviewLoop, interviewMode: mode,