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
199 changes: 199 additions & 0 deletions tests/browser/final-code-copy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
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("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!");
});
25 changes: 25 additions & 0 deletions tests/browser/lobby.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4115,6 +4116,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 = [];
Expand Down
1 change: 1 addition & 0 deletions tests/browser/replay-render.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -696,6 +696,7 @@ test("the report card this page renders names no finding either", () => {
"Communication",
"Complexity",
"Contract",
"Copy",
"Done",
"Download",
"Edge",
Expand Down
10 changes: 8 additions & 2 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1784,9 +1789,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", () => {
Expand Down
2 changes: 2 additions & 0 deletions web/interview.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import {
problemMarkup,
reportMarkdown,
reportMarkup,
mountFinalCodeCopy,
reportSaveStatus,
resultsMarkup,
runnerStatusMarkup,
Expand Down Expand Up @@ -3953,6 +3954,7 @@ function renderReport() {
saveResult: null,
unclearLines: unclearLines(),
});
mountFinalCodeCopy(nodes.report, currentCode());
mountBehavioralReview(nodes.report, state.transcript.values());
}

Expand Down
44 changes: 42 additions & 2 deletions web/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -415,7 +417,45 @@ export function reportMarkup({

/// The candidate's code, at the foot of an editor interview's card.
function finalCodeMarkup(language, code) {
return `<details><summary>Your final code (${escapeHtml(languageLabel(language) || "not recorded")})</summary><pre>${escapeHtml(code.trimEnd() || "(editor was empty)")}</pre></details>`;
const available =
typeof code === "string" &&
code.trim() !== "" &&
code !== FINAL_CODE_NOT_SAVED;
return `<section class="final-code"><details><summary>Your final code (${escapeHtml(languageLabel(language) || "not recorded")})</summary><pre>${escapeHtml(code?.trimEnd() || "(editor was empty)")}</pre></details>${available ? '<button type="button" class="secondary" data-copy-code>Copy code</button><span class="muted small" role="status" data-copy-code-status></span>' : ""}</section>`;
}

/// 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
Expand Down Expand Up @@ -695,7 +735,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(
Expand Down
3 changes: 2 additions & 1 deletion web/replay.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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());
Expand Down
Loading
Loading