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, + /
<\/p>/,
+ );
+ assert.doesNotMatch(html, /leetcode\.com/);
+});
+
+test("the original problem link uses the published slug only on a report", () => {
+ const pages = {
+ "two-sum": { page: "chargeback-pair-match", source: "Two Sum" },
+ };
+ assert.deepEqual(originalProblemForPage(pages, "chargeback-pair-match"), {
+ title: "Two Sum",
+ url: "https://leetcode.com/problems/two-sum/",
+ });
+ assert.deepEqual(originalProblemForPage(pages, "two-sum"), {
+ title: "Two Sum",
+ url: "https://leetcode.com/problems/two-sum/",
+ });
+ assert.equal(originalProblemForPage(pages, "unknown"), null);
+ assert.equal(originalProblemForPage({ "two-sum": null }, "two-sum"), null);
+ assert.equal(
+ originalProblemForPage({ custom: { page: "custom" } }, "custom"),
+ null,
+ );
+ assert.equal(
+ originalProblemForPage(
+ { 'bad" onclick="alert(1)': { page: "scenario", source: "Bad" } },
+ "scenario",
+ ),
+ null,
+ );
+
+ const line = {
+ hidden: true,
+ ownerDocument: { createElement: () => ({}) },
+ replaceChildren(...children) {
+ this.children = children;
+ },
+ };
+ const container = {
+ querySelector(selector) {
+ assert.equal(selector, ".report-original-problem");
+ return line;
+ },
+ };
+ showOriginalProblem(container, pages, "chargeback-pair-match");
+ assert.equal(line.hidden, false);
+ assert.equal(line.children[0], "Original problem: ");
+ assert.deepEqual(line.children[1], {
+ href: "https://leetcode.com/problems/two-sum/",
+ target: "_blank",
+ rel: "noopener noreferrer",
+ textContent: "Two Sum",
+ });
+ showOriginalProblem(
+ container,
+ { "two-sum": { page: "chargeback-pair-match", source: "" } },
+ "chargeback-pair-match",
+ );
+ assert.equal(line.children[1].textContent, "
");
+ assert.equal(line.children[1].innerHTML, undefined);
+});
+
+test("every published problem in the generated bank resolves to its page", () => {
+ const pages = JSON.parse(
+ readFileSync(new URL("../../web/problem-pages.json", import.meta.url)),
+ );
+ for (const [slug, entry] of Object.entries(pages)) {
+ if (!entry.source) continue;
+ assert.deepEqual(originalProblemForPage(pages, entry.page), {
+ title: entry.source,
+ url: `https://leetcode.com/problems/${slug}/`,
+ });
+ }
+});
+
+test("markdown export links to a mapped original problem safely", () => {
+ const session = {
+ report: { incomplete: true, summary: "No evaluation was available." },
+ problemTitle: "Chargeback Pair Match",
+ problemPage: "chargeback-pair-match",
+ problemPages: {
+ "two-sum": { page: "chargeback-pair-match", source: "Two ] Sum" },
+ },
+ at: "2026-10-17",
+ };
+ assert.match(
+ reportMarkdown(session),
+ /Original problem: \[Two \\\] Sum\]\(https:\/\/leetcode\.com\/problems\/two-sum\/\)/,
+ );
+ assert.doesNotMatch(
+ reportMarkdown({ ...session, problemPages: {} }),
+ /Original problem:/,
+ );
+});
+
test("markdown export of an empty session carries no verdict either", () => {
const markdown = reportMarkdown({
report: {
diff --git a/web/app.js b/web/app.js
index ce01d5d5..7af5bdfa 100644
--- a/web/app.js
+++ b/web/app.js
@@ -24,9 +24,13 @@ import {
pickerEntry,
progressModelFrom,
} from "./progress.js";
-import { reportMarkdown, reportMarkup } from "./render.js";
+import { reportMarkdown, reportMarkup, showOriginalProblem } from "./render.js";
import { downloadMarkdown, reportFilename } from "./download.js";
-import { loadPageMap, loadTopicMap } from "./problem-data.js";
+import {
+ loadPageMap,
+ loadPageMapForDownload,
+ loadTopicMap,
+} from "./problem-data.js";
import {
availableTopics,
filterProblems,
@@ -192,8 +196,8 @@ for (const card of cards) mark(card.button, false);
// throws (a private window, blocked site data) just means off.
const showSources = document.querySelector("#show-sources");
showSources.checked = readStored(SHOW_SOURCES_KEY) === "1";
-// The published names are not in the page: they arrive with the map, fetched
-// the first time the candidate turns them on.
+// The published names are not in the initial page. The map arrives when the
+// candidate turns them on, or opens or downloads a completed report.
const applySources = async () => {
if (showSources.checked) {
const pages = await loadPageMap().catch(() => null);
@@ -1723,6 +1727,9 @@ function renderAttemptHistory(attempts) {
}
report.hidden = false;
open.textContent = "Collapse report";
+ void loadPageMap()
+ .then((pages) => showOriginalProblem(report, pages, attempt.problemId))
+ .catch(() => {});
});
retry.addEventListener("click", () => {
if (starting) return;
@@ -1778,7 +1785,8 @@ function renderAttemptHistory(attempts) {
}
}
-function downloadSavedReport(attempt) {
+async function downloadSavedReport(attempt) {
+ const problemPages = await loadPageMapForDownload();
const markdown = reportMarkdown({
report: {
...attempt.report,
@@ -1786,6 +1794,8 @@ function downloadSavedReport(attempt) {
attempt.report.incomplete || attempt.recordedDecision === null,
},
problemTitle: attempt.problemTitle,
+ problemPage: attempt.problemId,
+ problemPages,
language: attempt.language,
code: null,
transcript: null,
diff --git a/web/interview.js b/web/interview.js
index ec7f1dd2..8fac472a 100644
--- a/web/interview.js
+++ b/web/interview.js
@@ -6,7 +6,12 @@ import {
reconnectCodingInterview,
} from "./example-board.js";
import { mountInterviewDrawer } from "./interview-drawer.js";
-import { loadJudge, loadProblem } from "./problem-data.js";
+import {
+ loadJudge,
+ loadPageMap,
+ loadPageMapForDownload,
+ loadProblem,
+} from "./problem-data.js";
import { downloadMarkdown, reportFilename } from "./download.js";
import { consumeSharedFocus } from "./problem-picker.js";
import {
@@ -57,6 +62,7 @@ import {
reportSaveStatus,
resultsMarkup,
runnerStatusMarkup,
+ showOriginalProblem,
} from "./render.js";
import {
acceptsReport,
@@ -3465,6 +3471,9 @@ function renderReport() {
boardPhases: [...board.snapshots],
saveResult: null,
});
+ void loadPageMap()
+ .then((pages) => showOriginalProblem(nodes.report, pages, problem.page))
+ .catch(() => {});
mountBehavioralReview(nodes.report, state.transcript.values());
}
@@ -3523,15 +3532,21 @@ async function saveHistory(report = state.report) {
return result;
}
-function downloadReport() {
+async function downloadReport() {
const at = new Date();
- downloadMarkdown(buildMarkdown(at), reportFilename(problem.page, at));
+ const problemPages = await loadPageMapForDownload();
+ downloadMarkdown(
+ buildMarkdown(at, problemPages),
+ reportFilename(problem.page, at),
+ );
}
-function buildMarkdown(at) {
+function buildMarkdown(at, problemPages) {
return reportMarkdown({
report: state.report,
problemTitle: problem.title,
+ problemPage: problem.page,
+ problemPages,
language: state.language,
code: currentCode(),
board: finalBoardImage(),
diff --git a/web/problem-data.js b/web/problem-data.js
index 53b62085..c1c705a2 100644
--- a/web/problem-data.js
+++ b/web/problem-data.js
@@ -87,8 +87,9 @@ export async function loadProblem(page) {
/// The published id of each problem, with its page, scenario title and
/// published title. Fetched only by the paths that start from a published id
-/// or name: an old link, history saved before pages had names, and the lobby's
-/// opt-in toggle. Cached for the page's lifetime once it arrives; a failed fetch
+/// or name: an old link, history saved before pages had names, the lobby's
+/// opt-in toggle, and a completed report opened or downloaded after an interview.
+/// Cached for the page's lifetime once it arrives; a failed fetch
/// is forgotten, so a dropped connection costs one lookup rather than every
/// later one on the page.
export function loadPageMap() {
@@ -99,6 +100,22 @@ export function loadPageMap() {
return pageMap;
}
+/// A report can be exported without its optional original-problem link. Bound
+/// only this caller's wait; the shared lookup may still serve other consumers.
+export async function loadPageMapForDownload() {
+ let timer;
+ try {
+ return await Promise.race([
+ loadPageMap().catch(() => null),
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(null), 10_000);
+ }),
+ ]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
/// The page-to-topic lookup is absent from the initial lobby HTML because it
/// gives away the techniques behind every scenario. It arrives only after the
/// candidate opens the problem picker to filter by topic.
diff --git a/web/render.js b/web/render.js
index f4ee3632..418f5a4b 100644
--- a/web/render.js
+++ b/web/render.js
@@ -264,6 +264,38 @@ export function reportSaveStatus(result) {
return { message: "Report was not saved", className: "critical small" };
}
+/// Find the published problem for a completed scenario. The map's key is the
+/// LeetCode URL slug; scenario page names and saved legacy ids both work here.
+/// A malformed map entry cannot turn the fixed destination into another host.
+export function originalProblemForPage(pages, page) {
+ if (typeof page !== "string") return null;
+ for (const [slug, entry] of Object.entries(pages ?? {})) {
+ if (entry?.page !== page && slug !== page) continue;
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) continue;
+ if (typeof entry?.source !== "string" || !entry.source.trim()) continue;
+ return {
+ title: entry.source,
+ url: `https://leetcode.com/problems/${slug}/`,
+ };
+ }
+ return null;
+}
+
+/// The report is already on screen when the page map arrives. Fill only its
+/// reserved line, so a late lookup never redraws a report the reader opened.
+export function showOriginalProblem(container, pages, page) {
+ const reference = originalProblemForPage(pages, page);
+ const line = container.querySelector(".report-original-problem");
+ if (!reference || !line) return;
+ const link = line.ownerDocument.createElement("a");
+ link.href = reference.url;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ link.textContent = reference.title;
+ line.replaceChildren("Original problem: ", link);
+ line.hidden = false;
+}
+
function interviewerStyle(report) {
return [
report.interviewerVoice ? `Voice: ${report.interviewerVoice}` : "",
@@ -386,7 +418,7 @@ export function reportMarkup({
return `
${report.mode ? `${modeLabel(report.mode)} ` : ""}interview report${loop}${surface} · ${escapeHtml(problemTitle)}
${escapeHtml(contract)}
${interviewerStyle(report) ? `${escapeHtml(interviewerStyle(report))}
` : ""}${practiceLevel ? `${practiceLevel}
` : ""}${report.mode ? `${modeLabel(report.mode)} ` : ""}interview report${loop}${surface} · ${escapeHtml(problemTitle)}
${escapeHtml(contract)}
${interviewerStyle(report) ? `${escapeHtml(interviewerStyle(report))}
` : ""}${practiceLevel ? `${practiceLevel}
` : ""}${escapeHtml(report.summary)}