diff --git a/tests/browser/final-code-copy.test.js b/tests/browser/final-code-copy.test.js new file mode 100644 index 00000000..e77e095b --- /dev/null +++ b/tests/browser/final-code-copy.test.js @@ -0,0 +1,219 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + FINAL_CODE_NOT_SAVED, + reportMarkup, + reportMarkdown, + mountFinalCodeCopy, +} from "../../web/render.js"; +import { launchChromium, startStaticServer } from "./source.js"; + +const session = (code) => ({ + report: { incomplete: true, summary: "Interview ended.", hintsUsed: 0 }, + problemTitle: "Example", + language: "python", + code, +}); + +function controls() { + const attributes = new Map(); + const button = { + addEventListener: (_, listener) => { + button.click = listener; + }, + setAttribute: (name, value) => attributes.set(name, value), + removeAttribute: (name) => attributes.delete(name), + }; + const status = { textContent: "" }; + const root = { + querySelector: (selector) => + ({ + "[data-copy-code]": button, + "[data-copy-code-status]": status, + })[selector] ?? null, + }; + return { root, button, status, attributes }; +} + +test("copy controls appear only for available editor code", () => { + assert.match(reportMarkup(session("print(1)")), /data-copy-code>Copy code/); + for (const code of ["", " \n\t", undefined, null, FINAL_CODE_NOT_SAVED]) { + assert.doesNotMatch(reportMarkup(session(code)), /data-copy-code/); + } + assert.doesNotMatch( + reportMarkup({ + ...session("print(1)"), + board: "data:image/png;base64,YQ==", + }), + /data-copy-code/, + ); + mountFinalCodeCopy({ querySelector: () => null }, undefined); +}); + +test("unsaved code uses the same placeholder in the report and download", () => { + assert.ok( + reportMarkup(session(FINAL_CODE_NOT_SAVED)).includes(FINAL_CODE_NOT_SAVED), + ); + assert.ok(reportMarkdown(session(null)).includes(FINAL_CODE_NOT_SAVED)); +}); + +test("copy updates the status emitted by reportMarkup in a browser", async (t) => { + const browser = await launchChromium(); + if (!browser) return t.skip("playwright chromium unavailable"); + let server; + try { + const hosted = await startStaticServer(); + server = hosted.server; + const page = await browser.newPage(); + await page.goto(hosted.base); + const code = "\tprint('<&>') \r\n"; + const result = await page.evaluate(async (code) => { + const { reportMarkup, mountFinalCodeCopy } = await import("/render.js"); + const root = document.createElement("main"); + root.innerHTML = reportMarkup({ + report: { incomplete: true, summary: "Interview ended.", hintsUsed: 0 }, + problemTitle: "Example", + language: "python", + code, + }); + document.body.replaceChildren(root); + const button = root.querySelector("[data-copy-code]"); + const status = root.querySelector("[data-copy-code-status]"); + const writes = []; + let finish; + mountFinalCodeCopy(root, code, { + writeText: (text) => { + writes.push(text); + return new Promise((resolve) => { + finish = resolve; + }); + }, + }); + const originalCode = root.querySelector("pre").textContent; + button.focus(); + button.click(); + const pending = status.textContent; + button.click(); + finish(); + await Promise.resolve(); + const success = status.textContent; + const enabled = !button.hasAttribute("aria-disabled"); + const focused = document.activeElement === button; + return { + writes, + pending, + success, + enabled, + focused, + unchanged: root.querySelector("pre").textContent === originalCode, + }; + }, code); + assert.deepEqual(result, { + writes: [code], + pending: "Copying...", + success: "Copied!", + enabled: true, + focused: true, + unchanged: true, + }); + } finally { + await browser.close(); + if (server) await new Promise((resolve) => server.close(resolve)); + } +}); + +test("copy preserves original whitespace, line endings and HTML characters", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const code = "\tif x < 2:\r\n print('&<>') \r\n\r\n"; + const original = reportMarkup(session(code)); + const view = controls(); + const writes = []; + mountFinalCodeCopy(view.root, code, { + writeText: async (text) => writes.push(text), + }); + await view.button.click(); + assert.deepEqual(writes, [code]); + assert.equal(view.status.textContent, "Copied!"); + assert.equal(view.attributes.has("aria-disabled"), false); + assert.equal(reportMarkup(session(code)), original); + t.mock.timers.tick(2000); + assert.equal(view.status.textContent, ""); +}); + +test("repeated copies restart the success feedback timer", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const view = controls(); + const writes = []; + mountFinalCodeCopy(view.root, "code", { + writeText: async (text) => writes.push(text), + }); + + await view.button.click(); + t.mock.timers.tick(1500); + await view.button.click(); + assert.deepEqual(writes, ["code", "code"]); + t.mock.timers.tick(1000); + assert.equal(view.status.textContent, "Copied!"); + t.mock.timers.tick(999); + assert.equal(view.status.textContent, "Copied!"); + t.mock.timers.tick(1); + assert.equal(view.status.textContent, ""); +}); + +test("clipboard failure and unavailable API show an error and allow retry", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (const clipboard of [ + undefined, + {}, + { + writeText: () => { + throw new Error("denied"); + }, + }, + { + writeText: async () => { + throw new Error("denied"); + }, + }, + ]) { + const view = controls(); + mountFinalCodeCopy(view.root, "code", clipboard); + await view.button.click(); + assert.match(view.status.textContent, /Could not copy code.*manually/); + assert.equal(view.attributes.has("aria-disabled"), false); + } + const view = controls(); + let reject = true; + mountFinalCodeCopy(view.root, "code", { + writeText: async () => { + if (reject) throw new Error("denied"); + }, + }); + await view.button.click(); + reject = false; + await view.button.click(); + assert.equal(view.status.textContent, "Copied!"); +}); + +test("a pending copy ignores duplicate clicks", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const view = controls(); + let finish; + let calls = 0; + mountFinalCodeCopy(view.root, "code", { + writeText: () => { + calls++; + return new Promise((resolve) => { + finish = resolve; + }); + }, + }); + const copying = view.button.click(); + assert.equal(view.status.textContent, "Copying..."); + assert.equal(view.attributes.get("aria-disabled"), "true"); + await view.button.click(); + assert.equal(calls, 1); + finish(); + await copying; + assert.equal(view.status.textContent, "Copied!"); +}); diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 4096960e..eee2ccb3 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -3979,6 +3979,7 @@ for (const storage of ["account", "device"]) { } await lobby(page); const history = page.locator("#attempt-history"); + assert.equal(await history.locator("[data-copy-code]").count(), 0); assert.equal( await history.getByRole("button", { name: /^Download report/ }).count(), cases.length, @@ -4463,6 +4464,30 @@ for (const storage of ["device", "account"]) { .filter({ hasText: "Your final code" }); assert.equal(await finalCode.locator("pre").textContent(), shown); + const copy = row.getByRole("button", { + name: "Copy code", + exact: true, + }); + if (title === "Kept code") { + await page.evaluate(() => { + Object.defineProperty(navigator.clipboard, "writeText", { + configurable: true, + value: async (text) => { + window.copiedCode = text; + }, + }); + }); + await copy.click(); + await page.waitForFunction( + () => + document.querySelector("[data-copy-code-status]")?.textContent === + "Copied!", + ); + assert.equal(await page.evaluate(() => window.copiedCode), code); + } else { + assert.equal(await copy.count(), 0); + } + const downloading = page.waitForEvent("download"); await row.getByRole("button", { name: /^Download report/ }).click(); const chunks = []; diff --git a/tests/browser/replay-render.test.js b/tests/browser/replay-render.test.js index 5349c221..9032fec4 100644 --- a/tests/browser/replay-render.test.js +++ b/tests/browser/replay-render.test.js @@ -697,6 +697,7 @@ test("the report card this page renders names no finding either", () => { "Communication", "Complexity", "Contract", + "Copy", "Done", "Download", "Edge", diff --git a/web/app.js b/web/app.js index a591c75a..f0725ba7 100644 --- a/web/app.js +++ b/web/app.js @@ -27,7 +27,12 @@ import { pickerEntry, progressModelFrom, } from "./progress.js"; -import { reportMarkdown, reportMarkup } from "./render.js"; +import { + FINAL_CODE_NOT_SAVED, + mountFinalCodeCopy, + reportMarkdown, + reportMarkup, +} from "./render.js"; import { downloadMarkdown, reportFilename } from "./download.js"; import { loadPageMap, loadTopicMap } from "./problem-data.js"; import { @@ -1855,9 +1860,10 @@ function renderAttemptHistory(attempts) { report: attempt.report, problemTitle: attempt.problemTitle, language: languageLabel(attempt.language ?? "not recorded"), - code: attempt.code ?? "(final code was not saved)", + code: attempt.code ?? FINAL_CODE_NOT_SAVED, unclearLines: attempt.unclearLines, }); + mountFinalCodeCopy(report, attempt.code); report.querySelector(".report-actions")?.remove(); report.hidden = true; open.addEventListener("click", () => { diff --git a/web/interview.js b/web/interview.js index b7b1f739..89983037 100644 --- a/web/interview.js +++ b/web/interview.js @@ -62,6 +62,7 @@ import { problemMarkup, reportMarkdown, reportMarkup, + mountFinalCodeCopy, reportSaveStatus, resultsMarkup, runnerStatusMarkup, @@ -3957,6 +3958,7 @@ function renderReport() { saveResult: null, unclearLines: unclearLines(), }); + mountFinalCodeCopy(nodes.report, currentCode()); mountBehavioralReview(nodes.report, state.transcript.values()); } diff --git a/web/render.js b/web/render.js index 48949871..bd40c7ac 100644 --- a/web/render.js +++ b/web/render.js @@ -16,6 +16,8 @@ import { languageLabel, } from "./lib.js"; +export const FINAL_CODE_NOT_SAVED = "(final code was not saved)"; + /// The six coding steps as a whiteboard candidate was shown them, in REACTO /// order, so index `i` here is index `i` of `FRAMEWORKS.coding.steps`. const BOARD_STEPS = frameworkChecklist("coding", [], "whiteboard").steps; @@ -419,7 +421,45 @@ export function reportMarkup({ /// The candidate's code, at the foot of an editor interview's card. function finalCodeMarkup(language, code) { - return `Your final code (${escapeHtml(languageLabel(language) || "not recorded")})${escapeHtml(code.trimEnd() || "(editor was empty)")}`; + const available = + typeof code === "string" && + code.trim() !== "" && + code !== FINAL_CODE_NOT_SAVED; + return `Your final code (${escapeHtml(languageLabel(language) || "not recorded")})${escapeHtml(code?.trimEnd() || "(editor was empty)")}${available ? 'Copy code' : ""}`; +} + +/// Keep the original submission in the handler: the rendered preview trims +/// trailing whitespace and HTML parsing normalizes line endings. +export function mountFinalCodeCopy( + root, + code, + clipboard = globalThis.navigator?.clipboard, +) { + const button = root.querySelector("[data-copy-code]"); + if (!button) return; + const status = root.querySelector("[data-copy-code-status]"); + let pending = false; + let reset; + button.addEventListener("click", async () => { + if (pending) return; + pending = true; + clearTimeout(reset); + button.setAttribute("aria-disabled", "true"); + status.textContent = "Copying..."; + try { + await clipboard.writeText(code); + status.textContent = "Copied!"; + reset = setTimeout(() => { + status.textContent = ""; + }, 2000); + } catch { + status.textContent = + "Could not copy code. Select the code and copy it manually."; + } finally { + pending = false; + button.removeAttribute("aria-disabled"); + } + }); } /// A whiteboard interview's work, step by step: the board when each step was @@ -699,7 +739,7 @@ export function reportMarkdown({ // the candidate's own download button would do nothing. const body = code == null - ? "(final code was not saved)" + ? FINAL_CODE_NOT_SAVED : code.trimEnd() || "(editor was empty)"; const fencedCodeLines = (body, language) => { const longestRun = (body.match(/`+/g) || []).reduce( diff --git a/web/replay.js b/web/replay.js index 2ac023e1..c5762603 100644 --- a/web/replay.js +++ b/web/replay.js @@ -10,7 +10,7 @@ // `/api/reports` sync module the lobby uses, and repurposing it would break the // section it already serves. -import { reportMarkup } from "/render.js"; +import { reportMarkup, mountFinalCodeCopy } from "/render.js"; import { modeLabel, replayRows, @@ -515,6 +515,7 @@ async function loadReport(interviewId, recordingId) { unclearLines: unclearLinesOf(saved.unclearLines), ...final, }); + mountFinalCodeCopy(nodes.report, final.code); } nodes.more.addEventListener("click", () => void loadList()); diff --git a/web/styles.css b/web/styles.css index 377138e3..f9ba1b08 100644 --- a/web/styles.css +++ b/web/styles.css @@ -2065,11 +2065,23 @@ table.phase-scores { color: var(--ink); } +.final-code { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; + gap: 0.5rem; +} + +.final-code [role="status"] { + grid-column: 1 / -1; +} + .report-actions { justify-content: flex-end; } -.report-actions button { +.report-actions button, +.final-code button { border: 1px solid var(--hairline); border-radius: var(--radius-sm); background: var(--accent-fill); @@ -2081,18 +2093,21 @@ table.phase-scores { /* Done is the way on; the download is a copy to keep, so it is outlined rather than a second filled button of equal weight beside it. */ -.report-actions .secondary { +.report-actions .secondary, +.final-code .secondary { border-color: var(--control-border); background: transparent; color: var(--ink); } -.report-actions .secondary:hover { +.report-actions .secondary:hover, +.final-code .secondary:hover { border-color: var(--accent); } -/* Done waits for the report to be saved; until then it should look it. */ -.report-actions button:disabled { +/* Saving and copying both keep their controls visibly waiting. */ +.report-actions button:disabled, +.final-code button[aria-disabled="true"] { opacity: 0.55; cursor: progress; }
${escapeHtml(code.trimEnd() || "(editor was empty)")}
${escapeHtml(code?.trimEnd() || "(editor was empty)")}