diff --git a/docs/development.md b/docs/development.md index f7bf21bb..ee76c2c0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -261,9 +261,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/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 5ad6e22a..ab2dca3d 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -3487,6 +3487,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) => { @@ -3700,6 +3730,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( @@ -3759,6 +3793,45 @@ lobbyTest( }, ); +lobbyTest( + "a stalled problem map does not block a 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"); + await page.getByRole("button", { name: /^Download report/ }).click(); + await page.waitForFunction(() => window.mapRequested && window.mapDeadline); + 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:/); + }, + { 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 b6d71104..b811276e 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)}

@@ -491,6 +523,8 @@ function boardStepsMarkdown(report, mdText) { export function reportMarkdown({ report, problemTitle, + problemPage, + problemPages, language, code, board, @@ -499,6 +533,7 @@ export function reportMarkdown({ testHistory = [], }) { 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 @@ -763,6 +798,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 d9cfae14..c9932924 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, @@ -513,6 +514,12 @@ async function loadReport(interviewId, recordingId) { problemTitle: saved.problemTitle || "", ...final, }); + void loadPageMap() + .then((pages) => { + if (selected === recordingId) + showOriginalProblem(nodes.report, pages, saved.problemId); + }) + .catch(() => {}); } nodes.more.addEventListener("click", () => void loadList());