From dbe686f77a021b6e87e0959524fb5bbf81045e82 Mon Sep 17 00:00:00 2001 From: Fang-Yu Zhang Date: Fri, 16 Oct 2026 02:44:44 +0800 Subject: [PATCH] Keep the final code in the local history A report reopened from Past attempts always showed "(final code was not saved)", on the page and in its download. saveHistory never put the editor contents in the history entry, and normalizeProgressEntry would have dropped them before the lobby saw them. The entry now carries the code, or null at a whiteboard, which has none, and the reopened report and its download use it. It joins the entry after the unclear lines are bounded, since that bound is the account body's and the account copy never carries the code. Only the short history of the newest 20 attempts keeps it: the review store is built without it, so its 164 KB budget holds as many older attempts as before, and those keep the placeholder. When the quota refuses the history, or the review store write that follows it, the history is written again with the older rows' code left out, then without this report's own, so the attempt just finished keeps its code longest and the code never costs an attempt the device would keep. When no step lets the review store in, the refusal was not the code's, and the history goes back to the step it first landed at. The /api/reports body leaves it out, since the server keeps that body as sent; a signed-in lobby fills it in by report id from this device's history instead. README.md says the browser keeps it. Closes #221 Co-Authored-By: Fang-Yu Zhang --- README.md | 5 +- tests/browser/account.test.js | 218 +++++++++++++++++++++- tests/browser/interview-lifecycle.test.js | 187 ++++++++++--------- tests/browser/lobby.test.js | 67 +++++++ tests/browser/progress.test.js | 15 ++ web/app.js | 10 +- web/history.js | 122 ++++++++++-- web/interview.js | 10 +- web/progress.js | 2 + 9 files changed, 523 insertions(+), 113 deletions(-) diff --git a/README.md b/README.md index 8ab82444..9e08cf95 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,10 @@ strokes that made it, which is what lets the replay redraw any moment of it. How to hold one is under [Whiteboard interviews](#whiteboard-interviews). Audio and code snapshots stay in memory unless [recording](#recording) is -enabled, which is off by default. Candidate video reaches Gemini only with +enabled, which is off by default. The browser does keep the final code of +up to its 20 newest attempts in its local storage, so Past attempts can +reopen it on that device; the account copy never carries it, and deleting an +attempt removes it. Candidate video reaches Gemini only with `CODETRIAL_GEMINI_CANDIDATE_VIDEO_ENABLED=true`, and never in a whiteboard interview, where the board travels the stream a camera frame would. Face-presence analysis runs in the browser and reports itself unavailable diff --git a/tests/browser/account.test.js b/tests/browser/account.test.js index 3a6b248b..38effe57 100644 --- a/tests/browser/account.test.js +++ b/tests/browser/account.test.js @@ -16,6 +16,7 @@ import { reportIsPersisted, reviewHistoryKey, saveReportHistory, + withLocalCode, } from "../../web/history.js"; import { assertIncludesCompact, @@ -460,6 +461,67 @@ test("review inputs survive the full-report cap", async () => { }); }); +test("final code stays in the short history and out of reviews and the account", async () => { + // Ten kilobytes each, so twenty-one of them would not fit the review budget: + // a review store that kept the code would also hold fewer attempts. + const code = (index) => `// attempt ${index}\n${"x".repeat(10_000)}`; + const run = async (withCode) => { + const storage = memoryStorage(); + const posted = []; + const fetcher = async (url, options) => { + if (url === "/api/session") return response({ signedIn: true }); + posted.push(JSON.parse(options.body)); + return response({ id: "saved" }); + }; + for (let index = 0; index < 21; index += 1) { + const entry = { + id: `attempt-${index}`, + problemId: "two-sum", + date: `2026-01-${String(index + 1).padStart(2, "0")}T00:00:00Z`, + report: { decision: "HIRE" }, + }; + if (withCode) entry.code = code(index); + await saveReportHistory(entry, { storage, fetcher }); + } + return { storage, posted }; + }; + const kept = await run(true); + const plain = await run(false); + + const history = readLocalHistory(kept.storage); + assert.equal(history.length, 20); + assert.deepEqual( + history.map((row) => row.code), + Array.from({ length: 20 }, (_, index) => code(20 - index)), + ); + assert.equal( + kept.storage.getItem(reviewHistoryKey), + plain.storage.getItem(reviewHistoryKey), + ); + assert.equal(readReviewHistory(kept.storage).length, 21); + assert.equal(kept.posted.length, 21); + assert.deepEqual(kept.posted, plain.posted); +}); + +test("leaving the code out keeps every other stored field, __proto__ included", async () => { + const storage = memoryStorage(); + const posted = []; + const fetcher = async (url, options) => { + if (url === "/api/session") return response({ signedIn: true }); + posted.push(options.body); + return response({ id: "saved" }); + }; + // Parsed, as a stored row is, so `__proto__` is an own field of it. + const entry = JSON.parse( + '{"id":"odd","problemId":"two-sum","__proto__":{"kept":true},"code":"x","report":{"decision":"HIRE"}}', + ); + await saveReportHistory(entry, { storage, fetcher }); + const expected = + '{"id":"odd","problemId":"two-sum","__proto__":{"kept":true},"report":{"decision":"HIRE"}}'; + assert.equal(storage.getItem(reviewHistoryKey), `[${expected}]`); + assert.deepEqual(posted, [expected]); +}); + test("a rebuilt review history keeps the local-storage budget", () => { const storage = memoryStorage(); storage.setItem( @@ -635,18 +697,158 @@ test("a report the review store refuses is still saved on this device", async () memory.setItem(key, value); }, }; - const saved = await saveReportHistory( - { id: "only-local", problemId: "two-sum", report: { decision: "HIRE" } }, - { - storage, - fetcher: async () => response({ signedIn: false }), + // Code on every save: a refusal the code did not cause must not take it, + // from this report or from the one saved before it. + for (const id of ["earlier", "only-local"]) { + const saved = await saveReportHistory( + { + id, + problemId: "two-sum", + code: `return "${id}";`, + report: { decision: "HIRE" }, + }, + { + storage, + fetcher: async () => response({ signedIn: false }), + }, + ); + assert.equal(saved.local, "saved"); + } + assert.deepEqual( + readLocalHistory(memory).map((entry) => [entry.id, entry.code]), + [ + ["only-local", 'return "only-local";'], + ["earlier", 'return "earlier";'], + ], + ); +}); + +test("account rows take the final code this device kept, by report id", () => { + const storage = memoryStorage(); + storage.setItem( + historyKey, + JSON.stringify([ + { id: "kept", code: "return 1;" }, + { id: "row-id", code: "return 2;" }, + { id: "no-code", code: null }, + { code: "no id" }, + ]), + ); + const rows = [ + { id: "kept", payload: { id: "kept", report: {} } }, + // An account row whose payload predates ids is paired by the row's own. + { id: "row-id", payload: { report: {} } }, + { id: "no-code", payload: { id: "no-code", report: {} } }, + { id: "elsewhere", payload: { id: "elsewhere", report: {} } }, + { id: "unwrapped" }, + ]; + const filled = withLocalCode(rows, storage); + assert.deepEqual( + filled.map((row) => row.payload?.code), + ["return 1;", "return 2;", undefined, undefined, undefined], + ); + assert.equal(filled[2], rows[2]); + // Filled into copies: the rows the server sent are left as they came. + assert.equal(rows[0].payload.code, undefined); + + const unreadable = { + getItem: () => { + throw new Error("SecurityError"); + }, + }; + assert.equal(withLocalCode(rows, unreadable), rows); +}); + +/// A quota on everything stored, as a browser counts it: a write that would +/// take the total past it is refused and leaves the store as it was. `memory` +/// is the store behind it, for reading back without a quota in the way. +function quotaStorage(quota) { + const memory = memoryStorage(); + const storage = { + getItem: (key) => memory.getItem(key), + setItem: (key, value) => { + const others = [historyKey, reviewHistoryKey] + .filter((other) => other !== key) + .reduce( + (total, other) => total + (memory.getItem(other) ?? "").length, + 0, + ); + if (others + value.length > quota) throw new Error("QuotaExceededError"); + memory.setItem(key, value); }, + }; + return { memory, storage }; +} + +test("a history the quota refuses drops older code before the new report's", async () => { + const quota = 120_000; + const { memory, storage } = quotaStorage(quota); + const fetcher = async () => response({ signedIn: false }); + const save = (id, code) => + saveReportHistory( + { id, problemId: "two-sum", code, report: { decision: "HIRE" } }, + { storage, fetcher }, + ); + const code = "x".repeat(10_000); + const outcomes = []; + // Whether each save, the one a refusal is about, reopens with its own code. + const ownCode = []; + for (let index = 0; index < 16; index += 1) { + outcomes.push((await save(`attempt-${index}`, code)).local); + ownCode.push(readLocalHistory(memory)[0].code === code); + } + // Each of these fits on its own; only the code of all of them together does + // not, and the saves after the first refusal meet that same history. + assert.deepEqual(outcomes, Array(16).fill("saved")); + assert.deepEqual(ownCode, Array(16).fill(true)); + const history = readLocalHistory(memory); + assert.deepEqual( + history.map((row) => row.id), + Array.from({ length: 16 }, (_, index) => `attempt-${15 - index}`), ); - assert.equal(saved.local, "saved"); + // The code that went is the oldest, and every row without code is older + // than every row with it. + const kept = history.map((row) => row.code === code); + assert.ok(kept.includes(false)); assert.deepEqual( - readLocalHistory(memory).map((entry) => entry.id), - ["only-local"], + kept, + [...kept].sort((a, b) => b - a), ); + assert.equal(readReviewHistory(memory).length, 16); + + // A report whose code does not fit even alone is still saved, without it. + assert.equal((await save("too-large", "y".repeat(quota))).local, "saved"); + const after = readLocalHistory(memory); + assert.equal(after[0].id, "too-large"); + assert.equal(after[0].code, undefined); +}); + +test("code gives way when the review store would not fit beside it", async () => { + // The history fits with its code each time, but grows into the room the + // review store needs. Each review is large enough that the store, holding + // every attempt, cannot fit beside a history full of code. + const { memory, storage } = quotaStorage(150_000); + const fetcher = async () => response({ signedIn: false }); + const code = "x".repeat(4_000); + const summary = "s".repeat(2_000); + for (let index = 0; index < 30; index += 1) { + const id = `attempt-${index}`; + const saved = await saveReportHistory( + { id, problemId: "two-sum", code, report: { summary } }, + { storage, fetcher }, + ); + assert.equal(saved.local, "saved", id); + assert.equal(readLocalHistory(memory)[0].code, code, id); + // Read as stored, not through `readReviewHistory`, which would rebuild a + // missing store from the history and hide an attempt it had dropped. + const reviews = JSON.parse(memory.getItem(reviewHistoryKey)); + assert.deepEqual( + reviews.map((row) => row.id), + Array.from({ length: index + 1 }, (_, back) => `attempt-${index - back}`), + id, + ); + } + assert.ok(readLocalHistory(memory).some((row) => row.code === undefined)); }); test("an oversized newest review does not erase older review history", async () => { diff --git a/tests/browser/interview-lifecycle.test.js b/tests/browser/interview-lifecycle.test.js index 9b20b2f4..0654a854 100644 --- a/tests/browser/interview-lifecycle.test.js +++ b/tests/browser/interview-lifecycle.test.js @@ -5,7 +5,7 @@ import { shouldWarnBeforeUnload, } from "../../web/before-unload.js"; import { reportIsPersisted } from "../../web/history.js"; -import { withUnclearLines } from "../../web/lib.js"; +import { MAX_REPORT_BODY_BYTES, withUnclearLines } from "../../web/lib.js"; import { functionBody, read } from "./source.js"; function beforeUnloadEvent(target) { @@ -232,8 +232,54 @@ test("the Leave button releases the warning before tearing down the session", () assert.equal(target.removed, 1); }); +/// `saveHistory` from web/interview.js, compiled against the module state it +/// reads. `whiteboard` and `mode` are passed separately, as the page derives +/// them, so a test can set one without the other. +function loadSaveHistory({ + state, + whiteboard = false, + mode = "coding", + code = "", + id = "unused", + unclear = [], + saveReportHistory, + persisted = reportIsPersisted, + updateBeforeUnloadGuard = () => {}, +}) { + const body = functionBody(read("web/interview.js"), "saveHistory"); + return new Function( + "state", + "randomId", + "problem", + "whiteboard", + "durationMin", + "interviewLoop", + "mode", + "currentCode", + "saveReportHistory", + "reportIsPersisted", + "updateBeforeUnloadGuard", + "unclearLines", + "withUnclearLines", + `let saveGeneration = 0;\n${body}\n}\nreturn saveHistory;`, + )( + state, + () => id, + { page: "two-sum", title: "Two Sum", difficulty: "Easy" }, + whiteboard, + 45, + "coding_only", + mode, + () => code, + saveReportHistory, + persisted, + updateBeforeUnloadGuard, + () => unclear, + withUnclearLines, + ); +} + test("guest reports are not persisted across sessions", async () => { - const source = read("web/interview.js"); const guardStates = []; let saveCalls = 0; @@ -257,37 +303,12 @@ test("guest reports are not persisted across sessions", async () => { guardStates.push(state.reportPersisted); }; - const body = functionBody(source, "saveHistory"); - const saveHistory = new Function( - "state", - "randomId", - "problem", - "whiteboard", - "durationMin", - "interviewLoop", - "mode", - "sessionPromise", - "saveReportHistory", - "reportIsPersisted", - "updateBeforeUnloadGuard", - "unclearLines", - "withUnclearLines", - `let saveGeneration = 0;\n${body}\n}\nreturn saveHistory;`, - )( + const saveHistory = loadSaveHistory({ state, - () => "unused", - { page: "two-sum", title: "Two Sum", difficulty: "Easy" }, - false, - 45, - "coding_only", - "whiteboard", - Promise.resolve({ signedIn: false, guestModeAvailable: true }), + mode: "whiteboard", saveReportHistory, - reportIsPersisted, updateBeforeUnloadGuard, - () => [], - withUnclearLines, - ); + }); const result = await saveHistory({ summary: "guest report" }); @@ -298,7 +319,6 @@ test("guest reports are not persisted across sessions", async () => { }); test("report is saved locally when session state is unavailable", async () => { - const source = read("web/interview.js"); const guardStates = []; let saveCalls = 0; @@ -319,35 +339,14 @@ test("report is saved locally when session state is unavailable", async () => { guardStates.push(state.reportPersisted); }; - const body = functionBody(source, "saveHistory"); - const saveHistory = new Function( - "state", - "randomId", - "problem", - "whiteboard", - "durationMin", - "interviewLoop", - "mode", - "saveReportHistory", - "reportIsPersisted", - "updateBeforeUnloadGuard", - "unclearLines", - "withUnclearLines", - `let saveGeneration = 0;\n${body}\n}\nreturn saveHistory;`, - )( + const saveHistory = loadSaveHistory({ state, - () => "attempt-unknown-session", - { page: "two-sum", title: "Two Sum", difficulty: "Easy" }, - false, - 45, - "coding_only", - "whiteboard", + mode: "whiteboard", + id: "attempt-unknown-session", saveReportHistory, - () => true, + persisted: () => true, updateBeforeUnloadGuard, - () => [], - withUnclearLines, - ); + }); const result = await saveHistory({ summary: "temporary report" }); @@ -362,7 +361,6 @@ test("report is saved locally when session state is unavailable", async () => { }); test("only the newest overlapping report save can release the warning", async () => { - const source = read("web/interview.js"); const pending = []; const guardStates = []; const state = { @@ -375,37 +373,12 @@ test("only the newest overlapping report save can release the warning", async () const saveReportHistory = (entry) => new Promise((resolve) => pending.push({ entry, resolve })); const updateBeforeUnloadGuard = () => guardStates.push(state.reportPersisted); - const body = functionBody(source, "saveHistory"); - const saveHistory = new Function( - "state", - "randomId", - "problem", - "whiteboard", - "durationMin", - "interviewLoop", - "mode", - "sessionPromise", - "saveReportHistory", - "reportIsPersisted", - "updateBeforeUnloadGuard", - "unclearLines", - "withUnclearLines", - `let saveGeneration = 0;\n${body}\n}\nreturn saveHistory;`, - )( + const saveHistory = loadSaveHistory({ state, - () => "unused", - { page: "two-sum", title: "Two Sum", difficulty: "Easy" }, - false, - 45, - "coding_only", - "whiteboard", - Promise.resolve({ signedIn: true }), + mode: "whiteboard", saveReportHistory, - reportIsPersisted, updateBeforeUnloadGuard, - () => [], - withUnclearLines, - ); + }); const provisional = saveHistory({ summary: "provisional" }); const final = saveHistory({ summary: "final" }); @@ -429,6 +402,48 @@ test("only the newest overlapping report save can release the warning", async () assert.deepEqual(guardStates, [false, false]); }); +test("a saved attempt carries the final code, and a whiteboard none", async () => { + const code = "function twoSum() {\n return [0, 1];\n}\n"; + for (const [whiteboard, expected] of [ + [false, code], + [true, null], + ]) { + const saved = []; + const saveHistory = loadSaveHistory({ + state: { reportId: "attempt-1", language: "javascript" }, + whiteboard, + code: whiteboard ? "" : code, + saveReportHistory: async (entry) => { + saved.push(entry); + return { local: "saved", account: "skipped" }; + }, + }); + await saveHistory({ decision: "HIRE" }); + assert.equal(saved.length, 1); + assert.equal(saved[0].code, expected); + } +}); + +test("a long final code does not cost the attempt its unclear lines", async () => { + // The unclear lines are cut to fit the account body, which never carries + // the code, so code past that whole limit still leaves every line kept. + const code = "x".repeat(MAX_REPORT_BODY_BYTES); + const unclear = ["Line 1.", "Line 2."]; + const saved = []; + const saveHistory = loadSaveHistory({ + state: { reportId: "attempt-1", language: "javascript" }, + code, + unclear, + saveReportHistory: async (entry) => { + saved.push(entry); + return { local: "saved", account: "skipped" }; + }, + }); + await saveHistory({ decision: "HIRE" }); + assert.equal(saved[0].code, code); + assert.deepEqual(saved[0].unclearLines, unclear); +}); + test("pagehide finalizes recovery before disconnecting the room", () => { const source = read("web/interview.js"); const body = functionBody(source, "finalizeRecoveryOnPageHide"); diff --git a/tests/browser/lobby.test.js b/tests/browser/lobby.test.js index 6978e955..5890bc80 100644 --- a/tests/browser/lobby.test.js +++ b/tests/browser/lobby.test.js @@ -4017,6 +4017,73 @@ lobbyTest( }, ); +for (const storage of ["device", "account"]) { + lobbyTest( + `${storage} attempts reopen and download with the code this device kept`, + async (page) => { + const code = "def solve():\n return 42"; + const entries = [ + { title: "Kept code", code }, + { title: "No code kept", code: undefined }, + ].map((item, index) => ({ + id: `attempt-${index}`, + problemId: EASY[0], + problemTitle: item.title, + date: `2026-01-0${2 - index}T00:00:00Z`, + language: "python", + code: item.code, + report: { decision: "HIRE", codingScore: 81 }, + })); + if (storage === "account") { + // The account copy never carries the code; only this device does. + reports = entries.map((entry) => ({ + id: entry.id, + payload: { ...entry, code: undefined }, + })); + } else { + session = { signedIn: false }; + } + await page.addInitScript((entries) => { + localStorage.setItem("codetrial_history", JSON.stringify(entries)); + }, entries); + await lobby(page); + const history = page.locator("#attempt-history"); + assert.match( + await page.locator("#progress-summary").textContent(), + storage === "account" + ? /saved to your account/ + : /saved on this device/, + ); + for (const [title, shown, exported] of [ + ["Kept code", code, "```python\n" + code + "\n```"], + [ + "No code kept", + "(final code was not saved)", + "## Final code (Python)\n(final code was not saved)\n", + ], + ]) { + const row = history.locator("li").filter({ hasText: title }); + await row.getByRole("button", { name: "Open report" }).click(); + const finalCode = row + .locator(".report-card details") + .filter({ hasText: "Your final code" }); + assert.equal(await finalCode.locator("pre").textContent(), shown); + + const downloading = page.waitForEvent("download"); + await row.getByRole("button", { name: /^Download report/ }).click(); + const chunks = []; + for await (const chunk of await (await downloading).createReadStream()) + chunks.push(chunk); + assert.ok( + Buffer.concat(chunks).toString("utf8").includes(exported), + title, + ); + } + }, + { acceptDownloads: true }, + ); +} + lobbyTest("try again selects the problem", async (page) => { reports = [savedAttempt(EASY[0])]; await lobby(page); diff --git a/tests/browser/progress.test.js b/tests/browser/progress.test.js index 242f7e11..8d1809c9 100644 --- a/tests/browser/progress.test.js +++ b/tests/browser/progress.test.js @@ -869,3 +869,18 @@ test("problem summaries preserve distinct id-less attempts and epoch timestamps" lastAttemptAt: 0, }); }); + +test("an attempt carries its final code only as a string", () => { + const code = "return 42;\n"; + assert.equal(normalizeProgressEntry({ code, report: {} }).code, code); + assert.equal(normalizeProgressEntry({ code: "", report: {} }).code, ""); + assert.equal( + normalizeProgressEntry({ payload: { code, report: {} } }).code, + code, + ); + for (const stored of [undefined, null, 42, { text: code }, [code]]) + assert.equal( + normalizeProgressEntry({ code: stored, report: {} }).code, + null, + ); +}); diff --git a/web/app.js b/web/app.js index 18f14642..6f42e1b7 100644 --- a/web/app.js +++ b/web/app.js @@ -12,6 +12,7 @@ import { deleteReport, readDeviceHistory, renameLocalHistory, + withLocalCode, } from "./history.js"; import { pickProblem, @@ -1032,8 +1033,9 @@ async function renderServerHistory(generation) { // The picker needs the account row's timestamp for review scheduling and // the verdict as it was saved; the progress panel normalizes its own. // The server reads its rows back under page names, so no map is needed. - reports = data.reports.map(pickerEntry); - showProgress(data.reports, "saved to your account"); + const rows = withLocalCode(data.reports); + reports = rows.map(pickerEntry); + showProgress(rows, "saved to your account"); } catch { if (generation !== historyLoad) return; showProgressError("Could not load saved account progress."); @@ -1782,7 +1784,7 @@ function renderAttemptHistory(attempts) { report: attempt.report, problemTitle: attempt.problemTitle, language: languageLabel(attempt.language ?? "not recorded"), - code: "(final code was not saved)", + code: attempt.code ?? "(final code was not saved)", unclearLines: attempt.unclearLines, }); report.querySelector(".report-actions")?.remove(); @@ -1861,7 +1863,7 @@ function downloadSavedReport(attempt) { }, problemTitle: attempt.problemTitle, language: attempt.language, - code: null, + code: attempt.code, transcript: null, at: new Date(attempt.at).toLocaleString(), unclearLines: attempt.unclearLines, diff --git a/web/history.js b/web/history.js index a1f76bd4..6f9c6532 100644 --- a/web/history.js +++ b/web/history.js @@ -386,10 +386,27 @@ function saveLocalReport(entry, storage) { const others = previous.filter( (row) => entry?.id == null || row?.id !== entry.id, ); - storage.setItem( - historyKey, - JSON.stringify([entry, ...others].slice(0, 20)), - ); + const rows = [entry, ...others].slice(0, 20); + // The final code is what makes this history heavy enough to meet the + // quota, so each step gives some up: the older rows' first, since this + // report's code is the one the candidate came back for, then its own. + // Refusing the save would lose the whole attempt, and the next save would + // meet the same rows and fail too. + const steps = [ + rows, + rows.map((row, index) => (index ? withoutCode(row) : row)), + rows.map(withoutCode), + ]; + let step = 0; + for (;;) { + try { + storage.setItem(historyKey, JSON.stringify(steps[step])); + break; + } catch (error) { + step += 1; + if (step === steps.length) throw error; + } + } // The short history is what the lobby draws this report from, so a review // store that refuses the write costs the reopen twenty attempts from now, // not this save. Reporting "Report was not saved" for it told the candidate @@ -398,13 +415,43 @@ function saveLocalReport(entry, storage) { // Rebuilt from the merged view rather than from the review store alone, so // an attempt whose own review write was refused goes back in the next time // one lands, instead of being lost the moment it rolls past the twenty. - try { - storage.setItem( - reviewHistoryKey, - boundedReviews(readDeviceHistory(storage)).json, - ); - } catch { - /* the report is on this device either way */ + // + // A refusal here may be the code's doing too: a history that fits with it + // can leave too little for this write, and an attempt it drops is gone + // once it rolls past the twenty. So the history takes the next step and + // the write is tried again, until it lands or no code is left to give up. + // + // When nothing given up lets it land, the refusal was never the code's, + // as on an origin already near its quota, and the history goes back to + // the step it first landed at: leaving it at the last would save every + // attempt there without its code, this one included. + const landed = step; + for (;;) { + try { + storage.setItem( + reviewHistoryKey, + boundedReviews(readDeviceHistory(storage)).json, + ); + break; + } catch { + if (step + 1 < steps.length) { + try { + storage.setItem(historyKey, JSON.stringify(steps[step + 1])); + step += 1; + continue; + } catch { + /* no lighter history to try the write beside */ + } + } + if (step !== landed) { + try { + storage.setItem(historyKey, JSON.stringify(steps[landed])); + } catch { + /* the lighter history still holds the report */ + } + } + break; + } } return "saved"; } catch { @@ -417,13 +464,17 @@ function saveLocalReport(entry, storage) { // hundred times over on a single save. A review too large for what is left is // skipped and the walk carries on, so one outsized report costs itself and not // the older attempts behind it. +// +// Without the final code. It stays in the short history, so the newest twenty +// reopen with it, while the budget here buys as many older attempts as before. function boundedReviews(reviews) { const encoder = new TextEncoder(); const retained = []; const texts = []; // The brackets the array itself serializes to, before any element. let bytes = 2; - for (const review of reviews.slice(0, REVIEW_HISTORY_CAP)) { + for (const row of reviews.slice(0, REVIEW_HISTORY_CAP)) { + const review = withoutCode(row); const text = JSON.stringify(review); // A separator for every element after the first. const size = encoder.encode(text).length + (retained.length ? 1 : 0); @@ -438,6 +489,51 @@ function boundedReviews(reviews) { return { retained, json: `[${texts.join(",")}]` }; } +/// A stored row with its final code left out, and any other row as it is. +/// Copied field by field for the reason `legacyKey` gives, and defined rather +/// than assigned: assigning a stored `__proto__` field would set the copy's +/// prototype instead and drop the field from the row written back. Not a +/// null-prototype object as there, because these rows go on to the lobby. +function withoutCode(entry) { + if (!pairable(entry) || !Object.hasOwn(entry, "code")) return entry; + const copy = {}; + for (const field of Object.keys(entry)) { + if (field === "code") continue; + Object.defineProperty(copy, field, { + value: entry[field], + enumerable: true, + writable: true, + configurable: true, + }); + } + return copy; +} + +/// Account rows, each given the final code this device kept for its report. +/// +/// The account copy carries none, so a signed-in lobby drew the placeholder +/// even on the device that still had the code. Paired by the report id the +/// two copies share, the way `normalizeProgressEntry` reads it; a row this +/// device holds no code for is passed through as it came. Storage that cannot +/// be read is no history to `readLocalHistory`, so it costs the code alone. +export function withLocalCode(reports, storage) { + const codes = new Map(); + for (const row of readLocalHistory(storage)) { + if (row?.id != null && typeof row.code === "string") + codes.set(String(row.id), row.code); + } + if (codes.size === 0) return reports; + return reports.map((row) => { + if (!pairable(row?.payload)) return row; + const code = codes.get(String(row.payload.id ?? row.id ?? "")); + return code === undefined + ? row + : { ...row, payload: { ...row.payload, code } }; + }); +} + +/// The account copy carries no code: README.md says code snapshots stay in +/// memory unless recording is enabled, and the server keeps this body as sent. async function saveAccountReport(entry, fetcher) { const session = await sessionState(fetcher); if (session === "out") return "skipped"; @@ -446,7 +542,7 @@ async function saveAccountReport(entry, fetcher) { const saved = await fetcher("/api/reports", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify(entry), + body: JSON.stringify(withoutCode(entry)), signal: AbortSignal.timeout(requestTimeoutMs), }); return saved.ok ? "saved" : "failed"; diff --git a/web/interview.js b/web/interview.js index 3498ee0a..913d8626 100644 --- a/web/interview.js +++ b/web/interview.js @@ -3994,6 +3994,11 @@ async function saveHistory(report = state.report) { // No language at a whiteboard: nothing is compiled, the tabs are not on // screen, and `state.language` is the default nobody chose. Recorded as one, // it would filter the candidate's own history by a choice they never made. + // + // The code for the same reason: there is none, and an empty string would be + // shown as an empty editor. history.js keeps it in the short history only, + // so it joins after the unclear lines are bounded: they are cut to fit the + // account body, which never carries the code. const base = { id: state.reportId, date: new Date().toISOString(), @@ -4007,7 +4012,10 @@ async function saveHistory(report = state.report) { interviewMode: mode, report, }; - const entry = withUnclearLines(base, unclearLines()); + const entry = { + ...withUnclearLines(base, unclearLines()), + code: whiteboard ? null : currentCode(), + }; // A later save may replace an earlier report under this same id. Keep the // warning active until this save itself confirms that Past attempts can // reopen the current copy. diff --git a/web/progress.js b/web/progress.js index 6137eb1a..4352944d 100644 --- a/web/progress.js +++ b/web/progress.js @@ -101,6 +101,8 @@ export function normalizeProgressEntry(raw) { recordedDecision: ["HIRE", "NO_HIRE"].includes(entry.report?.decision) ? entry.report.decision : null, + // Only the short local history keeps it; anything else reopens without. + code: typeof entry.code === "string" ? entry.code : null, report, unclearLines: unclearLinesOf(entry.unclearLines), };