Repository navigation
Add a copy button to the final report #457
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
yansawu
wants to merge
1
commit into
sysprog21:main
Choose a base branch
from
yansawu:add-report-copy-code
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+319
−10
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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!"); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nothing exercises the
clearTimeout(reset)inmountFinalCodeCopy: every test copies once, then ticks past the timer or never ticks. Delete that line and all of them still pass, yet a second copy 1.5s after the first would have itsCopied!wiped at the 2s mark by the first copy's timer. Add a case that copies twice, ticks 1500 then 1000, and asserts the status still readsCopied!.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added a two-copy regression test with a 1500 ms gap. After another 1000 ms, the status still reads Copied!, and it clears only at the second copy's deadline. Verified that removing clearTimeout(reset) makes this test fail.