Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/browser/history.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions tests/browser/lobby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
107 changes: 107 additions & 0 deletions tests/browser/render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
/<h2>No evaluation<\/h2><p class="report-original-problem" hidden><\/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: "<img>" } },
"chargeback-pair-match",
);
assert.equal(line.children[1].textContent, "<img>");
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: {
Expand Down
20 changes: 15 additions & 5 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1778,14 +1785,17 @@ function renderAttemptHistory(attempts) {
}
}

function downloadSavedReport(attempt) {
async function downloadSavedReport(attempt) {
const problemPages = await loadPageMapForDownload();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

downloadSavedReport now waits for loadPageMapForDownload before anything happens. If /problem-pages.json is slow or stalled, the button shows nothing and stays enabled for up to ten seconds. Every extra click starts another export, and all of them download once the map arrives or the time runs out. Ignore clicks (or disable the button) while an export is pending and reset that in finally; downloadReport in web/interview.js needs the same guard.

const markdown = reportMarkdown({
report: {
...attempt.report,
incomplete:
attempt.report.incomplete || attempt.recordedDecision === null,
},
problemTitle: attempt.problemTitle,
problemPage: attempt.problemId,
problemPages,
language: attempt.language,
code: null,
transcript: null,
Expand Down
23 changes: 19 additions & 4 deletions web/interview.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -57,6 +62,7 @@ import {
reportSaveStatus,
resultsMarkup,
runnerStatusMarkup,
showOriginalProblem,
} from "./render.js";
import {
acceptsReport,
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -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(),
Expand Down
Loading