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, + /
<\/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/tests/browser/report-original-problem.test.js b/tests/browser/report-original-problem.test.js
new file mode 100644
index 00000000..d31e1c42
--- /dev/null
+++ b/tests/browser/report-original-problem.test.js
@@ -0,0 +1,140 @@
+import { after, before, test } from "node:test";
+import assert from "node:assert/strict";
+
+import {
+ closeSetupCheck,
+ DEFAULT_RUNTIME_CONFIG,
+ launchChromium,
+ startStaticServer,
+} from "./source.js";
+
+let browser = null;
+let server = null;
+let base = "";
+
+before(async () => {
+ browser = await launchChromium();
+ if (!browser) return;
+ ({ server, base } = await startStaticServer({
+ runtimeConfig: DEFAULT_RUNTIME_CONFIG,
+ }));
+});
+
+after(async () => {
+ await browser?.close();
+ await new Promise((closed) => (server ? server.close(closed) : closed()));
+});
+
+test("ending an interview shows the original problem below the report title", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await browser.newPage();
+ page.on("dialog", (dialog) => dialog.accept());
+ try {
+ await page.goto(`${base}/interview.html?problem=chargeback-pair-match`, {
+ waitUntil: "domcontentloaded",
+ });
+ await page.waitForFunction(
+ () => document.querySelector("#candidate-case-input").placeholder !== "",
+ );
+ await closeSetupCheck(page);
+ await page.locator("#end").click();
+ const link = page.locator("#report-modal h2 + .report-original-problem a");
+ await link.waitFor();
+ assert.equal(await link.textContent(), "Two Sum");
+ assert.equal(
+ await link.getAttribute("href"),
+ "https://leetcode.com/problems/two-sum/",
+ );
+ } finally {
+ await page.close();
+ }
+});
+
+test("a late original-problem lookup cannot put recording A's link on B", async (t) => {
+ if (!browser) return t.skip("playwright chromium unavailable");
+ const page = await browser.newPage();
+ const pendingMap = Promise.withResolvers();
+ await page.route("**/problem-pages.json", async (route) => {
+ await pendingMap.promise;
+ await route.continue();
+ });
+ await page.route("**/api/recordings", (route) =>
+ route.fulfill({
+ json: {
+ recordings: ["A", "B"].map((recordingId) => ({
+ recordingId,
+ createdAt: 1700000000,
+ state: "ready",
+ })),
+ },
+ }),
+ );
+ await page.route(/\/api\/recordings\/[AB]$/, (route) => {
+ const id = route.request().url().split("/").at(-1);
+ return route.fulfill({
+ json: { recordingId: id, interviewId: id, state: "ready" },
+ });
+ });
+ await page.route("**/api/recordings/*/events?*", (route) =>
+ route.fulfill({ json: { events: [] } }),
+ );
+ await page.route("**/api/reports", (route) =>
+ route.fulfill({
+ json: {
+ reports: [
+ ["A", "chargeback-pair-match", "Chargeback Pair Match"],
+ ["B", "template-delimiter-audit", "Template Delimiter Audit"],
+ ].map(([interviewId, problemId, problemTitle]) => ({
+ payload: {
+ interviewId,
+ problemId,
+ problemTitle,
+ report: { decision: "HIRE", codingScore: 80 },
+ },
+ })),
+ },
+ }),
+ );
+ try {
+ await page.goto(`${base}/replay.html`, { waitUntil: "domcontentloaded" });
+ const mapRequested = page.waitForRequest("**/problem-pages.json");
+ await page.locator('.replay-item[data-recording="A"]').click();
+ await mapRequested;
+ await page.locator("#replay-report h2").waitFor();
+ await page.locator('.replay-item[data-recording="B"]').click();
+ await page.waitForFunction(() =>
+ document
+ .querySelector("#replay-report")
+ .textContent.includes("Template Delimiter Audit"),
+ );
+ await page.evaluate(() => {
+ window.reportLinksAdded = [];
+ // Both callbacks share the cached lookup. Record even a stale link
+ // replaced by B's callback in the same turn of the event loop.
+ new MutationObserver((records) => {
+ for (const record of records) {
+ for (const node of record.addedNodes) {
+ if (node.nodeName === "A") window.reportLinksAdded.push(node.href);
+ }
+ }
+ }).observe(document.querySelector("#replay-report"), {
+ childList: true,
+ subtree: true,
+ });
+ });
+ pendingMap.resolve();
+ const link = page.locator("#replay-report .report-original-problem a");
+ await link.waitFor();
+ assert.equal(await link.textContent(), "Valid Parentheses");
+ assert.equal(
+ await link.getAttribute("href"),
+ "https://leetcode.com/problems/valid-parentheses/",
+ );
+ assert.deepEqual(await page.evaluate(() => window.reportLinksAdded), [
+ "https://leetcode.com/problems/valid-parentheses/",
+ ]);
+ } finally {
+ pendingMap.resolve();
+ await page.close();
+ }
+});
diff --git a/web/app.js b/web/app.js
index 2860f377..805acab6 100644
--- a/web/app.js
+++ b/web/app.js
@@ -27,9 +27,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,
@@ -209,8 +213,8 @@ for (const card of cards) {
// 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);
@@ -1840,7 +1844,9 @@ function renderAttemptHistory(attempts) {
"aria-label",
`Download report (.md) for ${attempt.problemTitle}, ${when}`,
);
- download.addEventListener("click", () => downloadSavedReport(attempt));
+ download.addEventListener("click", () =>
+ downloadSavedReport(attempt, download),
+ );
const retry = document.createElement("button");
retry.type = "button";
retry.textContent = "Try again";
@@ -1862,6 +1868,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;
@@ -1950,24 +1959,38 @@ function syncAttemptSelection() {
nodes.attemptDownloadSelected.disabled = selected === 0;
}
-function downloadSavedReport(attempt) {
- const markdown = reportMarkdown({
- report: {
- ...attempt.report,
- incomplete:
- attempt.report.incomplete || attempt.recordedDecision === null,
- },
- problemTitle: attempt.problemTitle,
- language: attempt.language,
- code: attempt.code,
- transcript: null,
- at: new Date(attempt.at).toLocaleString(),
- unclearLines: attempt.unclearLines,
- });
- downloadMarkdown(
- markdown,
- reportFilename(attempt.problemId, new Date(attempt.at)),
- );
+const pendingReportDownloads = new Set();
+
+async function downloadSavedReport(attempt, button) {
+ const key = selectionKey(attempt);
+ if (pendingReportDownloads.has(key)) return;
+ pendingReportDownloads.add(key);
+ if (button) button.disabled = true;
+ try {
+ const problemPages = await loadPageMapForDownload();
+ const markdown = reportMarkdown({
+ report: {
+ ...attempt.report,
+ incomplete:
+ attempt.report.incomplete || attempt.recordedDecision === null,
+ },
+ problemTitle: attempt.problemTitle,
+ problemPage: attempt.problemId,
+ problemPages,
+ language: attempt.language,
+ code: attempt.code,
+ transcript: null,
+ at: new Date(attempt.at).toLocaleString(),
+ unclearLines: attempt.unclearLines,
+ });
+ downloadMarkdown(
+ markdown,
+ reportFilename(attempt.problemId, new Date(attempt.at)),
+ );
+ } finally {
+ pendingReportDownloads.delete(key);
+ if (button) button.disabled = false;
+ }
}
// How many weaknesses a phase row lists before folding the rest away. A
diff --git a/web/interview.js b/web/interview.js
index 913d8626..7681b0ca 100644
--- a/web/interview.js
+++ b/web/interview.js
@@ -12,7 +12,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 {
@@ -65,6 +70,7 @@ import {
reportSaveStatus,
resultsMarkup,
runnerStatusMarkup,
+ showOriginalProblem,
} from "./render.js";
import {
acceptsReport,
@@ -1485,7 +1491,8 @@ function bindEvents() {
});
nodes.report.addEventListener("click", (event) => {
if (event.target.closest("#done")) leaveRoom();
- if (event.target.closest("#download-report")) downloadReport();
+ const download = event.target.closest("#download-report");
+ if (download) downloadReport(download);
});
// Below the report listener on purpose: captions.test.js evaluates the source
// from the first `nodes.editor` input listener to that one on its own, so a
@@ -3953,6 +3960,9 @@ function renderReport() {
saveResult: null,
unclearLines: unclearLines(),
});
+ void loadPageMap()
+ .then((pages) => showOriginalProblem(nodes.report, pages, problem.page))
+ .catch(() => {});
mountBehavioralReview(nodes.report, state.transcript.values());
}
@@ -4032,15 +4042,27 @@ async function saveHistory(report = state.report) {
return result;
}
-function downloadReport() {
- const at = new Date();
- downloadMarkdown(buildMarkdown(at), reportFilename(problem.page, at));
+async function downloadReport(button) {
+ if (button.disabled) return;
+ button.disabled = true;
+ try {
+ const at = new Date();
+ const problemPages = await loadPageMapForDownload();
+ downloadMarkdown(
+ buildMarkdown(at, problemPages),
+ reportFilename(problem.page, at),
+ );
+ } finally {
+ button.disabled = false;
+ }
}
-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 10044500..1491941e 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}` : "",
@@ -392,7 +424,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)}