From 5c530260831466e93813670b8ee9b2b77b44e131 Mon Sep 17 00:00:00 2001 From: ZuoTso Date: Mon, 12 Oct 2026 02:38:03 +0800 Subject: [PATCH] Add server-selected strict interview mode Practice needs reproducible problem links, while strict interviews should prevent candidates from choosing known problems. Preserve difficulty selection in both modes and start a new strict attempt on reload instead of recovering the previous interview. --- README.md | 11 +- docs/development.md | 5 + docs/interview-selection.md | 148 ++ docs/provider-cost-and-degradation.md | 12 +- src/web/mod.rs | 1 + src/web/pool.rs | 10 +- src/web/selection.rs | 211 +++ src/web/token.rs | 78 +- tests/browser/account.test.js | 7 +- tests/browser/dom-contract.test.js | 25 +- tests/browser/editor-pipeline.test.js | 3 + tests/browser/lobby.test.js | 372 ++++- tests/browser/report-recovery.test.js | 1 + tests/browser/strict-selection.test.js | 1840 ++++++++++++++++++++++++ tests/unit/web/selection.rs | 393 +++++ tests/web/contract.rs | 4 +- tests/web/token.rs | 405 ++++++ web/app.js | 57 +- web/index.html | 24 +- web/interview.html | 5 + web/interview.js | 258 +++- web/problem-grid.js | 12 +- web/selection.js | 38 + web/styles.css | 1 + 24 files changed, 3811 insertions(+), 110 deletions(-) create mode 100644 docs/interview-selection.md create mode 100644 src/web/selection.rs create mode 100644 tests/browser/strict-selection.test.js create mode 100644 tests/unit/web/selection.rs create mode 100644 web/selection.js diff --git a/README.md b/README.md index 0465e604e..59326b489 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ INTERVIEW_ROOM_NAME=interview-local make web ``` Open , choose a problem and duration, then complete the -media preflight. `INTERVIEW_ROOM_NAME` pins every interview to that one room +media preflight. `INTERVIEW_ROOM_NAME` pins Practice interviews to that one room name, which is what a local run wanting a stable URL usually wants; without it each session gets a random room. The pin is dropped for a session if its LiveKit project has run out of connection minutes, so a spent project costs the stable @@ -130,6 +130,15 @@ cannot be run cannot complete the REACTO Test step. ## Interview behavior +Practice is the default: choose a problem, reopen its URL or reload to practice +it again after media preflight. Strict Interview requires sign-in, keeps the +difficulty choice and assigns a matching problem on the server only after preflight. Reloading +returns to preflight, then starts a fresh room with a different matching problem +from this tab's previous assignment, or refuses if none remains. It ignores URL +problem selection and restores no interview progress. Neither mode treats a +refresh as cheating. See [interview selection](docs/interview-selection.md) for +tab behavior, storage requirements and the selection boundary. + Jim asks short, targeted questions between logical blocks, offers progressive conceptual hints, and uses the latest test run in the final assessment. Voice responses stop when the candidate interrupts. Say "can I get a hint?" when diff --git a/docs/development.md b/docs/development.md index 219ecf973..8ce5f8f90 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,6 +5,11 @@ time actually goes. `make` targets and the one-line summary of each are in [README.md](../README.md#development); everything here is the detail behind them. +Selection admission and refresh behavior are specified in +[interview selection](interview-selection.md). The Strict browser regression tests use +the existing real media preflight with a fake media grant and room transport; +they do not exercise paid LiveKit, Gemini or Egress services. + ## Dependency security checks A new RustSec advisory must reach maintainers without making an unrelated diff --git a/docs/interview-selection.md b/docs/interview-selection.md new file mode 100644 index 000000000..f8d7416be --- /dev/null +++ b/docs/interview-selection.md @@ -0,0 +1,148 @@ +# Interview selection + +Practice is the default. Its problem picker, published-id and scenario URLs, +repeat attempts, refresh behavior, offline fallback and local candidate cases +are unchanged. Reloading a Practice URL runs media preflight and starts the same +problem again; it does not recover the previous attempt. + +Strict Interview is an opt-in selection policy, separate from the coding or +whiteboard surface and the coding-only or behavioral loop. Difficulty remains +a multi-select choice in both modes. Strict hides the random, saved-list and +specific-problem pickers; the server draws from the selected levels, without +using Practice's history recommendations or topic filter. The lobby sends `selection=strict` +and one `difficulty` query parameter per checked level, without a problem. A +Strict URL's `problem` parameter is ignored; reopening it cannot request that +problem. Switching to Practice is allowed. This is a self-assessment mode, not +an administrator-enforced exam. + +## Admission and selection + +Strict requires a signed-in account even when the deployment allows guests. +Practice retains guest admission, its separate rate limit and local report +history. The lobby keeps Strict Start disabled until the account is signed in; +its existing GitHub sign-in control remains available. The server refuses guest +Strict requests with HTTP 401 and +`strict_login_required` before selecting a problem, reserving a room or +dispatching an agent. No guest receipt format or anonymous identity is added. + +The recording-consent notice appears during media preflight; the camera +and notice are required on recording deployments. Consent is recorded and a +room requested only after preflight succeeds; failure or leaving for the lobby creates no attempt. + +`POST /api/token` accepts `selectionMode: "strict"`, optional `difficulties` +and an optional `previousAssignment` receipt. The browser passes the URL's +`difficulty` values as the `difficulties` array without dropping invalid values. +When present, it must contain one to three distinct strings drawn from exactly +`"Easy"`, `"Medium"` and `"Hard"`. Empty arrays, duplicates, unknown values, +wrong casing and non-string values are refused with HTTP 400 and +`strict_difficulty_invalid`. Missing difficulties keep the all-level draw for +existing Strict URLs and API clients; this is not a fallback for invalid input. + +The endpoint rejects any `problemId` field in a Strict request, including null, +even with valid difficulties. Missing selection mode and explicit `"practice"` +keep the existing API. Unknown modes are refused. + +After the existing account and consent checks, the server randomly selects +from the checked-in problems matching any selected difficulty, excluding only +the preceding assignment. The response adds `problemPage`, the public scenario +page named in the signed LiveKit metadata, and a new `previousAssignment` +receipt. It uses a fresh room +and candidate identity even when `INTERVIEW_ROOM_NAME` pins Practice to a +development room. The normal provider quota, capacity, consent claim and +dispatcher gates still apply. The hosting dispatcher starts an independent +agent; a web-only deployment must staff these dynamically named rooms through +its external agent host, just as for unpinned Practice rooms. An agent launched +manually for the fixed room does not serve Strict attempts. + +If no eligible problem remains after difficulty filtering and exclusion, +admission is refused with HTTP 409 and `strict_no_alternative`, before a room +or agent is allocated. The browser shows the reason and keeps its previous +receipt. There is no fallback to another level or to the previous problem. +A pool with one matching problem allows it once, then refuses a request +carrying that assignment. A later return to an older problem is allowed (A, B, A). + +The selected difficulties stay in the interview URL across a normal reload. +The receipt format is unchanged: it names the previous problem regardless of +difficulty. If a caller changes the difficulty filter, that page is still +excluded whenever it belongs to the new pool. + +Try again always selects Practice, including for a report from a Strict attempt. +It keeps the original problem, a valid saved coding language and the existing +duration, loop and surface settings. Switching to Strict clears the retry +language; its lobby URL contains neither that preference nor a specific problem. + +A direct Strict URL may request a coding language with `language`, just as in +Practice. Python is the default when no supported language is requested. After +assignment, language availability follows the assigned problem and its judge. +A delayed judge may restore the requested language only before the candidate +chooses a language, edits or runs code, or ends the interview. + +## The previous-assignment receipt + +Only one receipt is kept in this tab's `sessionStorage`. It contains a purpose +and format version, account id and public scenario page, authenticated with the +server's session signing secret. It conveys neither room access nor recording +consent and contains no interview content. The server verifies the signature, +account and current scenario before excluding the page. Invalid receipts are +refused; another account or a rotated signing secret requires a new sequence. + +The browser checks storage before allocation and saves the new receipt before +loading or revealing the assigned problem or joining the room. A token refusal +keeps the old receipt; a subsequent problem-load or join failure keeps the new +one. Storage failure, an invalid assignment or a missing problem stops Strict +admission without starting offline practice or falling back to the default +problem. Retry returns to preflight. Strict candidate cases stay in memory. + +Strict connection failures show the failure reason and a Not connected agent +label, without offering offline practice. The token request has a 20-second +browser deadline, including its response body. A timeout offers Back to lobby +or Retry media preflight; retry keeps the receipt and requires preflight and +consent again. Practice keeps its existing offline fallback. + +Reloading retains this tab's receipt and returns to preflight. A browser history +restore from the back/forward cache reloads Strict through the same gate only +when the existing unload policy allows leaving. A started attempt without a +saved report stays on the restored page, preserving its content and warning. +Opening an old URL in a new tab has no prior assignment unless the browser +copied the opener's session storage. Independent tabs have independent sequences; a duplicated tab +initially excludes the copied assignment, then progresses independently. Two +tabs may receive the same new problem. Explicitly starting Strict from the lobby +clears this tab's sequence; returning to the lobby alone does not. + +## Limits + +Strict is an opt-in self-assessment mode. Excluding only the previous problem +does not prevent repeated refreshes from eventually drawing a familiar one. +It keeps the existing agent cleanup policy, so an old agent may temporarily +occupy capacity while a new attempt starts. This change adds neither +multi-problem history nor Strict-specific cleanup. + +The receipt is not an account-wide attempt ledger or a replay-proof credential. +Clearing storage, omitting the receipt or replaying an older valid receipt can +reset the exclusion. The server still selects the next problem and +never accepts a caller-selected Strict problem. Enforcing the latest attempt +across tabs or devices would need server-side history and a separate product +decision. There is no cheating signal, score penalty or permanent exclusion. + +## Lifecycle and contracts + +A browser reload starts a new attempt. It restores no editor, transcript, +timer, framework progress, evidence, report-recovery inputs or Gemini session. +The previous room follows its existing participant-absence cleanup and recording +lifecycle; it may briefly overlap the new attempt and consume a capacity slot. + +LiveKit reconnect within a loaded page keeps its current room and problem. +Gemini reconnect and same-agent report recovery remain unchanged. The existing +beforeunload warning still protects an active, unsaved interview in both modes; +it does not classify refresh or trigger allocation. + +Recording consent is still claimed once per room, and starting again requires +fresh consent. Report shape, assessment prompts, rubric and integrity evidence +semantics do not change, so no interview contract bundle or component version +is bumped. The receipt's format tag versions only selection metadata. + +Regression coverage lives in `tests/unit/web/selection.rs`, +`tests/web/token.rs` and `tests/browser/strict-selection.test.js`. The +credential-free browser tests use the real preflight and page reload with a +fake media grant and room transport; provider reconnect and recording still +need the existing credentialed integration checks for end-to-end assurance. diff --git a/docs/provider-cost-and-degradation.md b/docs/provider-cost-and-degradation.md index 41172b481..3dd8dbcda 100644 --- a/docs/provider-cost-and-degradation.md +++ b/docs/provider-cost-and-degradation.md @@ -287,9 +287,15 @@ keys, prompts, transcripts, code, or grounding text. ## What may be cached -Checked-in and static problem-bank data, schemas, and vendored runtime metadata, -and nothing else. Never candidate prompts, transcript, code, profile, job -description or resume grounding, provider output, repair output, framework +Checked-in and static problem-bank data, schemas, and vendored runtime metadata +may be cached. The one selection-only exception is a single server-signed +previous-assignment receipt in tab-scoped session storage for +[Strict Interview](interview-selection.md): purpose/version, account id and +public scenario page only, with no room credential or interview content. It +excludes the immediately preceding problem on a new attempt and is never an +input to assessment or session recovery. + +Never cache candidate prompts, transcript, code, profile, job description or resume grounding, provider output, repair output, framework evidence, or personalized feedback. Report requests are built from the current session and each retry uses that same session's immutable prompt, so there is no cross-session response cache. A failed report may keep its frozen prompt and diff --git a/src/web/mod.rs b/src/web/mod.rs index d519cfee9..466925267 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -24,6 +24,7 @@ mod interviews; mod policy; mod pool; mod recordings; +mod selection; mod setup; mod token; diff --git a/src/web/pool.rs b/src/web/pool.rs index d0b0c4d4a..d9f8baa64 100644 --- a/src/web/pool.rs +++ b/src/web/pool.rs @@ -246,7 +246,12 @@ pub(crate) enum ProviderChoice<'a> { /// /// The counter advances per attempt, not per request, so a dead project does /// not pin every later interview to the one after it. -pub(crate) async fn room_and_available_provider(state: &AppState) -> ProviderChoice<'_> { +/// `require_fresh_room` bypasses the pinned development room so a new attempt +/// cannot reuse its existing interviewer and evidence. +pub(crate) async fn room_and_available_provider( + state: &AppState, + require_fresh_room: bool, +) -> ProviderChoice<'_> { // A pinned development room names the project it belongs to, so it is // preferred while that project can still take a connection, and only while. // A pin whose project is out of minutes is a room nobody can join: the @@ -258,7 +263,8 @@ pub(crate) async fn room_and_available_provider(state: &AppState) -> ProviderCho // the pin is the room name, so nothing here rotates and no counter is spent // on it. Spending one anyway would advance the rotation on every pinned // request, which is the opposite of what a pin is for. - if !state.config.production + if !require_fresh_room + && !state.config.production && let Some(room_name) = state .config .fixed_room_name diff --git a/src/web/selection.rs b/src/web/selection.rs new file mode 100644 index 000000000..6ce55263d --- /dev/null +++ b/src/web/selection.rs @@ -0,0 +1,211 @@ +//! Strict selection changes admission, never the interviewer or its assessment. +//! The receipt remembers only this tab's previous assignment. It grants no +//! room access and is not proof that the browser kept its newest receipt. + +use axum::http::StatusCode; +use axum::response::Response; +use ring::rand::SecureRandom; +use serde_json::{Value, json}; + +use crate::agent::{PROBLEMS, Problem, find_problem}; +use crate::token::{sign_hs256, verify_hs256}; + +use super::json_response; + +const PURPOSE: &str = "strict-selection-v1"; +const DIFFICULTIES: &[&str] = &["Easy", "Medium", "Hard"]; + +pub(super) struct Assignment { + pub page: &'static str, + pub receipt: String, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum SelectionError { + Mode, + Problem, + Difficulty, + Receipt, + NoAlternative, + Random, +} + +impl SelectionError { + pub(super) fn response(self) -> Response { + let (status, code, error) = match self { + Self::Mode => ( + StatusCode::BAD_REQUEST, + "selection_mode_invalid", + "Choose Practice or Strict Interview.", + ), + Self::Problem => ( + StatusCode::BAD_REQUEST, + "strict_problem_forbidden", + "Strict Interview assigns its problem on the server.", + ), + Self::Difficulty => ( + StatusCode::BAD_REQUEST, + "strict_difficulty_invalid", + "Choose one or more distinct difficulties: Easy, Medium or Hard.", + ), + Self::Receipt => ( + StatusCode::BAD_REQUEST, + "strict_assignment_invalid", + "The previous Strict assignment could not be verified. Return to the lobby to start a new selection sequence.", + ), + Self::NoAlternative => ( + StatusCode::CONFLICT, + "strict_no_alternative", + "No different problem is available at the selected difficulties. Return to the lobby to change the selection.", + ), + Self::Random => ( + StatusCode::SERVICE_UNAVAILABLE, + "strict_selection_unavailable", + "A problem could not be assigned. Try again later.", + ), + }; + json_response(status, json!({ "code": code, "error": error })) + } +} + +/// Missing means Practice for old pages, bookmarks and API clients. +pub(super) fn assign( + request: &Value, + owner: i64, + secret: &str, +) -> Result, SelectionError> { + assign_with_entropy(request, owner, secret, |bytes| { + ring::rand::SystemRandom::new() + .fill(bytes) + .map_err(|_| SelectionError::Random) + }) +} + +fn assign_with_entropy( + request: &Value, + owner: i64, + secret: &str, + fill: impl FnMut(&mut [u8]) -> Result<(), SelectionError>, +) -> Result, SelectionError> { + match request.get("selectionMode") { + None => return Ok(None), + Some(Value::String(mode)) if mode == "practice" => return Ok(None), + Some(Value::String(mode)) if mode == "strict" => {} + _ => return Err(SelectionError::Mode), + } + if request.get("problemId").is_some() { + return Err(SelectionError::Problem); + } + + // Old Strict links name no difficulty. Explicit filters must be valid in + // full: dropping a misspelling or an empty list would broaden the draw. + let difficulties = match request.get("difficulties") { + None => DIFFICULTIES.to_vec(), + Some(Value::Array(values)) if !values.is_empty() && values.len() <= DIFFICULTIES.len() => { + values + .iter() + .map(|value| { + value + .as_str() + .filter(|level| DIFFICULTIES.contains(level)) + .ok_or(SelectionError::Difficulty) + }) + .collect::, _>>()? + } + _ => return Err(SelectionError::Difficulty), + }; + if difficulties + .iter() + .enumerate() + .any(|(index, level)| difficulties[..index].contains(level)) + { + return Err(SelectionError::Difficulty); + } + let previous = match request.get("previousAssignment") { + None => None, + Some(Value::String(receipt)) => Some(read_receipt(receipt, owner, secret)?), + _ => return Err(SelectionError::Receipt), + }; + let problem = choose(PROBLEMS, &difficulties, previous, |count| { + random_index(count, fill) + })?; + let page = problem.variant().page; + Ok(Some(Assignment { + page, + receipt: receipt_for(page, owner, secret), + })) +} + +fn receipt_for(page: &str, owner: i64, secret: &str) -> String { + let payload = format!("{PURPOSE}:{owner}:{page}"); + format!("{payload}.{}", sign_hs256(secret, &payload)) +} + +/// Bound parsing and authentication work separately from whether a receipt +/// authenticates: an oversized forgery must not reach the HMAC check. +fn receipt_parts(receipt: &str) -> Result<(&str, &str), SelectionError> { + if receipt.len() > 512 { + return Err(SelectionError::Receipt); + } + receipt.rsplit_once('.').ok_or(SelectionError::Receipt) +} + +fn read_receipt<'a>(receipt: &'a str, owner: i64, secret: &str) -> Result<&'a str, SelectionError> { + let (payload, signature) = receipt_parts(receipt)?; + if !verify_hs256(secret, payload, signature) { + return Err(SelectionError::Receipt); + } + let prefix = format!("{PURPOSE}:{owner}:"); + let page = payload + .strip_prefix(&prefix) + .ok_or(SelectionError::Receipt)?; + + // Do not resolve a retired page to the default exercise or accept a + // published-id alias as though it were the page the server signed. + if !find_problem(page).is_some_and(|problem| problem.variant().page == page) { + return Err(SelectionError::Receipt); + } + Ok(page) +} + +fn choose<'a>( + problems: &'a [Problem], + difficulties: &[&str], + previous: Option<&str>, + index: impl FnOnce(usize) -> Result, +) -> Result<&'a Problem, SelectionError> { + let choices: Vec<_> = problems + .iter() + .filter(|problem| difficulties.contains(&problem.difficulty)) + .filter(|problem| Some(problem.variant().page) != previous) + .collect(); + if choices.is_empty() { + return Err(SelectionError::NoAlternative); + } + Ok(choices[index(choices.len())?]) +} + +/// The caller has already refused an empty pool. Inject only the entropy +/// source so tests can reach the rejection boundary without relying on luck. +fn random_index( + count: usize, + mut fill: impl FnMut(&mut [u8]) -> Result<(), SelectionError>, +) -> Result { + let count = count as u64; + + // Reject the partial bucket rather than biasing the last few exercises. An + // unavailable entropy source refuses admission, never chooses a default. + let limit = u64::MAX - u64::MAX % count; + loop { + let mut bytes = [0; 8]; + fill(&mut bytes)?; + let value = u64::from_le_bytes(bytes); + if value < limit { + return Ok((value % count) as usize); + } + } +} + +#[cfg(test)] +#[path = "../../tests/unit/web/selection.rs"] +mod tests; diff --git a/src/web/token.rs b/src/web/token.rs index 6989871ec..b3c4d6b26 100644 --- a/src/web/token.rs +++ b/src/web/token.rs @@ -411,8 +411,11 @@ pub(crate) fn token_duration_min(value: Option<&Value>, recording_max_min: Optio /// never finished setting up, and every provider spent is a server that worked /// until this morning. #[allow(clippy::result_large_err)] // Responses are immediately returned by HTTP handlers. -async fn reserved_room(state: &AppState) -> Result<(String, &crate::config::Provider), Response> { - match room_and_available_provider(state).await { +async fn reserved_room( + state: &AppState, + require_fresh_room: bool, +) -> Result<(String, &crate::config::Provider), Response> { + match room_and_available_provider(state, require_fresh_room).await { ProviderChoice::Ready(room_name, provider) => Ok((room_name, provider)), ProviderChoice::NoneConfigured => Err(json_response( StatusCode::INTERNAL_SERVER_ERROR, @@ -696,12 +699,12 @@ pub(crate) async fn token_handler( json!({ "error": "Session request must be JSON." }), ); } - let selection_request = if body.is_empty() { + let mut request = if body.is_empty() { json!({}) } else { serde_json::from_slice::(&body).expect("JSON was checked above") }; - if let Err(error) = validate_interviewer_selections(&selection_request) { + if let Err(error) = validate_interviewer_selections(&request) { return json_response(StatusCode::BAD_REQUEST, json!({ "error": error })); } @@ -715,12 +718,46 @@ pub(crate) async fn token_handler( None }; - // One record, so the three values cannot come from different providers. - // Complete request and consent validation before reserving a room. - let (room_name, provider) = match reserved_room(&state).await { + let assignment = if let Some(owner) = owner.as_ref() { + match super::selection::assign( + &request, + owner.user.id, + &owner.accounts.config().session_secret, + ) { + Ok(assignment) => assignment, + Err(error) => return error.response(), + } + } else { + // Strict receipts authenticate an account's preceding assignment. + // Practice guests need neither an account nor a receipt secret. + match request.get("selectionMode") { + None => None, + Some(Value::String(mode)) if mode == "practice" => None, + Some(Value::String(mode)) if mode == "strict" => { + return json_response( + StatusCode::UNAUTHORIZED, + json!({ + "code": "strict_login_required", + "error": "Sign in to start Strict Interview. Guest interviews use Practice." + }), + ); + } + _ => return super::selection::SelectionError::Mode.response(), + } + }; + + // Strict starts a new attempt even on a server with a pinned development + // room. Reusing that room would reuse its interviewer and evidence. + let (room_name, provider) = match reserved_room(&state, assignment.is_some()).await { Ok(reserved) => reserved, Err(response) => return response, }; + let body = if let Some(assignment) = &assignment { + request["problemId"] = json!(assignment.page); + serde_json::to_vec(&request).expect("JSON value is serializable") + } else { + body.to_vec() + }; let identity = generated_candidate_identity(); let response = match minted_token(&state, provider, body.as_ref(), &room_name, &identity) { Ok(response) => response, @@ -765,20 +802,19 @@ pub(crate) async fn token_handler( .grant(room_name, owner.user.id, current_epoch_seconds()); } - json_response( - StatusCode::OK, - json!({ - "token": response.token, - "serverUrl": response.server_url, - "roomName": response.room_name, - "guest": owner.is_none(), - - // The same number the token metadata carries, so the page counts - // down the interview the agent is running rather than the one the - // lobby asked for. - "durationMin": response.duration_min - }), - ) + let mut body = json!({ + "token": response.token, + "serverUrl": response.server_url, + "roomName": response.room_name, + "guest": owner.is_none(), + // The same granted length the agent reads from its token metadata. + "durationMin": response.duration_min + }); + if let Some(assignment) = assignment { + body["problemPage"] = json!(assignment.page); + body["previousAssignment"] = json!(assignment.receipt); + } + json_response(StatusCode::OK, body) } pub(crate) async fn observer_token_handler( diff --git a/tests/browser/account.test.js b/tests/browser/account.test.js index ab66e2543..ae7a4d625 100644 --- a/tests/browser/account.test.js +++ b/tests/browser/account.test.js @@ -169,7 +169,10 @@ test("the lobby offers an editor or a whiteboard and carries the choice into the // coding interview with this test still green. assert.match(app, /destination\.searchParams\.set\("mode", mode\)/); assert.match(interview, /modeIsWhiteboard\(params\.get\("mode"\)\)/); - assert.doesNotMatch(app + lobby + interview, /"(practice|scored)"/); + // Selection policy is separate from the coding/whiteboard surface. Neither + // selection mode offers the old scored/coached assessment switch. + assert.doesNotMatch(app + lobby + interview, /"scored"/); + assert.match(interview, /interviewMode: mode/); // Pause stayed; the two coaching controls went with the mode that gated them. assert.match(page, /id="pause"/); assert.doesNotMatch(interview, /retryPractice/); @@ -219,7 +222,7 @@ test("the lobby offers an editor or a whiteboard and carries the choice into the ); assertIncludesCompact( interview, - "JSON.stringify({ problemId: problem.page, durationMin, interviewId, interviewLoop, interviewMode: mode, interviewProfile, ...(interviewGrounding", + "durationMin, interviewId, interviewLoop, interviewMode: mode, interviewProfile, ...(interviewGrounding", ); assertIncludesCompact( interview, diff --git a/tests/browser/dom-contract.test.js b/tests/browser/dom-contract.test.js index d0d0a24fc..3d8ef3cf6 100644 --- a/tests/browser/dom-contract.test.js +++ b/tests/browser/dom-contract.test.js @@ -556,7 +556,23 @@ test("the module state the first paint reads exists before init paints", () => { const init = functionBody(script, "init"); const prefix = init.slice(0, init.indexOf("await")); - assert.match(prefix, /setLanguage\(/, "init paints before its first await"); + assert.match( + prefix, + /initializeLanguages\(/, + "init initializes languages before its first await", + ); + const initialize = functionBody(script, "initializeLanguages"); + assert.match(initialize, /^function initializeLanguages\(/); + const initialLanguages = initialize.slice( + 0, + initialize.indexOf("void judgePromise.then"), + ); + assert.doesNotMatch(initialLanguages, /\bawait\b|\.then\(/); + assert.match( + initialLanguages, + /setLanguage\(/, + "initialization paints synchronously", + ); const topLevel = new Map(); lines.forEach((line, index) => { @@ -861,7 +877,12 @@ test("a refused interview says why instead of silently degrading", () => { const script = interviewSource(); const connect = functionBody(script, "connect"); - assert.match(connect, /providerUiState\("degraded", failure\?\.message\)/); + assert.match( + connect, + /const reason =\s*strict &&[\s\S]*?:\s*failure\?\.message;/, + "other refusals retain the server's reason", + ); + assert.match(connect, /providerUiState\("degraded", reason\)/); // The reason has to reach the candidate, not only the console. const bannerAt = connect.indexOf('setBanner("connection", degraded.message)'); const offlineAt = connect.indexOf("Offline mode is ready"); diff --git a/tests/browser/editor-pipeline.test.js b/tests/browser/editor-pipeline.test.js index 3ed180010..689911d81 100644 --- a/tests/browser/editor-pipeline.test.js +++ b/tests/browser/editor-pipeline.test.js @@ -1087,6 +1087,9 @@ test("a new Jim turn starts at the top of the caption bar", async (t) => { try { await page.evaluate(() => import("/interview.js")); await closeSetupCheck(page); + // Advancing caption timers must not render minutes of the unrelated 3D + // avatar on a software GPU. Its rendering is covered by the avatar tests. + await page.evaluate(() => document.querySelector("#hide-avatar").click()); await showJim(firstText, "jim-scroll-1"); assert.equal(await bar.isVisible(), true); diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 5ad6e22a9..89f7097e3 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -43,6 +43,8 @@ let failing = new Set(); /// release it deliberately, rather than racing a stopwatch that a loaded CI /// worker wins. let holdReports = null; +/// Hold the initial account check separately from the reports it may load. +let holdSession = null; /// The same gate for `/api/login`, so a test about what the candidate can touch /// while a start is mid-flight holds that request open rather than guessing a /// latency long enough to win the race. @@ -76,7 +78,10 @@ before(async () => { response.end("nope"); return true; } - if (url.pathname === "/api/session") return json(session); + if (url.pathname === "/api/session") { + if (holdSession) await holdSession; + return json(session); + } if ( request.method === "DELETE" && url.pathname.startsWith("/api/reports/") @@ -124,6 +129,7 @@ beforeEach(() => { reports = []; failing = new Set(); holdReports = null; + holdSession = null; holdLogin = null; deleteRequests = 0; deletedIds = []; @@ -299,6 +305,258 @@ function lobbyTest(name, body, options) { }); } +// Wait for the synchronization task, not a guessed network delay. +const normalPageshow = (page) => + page.evaluate( + () => + new Promise((resolve) => { + window.dispatchEvent(new PageTransitionEvent("pageshow")); + setTimeout(resolve, 0); + }), + ); + +lobbyTest( + "restored Strict waits for the initial session and reports despite deferred synchronization", + async (page) => { + let releaseSession; + holdSession = new Promise((resolve) => (releaseSession = resolve)); + const releaseReports = holdHistory(); + await page.route("**/app.js", (route) => + route.fulfill({ + contentType: "text/javascript", + body: `document.querySelector("#selection-mode").value = "strict"; +${read("web/app.js")}`, + }), + ); + try { + const sessionRequest = page.waitForRequest( + (request) => new URL(request.url()).pathname === "/api/session", + ); + await page.goto(`${base}/`, { waitUntil: "load" }); + await sessionRequest; + + for (const pending of ["session", "reports"]) { + await normalPageshow(page); + assert.equal( + await page.isDisabled("#start"), + true, + `Strict was enabled while the initial ${pending} request was pending`, + ); + // Dispatch directly to cover the entry guard as well as the disabled + // button: DOM dispatch does not enforce a button's disabled attribute. + const afterClick = await page.evaluate(() => { + document + .querySelector("#start") + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + return { + startDisabled: document.querySelector("#start").disabled, + selectionDisabled: + document.querySelector("#selection-mode").disabled, + }; + }); + assert.deepEqual(afterClick, { + startDisabled: true, + selectionDisabled: false, + }); + if (pending === "session") { + const reportsRequest = page.waitForRequest( + (request) => new URL(request.url()).pathname === "/api/reports", + ); + releaseSession(); + await reportsRequest; + } + } + + releaseReports(); + await awaitReady(page); + assert.equal(await page.isDisabled("#start"), false); + await page.click("#start"); + await page.waitForURL(/\/interview/); + const params = new URL(page.url()).searchParams; + assert.equal(params.get("selection"), "strict"); + assert.deepEqual(params.getAll("difficulty"), ["Medium"]); + assert.equal(params.has("problem"), false); + } finally { + releaseSession(); + releaseReports(); + } + }, +); + +// Restore the DOM value directly: browser form restoration emits no change. +for (const restoration of [ + { name: "before initialization", initial: true }, + { name: "before normal pageshow", persisted: false }, + { name: "after normal pageshow", persisted: false, after: true }, + { name: "before cached pageshow", persisted: true }, + { name: "after cached pageshow", persisted: true, after: true }, +]) { + lobbyTest( + `a Strict selection restored ${restoration.name} keeps its view and Start URL`, + async (page) => { + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.addInitScript(() => { + window.selectionChanges = 0; + document.addEventListener("change", (event) => { + if (event.target.id === "selection-mode") window.selectionChanges++; + }); + }); + if (restoration.initial) { + await page.route("**/app.js", (route) => + route.fulfill({ + contentType: "text/javascript", + // Set the restored value before the module runs, then observe its + // view before pageshow can mask a missing initialization call. + body: ` +document.querySelector("#selection-mode").value = "strict"; +${read("web/app.js")} +window.initialSelectionView = [ + document.querySelector(".random-picker").hidden, + document.querySelector(".problem-picker").hidden, + document.querySelector("#selection-note").hidden, +]; +`, + }), + ); + } + await lobby(page); + if (restoration.initial) { + assert.deepEqual( + await page.evaluate(() => window.initialSelectionView), + [true, true, false], + ); + } else { + await page.evaluate(({ persisted, after }) => { + const select = document.querySelector("#selection-mode"); + if (!after) select.value = "strict"; + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted }), + ); + if (after) select.value = "strict"; + }, restoration); + } + await page.waitForFunction( + () => document.querySelector(".problem-picker").hidden, + ); + assert.equal( + await page.locator("#selection-mode").inputValue(), + "strict", + ); + assert.equal(await page.locator(".random-picker").isVisible(), false); + assert.equal(await page.locator(".problem-picker").isVisible(), false); + assert.equal(await page.locator("#selection-note").isVisible(), true); + assert.equal(await page.locator(".difficulty-row").isVisible(), true); + assert.equal(await page.evaluate(() => window.selectionChanges), 0); + await awaitReady(page); + await page.locator("#start").click(); + await page.waitForURL(/\/interview/); + const params = new URL(page.url()).searchParams; + assert.equal(params.get("selection"), "strict"); + assert.deepEqual(params.getAll("difficulty"), ["Medium"]); + assert.equal(params.has("problem"), false); + assert.deepEqual(errors, []); + }, + ); +} + +lobbyTest( + "Start reads a silently restored Strict selection before building its URL", + async (page) => { + await lobby(page); + // Restoring and clicking in one task also covers a pageshow sync that + // has been scheduled but has not run yet. + const view = await page.evaluate(() => { + window.dispatchEvent(new PageTransitionEvent("pageshow")); + document.querySelector("#selection-mode").value = "strict"; + document.querySelector("#start").click(); + return [ + document.querySelector(".random-picker").hidden, + document.querySelector(".problem-picker").hidden, + document.querySelector("#selection-note").hidden, + ]; + }); + assert.deepEqual(view, [true, true, false]); + await page.waitForURL(/\/interview/); + const params = new URL(page.url()).searchParams; + assert.equal(params.get("selection"), "strict"); + assert.deepEqual(params.getAll("difficulty"), ["Medium"]); + assert.equal(params.has("problem"), false); + }, +); + +for (const guestModeAvailable of [false, true]) { + lobbyTest( + `Strict requires lobby sign-in when guest admission is ${guestModeAvailable ? "enabled" : "disabled"}`, + async (page) => { + session = { signedIn: false, loginRequired: true, guestModeAvailable }; + let logins = 0; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/login") logins++; + }); + await lobby(page); + // Restore the form silently, as Firefox may do after initialization. + await page.evaluate(() => { + document.querySelector("#selection-mode").value = "strict"; + }); + await normalPageshow(page); + assert.equal(await page.isDisabled("#start"), true); + assert.match( + await page.locator("#selection-note").textContent(), + /Sign in/, + ); + assert.equal(await page.locator("#github-login").isVisible(), true); + assert.equal(await page.locator("#login-link").isVisible(), true); + await page.evaluate(() => { + document.querySelector("#start").dispatchEvent(new MouseEvent("click")); + }); + await normalPageshow(page); + assert.equal(new URL(page.url()).pathname, "/"); + assert.equal(logins, 0); + assert.equal(await page.isDisabled("#selection-mode"), false); + + await page.fill("#github-login", "candidate"); + await page.click("#login-link"); + await awaitReady(page); + assert.match((await snapshot(page)).account, /Signed in as candidate/); + assert.equal(logins, 1); + await page.click("#start"); + await page.waitForURL("**/interview?**"); + const params = new URL(page.url()).searchParams; + assert.equal(params.get("selection"), "strict"); + assert.equal(params.has("problem"), false); + assert.equal(params.has("language"), false); + }, + ); +} + +lobbyTest( + "Guest Practice still starts without signing in after visiting Strict", + async (page) => { + session = { + signedIn: false, + loginRequired: true, + guestModeAvailable: true, + }; + let logins = 0; + page.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/login") logins++; + }); + await lobby(page); + const picked = (await snapshot(page)).card; + await page.selectOption("#selection-mode", "strict"); + assert.equal(await page.isDisabled("#start"), true); + await page.selectOption("#selection-mode", "practice"); + assert.equal(await page.isDisabled("#start"), false); + await page.click("#start"); + await page.waitForURL("**/interview?**"); + const params = new URL(page.url()).searchParams; + assert.equal(params.get("problem"), picked); + assert.equal(params.has("selection"), false); + assert.equal(logins, 0); + }, +); + lobbyTest( "editor options default to all five features enabled", async (page) => { @@ -3831,6 +4089,106 @@ lobbyTest("try again restores the saved language", async (page) => { assert.equal(new URL(page.url()).searchParams.get("language"), "javascript"); }); +for (const account of [false, true]) { + lobbyTest( + `Try again from Strict selects Practice and restores retry settings from ${account ? "account" : "device"} history`, + async (page) => { + const attempt = { + ...savedAttempt(EASY[0]).payload, + selectionMode: "strict", + language: "javascript", + durationMin: 60, + interviewLoop: "coding_only", + interviewMode: "coding", + }; + if (account) reports = [{ payload: attempt }]; + else { + session = { + signedIn: false, + loginRequired: true, + guestModeAvailable: true, + }; + await page.addInitScript((entry) => { + localStorage.setItem("codetrial_history", JSON.stringify([entry])); + }, attempt); + } + await lobby(page); + await setLevel(page, "Hard", true); + const levels = (await snapshot(page)).levels; + await page.selectOption("#selection-mode", "strict"); + await page + .getByRole("button", { name: "Try again", exact: true }) + .click(); + assert.equal( + await page.locator("#selection-mode").inputValue(), + "practice", + ); + assert.equal(await page.locator(".problem-picker").isHidden(), false); + assert.equal((await snapshot(page)).card, EASY[0]); + assert.deepEqual((await snapshot(page)).levels, levels); + await page.click("#start"); + await page.waitForURL("**/interview?**"); + const params = new URL(page.url()).searchParams; + assert.equal(params.has("selection"), false); + assert.equal(params.get("problem"), EASY[0]); + assert.equal(params.get("language"), "javascript"); + assert.equal(params.get("duration"), "60"); + assert.equal(params.get("loop"), "coding_only"); + assert.equal(params.get("mode"), "coding"); + }, + ); +} + +for (const transition of ["change", "pageshow", "Start", "back to Practice"]) { + lobbyTest( + `Strict clears Try again language through ${transition}`, + async (page) => { + reports = [ + { + payload: { + ...savedAttempt(EASY[0]).payload, + language: "javascript", + durationMin: 60, + interviewLoop: "coding_only", + interviewMode: "coding", + }, + }, + ]; + await lobby(page); + await setLevel(page, "Hard", true); + const levels = (await snapshot(page)).levels; + await page + .getByRole("button", { name: "Try again", exact: true }) + .click(); + if (transition === "change" || transition === "back to Practice") { + await page.selectOption("#selection-mode", "strict"); + } else { + await page.evaluate(() => { + document.querySelector("#selection-mode").value = "strict"; + }); + if (transition === "pageshow") await normalPageshow(page); + } + if (transition === "back to Practice") + await page.selectOption("#selection-mode", "practice"); + await page.click("#start"); + await page.waitForURL("**/interview?**"); + const params = new URL(page.url()).searchParams; + assert.equal(params.has("language"), false); + if (transition === "back to Practice") { + assert.equal(params.has("selection"), false); + assert.equal(params.get("problem"), EASY[0]); + } else { + assert.equal(params.get("selection"), "strict"); + assert.equal(params.has("problem"), false); + assert.deepEqual(params.getAll("difficulty"), levels); + } + assert.equal(params.get("duration"), "60"); + assert.equal(params.get("loop"), "coding_only"); + assert.equal(params.get("mode"), "coding"); + }, + ); +} + lobbyTest( "an unmappable history entry fetches the page map at most once", async (page) => { @@ -4102,6 +4460,12 @@ lobbyTest( ); assert.deepEqual(widened.levels, ["Medium", "Hard"]); assert.equal(widened.startDisabled, false); + await normalPageshow(page); + assert.equal( + await page.isDisabled("#start"), + false, + "Practice must keep a manual pick startable during the initial history load", + ); // And the arriving history does not overrule it either. release(); @@ -4439,6 +4803,12 @@ lobbyTest( })(); await page.click("#start"); + // A normal pageshow may finish after Start was pressed. Its selection + // sync must leave the pending start locked, without rechecking the account. + await normalPageshow(page); + assert.equal(await page.isDisabled("#start"), true); + assert.equal(await page.isDisabled("#selection-mode"), true); + // A second card while the button reads "Recording GitHub...": re-arming it // here buys the candidate a second navigation out of one start. await page.click(`[data-problem="${pageOf("gas-station")}"]`); diff --git a/tests/browser/report-recovery.test.js b/tests/browser/report-recovery.test.js index 788f5c769..b4949d6c1 100644 --- a/tests/browser/report-recovery.test.js +++ b/tests/browser/report-recovery.test.js @@ -267,6 +267,7 @@ function pageHarness(phase = "live") { nodes.endingTitle.textContent = "Jim is writing up your evaluation..."; nodes.leaveRoom.textContent = "Taking too long? Leave the room"; const scope = { + strict: false, state: { phase, room, connected: true }, nodes, topics, diff --git a/tests/browser/strict-selection.test.js b/tests/browser/strict-selection.test.js new file mode 100644 index 000000000..85daba613 --- /dev/null +++ b/tests/browser/strict-selection.test.js @@ -0,0 +1,1840 @@ +import { PRACTICE_LIST_KEY } from "../../web/practice-list.js"; +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { + STRICT_ASSIGNMENT_KEY, + previousAssignment, + rememberAssignment, + resetAssignments, +} from "../../web/selection.js"; +import { + DEFAULT_RUNTIME_CONFIG, + launchChromium, + memoryStorage, + read, + startStaticServer, +} from "./source.js"; + +test("selection receipts stay tab-local and storage failure is not a new sequence", () => { + const first = memoryStorage(); + const second = memoryStorage(); + assert.equal(previousAssignment(first), null); + rememberAssignment(first, "signed-first"); + assert.equal(previousAssignment(first), "signed-first"); + assert.equal(previousAssignment(second), null); + rememberAssignment(first, "signed-second"); + assert.equal(previousAssignment(first), "signed-second"); + resetAssignments(first); + assert.equal(previousAssignment(first), null); + assert.throws(() => previousAssignment(null), /session storage/); + assert.throws( + () => + previousAssignment({ + getItem: () => "previous", + setItem: () => { + throw new Error("quota"); + }, + }), + /session storage/, + ); + assert.throws(() => rememberAssignment(second, ""), /invalid/); + assert.throws( + () => + rememberAssignment( + { + setItem: () => {}, + getItem: () => null, + }, + "receipt", + ), + /session storage/, + ); +}); + +let browser; +let server; +let base; +before(async () => { + browser = await launchChromium(); + if (browser) + ({ server, base } = await startStaticServer({ + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + })); +}); +after(async () => { + await browser?.close(); + await new Promise((resolve) => (server ? server.close(resolve) : resolve())); +}); + +// Real MediaStream tracks with deterministic microphone samples exercise the +// existing readiness gate without depending on headless audio scheduling. +// The room transport is fake; reload still discards the whole page state. +function mediaAndRoom() { + const NativeAudioContext = window.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; + } + }; + navigator.mediaDevices.getUserMedia = async (constraints) => { + if (constraints.video) throw new DOMException("No camera", "NotFoundError"); + return new NativeAudioContext().createMediaStreamDestination().stream; + }; + window.roomJoins = []; + window.roomEvents = {}; + window.LivekitClient = { + RoomEvent: new Proxy({}, { get: (_, key) => key }), + Room: class { + remoteParticipants = new Map(); + localParticipant = { + publishTrack: async () => {}, + publishData: async () => {}, + }; + on(event, callback) { + (window.roomEvents[event] ??= []).push(callback); + return this; + } + off(event, callback) { + window.roomEvents[event] = (window.roomEvents[event] ?? []).filter( + (handler) => handler !== callback, + ); + return this; + } + async connect(url, token) { + window.roomJoins.push({ url, token }); + } + async disconnect() {} + }, + }; +} + +async function interview( + t, + query, + handler, + context = browser, + session = { signedIn: true, loginRequired: true }, +) { + if (!browser) { + t.skip("playwright chromium unavailable"); + return null; + } + if (context === browser) { + context = await browser.newContext(); + t.after(() => context.close()); + } + const page = await context.newPage(); + t.after(() => page.close()); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + t.after(() => assert.deepEqual(errors, [])); + page.on("dialog", (dialog) => dialog.accept()); + await page.route("**/vendor/livekit-client.js", (route) => + route.fulfill({ + contentType: "text/javascript", + body: "", + }), + ); + await page.route("**/api/session", (route) => + route.fulfill({ + json: session, + }), + ); + await page.route("**/api/token", handler); + await page.addInitScript(mediaAndRoom); + await page.goto(`${base}/interview.html?${query}`); + return page; +} + +async function consentContext(t) { + const context = await browser.newContext(); + t.after(() => context.close()); + const consents = []; + await context.exposeBinding("noteConsent", () => consents.push("consent")); + await context.route("**/replay-feed.js", (route) => + route.fulfill({ + contentType: "text/javascript", + body: read("web/replay-feed.js").replace( + "export async function recordConsent() {", + "export async function recordConsent() { await window.noteConsent();", + ), + }), + ); + return { context, consents }; +} + +async function strictFailure(page, message) { + await page.waitForFunction( + (text) => + document.querySelector("#audio-check-status").textContent === text, + message, + ); + assert.equal(await page.locator("#audio-check").isVisible(), true); + assert.equal(await page.locator("#audio-check-leave").isEnabled(), true); + assert.equal(await page.locator("#editor").isDisabled(), true); + assert.equal(await page.locator("#run-tests").isDisabled(), true); + assert.equal( + await page.locator("#agent-state").textContent(), + "Not connected", + ); + assert.doesNotMatch( + await page.locator("#presence-banner").textContent(), + /offline|personalized evaluation|still work the problem/i, + ); + assert.equal(await page.evaluate(() => window.roomJoins.length), 0); + assert.doesNotMatch( + await page.locator("#transcript-panel").textContent(), + /Offline (?:mode|practice)/, + ); +} + +async function preflight(page) { + await page.locator("#audio-heard").click(); + await page.locator("#camera-skip").click(); + try { + await page.locator("#audio-check-join").click({ timeout: 10000 }); + } catch (error) { + throw new Error(await page.locator("#audio-check-status").textContent(), { + cause: error, + }); + } +} + +function grant(page, receipt, room = receipt) { + return { + problemPage: page, + previousAssignment: receipt, + roomName: room, + serverUrl: "wss://example.test", + token: room, + durationMin: 30, + }; +} + +for (const [requested, expected] of [ + [null, "python"], + ["javascript", "javascript"], + ["unsupported", "python"], +]) { + test(`Strict restores ${requested ?? "the default"} language for its server-assigned problem`, async (t) => { + const query = new URLSearchParams({ selection: "strict" }); + if (requested) query.set("language", requested); + const page = await interview(t, query, (route) => + route.fulfill({ + json: grant("chargeback-pair-match", "language-assignment"), + }), + ); + if (!page) return; + await preflight(page); + await page.locator("#editor:not([disabled])").waitFor(); + assert.equal(await page.locator("#language-select").inputValue(), expected); + assert.match( + await page.locator("#editor").inputValue(), + expected === "python" + ? /def matchDisputedCharge/ + : /function matchDisputedCharge/, + ); + }); +} + +for (const action of ["restore", "edit", "choose", "run", "end"]) { + test(`Strict delayed judge language restoration respects ${action}`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + let releaseJudge; + const held = new Promise((resolve) => { + releaseJudge = resolve; + }); + t.after(() => releaseJudge()); + const context = await browser.newContext(); + t.after(() => context.close()); + await context.route("**/runtime-config.js", (route) => + route.fulfill({ + contentType: "text/javascript", + body: DEFAULT_RUNTIME_CONFIG.replace( + "ENABLED = false", + "ENABLED = true", + ).replace('BASE_URL = ""', 'BASE_URL = "https://godbolt.org"'), + }), + ); + await context.route("**/judges/*.json", async (route) => { + await held; + await route.continue(); + }); + const page = await interview( + t, + "selection=strict&language=c", + (route) => + route.fulfill({ + json: grant("chargeback-pair-match", "delayed-language-assignment"), + }), + context, + ); + await preflight(page); + await page.locator("#editor:not([disabled])").waitFor(); + assert.equal(await page.locator("#language-select").inputValue(), "python"); + assert.equal( + await page.locator('#language-select option[value="c"]').isDisabled(), + true, + ); + let expected = "python"; + let code = await page.locator("#editor").inputValue(); + if (action === "edit") { + code = "# Keep this candidate edit.\n"; + await page.locator("#editor").fill(code); + } else if (action === "choose") { + expected = "javascript"; + await page.locator("#language-select").selectOption(expected); + code = await page.locator("#editor").inputValue(); + } else if (action === "run") { + await page.locator("#run-tests").click(); + assert.equal( + await page.locator("#run-tests").textContent(), + "Running...", + ); + } else if (action === "end") { + await page.locator("#end").click(); + await page.locator("#ending-overlay:not([hidden])").waitFor(); + } else { + expected = "c"; + } + releaseJudge(); + await page.waitForFunction( + () => + !document.querySelector('#language-select option[value="c"]').disabled, + ); + assert.equal(await page.locator("#language-select").inputValue(), expected); + if (action === "restore") + assert.match( + await page.locator("#editor").inputValue(), + /int\* matchDisputedCharge/, + ); + else assert.equal(await page.locator("#editor").inputValue(), code); + }); +} + +for (const loginRequired of [false, true]) { + test(`Strict redirects guests to sign in when loginRequired is ${loginRequired}`, async (t) => { + let requests = 0; + const page = await interview( + t, + "selection=strict&problem=chargeback-pair-match", + (route) => { + requests++; + return route.fulfill({ json: { error: "Unexpected token request" } }); + }, + browser, + { signedIn: false, loginRequired, guestModeAvailable: true }, + ); + if (!page) return; + await page.waitForURL(`${base}/`); + assert.equal(requests, 0); + }); +} + +test("Practice guests still join their specified problem after preflight", async (t) => { + const requests = []; + const page = await interview( + t, + "problem=kiosk-panel-mirror-audit", + (route) => { + requests.push(route.request().postDataJSON()); + return route.fulfill({ + json: { + serverUrl: "wss://example.test", + token: "guest-practice", + roomName: "guest-practice", + durationMin: 30, + guest: true, + }, + }); + }, + browser, + { signedIn: false, loginRequired: true, guestModeAvailable: true }, + ); + if (!page) return; + await preflight(page); + await page.waitForFunction(() => window.roomJoins.length === 1); + assert.equal(requests.length, 1); + assert.equal(requests[0].problemId, "kiosk-panel-mirror-audit"); + assert.equal(requests[0].selectionMode, undefined); + assert.equal(requests[0].previousAssignment, undefined); +}); + +test("Strict refresh keeps difficulty, ignores URL problem and joins a fresh assignment", async (t) => { + const calls = []; + const pages = ["chargeback-pair-match", "kiosk-panel-mirror-audit"]; + const page = await interview( + t, + "selection=strict&difficulty=Easy&problem=terrain-runoff-pooling&exampleDrawing=1", + (route) => { + calls.push(route.request().postDataJSON()); + return route.fulfill({ + json: grant(pages[calls.length - 1], `receipt-${calls.length}`), + }); + }, + ); + if (!page) return; + let warnings = 0; + page.on("dialog", () => warnings++); + assert.equal(calls.length, 0); + assert.equal(await page.locator("#end").isDisabled(), true); + assert.equal(await page.locator("#pause").isDisabled(), true); + assert.equal( + await page.locator("#problem-title").textContent(), + "Strict Interview", + ); + await page.locator("#interviewer-voice").selectOption("Puck"); + await page.locator("#interviewer-accent").selectOption("British"); + await preflight(page); + await page.waitForFunction(() => window.roomJoins.length === 1); + await page.locator("#editor").fill("previous attempt code"); + const firstTitle = await page.locator("#problem-title").textContent(); + assert.equal(await page.locator("#end").isEnabled(), true); + assert.equal(await page.locator("#pause").isEnabled(), true); + assert.equal(calls[0].selectionMode, "strict"); + assert.equal(calls[0].exampleDrawing, true); + assert.equal(await page.locator("#example-board").isVisible(), true); + assert.equal(calls[0].interviewerVoice, "Puck"); + assert.equal(calls[0].interviewerAccent, "British"); + assert.deepEqual(calls[0].difficulties, ["Easy"]); + assert.equal(calls[0].codeExecution, undefined); + assert.equal(calls[0].problemId, undefined); + assert.equal(calls[0].previousAssignment, undefined); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "receipt-1", + ); + // A transport reconnect reuses this attempt and does not reallocate. + await page.evaluate(() => { + for (const event of ["Reconnecting", "Reconnected"]) + for (const callback of window.roomEvents[event] ?? []) callback(); + }); + assert.equal(calls.length, 1); + await page.reload(); + assert.equal( + warnings, + 1, + "the existing unsaved-interview warning still runs", + ); + assert.equal(await page.locator("#audio-check").isVisible(), true); + assert.equal(calls.length, 1); + assert.equal(await page.locator("#editor").inputValue(), ""); + await page.locator("#interviewer-voice").selectOption("Random"); + await page.locator("#interviewer-accent").selectOption("Random"); + await preflight(page); + await page.waitForFunction(() => window.roomJoins.length === 1); + assert.notEqual( + await page.locator("#problem-title").textContent(), + firstTitle, + ); + assert.equal(calls[1].exampleDrawing, true); + assert.equal(await page.locator("#example-board").isVisible(), true); + assert.equal(calls[1].interviewerVoice, "Random"); + assert.equal(calls[1].interviewerAccent, "Random"); + assert.deepEqual(calls[1].difficulties, ["Easy"]); + assert.deepEqual(new URL(page.url()).searchParams.getAll("difficulty"), [ + "Easy", + ]); + assert.equal(calls[1].previousAssignment, "receipt-1"); + assert.equal(calls[1].problemId, undefined); + assert.equal( + await page.evaluate(() => window.roomJoins[0].token), + "receipt-2", + ); + assert.notEqual( + await page.locator("#editor").inputValue(), + "previous attempt code", + ); +}); + +test("Practice reopens its URL problem and keeps the existing offline fallback", async (t) => { + const calls = []; + const page = await interview(t, "problem=chargeback-pair-match", (route) => { + calls.push(route.request().postDataJSON()); + return route.fulfill({ status: 503, json: { error: "No provider" } }); + }); + if (!page) return; + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt) await page.reload(); + await preflight(page); + await page.waitForFunction(() => + document + .querySelector("#transcript-panel") + .textContent.includes("Offline mode is ready"), + ); + assert.equal(calls[attempt].problemId, "chargeback-pair-match"); + assert.equal(calls[attempt].selectionMode, undefined); + assert.equal(calls[attempt].difficulties, undefined); + assert.equal(await page.locator("#editor").isEnabled(), true); + assert.equal(await page.locator("#run-tests").isEnabled(), true); + } +}); + +test("a refused Strict assignment returns to the lobby without retrying or spending its receipt", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const { context, consents } = await consentContext(t); + const calls = []; + const message = "Selection unavailable. Retry media preflight."; + const query = "selection=strict&difficulty=Easy"; + const page = await interview( + t, + query, + (route) => { + const request = route.request().postDataJSON(); + calls.push(request); + assert.deepEqual(request.difficulties, ["Easy"]); + assert.equal(request.previousAssignment, "previous"); + return route.fulfill({ + status: 409, + json: { code: "strict_no_alternative", error: message }, + }); + }, + context, + ); + await page.evaluate( + (key) => sessionStorage.setItem(key, "previous"), + STRICT_ASSIGNMENT_KEY, + ); + await preflight(page); + await strictFailure(page, message); + await page.waitForFunction( + () => + document.querySelector(".editor-panel").inert && + document.querySelector("#jim-stage").inert && + document.activeElement === document.querySelector("#audio-check-title"), + ); + assert.equal(await page.locator("#audio-check-join").isHidden(), true); + assert.equal(await page.locator("#audio-check-join").isDisabled(), true); + // A disabled control cannot resubmit even when activated without a pointer. + await page.locator("#audio-check-join").evaluate((button) => button.click()); + assert.equal(page.url(), `${base}/interview.html?${query}`); + assert.equal(calls.length, 1); + assert.equal(consents.length, 1); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "previous", + ); + await page.locator("#audio-check-leave").click(); + await page.waitForURL(`${base}/`); + assert.equal(calls.length, 1); + assert.equal(consents.length, 1); +}); + +test("an invalid Strict receipt is cleared only by explicitly starting a new selection sequence", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const { context, consents } = await consentContext(t); + const calls = []; + const query = "selection=strict&difficulty=Easy&difficulty=Hard"; + const message = "Selection unavailable."; + const page = await interview( + t, + query, + (route) => { + calls.push(route.request().postDataJSON()); + return calls.length === 1 + ? route.fulfill({ + status: 400, + json: { code: "strict_assignment_invalid", error: message }, + }) + : route.fulfill({ + json: grant("chargeback-pair-match", "new-sequence"), + }); + }, + context, + ); + await page.evaluate( + (key) => sessionStorage.setItem(key, "invalid-receipt"), + STRICT_ASSIGNMENT_KEY, + ); + await preflight(page); + await strictFailure(page, message); + assert.equal( + await page.locator("#audio-check-join").textContent(), + "Start a new selection sequence", + ); + assert.equal(await page.locator("#audio-check-join").isEnabled(), true); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "invalid-receipt", + ); + assert.equal(calls.length, 1); + assert.equal(consents.length, 1); + + const reloaded = page.waitForEvent("domcontentloaded"); + await page.locator("#audio-check-join").click(); + await reloaded; + await page.locator("#audio-check").waitFor(); + assert.equal(page.url(), `${base}/interview.html?${query}`); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + null, + ); + assert.equal(calls.length, 1); + assert.equal(consents.length, 1); + assert.equal(await page.locator("#audio-check-join").isDisabled(), true); + await preflight(page); + await page.waitForFunction(() => window.roomJoins.length === 1); + assert.equal(calls.length, 2); + assert.equal(consents.length, 2); + assert.equal(calls[0].previousAssignment, "invalid-receipt"); + assert.equal(calls[1].previousAssignment, undefined); + assert.deepEqual(calls[1].difficulties, ["Easy", "Hard"]); + assert.equal(calls[1].problemId, undefined); +}); + +for (const failure of ["throws", "silently retains"]) { + test(`an invalid Strict receipt cannot restart when storage ${failure} the receipt removal`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const { context, consents } = await consentContext(t); + let calls = 0; + const message = "Selection unavailable."; + const page = await interview( + t, + "selection=strict&difficulty=Easy", + (route) => { + calls++; + return route.fulfill({ + status: 400, + json: { code: "strict_assignment_invalid", error: message }, + }); + }, + context, + ); + await page.evaluate( + (key) => sessionStorage.setItem(key, "invalid-receipt"), + STRICT_ASSIGNMENT_KEY, + ); + await preflight(page); + await strictFailure(page, message); + let navigations = 0; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) navigations++; + }); + await page.evaluate((mode) => { + Storage.prototype.removeItem = () => { + if (mode === "throws") throw new Error("blocked"); + }; + }, failure); + await page.locator("#audio-check-join").click(); + await strictFailure( + page, + "Could not clear the previous assignment. Allow site storage or return to the lobby.", + ); + assert.equal(navigations, 0); + assert.equal(calls, 1); + assert.equal(consents.length, 1); + assert.equal(await page.locator("#audio-check-join").isEnabled(), true); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "invalid-receipt", + ); + }); +} + +test("a temporary Strict refusal retains its receipt and retries only after another media preflight", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const { context, consents } = await consentContext(t); + const calls = []; + const message = "strict_no_alternative"; + const page = await interview( + t, + "selection=strict&difficulty=Easy", + (route) => { + calls.push(route.request().postDataJSON()); + return calls.length === 1 + ? route.fulfill({ + status: 503, + json: { code: "agent_capacity_exhausted", error: message }, + }) + : route.fulfill({ + json: grant("chargeback-pair-match", "retry-receipt"), + }); + }, + context, + ); + await page.evaluate( + (key) => sessionStorage.setItem(key, "previous"), + STRICT_ASSIGNMENT_KEY, + ); + await preflight(page); + await strictFailure(page, message); + assert.equal(await page.locator("#audio-check-join").isVisible(), true); + assert.equal(await page.locator("#audio-check-join").isEnabled(), true); + assert.equal( + await page.locator("#audio-check-join").textContent(), + "Retry media preflight", + ); + assert.equal(calls.length, 1); + assert.equal(consents.length, 1); + const reloaded = page.waitForEvent("domcontentloaded"); + await page.locator("#audio-check-join").click(); + await reloaded; + await page.locator("#audio-check").waitFor(); + assert.equal(calls.length, 1); + assert.equal(consents.length, 1); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "previous", + ); + await preflight(page); + await page.waitForFunction(() => window.roomJoins.length === 1); + assert.equal(calls.length, 2); + assert.equal(consents.length, 2); + assert.equal(calls[1].previousAssignment, "previous"); +}); + +for (const failure of ["headers", "body", 408, 504]) { + for (const strict of [true, false]) { + test(`${strict ? "Strict" : "Practice"} handles token timeout at ${failure} without changing mode`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const { context, consents } = await consentContext(t); + const requests = []; + if (typeof failure === "string") { + await context.addInitScript((phase) => { + const nativeFetch = window.fetch; + window.tokenFetches = 0; + window.fetch = async (...args) => { + if (new URL(args[0], location.href).pathname !== "/api/token") + return nativeFetch(...args); + window.tokenFetches++; + const timeout = () => { + throw new DOMException( + "Offline practice is ready", + "TimeoutError", + ); + }; + if (phase === "headers") return timeout(); + return { ok: true, status: 200, json: async () => timeout() }; + }; + }, failure); + } + const page = await interview( + t, + strict + ? "selection=strict&difficulty=Easy" + : "problem=chargeback-pair-match", + (route) => { + requests.push(route.request().postDataJSON()); + return route.fulfill({ + status: typeof failure === "number" ? failure : 500, + json: { + code: "token_timeout", + error: "Offline practice is ready", + }, + }); + }, + context, + ); + if (strict) + await page.evaluate( + (key) => sessionStorage.setItem(key, "previous"), + STRICT_ASSIGNMENT_KEY, + ); + await preflight(page); + if (strict) { + const message = + "Strict Interview connection timed out. Return to the lobby or retry media preflight."; + await strictFailure(page, message); + assert.equal( + await page.locator("#audio-check-join").textContent(), + "Retry media preflight", + ); + assert.equal(await page.locator("#audio-check-join").isVisible(), true); + assert.equal(await page.locator("#audio-check-join").isEnabled(), true); + assert.doesNotMatch( + await page.locator("#presence-banner").textContent(), + /Offline (?:mode|practice)/, + ); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "previous", + ); + } else { + await page.waitForFunction(() => + document + .querySelector("#transcript-panel") + .textContent.includes("Offline mode is ready"), + ); + assert.equal(await page.locator("#editor").isEnabled(), true); + assert.equal(await page.locator("#audio-check").isHidden(), true); + assert.equal(await page.evaluate(() => window.roomJoins.length), 0); + } + assert.equal(consents.length, 1); + if (typeof failure === "string") { + assert.equal(await page.evaluate(() => window.tokenFetches), 1); + assert.equal(requests.length, 0); + } else { + assert.equal(requests.length, 1); + assert.equal( + requests[0].problemId, + strict ? undefined : "chargeback-pair-match", + ); + } + }); + } +} + +for (const strict of [true, false]) { + test(`${strict ? "Strict" : "Practice"} handles the token deadline signal without an automatic retry`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const { context, consents } = await consentContext(t); + await context.addInitScript(() => { + const nativeFetch = window.fetch; + const nativeTimeout = AbortSignal.timeout.bind(AbortSignal); + const deadlines = new WeakMap(); + window.tokenDeadlines = []; + window.tokenFetches = 0; + AbortSignal.timeout = (milliseconds) => { + if (milliseconds !== 20_000) return nativeTimeout(milliseconds); + const controller = new AbortController(); + deadlines.set(controller.signal, milliseconds); + window.expireToken = () => + controller.abort(new DOMException("Token deadline", "TimeoutError")); + return controller.signal; + }; + window.fetch = (...args) => { + if (new URL(args[0], location.href).pathname !== "/api/token") + return nativeFetch(...args); + window.tokenFetches++; + window.tokenDeadlines.push(deadlines.get(args[1].signal)); + return new Promise((_, reject) => { + args[1].signal.addEventListener( + "abort", + () => reject(args[1].signal.reason), + { once: true }, + ); + }); + }; + }); + let requests = 0; + const page = await interview( + t, + strict ? "selection=strict" : "problem=chargeback-pair-match", + () => requests++, + context, + ); + await page.evaluate( + (key) => sessionStorage.setItem(key, "previous"), + STRICT_ASSIGNMENT_KEY, + ); + let navigations = 0; + page.on("framenavigated", (frame) => { + if (frame === page.mainFrame()) navigations++; + }); + await preflight(page); + await page.waitForFunction(() => window.tokenFetches === 1); + assert.deepEqual( + await page.evaluate(() => window.tokenDeadlines), + [20_000], + ); + await page.evaluate(() => window.expireToken()); + if (strict) { + const message = + "Strict Interview connection timed out. Return to the lobby or retry media preflight."; + await strictFailure(page, message); + assert.equal( + await page.locator("#presence-banner").textContent(), + message, + ); + assert.equal( + await page.locator("#audio-check-join").textContent(), + "Retry media preflight", + ); + assert.equal(await page.locator("#audio-check-join").isEnabled(), true); + } else { + await page.waitForFunction(() => + document + .querySelector("#transcript-panel") + .textContent.includes("Offline mode is ready"), + ); + assert.equal(await page.locator("#agent-state").textContent(), "Offline"); + assert.match( + await page.locator("#presence-banner").textContent(), + /You can still work the problem/, + ); + assert.equal(await page.locator("#editor").isEnabled(), true); + assert.equal(await page.locator("#audio-check").isHidden(), true); + } + assert.equal(navigations, 0); + assert.equal(consents.length, 1); + assert.equal(requests, 0); + assert.equal(await page.evaluate(() => window.tokenFetches), 1); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "previous", + ); + }); +} + +test("a Strict HTTP 504 with a non-JSON body retains its timeout status", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const { context, consents } = await consentContext(t); + let calls = 0; + const page = await interview( + t, + "selection=strict", + (route) => { + calls++; + return route.fulfill({ + status: 504, + contentType: "text/html", + body: "

Gateway Timeout

", + }); + }, + context, + ); + await preflight(page); + await strictFailure( + page, + "Strict Interview connection timed out. Return to the lobby or retry media preflight.", + ); + assert.equal( + await page.locator("#audio-check-join").textContent(), + "Retry media preflight", + ); + assert.equal(await page.locator("#audio-check-join").isVisible(), true); + assert.equal(await page.locator("#audio-check-join").isEnabled(), true); + assert.doesNotMatch( + await page.locator("#presence-banner").textContent(), + /Offline (?:mode|practice)/, + ); + assert.equal(calls, 1); + assert.equal(consents.length, 1); +}); + +test("a missing Strict problem retains its receipt without loading the fallback into an interview", async (t) => { + const page = await interview(t, "selection=strict", (route) => + route.fulfill({ json: grant("missing-problem", "new-receipt") }), + ); + if (!page) return; + await preflight(page); + await page.waitForFunction( + () => + document.querySelector("#audio-check-join").textContent === + "Retry media preflight", + ); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "new-receipt", + ); + assert.equal(await page.evaluate(() => window.roomJoins.length), 0); + assert.equal( + await page.locator("#problem-title").textContent(), + "Strict Interview", + ); +}); + +test("blocked storage refuses Strict before requesting an attempt", async (t) => { + let calls = 0; + const page = await interview(t, "selection=strict", (route) => { + calls++; + return route.fulfill({ json: grant("chargeback-pair-match", "receipt") }); + }); + if (!page) return; + await page.evaluate(() => { + Storage.prototype.setItem = () => { + throw new Error("blocked"); + }; + }); + await preflight(page); + await page.waitForFunction( + () => + document.querySelector("#audio-check-join").textContent === + "Retry media preflight", + ); + assert.equal(calls, 0); + assert.match( + await page.locator("#audio-check-status").textContent(), + /session storage/, + ); +}); + +test("two Strict tabs keep separate previous assignments in the same browser context", async (t) => { + const firstCalls = []; + const first = await interview(t, "selection=strict", (route) => { + firstCalls.push(route.request().postDataJSON()); + return route.fulfill({ json: grant("chargeback-pair-match", "first-tab") }); + }); + if (!first) return; + await preflight(first); + await first.waitForFunction(() => window.roomJoins.length === 1); + const secondCalls = []; + const second = await interview( + t, + "selection=strict&problem=two-sum", + (route) => { + secondCalls.push(route.request().postDataJSON()); + return route.fulfill({ + json: grant("kiosk-panel-mirror-audit", "second-tab"), + }); + }, + first.context(), + ); + await preflight(second); + await second.waitForFunction(() => window.roomJoins.length === 1); + assert.equal(firstCalls[0].previousAssignment, undefined); + assert.equal(secondCalls[0].previousAssignment, undefined); + assert.equal( + await first.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "first-tab", + ); + assert.equal( + await second.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "second-tab", + ); +}); + +test("a receipt write failing after allocation cannot reveal or join the assigned problem", async (t) => { + let calls = 0; + const page = await interview(t, "selection=strict", async (route) => { + calls++; + await page.evaluate(() => { + Storage.prototype.setItem = () => { + throw new Error("quota"); + }; + }); + await route.fulfill({ json: grant("chargeback-pair-match", "new") }); + }); + if (!page) return; + await preflight(page); + await page.waitForFunction( + () => + document.querySelector("#audio-check-join").textContent === + "Retry media preflight", + ); + assert.equal(calls, 1); + assert.equal( + await page.locator("#problem-title").textContent(), + "Strict Interview", + ); + assert.equal(await page.evaluate(() => window.roomJoins.length), 0); +}); + +test("a failed preflight starts no Strict attempt", async (t) => { + let calls = 0; + const page = await interview(t, "selection=strict", (route) => { + calls++; + return route.fulfill({ status: 500 }); + }); + if (!page) return; + // The output confirmation is deliberately absent, even after microphone + // sampling has succeeded and the optional camera has been skipped. + await page.locator("#camera-skip").click(); + assert.equal(await page.locator("#audio-check-join").isDisabled(), true); + assert.equal(calls, 0); +}); + +test("Strict can undo Camera Skip without allocating an attempt", async (t) => { + const calls = []; + const page = await interview(t, "selection=strict", (route) => { + calls.push(route.request().postDataJSON()); + return route.fulfill({ + json: grant("chargeback-pair-match", "camera-receipt"), + }); + }); + if (!page) return; + await page.locator("#audio-heard").click(); + await page.locator("#camera-skip").click(); + await page.locator("#audio-check-join:not([disabled])").waitFor(); + assert.equal(await page.locator("#camera-skip").textContent(), "Use camera"); + assert.equal(calls.length, 0); + + await page.locator("#camera-skip").click(); + assert.equal( + await page.locator("#camera-skip").textContent(), + "Continue without camera", + ); + assert.equal(await page.locator("#audio-check-join").isDisabled(), true); + assert.equal(await page.locator("#camera-name-disclosure").isVisible(), true); + assert.equal(calls.length, 0); + + await page.locator("#camera-skip").click(); + await page.locator("#audio-check-join").click(); + await page.waitForFunction(() => window.roomJoins.length === 1); + assert.equal(calls.length, 1); + assert.equal(calls[0].selectionMode, "strict"); + assert.equal(calls[0].problemId, undefined); +}); + +test("the lobby defaults to Practice and starts Strict without a selected problem URL", async (t) => { + if (!browser) { + t.skip("playwright chromium unavailable"); + return; + } + const page = await browser.newPage(); + t.after(() => page.close()); + await page.route("**/api/session", (route) => + route.fulfill({ + json: { signedIn: true, user: { login: "candidate" } }, + }), + ); + await page.route("**/api/reports", (route) => + route.fulfill({ json: { reports: [] } }), + ); + await page.route("**/interview?**", (route) => + route.fulfill({ + contentType: "text/html", + body: "

New interview

", + }), + ); + const savedProblem = "chargeback-pair-match"; + await page.addInitScript( + ({ key, problem }) => localStorage.setItem(key, JSON.stringify([problem])), + { key: PRACTICE_LIST_KEY, problem: savedProblem }, + ); + await page.goto(base); + await page.locator("#practice-list-pick:not([disabled])").waitFor(); + await page.locator("#practice-list-pick").click(); + assert.equal( + await page.locator(".problem-card.selected").getAttribute("data-problem"), + savedProblem, + ); + for (const option of [ + "highlight", + "execution", + "autoIndent", + "autoClose", + "monospace", + ]) + await page.locator(`[name="editor-option"][value="${option}"]`).uncheck(); + assert.equal(await page.locator(".setup-panel").count(), 3); + assert.equal( + await page + .locator('[aria-labelledby="challenge-heading"] #selection-mode') + .count(), + 1, + ); + assert.equal(await page.locator("#selection-mode").inputValue(), "practice"); + assert.equal(await page.locator(".random-picker").isVisible(), true); + assert.equal(await page.locator(".practice-list-picker").isVisible(), true); + assert.equal(await page.locator(".problem-picker").isVisible(), true); + assert.equal(await page.locator(".difficulty-row").isVisible(), true); + await page.locator(".problem-picker > summary").click(); + await page.locator(".setup-grid > .problem-grid").waitFor(); + await page.locator("#selection-mode").selectOption("strict"); + assert.equal(await page.locator(".random-picker").isVisible(), false); + assert.equal(await page.locator(".practice-list-picker").isVisible(), false); + assert.equal(await page.locator(".problem-picker").isVisible(), false); + await page.locator(".problem-grid").waitFor({ state: "hidden" }); + assert.equal( + await page.locator(".problem-picker-outline").isVisible(), + false, + ); + await page.locator(".problem-grid").dispatchEvent("beforematch"); + assert.equal(await page.locator(".problem-grid").isVisible(), false); + assert.equal(await page.locator(".difficulty-row").isVisible(), true); + for (const width of [320, 1280]) { + await page.setViewportSize({ width, height: 800 }); + await page.locator(".problem-grid").waitFor({ state: "hidden" }); + assert.equal( + await page.locator(".problem-picker-outline").isVisible(), + false, + ); + const bounds = await page.locator("#selection-mode").boundingBox(); + assert.ok( + bounds.width > 0 && bounds.x >= 0 && bounds.x + bounds.width <= width, + ); + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth > innerWidth, + ), + false, + ); + } + await page + .getByRole("checkbox", { name: "Enable Example drawing", exact: true }) + .check(); + await page.locator('[name="difficulty"][value="Easy"]').check(); + await page.locator('[name="difficulty"][value="Hard"]').check(); + await page.locator('[name="difficulty"][value="Medium"]').uncheck(); + await page.locator("#selection-mode").selectOption("practice"); + await page.locator(".problem-grid").waitFor({ state: "visible" }); + assert.equal(await page.locator(".random-picker").isVisible(), true); + assert.equal(await page.locator(".practice-list-picker").isVisible(), true); + assert.equal(await page.locator(".problem-picker").isVisible(), true); + await page.locator("#selection-mode").selectOption("strict"); + await page.evaluate( + (key) => sessionStorage.setItem(key, "previous"), + STRICT_ASSIGNMENT_KEY, + ); + await page.locator("#start").click(); + await page.waitForURL("**/interview?**"); + const url = new URL(page.url()); + assert.equal(url.searchParams.get("selection"), "strict"); + assert.equal(url.searchParams.get("exampleDrawing"), "1"); + assert.deepEqual(url.searchParams.getAll("difficulty"), ["Easy", "Hard"]); + assert.equal(url.searchParams.has("problem"), false); + for (const parameter of [ + "editorHighlight", + "editorExecution", + "editorAutoIndent", + "editorAutoClose", + "editorMonospace", + ]) + assert.equal(url.searchParams.get(parameter), "0", parameter); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + null, + ); +}); + +test("restoring a Strict preflight page from history repeats preflight without allocating an attempt", async (t) => { + let calls = 0; + const page = await interview(t, "selection=strict", (route) => { + calls++; + return route.fulfill({ + json: grant("chargeback-pair-match", "history-receipt"), + }); + }); + if (!page) return; + await page.locator("#audio-check").waitFor({ state: "visible" }); + await page.evaluate( + (key) => sessionStorage.setItem(key, "history-receipt"), + STRICT_ASSIGNMENT_KEY, + ); + const reloaded = page.waitForEvent("domcontentloaded"); + // Chromium's automation disables BFCache. Exercise its persisted-pageshow + // signal, as the lobby restore tests do, without weakening the unload path. + await page.evaluate(() => + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }), + ), + ); + await reloaded; + await page.locator("#audio-check").waitFor({ state: "visible" }); + assert.equal( + await page.locator("#problem-title").textContent(), + "Strict Interview", + ); + assert.equal(await page.locator("#editor").inputValue(), ""); + assert.equal(calls, 0); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "history-receipt", + ); +}); + +for (const selection of ["strict", "practice"]) { + test(`a cached ${selection} interview preserves its active attempt and unload warning`, async (t) => { + let calls = 0; + const query = new URLSearchParams({ + selection, + problem: "chargeback-pair-match", + }); + const page = await interview(t, query, (route) => { + calls++; + return route.fulfill({ + json: grant("chargeback-pair-match", "active-history-receipt"), + }); + }); + if (!page) return; + await preflight(page); + await page.locator("#editor:not([disabled])").waitFor(); + await page.locator("#editor").fill("active attempt must survive"); + await page.waitForFunction(() => { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }); + const guarded = await page.evaluate(() => { + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }), + ); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }); + assert.equal( + guarded, + true, + "history restoration keeps the existing warning", + ); + assert.equal( + await page.locator("#editor").inputValue(), + "active attempt must survive", + ); + assert.equal(await page.locator("#audio-check").isVisible(), false); + assert.equal(calls, 1); + assert.equal(await page.evaluate(() => window.roomJoins.length), 1); + if (selection === "strict") + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "active-history-receipt", + ); + }); +} + +for (const saved of [false, true]) { + test( + saved + ? "a cached Strict page returns to preflight after its report is saved" + : "a cached Strict page keeps a report that failed both history saves", + async (t) => { + let calls = 0; + let reportSaves = 0; + const page = await interview(t, "selection=strict", (route) => { + calls++; + return route.fulfill({ + json: grant("chargeback-pair-match", "report-history-receipt"), + }); + }); + if (!page) return; + await page.route("**/api/reports", (route) => { + assert.equal(route.request().method(), "POST"); + reportSaves++; + return route.fulfill({ status: saved ? 200 : 503, json: {} }); + }); + if (!saved) + await page.evaluate(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function store(key, value) { + if (this === localStorage && key === "codetrial_history") + throw new Error("History storage is full"); + return setItem.call(this, key, value); + }; + }); + await preflight(page); + await page.locator("#editor:not([disabled])").waitFor(); + await page.waitForFunction(() => { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }); + await page.evaluate(async () => { + const handlers = window.roomEvents.DataReceived; + if (!handlers?.length) throw new Error("Report handler is missing"); + const payload = new TextEncoder().encode( + JSON.stringify({ summary: "The cached report remains available." }), + ); + await Promise.all( + handlers.map((handler) => + handler(payload, { kind: "AGENT" }, null, "report"), + ), + ); + }); + assert.equal( + await page.locator("#report-save-status").textContent(), + saved ? "Saved to your account" : "Report was not saved", + ); + assert.equal(reportSaves, 1); + assert.equal(await page.locator("#report-modal").isVisible(), true); + assert.match( + await page.locator("#report-modal").textContent(), + /The cached report remains available/, + ); + if (saved) { + const reloaded = page.waitForEvent("domcontentloaded"); + await page.evaluate(() => + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }), + ), + ); + await reloaded; + await page.locator("#audio-check").waitFor({ state: "visible" }); + assert.equal(await page.locator("#report-modal").isVisible(), false); + } else { + const guarded = await page.evaluate(() => { + window.dispatchEvent( + new PageTransitionEvent("pageshow", { persisted: true }), + ); + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }); + assert.equal(guarded, true, "a failed save keeps the unload warning"); + assert.equal(await page.locator("#report-modal").isVisible(), true); + assert.match( + await page.locator("#report-modal").textContent(), + /The cached report remains available/, + ); + assert.equal(await page.locator("#audio-check").isVisible(), false); + } + assert.equal(calls, 1); + assert.equal(reportSaves, 1); + assert.equal( + await page.evaluate( + (key) => sessionStorage.getItem(key), + STRICT_ASSIGNMENT_KEY, + ), + "report-history-receipt", + ); + }, + ); +} + +test("Strict assignment, reconnect and refresh retain disabled editor assistance and execution", async (t) => { + const calls = []; + const pages = ["chargeback-pair-match", "kiosk-panel-mirror-audit"]; + const query = new URLSearchParams({ + selection: "strict", + problem: "two-sum", + editorHighlight: "0", + editorExecution: "0", + editorAutoIndent: "0", + editorAutoClose: "0", + editorMonospace: "0", + }); + const page = await interview(t, query, (route) => { + calls.push(route.request().postDataJSON()); + return route.fulfill({ + json: grant(pages[calls.length - 1], `options-${calls.length}`), + }); + }); + if (!page) return; + const parsers = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname.startsWith("/vendor/tree-sitter")) + parsers.push(request.url()); + }); + assert.equal(calls.length, 0); + for (let attempt = 0; attempt < 2; attempt++) { + if (attempt) await page.reload(); + assert.equal(await page.locator("#audio-check").isVisible(), true); + assert.equal(calls.length, attempt); + await preflight(page); + await page.locator("#editor:not([disabled])").waitFor(); + const request = calls[attempt]; + assert.equal(request.selectionMode, "strict"); + assert.equal( + request.difficulties, + undefined, + "old Strict URLs keep an unrestricted draw", + ); + assert.equal(request.problemId, undefined); + assert.equal(request.codeExecution, false); + assert.equal(request.previousAssignment, attempt ? "options-1" : undefined); + assert.equal(await page.locator("#editor-highlight").isVisible(), false); + assert.equal( + await page + .locator(".editor-stack") + .evaluate((element) => + element.style.getPropertyValue("--editor-font-family"), + ), + "inherit", + ); + await page.locator("#editor").fill(""); + await page.keyboard.type("("); + assert.equal(await page.locator("#editor").inputValue(), "("); + await page.locator("#editor").fill(" if ready:"); + await page.keyboard.press("End"); + await page.keyboard.press("Enter"); + assert.equal(await page.locator("#editor").inputValue(), " if ready:\n"); + + await page.locator("#language-select").selectOption("javascript"); + await page.locator("#editor").fill("const answer = 1;"); + const code = await page.locator("#editor").inputValue(); + for (const chord of ["Control+Enter", "Meta+Enter"]) + await page.keyboard.press(chord); + assert.equal(await page.locator("#editor").inputValue(), code); + await page.evaluate(() => { + for (const event of ["Reconnecting", "Reconnected"]) + for (const callback of window.roomEvents[event] ?? []) callback(); + }); + assert.equal(await page.locator("#run-tests").isVisible(), false); + assert.equal(await page.locator("#run-tests").isDisabled(), true); + assert.equal(await page.locator(".test-drawer").isVisible(), false); + assert.equal(await page.locator("#editor").inputValue(), code); + assert.equal( + await page.locator("#language-select").inputValue(), + "javascript", + ); + assert.equal(calls.length, attempt + 1); + } + assert.deepEqual(parsers, []); +}); + +for (const [levels, assigned] of [ + [["Easy"], "chargeback-pair-match"], + [["Medium"], "carousel-slot-shift"], + [["Hard"], "terrain-runoff-pooling"], + [["Easy", "Hard"], "terrain-runoff-pooling"], +]) { + test(`Strict forwards ${levels.join("/")} to the server and loads its assignment`, async (t) => { + const calls = []; + const query = new URLSearchParams({ + selection: "strict", + problem: "template-delimiter-audit", + }); + for (const level of levels) query.append("difficulty", level); + const page = await interview(t, query, (route) => { + calls.push(route.request().postDataJSON()); + return route.fulfill({ json: grant(assigned, "difficulty-receipt") }); + }); + if (!page) return; + await preflight(page); + await page.waitForFunction(() => window.roomJoins.length === 1); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].difficulties, levels); + assert.equal(calls[0].problemId, undefined); + const problem = await page.evaluate( + async (pageName) => (await fetch(`/problems/${pageName}.json`)).json(), + assigned, + ); + assert.equal( + await page.locator("#problem-title").textContent(), + problem.title, + ); + assert.ok(levels.includes(problem.difficulty)); + }); +} + +test("an invalid Strict URL difficulty returns to the lobby without broadening or retrying selection", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + const { context, consents } = await consentContext(t); + const calls = []; + const message = "Choose different options. Retry media preflight."; + const query = + "selection=strict&difficulty=Easy&difficulty=Impossible&problem=two-sum"; + const page = await interview( + t, + query, + (route) => { + calls.push(route.request().postDataJSON()); + return route.fulfill({ + status: 400, + json: { code: "strict_difficulty_invalid", error: message }, + }); + }, + context, + ); + await preflight(page); + await strictFailure(page, message); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0].difficulties, ["Easy", "Impossible"]); + assert.equal(calls[0].problemId, undefined); + assert.equal(await page.locator("#audio-check-join").isHidden(), true); + assert.equal(await page.locator("#audio-check-join").isDisabled(), true); + await page.locator("#audio-check-join").evaluate((button) => button.click()); + assert.equal(page.url(), `${base}/interview.html?${query}`); + assert.equal(calls.length, 1); + assert.equal(consents.length, 1); + await page.locator("#audio-check-leave").click(); + await page.waitForURL(`${base}/`); + assert.equal(calls.length, 1); + assert.equal(consents.length, 1); +}); + +test("Practice still filters by difficulty and opens a specifically chosen problem after switching modes", async (t) => { + if (!browser) { + t.skip("playwright chromium unavailable"); + return; + } + const page = await browser.newPage(); + t.after(() => page.close()); + await page.route("**/api/session", (route) => + route.fulfill({ + json: { signedIn: true, user: { login: "candidate" } }, + }), + ); + await page.route("**/api/reports", (route) => + route.fulfill({ json: { reports: [] } }), + ); + await page.route("**/interview?**", (route) => + route.fulfill({ + contentType: "text/html", + body: "

New interview

", + }), + ); + await page.goto(base); + await page.locator("#start:not([disabled])").waitFor(); + await page.locator("#selection-mode").selectOption("strict"); + await page.locator("#selection-mode").selectOption("practice"); + await page.locator('[name="difficulty"][value="Easy"]').check(); + await page.locator('[name="difficulty"][value="Medium"]').uncheck(); + await page.locator(".problem-picker summary").click(); + const chosen = "chargeback-pair-match"; + assert.equal( + await page.locator('[data-problem="carousel-slot-shift"]').isVisible(), + false, + ); + await page.locator(`[data-problem="${chosen}"]`).click(); + await page.locator("#start").click(); + await page.waitForURL("**/interview?**"); + const params = new URL(page.url()).searchParams; + assert.equal(params.get("problem"), chosen); + assert.equal(params.has("selection"), false); + assert.equal( + params.has("difficulty"), + false, + "Practice URL shape is unchanged", + ); +}); + +test("Restore Starter is harmless while Strict waits for its assigned problem", async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + let release; + let requested; + const pending = new Promise((resolve) => { + release = resolve; + }); + const tokenRequested = new Promise((resolve) => { + requested = resolve; + }); + t.after(() => release()); + const page = await interview(t, "selection=strict", async (route) => { + requested(); + await pending; + return route.fulfill({ + json: grant("chargeback-pair-match", "pending-starter"), + }); + }); + await preflight(page); + await tokenRequested; + assert.equal(await page.locator("#audio-check").isHidden(), true); + assert.equal(await page.locator("#editor").isDisabled(), true); + assert.equal(await page.locator("#run-tests").isDisabled(), true); + const before = await page.locator("#editor").inputValue(); + await page.locator("#editor-restore-starter").click(); + assert.equal(await page.locator("#editor").inputValue(), before); + release(); + await page.locator("#run-tests:not([disabled])").waitFor(); + assert.equal(await page.evaluate(() => window.roomJoins.length), 1); + assert.notEqual(await page.locator("#editor").inputValue(), ""); +}); + +for (const outcome of ["success", "failure"]) { + test(`Strict keeps Run disabled through a slow connection and ${outcome}`, async (t) => { + if (!browser) return t.skip("playwright chromium unavailable"); + let releaseJudge; + const judge = new Promise((resolve) => { + releaseJudge = resolve; + }); + t.after(() => releaseJudge()); + let calls = 0; + const page = await interview(t, "selection=strict", (route) => { + calls++; + return route.fulfill({ + json: grant("chargeback-pair-match", "slow-receipt"), + }); + }); + await page.route("**/judges/*.json", async (route) => { + await judge; + await route.continue(); + }); + await page.evaluate(() => { + const Room = window.LivekitClient.Room; + window.testPackets = []; + window.LivekitClient.Room = class extends Room { + constructor(...args) { + super(...args); + this.localParticipant.publishData = async (payload, options) => { + window.testPackets.push({ + topic: options.topic, + payload: JSON.parse(new TextDecoder().decode(payload)), + }); + }; + } + async connect(...args) { + window.connectionPending = true; + await new Promise((resolve, reject) => { + window.finishConnection = resolve; + window.failConnection = () => + reject(new Error("LiveKit join failed")); + }); + return super.connect(...args); + } + }; + }); + await preflight(page); + await page.waitForFunction(() => window.connectionPending); + assert.equal(await page.locator("#run-tests").isDisabled(), true); + await page.locator("#language-select").selectOption("javascript"); + const initialResults = await page.locator("#results-content").textContent(); + await page.evaluate(() => { + document + .querySelector("#run-tests") + .dispatchEvent(new MouseEvent("click")); + }); + assert.equal( + (await page.locator("#run-tests").textContent()).trim(), + "Run tests", + ); + assert.equal( + await page.locator("#results-content").textContent(), + initialResults, + ); + assert.equal( + await page.evaluate( + () => + window.testPackets.filter((p) => p.topic === "test_results").length, + ), + 0, + ); + + if (outcome === "failure") { + await page.evaluate(() => window.failConnection()); + await strictFailure(page, "LiveKit join failed"); + } + releaseJudge(); + // The placeholder is painted after the delayed judge's language update. + await page.waitForFunction( + () => document.querySelector("#candidate-case-input").placeholder !== "", + ); + assert.equal(await page.locator("#run-tests").isDisabled(), true); + await page.evaluate(() => { + document + .querySelector("#run-tests") + .dispatchEvent(new MouseEvent("click")); + }); + assert.equal( + (await page.locator("#run-tests").textContent()).trim(), + "Run tests", + ); + assert.equal( + await page.locator("#results-content").textContent(), + initialResults, + ); + assert.doesNotMatch( + await page.locator("#transcript-panel").textContent(), + /I ran the tests|tests passed/, + ); + assert.equal(calls, 1); + + if (outcome === "success") { + await page.evaluate(() => window.finishConnection()); + await page.locator("#run-tests:not([disabled])").waitFor(); + assert.equal(await page.locator("#editor").isEnabled(), true); + await page.locator("#run-tests").click(); + await page.waitForFunction(() => + window.testPackets.some((p) => p.topic === "test_results"), + ); + const packets = await page.evaluate(() => + window.testPackets.filter((p) => p.topic === "test_results"), + ); + assert.equal(packets.length, 1); + assert.ok(packets[0].payload.total > 0); + assert.doesNotMatch( + await page.locator("#transcript-panel").textContent(), + /I ran the tests/, + ); + } else { + assert.equal(await page.evaluate(() => window.roomJoins.length), 0); + } + }); +} + +test("Strict Run follows reconnect, pause, terminal disconnect and ending states", async (t) => { + const calls = []; + const page = await interview(t, "selection=strict", (route) => { + calls.push(route.request().postDataJSON()); + return route.fulfill({ + json: grant("chargeback-pair-match", "run-lifecycle"), + }); + }); + if (!page) return; + await preflight(page); + await page.locator("#end:not([disabled])").waitFor(); + await page.locator("#run-tests:not([disabled])").waitFor(); + const event = (name) => + page.evaluate((name) => { + const handlers = window.roomEvents[name]; + if (!handlers?.length) throw new Error(`Missing ${name} handler`); + for (const callback of handlers) callback(); + }, name); + const acknowledgePause = (paused) => + page.evaluate(async (paused) => { + const handlers = window.roomEvents.DataReceived; + if (!handlers?.length) throw new Error("Missing control handler"); + const payload = new TextEncoder().encode( + JSON.stringify({ type: "pause_state", paused }), + ); + await Promise.all( + handlers.map((handler) => + handler(payload, { kind: "AGENT" }, null, "control"), + ), + ); + }, paused); + const blockedRun = async () => { + assert.equal(await page.locator("#run-tests").isDisabled(), true); + await page.evaluate(() => { + document + .querySelector("#run-tests") + .dispatchEvent(new MouseEvent("click")); + }); + assert.equal( + (await page.locator("#run-tests").textContent()).trim(), + "Run tests", + ); + }; + await event("Reconnecting"); + await blockedRun(); + await page.locator("#language-select").selectOption("javascript"); + await blockedRun(); + await event("Reconnected"); + assert.equal(await page.locator("#run-tests").isEnabled(), true); + await page.locator("#pause").click(); + await acknowledgePause(true); + await event("Reconnecting"); + await event("Reconnected"); + await blockedRun(); + await page.locator("#pause").click(); + await acknowledgePause(false); + assert.equal(await page.locator("#run-tests").isEnabled(), true); + await event("Disconnected"); + await blockedRun(); + assert.equal( + await page.locator("#agent-state").textContent(), + "Not connected", + ); + assert.equal( + await page.locator("#presence-banner").textContent(), + "The Strict Interview connection dropped.", + ); + assert.equal(calls.length, 1); + assert.equal(await page.evaluate(() => window.roomJoins.length), 1); + // End during the terminal drop, then simulate a late transport notification. + const disabled = await page.evaluate(() => { + document.querySelector("#end").click(); + for (const callback of window.roomEvents.Reconnected) callback(); + return document.querySelector("#run-tests").disabled; + }); + assert.equal(disabled, true); + assert.equal(calls.length, 1); +}); diff --git a/tests/unit/web/selection.rs b/tests/unit/web/selection.rs new file mode 100644 index 000000000..c154fdf98 --- /dev/null +++ b/tests/unit/web/selection.rs @@ -0,0 +1,393 @@ +use super::*; + +// Policy checks need one known draw, not OS entropy. If a mutation rejects it, +// fail on the next request instead of waiting for an improbable random value. +// The token API tests still exercise the production SystemRandom adapter. +fn assign_with_zero( + request: &Value, + owner: i64, + secret: &str, +) -> Result, SelectionError> { + let mut word = Some(0_u64); + assign_with_entropy(request, owner, secret, |bytes| { + let value = word.take().expect("unexpected entropy request"); + bytes.copy_from_slice(&value.to_le_bytes()); + Ok(()) + }) +} + +#[test] +fn practice_keeps_the_requested_problem_and_needs_no_receipt() { + for request in [ + json!({}), + json!({"problemId": "anything"}), + json!({"selectionMode": "practice", "problemId": "two-sum", + "previousAssignment": "ignored"}), + ] { + assert!(assign_with_zero(&request, 7, "secret").unwrap().is_none()); + } + for mode in [json!("typo"), json!(null), json!(true)] { + assert_eq!( + assign_with_zero(&json!({"selectionMode": mode}), 7, "secret").err(), + Some(SelectionError::Mode) + ); + } +} + +#[test] +fn strict_refuses_client_problem_selection() { + for value in [json!("two-sum"), json!(null), json!("")] { + assert_eq!( + assign_with_zero( + &json!({"selectionMode": "strict", "problemId": value}), + 7, + "secret" + ) + .err(), + Some(SelectionError::Problem) + ); + } +} + +#[test] +fn a_receipt_is_bound_to_its_owner_purpose_and_exact_page() { + let page = PROBLEMS[0].variant().page; + let receipt = receipt_for(page, 7, "secret"); + assert_eq!(read_receipt(&receipt, 7, "secret").unwrap(), page); + for (value, owner, secret) in [ + (receipt.clone(), 8, "secret"), + (receipt.clone(), 7, "changed"), + (receipt.replace(page, "missing-page"), 7, "secret"), + (receipt_for("missing-page", 7, "secret"), 7, "secret"), + (receipt_for(PROBLEMS[0].id, 7, "secret"), 7, "secret"), + ("x".repeat(513), 7, "secret"), + ("unsigned".to_string(), 7, "secret"), + ] { + assert_eq!( + read_receipt(&value, owner, secret), + Err(SelectionError::Receipt) + ); + } + let payload = format!("another-purpose:7:{page}"); + let other = format!("{payload}.{}", sign_hs256("secret", &payload)); + assert_eq!( + read_receipt(&other, 7, "secret"), + Err(SelectionError::Receipt) + ); +} + +#[test] +fn selection_excludes_only_the_previous_problem() { + let bank = &PROBLEMS[..2]; + let first = choose(bank, DIFFICULTIES, None, |_| Ok(0)).unwrap(); + let second = choose(bank, DIFFICULTIES, Some(first.variant().page), |count| { + assert_eq!(count, 1); + Ok(0) + }) + .unwrap(); + assert_ne!(first.id, second.id); + let third = choose(bank, DIFFICULTIES, Some(second.variant().page), |_| Ok(0)).unwrap(); + assert_eq!(third.id, first.id); + for index in 0..PROBLEMS.len() - 1 { + assert_ne!( + choose(PROBLEMS, DIFFICULTIES, Some(first.variant().page), |_| Ok( + index + )) + .unwrap() + .id, + first.id + ); + } +} + +#[test] +fn an_empty_pool_or_a_lone_previous_problem_never_falls_back() { + assert_eq!( + choose(&[], DIFFICULTIES, None, |_| panic!("no draw")).err(), + Some(SelectionError::NoAlternative) + ); + let bank = &PROBLEMS[..1]; + assert_eq!( + choose(bank, DIFFICULTIES, None, |_| Ok(0)).unwrap().id, + bank[0].id + ); + assert_eq!( + choose( + bank, + DIFFICULTIES, + Some(bank[0].variant().page), + |_| panic!("no draw") + ) + .err(), + Some(SelectionError::NoAlternative) + ); + assert_eq!( + choose(bank, DIFFICULTIES, None, |_| Err(SelectionError::Random)).err(), + Some(SelectionError::Random) + ); +} + +#[test] +fn independent_tabs_exclude_their_own_previous_assignment() { + for previous in &PROBLEMS[..2] { + let receipt = receipt_for(previous.variant().page, 7, "secret"); + let assignment = assign_with_zero( + &json!({"selectionMode": "strict", + "previousAssignment": receipt}), + 7, + "secret", + ) + .unwrap() + .unwrap(); + assert_ne!(assignment.page, previous.variant().page); + assert_eq!( + read_receipt(&assignment.receipt, 7, "secret").unwrap(), + assignment.page + ); + } + for value in [json!("bad"), json!(null), json!(42)] { + assert_eq!( + assign_with_zero( + &json!({"selectionMode": "strict", + "previousAssignment": value}), + 7, + "secret" + ) + .err(), + Some(SelectionError::Receipt) + ); + } +} + +#[test] +fn every_eligible_draw_respects_difficulties_and_the_previous_assignment() { + for levels in [ + vec!["Easy"], + vec!["Medium"], + vec!["Hard"], + vec!["Easy", "Hard"], + DIFFICULTIES.to_vec(), + ] { + let first = PROBLEMS + .iter() + .find(|p| levels.contains(&p.difficulty)) + .unwrap(); + for previous in [None, Some(first.variant().page)] { + let eligible = PROBLEMS + .iter() + .filter(|p| levels.contains(&p.difficulty) && Some(p.variant().page) != previous) + .count(); + assert!(eligible > 0); + for index in 0..eligible { + let picked = choose(PROBLEMS, &levels, previous, |count| { + assert_eq!(count, eligible); + Ok(index) + }) + .unwrap(); + assert!(levels.contains(&picked.difficulty)); + assert_ne!(Some(picked.variant().page), previous); + } + } + } +} + +#[test] +fn difficulty_exhaustion_never_uses_another_level_or_the_previous_problem() { + let easy = PROBLEMS.iter().find(|p| p.difficulty == "Easy").unwrap(); + let hard = PROBLEMS.iter().find(|p| p.difficulty == "Hard").unwrap(); + let bank = [*easy, *hard]; + assert_eq!( + choose(&bank, &["Easy"], None, |count| { + assert_eq!(count, 1); + Ok(0) + }) + .unwrap(), + easy + ); + for (levels, previous) in [ + (vec!["Medium"], None), + (vec!["Easy"], Some(easy.variant().page)), + ] { + assert_eq!( + choose(&bank, &levels, previous, |_| panic!("no eligible draw")).err(), + Some(SelectionError::NoAlternative) + ); + } + // A receipt remains meaningful when the next request changes difficulty. + assert_eq!( + choose(&bank, &["Hard"], Some(easy.variant().page), |_| Ok(0)).unwrap(), + hard + ); + assert_eq!( + SelectionError::NoAlternative.response().status(), + StatusCode::CONFLICT + ); +} + +#[test] +fn strict_validates_the_entire_difficulty_filter() { + for value in [ + json!(null), + json!("Easy"), + json!(true), + json!(1), + json!({}), + json!([]), + json!(["easy"]), + json!(["Easy", "Extreme"]), + json!(["Easy", null]), + json!(["Easy", 1]), + json!(["Easy", ""]), + json!(["Easy", "Easy"]), + json!(["Easy", "Medium", "Hard", "Easy"]), + ] { + assert_eq!( + assign_with_zero( + &json!({"selectionMode": "strict", "difficulties": value}), + 7, + "secret" + ) + .err(), + Some(SelectionError::Difficulty) + ); + } + for levels in [ + json!(["Easy"]), + json!(["Medium"]), + json!(["Hard"]), + json!(["Easy", "Hard"]), + ] { + let assignment = assign_with_zero( + &json!({"selectionMode": "strict", "difficulties": levels}), + 7, + "secret", + ) + .unwrap() + .unwrap(); + let problem = find_problem(assignment.page).unwrap(); + assert!( + levels + .as_array() + .unwrap() + .contains(&json!(problem.difficulty)) + ); + assert_eq!( + read_receipt(&assignment.receipt, 7, "secret").unwrap(), + assignment.page + ); + } +} + +#[test] +fn receipt_parsing_bounds_bytes_before_authentication() { + // These parseable forgeries distinguish the size gate from the signature + // and page checks, which reject them even when the size gate is broken. + for len in [511, 512, 513, 1024] { + let payload = "x".repeat(len - 2); + let receipt = format!("{payload}.s"); + assert_eq!(receipt.len(), len); + if len <= 512 { + assert_eq!(receipt_parts(&receipt), Ok((payload.as_str(), "s"))); + } else { + assert_eq!(receipt_parts(&receipt), Err(SelectionError::Receipt)); + } + assert_eq!( + read_receipt(&receipt, 7, "secret"), + Err(SelectionError::Receipt) + ); + } + let multibyte = format!("{}.s", "\u{00e9}".repeat(256)); + assert_eq!(multibyte.len(), 514); + assert_eq!(receipt_parts(&multibyte), Err(SelectionError::Receipt)); + for receipt in ["", "unsigned"] { + assert_eq!(receipt_parts(receipt), Err(SelectionError::Receipt)); + } + assert_eq!( + receipt_parts("payload.with.dots.signature"), + Ok(("payload.with.dots", "signature")) + ); +} + +// Every supplied word must be consumed, and an unexpected retry fails instead +// of hanging. This observes the rejection path as well as the returned index. +fn index_from_words( + count: usize, + words: impl IntoIterator>, +) -> Result { + let mut words = words.into_iter(); + let result = random_index(count, |bytes| { + let word = words.next().expect("unexpected entropy request")?; + bytes.copy_from_slice(&word.to_le_bytes()); + Ok(()) + }); + assert!( + words.next().is_none(), + "returned without consuming the expected entropy" + ); + result +} + +#[test] +fn random_indices_map_complete_buckets_without_favoring_an_index() { + for count in [1, 2, 3, 7, 150] { + for bucket in [0, 1, 2] { + for expected in 0..count { + let word = (bucket * count + expected) as u64; + assert_eq!(index_from_words(count, [Ok(word)]), Ok(expected)); + } + } + } +} + +#[test] +fn random_indices_reject_the_upper_boundary_and_retry() { + // MAX is divisible by three. Two and ten leave partial buckets of different + // sizes; spelling out the boundaries avoids copying the formula. + for (count, limit, last_index) in [ + (1, u64::MAX, 0), + (2, u64::MAX - 1, 1), + (3, u64::MAX, 2), + (10, u64::MAX - 5, 9), + ] { + assert_eq!(index_from_words(count, [Ok(limit - 1)]), Ok(last_index)); + assert_eq!(index_from_words(count, [Ok(limit), Ok(0)]), Ok(0)); + assert_eq!( + index_from_words(count, [Ok(u64::MAX), Ok(limit), Ok(0)]), + Ok(0) + ); + } + assert_eq!(index_from_words(3, [Ok(u64::MAX), Ok(5)]), Ok(2)); +} + +#[test] +fn entropy_failure_never_selects_a_default_or_retries_the_source() { + assert_eq!( + assign_with_entropy(&json!({"selectionMode": "strict"}), 7, "secret", |_| Err( + SelectionError::Random + )) + .err(), + Some(SelectionError::Random) + ); + assert_eq!( + index_from_words(3, [Err(SelectionError::Random)]), + Err(SelectionError::Random) + ); + assert_eq!( + index_from_words(3, [Ok(u64::MAX), Err(SelectionError::Random)]), + Err(SelectionError::Random) + ); + assert_eq!( + SelectionError::Random.response().status(), + StatusCode::SERVICE_UNAVAILABLE + ); +} + +#[test] +fn random_indices_cover_the_largest_pool_on_this_target() { + let count = usize::MAX; + assert_eq!( + index_from_words(count, [Ok((count - 1) as u64)]), + Ok(count - 1) + ); + assert_eq!(index_from_words(count, [Ok(u64::MAX), Ok(1)]), Ok(1)); +} diff --git a/tests/web/contract.rs b/tests/web/contract.rs index 3dd13d979..6e1d441b9 100644 --- a/tests/web/contract.rs +++ b/tests/web/contract.rs @@ -258,8 +258,10 @@ fn static_interview_script_keeps_transcript_and_report_contract() { fn static_interview_script_leaves_candidate_identity_to_the_server() { let source = fs::read_to_string("web/interview.js").unwrap(); + // The shared request fields follow the mode-specific problem selection. + // Strict assignment must retain the optional execution restriction too. assert!(compact(&source).contains( - "JSON.stringify({problemId:problem.page,durationMin,interviewId,interviewLoop,interviewMode:mode,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}:{}),...(!editorOptions.execution?{codeExecution:false}:{})})" + "durationMin,interviewId,interviewLoop,interviewMode:mode,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}:{}),...(!editorOptions.execution?{codeExecution:false}:{})})" )); assert!(!source.contains("candidateIdentity")); } diff --git a/tests/web/token.rs b/tests/web/token.rs index ae57ad208..5274ad09e 100644 --- a/tests/web/token.rs +++ b/tests/web/token.rs @@ -1489,3 +1489,408 @@ async fn token_rejects_guest_when_dispatcher_does_not_support_guest_admission() server.shutdown().await; } + +/// Strict needs an account-bound receipt even on a guest-capable deployment. +#[tokio::test] +async fn strict_requires_login_without_changing_guest_practice_admission() { + for with_accounts in [false, true] { + let (mut config, db_path) = if with_accounts { + let (config, _, path) = signed_in_web_config("strict-guest"); + (config, Some(path)) + } else { + (web_config(), None) + }; + config.guest_mode_enabled = true; + let dispatcher = std::sync::Arc::::default(); + let (base, server) = + spawn_web_server_with_dispatcher(config, std::sync::Arc::clone(&dispatcher)).await; + let client = http_client(); + let url = format!("{base}/api/token"); + + for request in [ + json!({"selectionMode": "strict"}), + json!({"selectionMode": "strict", "problemId": "chargeback-pair-match"}), + json!({"selectionMode": "strict", "difficulties": ["Easy"]}), + json!({"selectionMode": "strict", "previousAssignment": "forged"}), + ] { + let response = client.post(&url).json(&request).send().await.unwrap(); + assert_eq!(response.status(), 401); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["code"], "strict_login_required"); + for field in ["token", "problemPage", "previousAssignment"] { + assert!(body.get(field).is_none()); + } + } + for mode in [json!("unknown"), json!(null), json!(true), json!(1)] { + let response = client + .post(&url) + .json(&json!({"selectionMode": mode})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["code"], "selection_mode_invalid"); + } + assert!(dispatcher.rooms().is_empty()); + + for mode in [None, Some("practice")] { + let mut request = json!({"problemId": "chargeback-pair-match"}); + if let Some(mode) = mode { + request["selectionMode"] = json!(mode); + } + let response = client.post(&url).json(&request).send().await.unwrap(); + assert_eq!(response.status(), 200); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["guest"], true); + let metadata: Value = serde_json::from_str( + claims(body["token"].as_str().unwrap())["metadata"] + .as_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(metadata["problemId"], "chargeback-pair-match"); + assert!(body.get("previousAssignment").is_none()); + } + assert_eq!(dispatcher.rooms().len(), 2); + server.shutdown().await; + if let Some(path) = db_path { + remove_database(path).await; + } + } +} + +/// Selection and staffing must agree even with the development room pinned. +#[tokio::test] +async fn strict_assignments_ignore_the_fixed_room_and_exclude_only_the_previous_page() { + let (mut config, cookie, db_path) = signed_in_web_config("strict-selection"); + config.fixed_room_name = Some("interview-local".to_string()); + let dispatcher = std::sync::Arc::::default(); + let (base, server) = + spawn_web_server_with_dispatcher(config, std::sync::Arc::clone(&dispatcher)).await; + let client = http_client(); + let mut previous = None; + let mut last_page = None; + let mut rooms = Vec::new(); + + // Server assignment must preserve the execution restriction in the signed + // metadata, including an attempt that returns to the default. + for (execution, voice, accent) in [ + ( + false, + codetrial::agent::INTERVIEWER_VOICES[0], + codetrial::agent::INTERVIEWER_ACCENTS[0], + ), + (true, "Random", "Random"), + (false, "Default", "Default"), + ] { + let mut request = json!({ + "selectionMode": "strict", + "durationMin": 42.8, + "exampleDrawing": true, + "interviewerVoice": voice, + "interviewerAccent": accent, + }); + if !execution { + request["codeExecution"] = json!(false); + } + if let Some(receipt) = previous { + request["previousAssignment"] = receipt; + } + let response = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["guest"], false); + let metadata: Value = serde_json::from_str( + claims(body["token"].as_str().unwrap())["metadata"] + .as_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(metadata["problemId"], body["problemPage"]); + assert_eq!(metadata["exampleDrawing"], true); + for (field, selected, choices) in [ + ( + "interviewerVoice", + voice, + codetrial::agent::INTERVIEWER_VOICES, + ), + ( + "interviewerAccent", + accent, + codetrial::agent::INTERVIEWER_ACCENTS, + ), + ] { + match selected { + "Default" => assert!(metadata.get(field).is_none()), + "Random" => assert!(choices.contains(&metadata[field].as_str().unwrap())), + _ => assert_eq!(metadata[field], selected), + } + } + assert_eq!( + metadata.get("codeExecution"), + if execution { None } else { Some(&json!(false)) } + ); + assert_eq!(body["durationMin"], 42); + assert_ne!(last_page, Some(body["problemPage"].clone())); + assert_ne!(body["roomName"], "interview-local"); + assert!(!rooms.contains(&body["roomName"].as_str().unwrap().to_string())); + rooms.push(body["roomName"].as_str().unwrap().to_string()); + previous = Some(body["previousAssignment"].clone()); + last_page = Some(body["problemPage"].clone()); + } + assert_eq!(dispatcher.rooms(), rooms); + + // Legacy requests still select their problem and use the pinned room. + for selection in [None, Some("practice")] { + let mut request = json!({"problemId": "two-sum"}); + if let Some(selection) = selection { + request["selectionMode"] = json!(selection); + } + let body: Value = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&request) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(body["roomName"], "interview-local"); + assert!(body.get("previousAssignment").is_none()); + assert!(body.get("problemPage").is_none()); + let metadata: Value = serde_json::from_str( + claims(body["token"].as_str().unwrap())["metadata"] + .as_str() + .unwrap(), + ) + .unwrap(); + assert_eq!(metadata["problemId"], "two-sum"); + } + server.shutdown().await; + remove_database(db_path).await; +} + +#[tokio::test] +async fn strict_refuses_url_selection_invalid_receipts_and_anonymous_callers_before_dispatch() { + let (config, cookie, db_path) = signed_in_web_config("strict-refusals"); + let dispatcher = std::sync::Arc::::default(); + let (base, server) = + spawn_web_server_with_dispatcher(config, std::sync::Arc::clone(&dispatcher)).await; + let client = http_client(); + for (request, code) in [ + ( + json!({"selectionMode": "strict", "problemId": "two-sum", "difficulties": ["Hard"]}), + "strict_problem_forbidden", + ), + ( + json!({"selectionMode": "strict", "previousAssignment": "forged"}), + "strict_assignment_invalid", + ), + ( + json!({"selectionMode": "strict", "problemId": null, "difficulties": ["Easy"]}), + "strict_problem_forbidden", + ), + (json!({"selectionMode": "typo"}), "selection_mode_invalid"), + ] { + let response = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["code"], code); + assert!(body.get("token").is_none()); + } + let anonymous = client + .post(format!("{base}/api/token")) + .json(&json!({"selectionMode": "strict"})) + .send() + .await + .unwrap(); + assert_eq!(anonymous.status(), 401); + assert!(dispatcher.rooms().is_empty()); + let granted: Value = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&json!({"selectionMode": "strict"})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let (other_cookie, _) = second_account(&client, &base, &db_path).await; + let borrowed = client + .post(format!("{base}/api/token")) + .header("cookie", &other_cookie) + .json(&json!({"selectionMode": "strict", + "previousAssignment": granted["previousAssignment"]})) + .send() + .await + .unwrap(); + assert_eq!(borrowed.status(), 400); + assert_eq!( + borrowed.json::().await.unwrap()["code"], + "strict_assignment_invalid" + ); + assert_eq!(dispatcher.rooms().len(), 1); + server.shutdown().await; + remove_database(db_path).await; +} + +#[tokio::test] +async fn strict_refresh_needs_fresh_consent_and_keeps_the_recording_duration_cap() { + let (base, server, path, client, cookie) = recorded_server("strict-consent").await; + let refused = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&json!({"selectionMode": "strict"})) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), 403); + let first_consent = start_interview(&client, &base, &cookie).await; + let first: Value = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&json!({"selectionMode": "strict", + "interviewId": first_consent, "durationMin": 90})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(first["durationMin"], recording_config().max_minutes); + let refused = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&json!({"selectionMode": "strict", + "interviewId": first_consent, "previousAssignment": first["previousAssignment"]})) + .send() + .await + .unwrap(); + assert_eq!(refused.status(), 403); + let next_consent = start_interview(&client, &base, &cookie).await; + assert_ne!(first_consent, next_consent); + let next: Value = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&json!({"selectionMode": "strict", + "interviewId": next_consent, "previousAssignment": first["previousAssignment"]})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_ne!(next["problemPage"], first["problemPage"]); + assert_ne!(next["roomName"], first["roomName"]); + assert_eq!(interview_rows(&path).len(), 2); + server.shutdown().await; + remove_database(path).await; +} + +#[tokio::test] +async fn strict_difficulty_filters_constrain_signed_assignments_across_refresh() { + let (config, cookie, db_path) = signed_in_web_config("strict-difficulty"); + let dispatcher = std::sync::Arc::::default(); + let (base, server) = + spawn_web_server_with_dispatcher(config, std::sync::Arc::clone(&dispatcher)).await; + let client = http_client(); + let mut previous: Option = None; + let mut last_page: Option = None; + let mut rooms = Vec::new(); + for levels in [ + vec!["Hard"], + vec!["Medium"], + vec!["Easy"], + vec!["Easy", "Hard"], + ] { + // Repeat each filter, then carry the receipt across a filter change. + for _ in 0..2 { + let mut request = json!({"selectionMode": "strict", "difficulties": levels}); + if let Some(receipt) = &previous { + request["previousAssignment"] = receipt.clone(); + } + // Neither query spelling can override the body or the server draw. + let response = client + .post(format!( + "{base}/api/token?problem=two-sum&problemId=two-sum" + )) + .header("cookie", &cookie) + .json(&request) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let body: Value = response.json().await.unwrap(); + let page = body["problemPage"].as_str().unwrap(); + let problem = codetrial::agent::find_problem(page).unwrap(); + assert!(levels.contains(&problem.difficulty)); + assert_ne!(last_page.as_deref(), Some(page)); + let token = claims(body["token"].as_str().unwrap()); + let metadata: Value = + serde_json::from_str(token["metadata"].as_str().unwrap()).unwrap(); + assert_eq!(metadata["problemId"], page); + assert_eq!(token["video"]["room"], body["roomName"]); + let room = body["roomName"].as_str().unwrap().to_string(); + assert!(!rooms.contains(&room)); + rooms.push(room); + previous = Some(body["previousAssignment"].clone()); + last_page = Some(page.to_string()); + } + } + assert_eq!(dispatcher.rooms(), rooms); + server.shutdown().await; + remove_database(db_path).await; +} + +#[tokio::test] +async fn invalid_strict_difficulties_are_refused_before_dispatch() { + let (config, cookie, db_path) = signed_in_web_config("strict-invalid-difficulty"); + let dispatcher = std::sync::Arc::::default(); + let (base, server) = + spawn_web_server_with_dispatcher(config, std::sync::Arc::clone(&dispatcher)).await; + let client = http_client(); + for value in [ + json!(null), + json!("Easy"), + json!(true), + json!([]), + json!(["easy"]), + json!(["Easy", "Extreme"]), + json!(["Easy", null]), + json!(["Easy", 1]), + json!(["Easy", ""]), + json!(["Easy", "Easy"]), + json!(["Easy", "Medium", "Hard", "Easy"]), + ] { + let response = client + .post(format!("{base}/api/token")) + .header("cookie", &cookie) + .json(&json!({"selectionMode": "strict", "difficulties": value})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["code"], "strict_difficulty_invalid"); + assert!(body.get("token").is_none()); + assert!(body.get("previousAssignment").is_none()); + } + assert!(dispatcher.rooms().is_empty()); + server.shutdown().await; + remove_database(db_path).await; +} diff --git a/web/app.js b/web/app.js index 4d2846db1..744bfa1b7 100644 --- a/web/app.js +++ b/web/app.js @@ -1,4 +1,5 @@ import { mountProblemGrid } from "./problem-grid.js"; +import { resetAssignments } from "./selection.js"; import { FRAMEWORKS, SHOW_SOURCES_KEY, @@ -54,7 +55,9 @@ import { const SETTINGS_KEY = "codetrial.interviewSettings"; const requestTimeoutMs = 10_000; +const selection = document.querySelector("#selection-mode"); let problem; +let selectionMode = "practice"; let duration; let interviewLoop = "coding_behavioral"; /// Which surface the interview runs on; see the format row in index.html. @@ -544,13 +547,39 @@ let starting = false; restoreSettings(); renderPracticeList(); +selection.addEventListener("change", syncSelectionMode); +syncSelectionMode(); + +function syncSelectionMode() { + selectionMode = selection.value; + const strict = selectionMode === "strict"; + if (strict) retryLanguage = null; + for (const panel of [ + document.querySelector(".random-picker"), + document.querySelector(".practice-list-picker"), + nodes.problemPicker, + ]) + panel.hidden = strict; + document.querySelector("#selection-note").hidden = !strict; + syncLobbyControls(); +} + // The button by name, not by asking the event which element it was dispatched // to: the browser clears that property when dispatch ends, so every line after // the first `await` was writing to null. A gated candidate signed in, the // handler threw on the next line, and the button stayed disabled on "Recording // GitHub..." with the interview never starting. start.addEventListener("click", async () => { - if (!problem || starting || accountUpdatePending() || deletingReports) return; + syncSelectionMode(); + if ( + (selectionMode === "practice" && !problem) || + (selectionMode === "strict" && + (!historyReady || accountHistory !== true)) || + starting || + accountUpdatePending() || + deletingReports + ) + return; // The whole form is read here, before the sign-in round trip below, because // every control the candidate can still touch during it feeds this URL. // Reading them afterwards shipped an interview that was 45 minutes when the @@ -559,13 +588,18 @@ start.addEventListener("click", async () => { const destination = new URL("/interview", window.location.origin); // The scenario's page name, not the id: the address bar is on screen for the // whole interview, and the id is the published problem's slug. - destination.searchParams.set("problem", problem.id); + const strict = selectionMode === "strict"; + if (strict) { + destination.searchParams.set("selection", "strict"); + for (const difficulty of selectedDifficulties()) + destination.searchParams.append("difficulty", difficulty); + } else destination.searchParams.set("problem", problem.id); destination.searchParams.set("duration", String(duration)); destination.searchParams.set("loop", interviewLoop); destination.searchParams.set("mode", mode); if (mode === "coding" && nodes.exampleDrawing.checked) destination.searchParams.set("exampleDrawing", "1"); - if (mode === "coding" && retryLanguage) + if (!strict && mode === "coding" && retryLanguage) destination.searchParams.set("language", retryLanguage); setEditorOptions(destination.searchParams, selectedEditorOptions()); const profile = { @@ -614,6 +648,7 @@ start.addEventListener("click", async () => { start.textContent = "Starting..."; if (!nodes.groundingConsent.checked) packet = null; try { + if (strict) resetAssignments(sessionStorage); storeGroundingPacket(sessionStorage, packet); storeSharedFocus(sessionStorage, focus?.weakness ?? null); } catch (error) { @@ -703,6 +738,9 @@ function clearGrounding() { // Returning from the media preflight can restore this page from the browser's // back/forward cache after the start button was deliberately disabled. window.addEventListener("pageshow", (event) => { + // Form restoration can follow pageshow without firing change. Read the DOM + // on the next task; syncLobbyControls keeps an in-flight start latched. + setTimeout(syncSelectionMode, 0); // Only the restore. A normal load fires this too, after `load`, which is // late enough to re-enable a button the candidate already pressed and hand // them a second navigation. @@ -753,7 +791,16 @@ nodes.loginLink.addEventListener("click", async () => { // view of the account transition in one place, including the GET-only retry. function syncLobbyControls() { const accountBusy = accountUpdatePending(); - start.disabled = !problem || starting || accountBusy || deletingReports; + // Practice may start a manual pick during the initial history load. Strict + // waits for a signed-in account and suggested difficulties to settle. + start.disabled = + (selectionMode === "practice" && !problem) || + (selectionMode === "strict" && + (!historyReady || accountHistory !== true)) || + starting || + accountBusy || + deletingReports; + selection.disabled = starting; nodes.loginLink.disabled = starting || loginPending || historyRefreshing || deletingReports; nodes.randomProblem.disabled = @@ -1731,6 +1778,8 @@ function renderAttemptHistory(attempts) { (candidate) => candidate.id === attempt.problemId, ); if (!card) return; + selection.value = "practice"; + syncSelectionMode(); manualProblem = true; // A history retry bypasses the picker filters, not active title search. if (!nodes.problemSearch.value.trim()) setCardHidden(card, false); diff --git a/web/index.html b/web/index.html index 26219c18b..3cb718e39 100644 --- a/web/index.html +++ b/web/index.html @@ -49,9 +49,28 @@

Practice a live technical interview

Choose a problem

-

Get a random problem at your level, or pick one by name.

+

Choose a difficulty for your next interview.

+

+ +

+ + + +

What happens next

diff --git a/web/interview.html b/web/interview.html index 125d685b0..a826e241e 100644 --- a/web/interview.html +++ b/web/interview.html @@ -469,6 +469,11 @@

Check your setup

Confirm you can hear Jim and he can hear you. The camera is optional unless this interview is recorded.

+

diff --git a/web/interview.js b/web/interview.js index 271b433c1..6b6bc2898 100644 --- a/web/interview.js +++ b/web/interview.js @@ -1,3 +1,8 @@ +import { + previousAssignment, + rememberAssignment, + resetAssignments, +} from "./selection.js"; import { createReportRecovery } from "./report-recovery.js"; import { createAudioPlayback } from "./audio-playback.js"; import { @@ -272,6 +277,7 @@ const replayVersion = globalThis.CODETRIAL_REPLAY_VERSION || 1; const params = new URLSearchParams(window.location.search); const editorOptions = editorOptionsFromParams(params); +const strict = params.get("selection") === "strict"; // Start this independent request while the problem data is loading. It has to // settle rather than reject: nothing awaits it until init() reaches the sign-in // gate, and a problem that fails to load throws out of the top-level await @@ -280,32 +286,21 @@ const editorOptions = editorOptionsFromParams(params); const sessionPromise = fetch("/api/session") .then((response) => (response.ok ? response.json() : null)) .catch(() => null); -// Top-level await: the whole module is written against a known problem, and -// interview.html loads it as a module, so waiting here beats threading a -// promise through every consumer below. -// -// The failure has to be said out loud. Every consumer below reads `problem` -// unconditionally, so an unreachable bank would otherwise leave the page on its -// "Loading interview..." heading forever with a console error nobody sees. -const problem = await loadProblem(params.get("problem")).catch((error) => { - const title = document.querySelector("#problem-title"); - if (title) - title.textContent = - "This interview could not load its problem. Reload the page."; - throw error; -}); -// Started here and awaited nowhere: which tabs are real is a property of the -// judge, but it is not worth holding a page the candidate can already work in. -// Every tab is offered until the answer arrives, which is what the row did -// before this and what it keeps doing if the judge never loads; the run path -// reports that failure itself. -const judgePromise = loadJudge(problem.page).catch(() => null); -// Up here rather than beside the functions that read them: `init()` runs -// during module evaluation and reaches `initializeCandidateCases` before its -// first await, so declarations further down are still uninitialized then. The -// read threw, the catch took it for an empty list, and every reload lost the -// candidate's saved cases. -const candidateCaseStorageKey = `codetrial.candidateCases.${problem.page}`; +// Practice keeps its URL lookup, including legacy aliases and fallback. +// Strict does not load a problem until preflight and server assignment finish. +let problem = strict + ? null + : await loadProblem(params.get("problem")).catch((error) => { + const title = document.querySelector("#problem-title"); + if (title) + title.textContent = + "This interview could not load its problem. Reload the page."; + throw error; + }); +let judgePromise = problem ? loadJudge(problem.page).catch(() => null) : null; +const candidateCaseStorageKey = problem + ? `codetrial.candidateCases.${problem.page}` + : ""; /// Filled by `applyLanguages`, which `init` calls before anything reads it. let languages = []; // A delayed judge may make the requested C tab available after the first @@ -445,7 +440,7 @@ const state = { interviewerPresent: false, agentSpeaking: false, agentThinking: false, - codeByLanguage: { ...problem.starterCode }, + codeByLanguage: { ...problem?.starterCode }, language: "python", // Thresholds already announced. A latch is released when the room pauses only // if it was set inside the pause round trip, because the agent drops either @@ -514,6 +509,23 @@ const state = { const beforeUnloadGuard = createBeforeUnloadGuard(window); +// A history restore may revive a whole old page instead of evaluating this +// module again. Recheck Strict preflight only when the existing unload policy +// allows leaving: an active attempt or unsaved report must remain available. +window.addEventListener("pageshow", (event) => { + if ( + !strict || + !event.persisted || + shouldWarnBeforeUnload({ + endsAt: state.endsAt, + reportPersisted: state.reportPersisted, + }) + ) + return; + beforeUnloadGuard.setEnabled(false); + window.location.reload(); +}); + function updateBeforeUnloadGuard() { beforeUnloadGuard.setEnabled( shouldWarnBeforeUnload({ @@ -754,6 +766,7 @@ function holdWorkspaceBehindDialogs() { async function init() { state.transcript = createTranscriptView(document, nodes.transcriptPanel); renderRuntimeConfig(); + document.querySelector("#strict-note").hidden = !strict; nodes.hideExamples.checked = readStored(HIDE_EXAMPLES_KEY) === "1"; nodes.editorHighlight.hidden = !editorOptions.highlight; nodes.editorStack.classList.toggle("plain-text", !editorOptions.highlight); @@ -761,13 +774,17 @@ async function init() { nodes.editorStack.style.setProperty("--editor-font-family", "inherit"); applyEditorFontSize(readStored(EDITOR_FONT_SIZE_KEY)); applyEditorTheme(readStored(EDITOR_THEME_KEY)); - renderProblem(); - applyLanguages(null); - setLanguage( - requestedLanguage && languages.includes(requestedLanguage) - ? requestedLanguage - : "python", - ); + if (problem) { + renderProblem(); + initializeLanguages(); + } else { + nodes.title.textContent = "Strict Interview"; + nodes.meta.textContent = "Assigned after media preflight"; + nodes.editor.disabled = true; + nodes.run.disabled = true; + nodes.end.disabled = true; + nodes.pause.disabled = true; + } bindEvents(); if (whiteboard) initWhiteboard(); else { @@ -856,20 +873,6 @@ async function init() { compactJimStageBesideNarrowPanel( whiteboard ? nodes.boardPanel : nodes.editorPanel, ); - // After bindEvents, so the callback cannot beat the row it edits: everything - // above here is synchronous, and a `then` runs no earlier than the next - // microtask. - void judgePromise.then((spec) => { - applyLanguages(spec); - if ( - state.phase === "live" && - !languageChoiceCommitted && - requestedLanguage && - state.language !== requestedLanguage && - languages.includes(requestedLanguage) - ) - setLanguage(requestedLanguage); - }); // No enumeration here. Before the preflight grant the browser reports only a // blank placeholder, so the list is discarded and repainted a few lines down; // the `toggle` handler covers a panel opened while the preflight is still up. @@ -902,7 +905,12 @@ async function init() { // is clear by the time the candidate is actually working rather than holding // a caption nobody said. showCaptions(); - await connect(preflight, presenting); + const started = await connect(preflight, presenting); + if (started === false) return; + if (strict) { + nodes.end.disabled = false; + nodes.pause.disabled = false; + } publishCode(); state.endsAt = Date.now() + durationMin * 60 * 1000; updateBeforeUnloadGuard(); @@ -1086,7 +1094,7 @@ function announceRestore(message, stillTrue) { /// not with `window.confirm`, which blocks the page's event loop while it is /// open and stalls the captions, the timer and the data channel with it. function restoreStarter() { - const starter = problem.starterCode[state.language]; + const starter = problem?.starterCode[state.language]; if (codingClosed() || nodes.editor.disabled || starter === undefined) { disarmRestore(); return; @@ -1206,7 +1214,7 @@ function bindEvents() { }); removeCandidateCase(Number(button.dataset.removeCase)); }); - void initializeCandidateCases(); + if (problem) void initializeCandidateCases(); for (const select of [nodes.meetOutputSelect, nodes.preflightOutput]) { select.addEventListener("change", () => { void applyAudioOutput(select.value); @@ -1471,8 +1479,7 @@ async function signInRequired() { if ( !session || session.signedIn || - !session.loginRequired || - session.guestModeAvailable + (!strict && (!session.loginRequired || session.guestModeAvailable)) ) return false; nodes.audioCheck.hidden = true; @@ -1725,7 +1732,7 @@ function runAudioCheck() { }; const finish = () => { - if (!sampleReadiness().ready) return; + if (finished || !sampleReadiness().ready) return; stopChecks(); nodes.audioCheck.hidden = true; resolve({ userStream: pool.stream, cameraSkipped, cameraSkipReason }); @@ -1931,6 +1938,7 @@ async function connect(preflight, presenting = false) { // write consent down, and it must not be reachable without one. if (!consentGiven()) throw new Error("Agree to the recording notice before starting."); + const previous = strict ? previousAssignment(tabStorage) : null; const interviewId = await recordConsent(); state.interviewId = interviewId; // `hideExamples` goes only when ticked, so Jim is not told the examples are @@ -1941,7 +1949,15 @@ async function connect(preflight, presenting = false) { headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(TOKEN_REQUEST_TIMEOUT_MS), body: JSON.stringify({ - problemId: problem.page, + ...(strict + ? { + selectionMode: "strict", + ...(params.has("difficulty") + ? { difficulties: params.getAll("difficulty") } + : {}), + ...(previous ? { previousAssignment: previous } : {}), + } + : { problemId: problem.page }), durationMin, interviewId, interviewLoop, @@ -1959,18 +1975,43 @@ async function connect(preflight, presenting = false) { ...(!editorOptions.execution ? { codeExecution: false } : {}), }), }); - if (!response.ok) - throw new Error( - (await response.json()).error || "Failed to create a session.", + if (!response.ok) { + const failure = await response.json().catch((error) => { + throw Object.assign(error, { status: response.status }); + }); + throw Object.assign( + new Error(failure?.error || "Failed to create a session."), + { code: failure?.code, status: response.status }, ); + } const connection = await response.json(); state.isGuest = connection.guest === true; + if (strict) { + // Save before revealing the problem or joining. A later load or join + // failure must still exclude this assignment on the next attempt. + rememberAssignment(tabStorage, connection.previousAssignment); + if (typeof connection.problemPage !== "string" || !connection.problemPage) + throw new Error("The server returned no Strict problem."); + const assigned = await loadProblem(connection.problemPage); + if (assigned.page !== connection.problemPage || assigned.requestedPage) + throw new Error( + "The assigned problem could not be loaded. Reload to try again.", + ); + problem = assigned; + judgePromise = loadJudge(problem.page).catch(() => null); + state.codeByLanguage = { ...problem.starterCode }; + renderProblem(); + initializeLanguages(); + void initializeCandidateCases(); + } // Before the room, and before anything that reads the length: the replay's // opening lifecycle event carries the round split, and a split computed // from the requested length would be the first thing written down. applyGrantedDuration(connection.durationMin); await connectLiveKit(connection, preflight, presenting); state.connected = true; + if (strict) updateRunAvailability(); + if (strict && !whiteboard) nodes.editor.disabled = false; // After the room, not before it. The server refuses to record an interview // that has not claimed a room, so starting here rather than alongside the // consent request is the difference between a recording and a failure @@ -2008,7 +2049,7 @@ async function connect(preflight, presenting = false) { } } catch (error) { const failure = - error?.name === "TimeoutError" + !strict && error?.name === "TimeoutError" ? new Error( "The interview connection timed out. Offline practice is ready.", ) @@ -2028,7 +2069,55 @@ async function connect(preflight, presenting = false) { state.room = null; if (joined) void joined.disconnect?.()?.catch?.(() => {}); stopPreflight(preflight); - const degraded = providerUiState("degraded", failure?.message); + const reason = + strict && + (error?.name === "TimeoutError" || [408, 504].includes(error?.status)) + ? "Strict Interview connection timed out. Return to the lobby or retry media preflight." + : failure?.message; + if (strict) { + const message = reason || "Could not start Strict Interview."; + setAgentStateLabel("Not connected"); + setBanner("connection", message); + // Selection refusals need a new choice, not another consent and token + // request with the same inputs. Keep the receipt unless the candidate + // explicitly starts a new sequence after the server rejects it. + nodes.editor.disabled = true; + nodes.run.disabled = true; + nodes.audioStatus.textContent = message; + nodes.audioCheck.hidden = false; + for (const control of nodes.audioCheck.querySelectorAll( + "button, input, select", + )) + control.disabled = control !== nodes.audioLeave; + const restart = error?.code === "strict_assignment_invalid"; + nodes.audioJoin.hidden = [ + "strict_no_alternative", + "strict_difficulty_invalid", + ].includes(error?.code); + nodes.audioJoin.disabled = nodes.audioJoin.hidden; + nodes.audioJoin.textContent = restart + ? "Start a new selection sequence" + : "Retry media preflight"; + if (!nodes.audioJoin.hidden) + nodes.audioJoin.addEventListener("click", () => { + if (nodes.audioJoin.disabled) return; + nodes.audioJoin.disabled = true; + if (restart) { + try { + resetAssignments(tabStorage); + if (previousAssignment(tabStorage) !== null) throw new Error(); + } catch { + nodes.audioStatus.textContent = + "Could not clear the previous assignment. Allow site storage or return to the lobby."; + nodes.audioJoin.disabled = false; + return; + } + } + window.location.reload(); + }); + return false; + } + const degraded = providerUiState("degraded", reason); setAgentStateLabel(degraded.label); // A degraded start without a reason reads as the product working. The server // says why it refused, in words written for a candidate, and a busy server @@ -2125,10 +2214,12 @@ async function connectLiveKit(connection, preflight, presenting = false) { // on its own, so the candidate is told to wait rather than to restart. room.on(livekit.RoomEvent.Reconnecting, () => { state.connected = false; + if (strict) updateRunAvailability(); setBanner("connection", providerUiState("reconnecting").message); }); room.on(livekit.RoomEvent.Reconnected, () => { state.connected = true; + if (strict) updateRunAvailability(); setBanner("connection", ""); // Everything held during the gap, in order, then the current buffer on top // so Jim is reading what the candidate is actually looking at rather than @@ -2155,6 +2246,7 @@ async function connectLiveKit(connection, preflight, presenting = false) { room.on(livekit.RoomEvent.Disconnected, () => { audioPlayback.release(room); state.connected = false; + if (strict) updateRunAvailability(); // Terminal, not transient: LiveKit fires Reconnecting for a recoverable // blip and only reaches here when it has given up. Dropping the reference // is what makes the banner's own advice possible: `endInterview` hides the @@ -2164,9 +2256,13 @@ async function connectLiveKit(connection, preflight, presenting = false) { // waiting on an overlay for a report that could not arrive. if (state.phase === "report_recovery") reportRecovery.finish(); state.room = null; + if (strict) setAgentStateLabel("Not connected"); setBanner( "connection", - providerUiState("degraded", "The interview connection dropped.").message, + strict + ? "The Strict Interview connection dropped." + : providerUiState("degraded", "The interview connection dropped.") + .message, ); console.warn("codetrial room_disconnected"); }); @@ -2341,6 +2437,7 @@ const reportRecovery = createReportRecovery({ }, offer: (ready, cooldown, cause) => { state.phase = "report_recovery"; + if (strict) updateRunAvailability(); if (!ready) stopEndingEscape(); globalThis.clearTimeout(frameworkHintTimer); nodes.frameworkHint.hidden = true; @@ -2700,6 +2797,7 @@ async function toggleMicrophone() { } function renderProblem() { + if (!problem) return; // Difficulty only. The topic tags name the technique, and the heading is the // scenario's own title rather than the published problem it was written from. nodes.title.textContent = problem.title; @@ -2723,6 +2821,29 @@ function selectTab(tab) { nodes.transcriptTab.classList.toggle("selected", transcript); } +function initializeLanguages() { + applyLanguages(null); + setLanguage( + requestedLanguage && languages.includes(requestedLanguage) + ? requestedLanguage + : "python", + ); + // Setup and event binding finish synchronously before this callback runs. + // A late judge may enable the requested language, but cannot replace a + // language or buffer the candidate has already committed to. + void judgePromise.then((spec) => { + applyLanguages(spec); + if ( + state.phase === "live" && + !languageChoiceCommitted && + requestedLanguage && + state.language !== requestedLanguage && + languages.includes(requestedLanguage) + ) + setLanguage(requestedLanguage); + }); +} + /// Unavailable labels and the active selector's title come from `harnessGap`, /// so a language is never withheld for a reason the candidate cannot find. An /// already-active buffer stays visible: hiding it would replace the candidate's @@ -3233,6 +3354,10 @@ function setResultsOpen(open) { } async function runTests() { + if (strict) { + updateRunAvailability(); + if (nodes.run.disabled) return; + } if (!editorOptions.execution) return; languageChoiceCommitted = true; flushPendingCodePublish(); @@ -3325,12 +3450,12 @@ async function initializeCandidateCases() { const wasActive = Boolean(readStored(activeSessionKey, tabStorage)); writeStored(activeSessionKey, "1", tabStorage); - if (!isReload && wasActive) { + if (!strict && !isReload && wasActive) { writeStored(candidateCaseStorageKey, "", tabStorage); } try { const saved = - !isReload && wasActive + strict || (!isReload && wasActive) ? [] : JSON.parse(readStored(candidateCaseStorageKey, tabStorage) || "[]"); state.candidateCases = Array.isArray(saved) @@ -3398,7 +3523,7 @@ async function addCandidateCaseNow() { writeStored( candidateCaseStorageKey, JSON.stringify(state.candidateCases), - tabStorage, + strict ? null : tabStorage, ); nodes.candidateCaseInput.value = ""; nodes.candidateCaseExpected.value = ""; @@ -3457,7 +3582,7 @@ function removeCandidateCase(index) { writeStored( candidateCaseStorageKey, JSON.stringify(state.candidateCases), - tabStorage, + strict ? null : tabStorage, ); renderCandidateCases(); // renderCandidateCases leaves the status alone on an empty list, so the @@ -3489,6 +3614,7 @@ function updateRunAvailability() { if (restoreOff) disarmRestore(); nodes.run.disabled = !editorOptions.execution || + (strict && !state.connected) || state.runningTests || state.paused || codingClosed() || @@ -3609,6 +3735,7 @@ function endInterview(reason) { if (state.phase !== "live" || state.reportReceiving) return; const exampleFinished = exampleBoard?.finish(); state.phase = "ending"; + if (strict) updateRunAvailability(); // The hint outranks the ending overlay in the stacking order, so a candidate // who ends while it is still up would read the report status through it. globalThis.clearTimeout(frameworkHintTimer); @@ -3848,6 +3975,7 @@ function reportRenderFailed(error, { offlineSummary }) { } function renderReport() { + if (strict) updateRunAvailability(); // The interview is over. Both report paths, agent-sent and offline, land // here, so this is the one place that has to stop the render loop and the // one place that has to release the devices. Only the agent-sent path used diff --git a/web/problem-grid.js b/web/problem-grid.js index 0f1ad01f9..7be29f833 100644 --- a/web/problem-grid.js +++ b/web/problem-grid.js @@ -22,7 +22,7 @@ export function mountProblemGrid() { // Measure the live layout, including changes to any setup panel's height. function drawOutline() { - const connected = columns.matches && picker.open; + const connected = columns.matches && picker.open && !picker.hidden; lobby.classList.toggle("problem-picker-connected", connected); outline.toggleAttribute("hidden", !connected); if (!connected) return; @@ -83,14 +83,16 @@ export function mountProblemGrid() { const hadFocus = grid.contains(focused); if (columns.matches) { if (grid.previousElementSibling !== panel) panel.after(grid); - if (picker.open) grid.removeAttribute("hidden"); + // Detached desktop cards must inherit the picker's hidden state. + if (picker.hidden) grid.hidden = true; + else if (picker.open) grid.removeAttribute("hidden"); else grid.setAttribute("hidden", "until-found"); } else { if (grid.parentElement !== picker) home.after(grid); - grid.hidden = false; + grid.hidden = picker.hidden; } drawOutline(); - if (hadFocus) { + if (hadFocus && !picker.hidden) { if (!picker.open) summary.focus({ preventScroll: true }); else focused.focus({ preventScroll: true }); } @@ -101,7 +103,7 @@ export function mountProblemGrid() { const stateObserver = new MutationObserver(update); stateObserver.observe(picker, { attributes: true, - attributeFilter: ["open"], + attributeFilter: ["open", "hidden"], }); columns.addEventListener("change", update); update(); diff --git a/web/selection.js b/web/selection.js new file mode 100644 index 000000000..32c57e0dd --- /dev/null +++ b/web/selection.js @@ -0,0 +1,38 @@ +// One previous assignment per tab. This is selection metadata, never recovery +// state, and the server checks its signature and owner before using it. +export const STRICT_ASSIGNMENT_KEY = "codetrial.strictAssignment"; + +const storageError = + "Strict Interview needs session storage to remember its previous assignment. Allow site storage and reload, or return to the lobby."; + +export function previousAssignment(storage) { + try { + if (!storage) throw new Error(); + const previous = storage.getItem(STRICT_ASSIGNMENT_KEY); + // Test writes before asking for consent or allocating a room. Restore the + // original absence rather than leaving a probe looking like a receipt. + storage.setItem(STRICT_ASSIGNMENT_KEY, previous ?? ""); + if (storage.getItem(STRICT_ASSIGNMENT_KEY) !== (previous ?? "")) + throw new Error(); + if (previous === null) storage.removeItem(STRICT_ASSIGNMENT_KEY); + return previous; + } catch { + throw new Error(storageError); + } +} + +export function rememberAssignment(storage, receipt) { + if (typeof receipt !== "string" || !receipt || receipt.length > 512) + throw new Error("The server returned an invalid Strict assignment."); + try { + storage.setItem(STRICT_ASSIGNMENT_KEY, receipt); + if (storage.getItem(STRICT_ASSIGNMENT_KEY) !== receipt) throw new Error(); + } catch { + throw new Error(storageError); + } +} + +export function resetAssignments(storage) { + previousAssignment(storage); + storage.removeItem(STRICT_ASSIGNMENT_KEY); +} diff --git a/web/styles.css b/web/styles.css index 27d955e8e..74652adc9 100644 --- a/web/styles.css +++ b/web/styles.css @@ -3329,6 +3329,7 @@ body:not([data-recording]) .replay-detail > :not(header) { } .lobby .account-input, +.lobby #selection-mode, .lobby .practice-filters select, .lobby .progress-filters select, .lobby .interview-context input:not([type="checkbox"]),