diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 98de42d03..26d3c5a8e 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -4036,6 +4036,354 @@ for (const storage of ["account", "device"]) { ); } +/// Names every report the page asks the browser to save, at the moment it +/// asks, so a test can say exactly which files one click produced rather than +/// waiting for download events that may still be on their way. +const recordDownloadNames = (page) => + page.addInitScript(() => { + window.downloadNames = []; + const click = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function () { + if (this.download) window.downloadNames.push(this.download); + return click.call(this); + }; + }); +const takeDownloadNames = (page) => + page.evaluate(() => window.downloadNames.splice(0).sort()); + +for (const storage of ["account", "device"]) { + lobbyTest( + `saved ${storage} reports download the ticked ones as their own markdown`, + async (page) => { + const reportsSaved = [ + { decision: "HIRE", codingScore: 81 }, + { decision: "NO_HIRE", codingScore: 42 }, + { incomplete: true, decision: "HIRE" }, + ]; + const entries = reportsSaved.map((report, index) => ({ + problemId: EASY[0], + problemTitle: "Saved report", + date: `2026-01-01T${17 + index}:30:00Z`, + language: "python", + report: { ...report, summary: `Stored summary ${index}` }, + })); + if (storage === "account") { + reports = entries.map((payload) => ({ payload })); + } else { + session = { signedIn: false }; + await page.addInitScript((entries) => { + localStorage.setItem("codetrial_history", JSON.stringify(entries)); + }, entries); + } + await recordDownloadNames(page); + await lobby(page); + const history = page.locator("#attempt-history"); + const boxes = history.getByRole("checkbox"); + const toggle = page.locator("#attempt-select-all"); + const downloadSelected = page.locator("#attempt-download-selected"); + const note = page.locator("#attempt-download-note"); + const when = (index) => `Jan 2, 2026, ${index + 1}:30 AM`; + const box = (index) => + history.getByRole("checkbox", { name: `${when(index)} · ` }); + const filename = (index) => + `interview-report-${EASY[0]}-2026-01-02-0${index + 1}3000.md`; + const text = async (download) => { + const chunks = []; + for await (const chunk of await download.createReadStream()) + chunks.push(chunk); + return Buffer.concat(chunks).toString("utf8"); + }; + + assert.equal(await boxes.count(), reportsSaved.length); + for (const box of await boxes.all()) assert.ok(await box.isChecked()); + assert.equal(await toggle.textContent(), "Deselect all"); + assert.equal( + await downloadSelected.textContent(), + "Download selected (3)", + ); + + // The description is the checkbox's label, so clicking the text toggles. + await history.locator("label", { hasText: when(1) }).click(); + assert.equal(await box(1).isChecked(), false); + assert.equal(await toggle.textContent(), "Select all"); + assert.equal( + await downloadSelected.textContent(), + "Download selected (2)", + ); + + const batch = new Map(); + let onDownload; + let timer; + const arrived = new Promise((resolve, reject) => { + onDownload = (download) => { + batch.set(download.suggestedFilename(), download); + if (batch.size === 2) resolve(); + }; + // node:test has no default timeout, so a download that never starts + // fails here with what did arrive instead of hanging the suite. + timer = setTimeout(() => { + const names = [...batch.keys()].join(", ") || "none"; + reject(new Error(`only these downloads arrived: ${names}`)); + }, 10_000); + }); + page.on("download", onDownload); + try { + await downloadSelected.click(); + assert.deepEqual(await takeDownloadNames(page), [ + filename(0), + filename(2), + ]); + assert.equal( + await note.textContent(), + "Your browser may ask to allow multiple downloads from this page. If only the first file arrived, allow automatic downloads for this site in the browser's site settings and try again.", + ); + await arrived; + } finally { + clearTimeout(timer); + page.off("download", onDownload); + } + + // The batch reuses the row's own download, so each file it writes is the + // one that row's button writes, an incomplete report included. + for (const index of [0, 2]) { + const downloading = page.waitForEvent("download"); + await history + .getByRole("button", { + name: `Download report (.md) for Saved report, ${when(index)}`, + exact: true, + }) + .click(); + const single = await downloading; + assert.equal(single.suggestedFilename(), filename(index)); + const markdown = await text(single); + assert.ok(markdown.includes(`Stored summary ${index}`)); + assert.equal(await text(batch.get(filename(index))), markdown); + } + await takeDownloadNames(page); + + await toggle.click(); + for (const box of await boxes.all()) assert.ok(await box.isChecked()); + assert.equal(await toggle.textContent(), "Deselect all"); + assert.equal( + await downloadSelected.textContent(), + "Download selected (3)", + ); + + await toggle.click(); + for (const box of await boxes.all()) + assert.equal(await box.isChecked(), false); + assert.equal(await toggle.textContent(), "Select all"); + assert.equal( + await downloadSelected.textContent(), + "Download selected (0)", + ); + assert.equal(await downloadSelected.isDisabled(), true); + }, + { timezoneId: "Asia/Taipei", locale: "en-US", acceptDownloads: true }, + ); +} + +lobbyTest( + "a filtered attempt list keeps its ticks and downloads only what it shows", + async (page) => { + const attempt = (id, language, day) => ({ + id, + problemId: EASY[0], + payload: { + id, + problemId: EASY[0], + problemTitle: `Attempt ${id}`, + language, + date: `2026-01-0${day}T00:00:00Z`, + report: { decision: "HIRE" }, + }, + }); + reports = [ + attempt("a", "python", 1), + attempt("b", "cpp", 2), + attempt("c", "python", 3), + ]; + await recordDownloadNames(page); + await lobby(page); + const box = (id) => + page.getByRole("checkbox", { name: ` · Attempt ${id} · ` }); + const toggle = page.locator("#attempt-select-all"); + const downloadSelected = page.locator("#attempt-download-selected"); + const note = page.locator("#attempt-download-note"); + const filename = (day) => + `interview-report-${EASY[0]}-2026-01-0${day}-080000.md`; + + await box("a").uncheck(); + await page.selectOption("#progress-language", "python"); + assert.equal(await box("a").isChecked(), false); + assert.equal(await box("c").isChecked(), true); + assert.equal(await toggle.textContent(), "Select all"); + assert.equal(await downloadSelected.textContent(), "Download selected (1)"); + await downloadSelected.click(); + assert.deepEqual(await takeDownloadNames(page), [filename(3)]); + // One file is one download, which no browser asks about. + assert.equal(await note.textContent(), ""); + + await page.selectOption("#progress-language", "cpp"); + assert.equal(await box("b").isChecked(), true); + assert.equal(await toggle.textContent(), "Deselect all"); + await downloadSelected.click(); + assert.deepEqual(await takeDownloadNames(page), [filename(2)]); + await toggle.click(); + assert.equal(await box("b").isChecked(), false); + assert.equal(await downloadSelected.isDisabled(), true); + + await page.selectOption("#progress-language", "all"); + assert.equal(await box("a").isChecked(), false); + assert.equal(await box("b").isChecked(), false); + assert.equal(await box("c").isChecked(), true); + assert.equal(await toggle.textContent(), "Select all"); + assert.equal(await downloadSelected.textContent(), "Download selected (1)"); + // Some ticked is not all ticked, so the toggle ticks the rest. + await toggle.click(); + for (const id of ["a", "b", "c"]) + assert.equal(await box(id).isChecked(), true); + assert.equal(await downloadSelected.textContent(), "Download selected (3)"); + }, + { timezoneId: "Asia/Taipei", locale: "en-US" }, +); + +lobbyTest( + "more than five shown and ticked reports ask before downloading", + async (page) => { + const attempt = (id, language, day) => ({ + id, + problemId: EASY[0], + payload: { + id, + problemId: EASY[0], + problemTitle: `Attempt ${id}`, + language, + date: `2026-01-0${day}T00:00:00Z`, + report: { decision: "HIRE" }, + }, + }); + reports = [ + attempt("a", "python", 1), + attempt("b", "python", 2), + attempt("c", "python", 3), + attempt("d", "python", 4), + attempt("e", "python", 5), + attempt("f", "python", 6), + attempt("g", "cpp", 7), + ]; + await recordDownloadNames(page); + const asked = []; + let accept = false; + page.on("dialog", (dialog) => { + asked.push(dialog.message()); + return accept ? dialog.accept() : dialog.dismiss(); + }); + await lobby(page); + const box = (id) => + page.getByRole("checkbox", { name: ` · Attempt ${id} · ` }); + const downloadSelected = page.locator("#attempt-download-selected"); + const filename = (day) => + `interview-report-${EASY[0]}-2026-01-0${day}-080000.md`; + + // Six ticked in all, but the hidden C++ one is not part of this click. + await page.selectOption("#progress-language", "python"); + await box("a").uncheck(); + await downloadSelected.click(); + assert.deepEqual(asked, []); + assert.deepEqual( + await takeDownloadNames(page), + [2, 3, 4, 5, 6].map(filename), + ); + + await box("a").check(); + await downloadSelected.click(); + assert.deepEqual(asked, [ + "Download 6 reports? Each one is saved as its own Markdown file.", + ]); + assert.deepEqual(await takeDownloadNames(page), []); + + accept = true; + await downloadSelected.click(); + assert.equal(asked.length, 2); + assert.deepEqual( + await takeDownloadNames(page), + [1, 2, 3, 4, 5, 6].map(filename), + ); + }, + { timezoneId: "Asia/Taipei", locale: "en-US" }, +); + +lobbyTest("deleting one report keeps another unticked", async (page) => { + const attempt = (id, day) => ({ + id, + problemId: EASY[0], + payload: { + id, + problemId: EASY[0], + problemTitle: `Attempt ${id}`, + language: "python", + date: `2026-01-0${day}T00:00:00Z`, + report: { decision: "HIRE" }, + }, + }); + reports = [attempt("a", 1), attempt("b", 2), attempt("c", 3)]; + await lobby(page); + const box = (id) => + page.getByRole("checkbox", { name: ` · Attempt ${id} · ` }); + await box("a").uncheck(); + + // The delete reloads the history, which builds every row afresh. + const deleteB = page.getByRole("button", { + name: /^Delete the .* report for Attempt b$/, + }); + page.once("dialog", (dialog) => dialog.accept()); + await deleteB.click(); + await reaches( + page, + () => document.querySelector("#report-delete-status").textContent !== "", + ); + assert.deepEqual(deletedIds, ["b"]); + assert.equal(await box("b").count(), 0); + assert.equal(await box("a").isChecked(), false); + assert.equal(await box("c").isChecked(), true); + assert.equal( + await page.locator("#attempt-download-selected").textContent(), + "Download selected (1)", + ); +}); + +lobbyTest( + "signing in does not carry a device untick onto the account copy", + async (page) => { + const entry = { + id: "shared", + problemId: EASY[0], + problemTitle: "Shared attempt", + language: "python", + date: "2026-01-01T00:00:00Z", + report: { decision: "HIRE" }, + }; + session = { signedIn: false, loginRequired: true }; + reports = [{ id: entry.id, problemId: EASY[0], payload: entry }]; + await page.addInitScript((entry) => { + localStorage.setItem("codetrial_history", JSON.stringify([entry])); + }, entry); + await lobby(page); + const summary = page.locator("#progress-summary"); + const box = page.getByRole("checkbox", { name: " · Shared attempt · " }); + assert.match(await summary.textContent(), /saved on this device/); + await box.uncheck(); + + await page.fill("#github-login", "candidate"); + await page.press("#github-login", "Enter"); + await summary.filter({ hasText: "saved to your account" }).waitFor(); + // The account copy shares the device row's id, so only the change of + // source keeps the untick made on the device off it. + assert.equal(await box.isChecked(), true); + }, +); + lobbyTest( "try again selects a problem without escaping an active title search", async (page) => { diff --git a/web/app.js b/web/app.js index 6f42e1b7b..2860f3775 100644 --- a/web/app.js +++ b/web/app.js @@ -135,6 +135,10 @@ const nodes = { ), progressSummary: document.querySelector("#progress-summary"), attemptHistory: document.querySelector("#attempt-history"), + attemptSelection: document.querySelector("#attempt-selection"), + attemptSelectAll: document.querySelector("#attempt-select-all"), + attemptDownloadSelected: document.querySelector("#attempt-download-selected"), + attemptDownloadNote: document.querySelector("#attempt-download-note"), progressTrends: document.querySelector("#progress-trends"), progressWeaknesses: document.querySelector("#progress-weaknesses"), progressTopics: document.querySelector("#progress-topics"), @@ -500,6 +504,12 @@ nodes.groundingClear.addEventListener("click", clearGrounding); let progressNormalized = []; let progressSuffix = "saved"; +/// Attempts unticked for "Download selected", by `selectionKey`. Emptied only +/// when the history changes source, so a choice survives a filter change and +/// the reload that deleting another report triggers. +const unselectedAttempts = new Set(); +/// What the attempt list draws now: the only reports a batch download takes. +let shownAttempts = []; const selectedProgressPhases = new Map(); /// Whether the history on screen came from an account: null until /api/session /// answers, and never assumed false, because a browser that cannot ask is not a @@ -514,6 +524,40 @@ for (const filter of [ filter.addEventListener("change", renderProgress); } nodes.deleteReports.addEventListener("click", deleteSavedReports); +nodes.attemptSelectAll.addEventListener("click", () => { + const select = !allAttemptsSelected(); + for (const attempt of shownAttempts) { + if (select) unselectedAttempts.delete(selectionKey(attempt)); + else unselectedAttempts.add(selectionKey(attempt)); + } + for (const box of nodes.attemptHistory.querySelectorAll( + "[data-attempt-select]", + )) + box.checked = select; + syncAttemptSelection(); +}); +// Every row starts ticked and device history keeps hundreds of reports, so one +// click with no filter could drop that many files into Downloads. Past a +// handful, the count is said and confirmed before anything is saved. +const MAX_UNCONFIRMED_DOWNLOADS = 5; +nodes.attemptDownloadSelected.addEventListener("click", () => { + const selected = selectedAttempts(); + if ( + selected.length > MAX_UNCONFIRMED_DOWNLOADS && + !window.confirm( + `Download ${selected.length} reports? Each one is saved as its own Markdown file.`, + ) + ) + return; + for (const attempt of selected) downloadSavedReport(attempt); + // Chrome saves the first file of one click and asks before the rest, or + // drops them silently once the site is blocked. Neither reaches the page, so + // the note says how to recover rather than what happened. + nodes.attemptDownloadNote.textContent = + selected.length > 1 + ? "Your browser may ask to allow multiple downloads from this page. If only the first file arrived, allow automatic downloads for this site in the browser's site settings and try again." + : ""; +}); const durations = [...document.querySelectorAll("[data-duration]")]; for (const button of durations) { @@ -1589,6 +1633,7 @@ function showProgressError(message) { // history it had last managed to load. reports = []; progressNormalized = []; + shownAttempts = []; renderPracticeFocus(); hideRecentPerformance(); nodes.historyHeader.hidden = false; @@ -1598,6 +1643,7 @@ function showProgressError(message) { nodes.progressWeaknesses.replaceChildren(); nodes.progressTopics.replaceChildren(); nodes.attemptHistory.replaceChildren(); + syncAttemptSelection(); } function renderProblemAttempts(attempts) { @@ -1618,6 +1664,9 @@ function showProgress(entries, suffix) { // these rows, so a dropdown change has nothing to re-sanitize. progressNormalized = normalizeProgressEntries(entries); renderProblemAttempts(progressNormalized); + // `suffix` names the store these rows came from. A device row and its account + // copy share an id, so a choice made in one is not carried into the other. + if (suffix !== progressSuffix) unselectedAttempts.clear(); progressSuffix = suffix; const erasable = entries.length > 0 || readDeviceHistory().length > 0; nodes.historyHeader.hidden = !erasable; @@ -1754,6 +1803,7 @@ function renderRecentPerformance() { /// and it cost one more sanitizing pass over every stored report. function renderAttemptHistory(attempts) { nodes.attemptHistory.replaceChildren(); + shownAttempts = attempts; for (const attempt of [...attempts].reverse()) { const item = document.createElement("li"); const when = new Date(attempt.at).toLocaleString(undefined, { @@ -1763,8 +1813,23 @@ function renderAttemptHistory(attempts) { const verdict = attempt.report.incomplete ? "INCOMPLETE" : (attempt.report.decision ?? "UNSCORED"); - const label = document.createElement("p"); - label.textContent = `${when} · ${attempt.problemTitle} · ${attempt.difficulty ?? "Difficulty not recorded"} · ${languageLabel(attempt.language ?? "not recorded")} · ${verdict}`; + const select = document.createElement("input"); + select.type = "checkbox"; + select.dataset.attemptSelect = ""; + const key = selectionKey(attempt); + select.checked = !unselectedAttempts.has(key); + select.addEventListener("change", () => { + if (select.checked) unselectedAttempts.delete(key); + else unselectedAttempts.add(key); + syncAttemptSelection(); + }); + const label = document.createElement("label"); + label.append( + select, + `${when} · ${attempt.problemTitle} · ${attempt.difficulty ?? "Difficulty not recorded"} · ${languageLabel(attempt.language ?? "not recorded")} · ${verdict}`, + ); + const line = document.createElement("p"); + line.append(label); const open = document.createElement("button"); open.type = "button"; open.textContent = "Open report"; @@ -1831,7 +1896,7 @@ function renderAttemptHistory(attempts) { : ""; nodes.recommendation.textContent = `Selected problem: ${title(card)}.${adjustment}`; }); - item.append(label, open, download, retry); + item.append(line, open, download, retry); // A row with no id has nothing both stores and the account agree on, and // deleting by anything weaker could take a different report than this one. if (attempt.id) { @@ -1852,6 +1917,37 @@ function renderAttemptHistory(attempts) { item.append(report); nodes.attemptHistory.append(item); } + syncAttemptSelection(); +} + +/// The id where there is one, which a reload keeps. A row with none has nothing +/// stable to name it, so it stands for itself and comes back ticked on reload. +function selectionKey(attempt) { + return attempt.id || attempt; +} + +function selectedAttempts() { + return shownAttempts.filter( + (attempt) => !unselectedAttempts.has(selectionKey(attempt)), + ); +} + +function allAttemptsSelected() { + return selectedAttempts().length === shownAttempts.length; +} + +function syncAttemptSelection() { + nodes.attemptDownloadNote.textContent = ""; + nodes.attemptSelection.hidden = shownAttempts.length === 0; + // With nothing listed, a count here would be the lobby speaking before any + // history arrived; the labels are written when there is something to count. + if (nodes.attemptSelection.hidden) return; + const selected = selectedAttempts().length; + nodes.attemptSelectAll.textContent = allAttemptsSelected() + ? "Deselect all" + : "Select all"; + nodes.attemptDownloadSelected.textContent = `Download selected (${selected})`; + nodes.attemptDownloadSelected.disabled = selected === 0; } function downloadSavedReport(attempt) { diff --git a/web/index.html b/web/index.html index 3cb718e39..bf1f804d5 100644 --- a/web/index.html +++ b/web/index.html @@ -2008,6 +2008,13 @@