diff --git a/docs/development.md b/docs/development.md index a8153032..73cca64b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -381,9 +381,15 @@ its published title once, as `source`, which the interview shows in small print beside the scenario so the problem can be found again afterwards. The published id is in one file, `web/problem-pages.json`, which the browser fetches only to open a link that still carries a published id, to read history saved before -pages had names, or when the candidate asks the lobby to show the titles on the -cards. The title reaches the live interview page only: saved history, the -downloaded report and the replay keep the scenario's. +pages had names, when the candidate asks the lobby to show the titles on the +cards, or to show or export a completed report. Completed reports, including +those opened from Past attempts or a replay, and downloaded Markdown keep the +scenario title and add an optional Original problem link with the published +title and LeetCode URL. Collapsed attempts do not request the map for this +link; opening or exporting their report does. Successful lookups are cached +for the page's lifetime. Missing or malformed mappings leave the link out, +and a failed lookup or a wait longer than ten seconds does not block a +Markdown download: it proceeds without the link. None of this is secrecy. The page map, each page's `source` and every judge are served to anyone who asks, and a candidate with developer tools can map a diff --git a/tests/browser/download.test.js b/tests/browser/download.test.js index 86565edb..89cad5a4 100644 --- a/tests/browser/download.test.js +++ b/tests/browser/download.test.js @@ -1,6 +1,119 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { downloadMarkdown, reportFilename } from "../../web/download.js"; +import { functionBody, read } from "./source.js"; + +for (const [path, name] of [ + ["web/app.js", "downloadSavedReport"], + ["web/interview.js", "downloadReport"], +]) { + function harness() { + const lookups = []; + const downloads = []; + const scope = { + pendingReportDownloads: new Set(), + selectionKey: (attempt) => attempt.id || attempt, + loadPageMapForDownload: () => + new Promise((resolve, reject) => { + lookups.push({ resolve, reject }); + }), + reportMarkdown: () => "# Report", + buildMarkdown: () => "# Report", + reportFilename, + problem: { page: "two-sum" }, + downloadMarkdown: (...args) => downloads.push(args), + }; + const download = new Function( + "scope", + `with (scope) { + ${functionBody(read(path), name)} + } + return ${name}; + }`, + )(scope); + const button = { disabled: false }; + const attempt = { report: {}, problemId: "two-sum", at: "2026-10-23" }; + const run = () => + name === "downloadSavedReport" + ? download(attempt, button) + : download(button); + return { run, button, lookups, downloads, scope, download, attempt }; + } + + test(`${name} exports once while pending and permits a later download`, async () => { + const h = harness(); + const first = h.run(); + assert.equal(h.button.disabled, true); + await h.run(); + await h.run(); + assert.equal(h.lookups.length, 1); + assert.equal(h.downloads.length, 0); + h.lookups[0].resolve({}); + await first; + assert.equal(h.downloads.length, 1); + assert.equal(h.button.disabled, false); + const next = h.run(); + assert.equal(h.button.disabled, true); + h.lookups[1].resolve(null); + await next; + assert.equal(h.downloads.length, 2); + assert.equal(h.button.disabled, false); + }); + + for (const failure of ["lookup", "export"]) { + test(`${name} restores the button after ${failure} fails`, async () => { + const h = harness(); + const error = new Error("download failed"); + const originalDownload = h.scope.downloadMarkdown; + if (failure === "export") + h.scope.downloadMarkdown = () => { + throw error; + }; + const pending = h.run(); + const rejected = assert.rejects(pending, error); + if (failure === "lookup") h.lookups[0].reject(error); + else h.lookups[0].resolve(null); + await rejected; + assert.equal(h.button.disabled, false); + h.scope.downloadMarkdown = originalDownload; + const retry = h.run(); + h.lookups[1].resolve(null); + await retry; + assert.equal(h.downloads.length, 1); + assert.equal(h.button.disabled, false); + }); + } + + if (name === "downloadSavedReport") { + test("batch and row downloads share the pending guard for one report", async () => { + const h = harness(); + h.attempt.id = "saved-report"; + const batch = h.download(h.attempt); + await h.run(); + await h.download({ ...h.attempt }); + assert.equal(h.lookups.length, 1); + h.lookups[0].resolve(null); + await batch; + const retry = h.run(); + assert.equal(h.button.disabled, true); + h.lookups[1].resolve(null); + await retry; + assert.equal(h.downloads.length, 2); + assert.equal(h.button.disabled, false); + }); + + test("different saved reports can export while another lookup is pending", async () => { + const h = harness(); + const first = h.download({ ...h.attempt, id: "first" }); + const second = h.download({ ...h.attempt, id: "second" }); + assert.equal(h.lookups.length, 2); + h.lookups[0].resolve(null); + h.lookups[1].resolve(null); + await Promise.all([first, second]); + assert.equal(h.downloads.length, 2); + }); + } +} test("report filenames use local timestamps and safe scenario names", () => { assert.equal( diff --git a/tests/browser/history.test.js b/tests/browser/history.test.js index 3f094d99..9c47b0b6 100644 --- a/tests/browser/history.test.js +++ b/tests/browser/history.test.js @@ -467,6 +467,7 @@ test("the response window panel says what the number is worth, in words a test c "", "/lib.js", "/render.js", + "/problem-data.js", "/api/reports", "button", "div", diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 26d3c5a8..f3ad2bd1 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -3791,6 +3791,36 @@ lobbyTest( }, ); +lobbyTest( + "opening a saved report fetches and shows its original problem", + async (page) => { + reports = [savedAttempt(EASY[0])]; + const fetched = []; + page.on("request", (request) => + fetched.push(new URL(request.url()).pathname), + ); + await lobby(page); + assert.ok(!fetched.includes("/problem-pages.json")); + + const history = page.locator("#attempt-history"); + assert.equal( + await history.locator(".report-original-problem").textContent(), + "", + ); + await history.getByRole("button", { name: "Open report" }).click(); + const link = history.getByRole("link", { name: "Two Sum" }); + await link.waitFor(); + assert.equal( + await link.getAttribute("href"), + "https://leetcode.com/problems/two-sum/", + ); + assert.equal( + fetched.filter((path) => path === "/problem-pages.json").length, + 1, + ); + }, +); + lobbyTest( "a problem already passed is not what gets recommended", async (page) => { @@ -4004,6 +4034,10 @@ for (const storage of ["account", "device"]) { assert.ok( markdown.startsWith(`# Interview Report - Saved report\n_${when}_`), ); + assert.match( + markdown, + /Original problem: \[Two Sum\]\(https:\/\/leetcode\.com\/problems\/two-sum\/\)/, + ); assert.ok(markdown.includes(`Stored summary ${index}`)); assert.ok( markdown.includes( @@ -4048,8 +4082,15 @@ const recordDownloadNames = (page) => return click.call(this); }; }); -const takeDownloadNames = (page) => - page.evaluate(() => window.downloadNames.splice(0).sort()); +const takeDownloadNames = async (page, count = 0) => { + // Report exports may still be waiting for the optional problem map. + if (count) + await page.waitForFunction( + (count) => window.downloadNames.length >= count, + count, + ); + return page.evaluate(() => window.downloadNames.splice(0).sort()); +}; for (const storage of ["account", "device"]) { lobbyTest( @@ -4129,7 +4170,7 @@ for (const storage of ["account", "device"]) { page.on("download", onDownload); try { await downloadSelected.click(); - assert.deepEqual(await takeDownloadNames(page), [ + assert.deepEqual(await takeDownloadNames(page, 2), [ filename(0), filename(2), ]); @@ -4220,7 +4261,7 @@ lobbyTest( 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)]); + assert.deepEqual(await takeDownloadNames(page, 1), [filename(3)]); // One file is one download, which no browser asks about. assert.equal(await note.textContent(), ""); @@ -4228,7 +4269,7 @@ lobbyTest( assert.equal(await box("b").isChecked(), true); assert.equal(await toggle.textContent(), "Deselect all"); await downloadSelected.click(); - assert.deepEqual(await takeDownloadNames(page), [filename(2)]); + assert.deepEqual(await takeDownloadNames(page, 1), [filename(2)]); await toggle.click(); assert.equal(await box("b").isChecked(), false); assert.equal(await downloadSelected.isDisabled(), true); @@ -4292,7 +4333,7 @@ lobbyTest( await downloadSelected.click(); assert.deepEqual(asked, []); assert.deepEqual( - await takeDownloadNames(page), + await takeDownloadNames(page, 5), [2, 3, 4, 5, 6].map(filename), ); @@ -4307,7 +4348,7 @@ lobbyTest( await downloadSelected.click(); assert.equal(asked.length, 2); assert.deepEqual( - await takeDownloadNames(page), + await takeDownloadNames(page, 6), [1, 2, 3, 4, 5, 6].map(filename), ); }, @@ -4478,6 +4519,56 @@ for (const storage of ["device", "account"]) { ); } +lobbyTest( + "a stalled problem map allows only one pending saved report download", + async (page) => { + reports = [savedAttempt(EASY[0])]; + await page.addInitScript(() => { + const fetch = window.fetch.bind(window); + window.mapRequested = false; + window.mapDeadline = null; + window.fetch = (path, options) => { + if (path !== "/problem-pages.json") return fetch(path, options); + window.mapRequested = true; + return new Promise(() => {}); + }; + const setTimeout = window.setTimeout.bind(window); + window.setTimeout = (callback, delay, ...args) => { + if (delay !== 10_000) return setTimeout(callback, delay, ...args); + window.mapDeadline = () => callback(...args); + return 0; + }; + }); + await lobby(page); + assert.equal(await page.evaluate(() => window.mapRequested), false); + + const downloading = page.waitForEvent("download"); + const button = page.getByRole("button", { name: /^Download report/ }); + await button.click(); + await page.waitForFunction(() => window.mapRequested && window.mapDeadline); + assert.equal(await button.isDisabled(), true); + await button.dispatchEvent("click"); + await button.dispatchEvent("click"); + await page.evaluate(() => window.mapDeadline()); + const download = await downloading; + assert.equal(await download.failure(), null); + const chunks = []; + for await (const chunk of await download.createReadStream()) + chunks.push(chunk); + const markdown = Buffer.concat(chunks).toString("utf8"); + assert.match(markdown, /^# Interview Report/); + assert.doesNotMatch(markdown, /Original problem:/); + assert.equal(await button.isEnabled(), true); + const retrying = page.waitForEvent("download"); + await button.click(); + assert.equal(await button.isDisabled(), true); + await page.evaluate(() => window.mapDeadline()); + assert.equal(await (await retrying).failure(), null); + assert.equal(await button.isEnabled(), true); + }, + { acceptDownloads: true }, +); + lobbyTest("try again selects the problem", async (page) => { reports = [savedAttempt(EASY[0])]; await lobby(page); diff --git a/tests/browser/render.test.js b/tests/browser/render.test.js index ba555d93..7f1f4e87 100644 --- a/tests/browser/render.test.js +++ b/tests/browser/render.test.js @@ -7,18 +7,21 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { createTranscriptView, feedbackMarkup, finalRunnerStatus, chainSentence, + originalProblemForPage, problemMarkup, reportMarkdown, reportMarkup, reportSaveStatus, resultsMarkup, runnerStatusMarkup, + showOriginalProblem, } from "../../web/render.js"; import { frameworkPhases, sanitizeReport } from "../../web/lib.js"; @@ -549,6 +552,110 @@ test("report markup refuses to score a session with nothing in it", () => { assert.match(body, /def two_sum/); }); +test("a completed report reserves the line directly below its title", () => { + const html = reportMarkup({ + report: { incomplete: true, summary: "No evaluation was available." }, + problemTitle: "Chargeback Pair Match", + language: "python", + code: "", + }); + assert.match( + html, + /

No evaluation<\/h2>
-

${report.mode ? `${modeLabel(report.mode)} ` : ""}interview report${loop}${surface} · ${escapeHtml(problemTitle)}

${escapeHtml(contract)}

${interviewerStyle(report) ? `

${escapeHtml(interviewerStyle(report))}

` : ""}${practiceLevel ? `

${practiceLevel}

` : ""}

${heading}

+

${report.mode ? `${modeLabel(report.mode)} ` : ""}interview report${loop}${surface} · ${escapeHtml(problemTitle)}

${escapeHtml(contract)}

${interviewerStyle(report) ? `

${escapeHtml(interviewerStyle(report))}

` : ""}${practiceLevel ? `

${practiceLevel}

` : ""}

${heading}

${badge}
${scores}

${report.incomplete ? "What happened" : "Committee summary"}

${escapeHtml(report.summary)}

@@ -498,6 +530,8 @@ function boardStepsMarkdown(report, mdText) { export function reportMarkdown({ report, problemTitle, + problemPage, + problemPages, language, code, board, @@ -508,6 +542,7 @@ export function reportMarkdown({ unclearIds = [], }) { const atBoard = heldAtBoard(report, board); + const originalProblem = originalProblemForPage(problemPages, problemPage); const workTitle = atBoard ? "Board work" : "Coding"; // One rule for every untrusted string in this document, where there used to // be three. Evidence rows ran through escapeHtml, feedback bullets and @@ -789,6 +824,11 @@ export function reportMarkdown({ return [ `# Interview Report - ${mdText(problemTitle)}`, `_${at}_`, + ...(originalProblem + ? [ + `Original problem: [${mdText(originalProblem.title).replace(/\]/g, "\\]")}](${originalProblem.url})`, + ] + : []), ...(report.mode ? [`Mode: ${modeLabel(report.mode)}`] : []), ...(report.interviewLoop ? [`Loop: ${loopLabel(report.interviewLoop)}`] diff --git a/web/replay.js b/web/replay.js index 2ac023e1..d34f038c 100644 --- a/web/replay.js +++ b/web/replay.js @@ -10,7 +10,8 @@ // `/api/reports` sync module the lobby uses, and repurposing it would break the // section it already serves. -import { reportMarkup } from "/render.js"; +import { reportMarkup, showOriginalProblem } from "/render.js"; +import { loadPageMap } from "/problem-data.js"; import { modeLabel, replayRows, @@ -515,6 +516,12 @@ async function loadReport(interviewId, recordingId) { unclearLines: unclearLinesOf(saved.unclearLines), ...final, }); + void loadPageMap() + .then((pages) => { + if (selected === recordingId) + showOriginalProblem(nodes.report, pages, saved.problemId); + }) + .catch(() => {}); } nodes.more.addEventListener("click", () => void loadList());