Skip to content
Merged
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
183 changes: 183 additions & 0 deletions tests/browser/editor-font-size.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Run with: node --test tests/browser/editor-font-size.test.js
//
// The code font size is a candidate's choice kept between visits. What can
// break is the agreement between the textarea, the highlight overlay and the
// gutter: one layer at a different size or offset and the caret drifts away
// from the glyphs it edits, so every offered size is checked in a real browser.

import { after, before, test } from "node:test";
import assert from "node:assert/strict";

import {
DEFAULT_RUNTIME_CONFIG,
launchChromium,
startStaticServer,
} from "./source.js";

let browser = null;
let server = null;
let base = "";

before(async () => {
browser = await launchChromium();
if (!browser) return;
({ server, base } = await startStaticServer({
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
}));
});

after(async () => {
await browser?.close();
await new Promise((closed) => (server ? server.close(closed) : closed()));
});

/// Out from behind the media preflight, which covers the toolbar until a device
/// check clears it. Hidden rather than reached through with a scripted click,
/// so the clicks below still have to land on the buttons a candidate sees.
/// Waits on the placeholder `init()` sets, not on static markup, so the stored
/// size has been applied and the buttons have their handlers.
async function openEditor(page) {
await page.waitForFunction(
() => document.querySelector("#candidate-case-input").placeholder !== "",
);
await page.evaluate(() => {
document.querySelector("#audio-check").hidden = true;
});
}

/// Four-digit line numbers, which the gutter is tightest for at the smallest
/// size. Refilled after every reload, because the buffer is not kept. Set with
/// one input event rather than `page.fill`, which takes half a minute to type
/// this many lines.
const fillLongBuffer = (page) =>
page.evaluate(() => {
const editor = document.querySelector("#editor");
editor.value = Array.from({ length: 1200 }, (_, i) => `x = ${i}`).join(
"\n",
);
editor.dispatchEvent(new Event("input", { bubbles: true }));
});

const editorMetrics = (page) =>
page.evaluate(() => {
const layers = ["#editor", "#editor-highlight", "#editor-lines"].map((id) =>
document.querySelector(id),
);
const [editor, , gutter] = layers;
return {
sizes: layers.map((node) => getComputedStyle(node).fontSize),
lineHeights: layers.map((node) => getComputedStyle(node).lineHeight),
gutterRight: gutter.getBoundingClientRect().right,
editorLeft: editor.getBoundingClientRect().left,
highlightLeft: document
.querySelector("#editor-highlight")
.getBoundingClientRect().left,
gutterOverflows: gutter.scrollWidth > gutter.clientWidth,
smallerDisabled:
document
.querySelector("#editor-font-smaller")
.getAttribute("aria-disabled") === "true",
largerDisabled:
document
.querySelector("#editor-font-larger")
.getAttribute("aria-disabled") === "true",
focused: document.activeElement?.id ?? "",
stored: localStorage.getItem("codetrial:editorFontSize"),
};
});

function assertLayersAgree(metrics, label) {
assert.equal(new Set(metrics.sizes).size, 1, `${label}: ${metrics.sizes}`);
assert.equal(
new Set(metrics.lineHeights).size,
1,
`${label}: ${metrics.lineHeights}`,
);
assert.equal(metrics.editorLeft, metrics.highlightLeft, label);
assert.equal(metrics.editorLeft, metrics.gutterRight, label);
assert.equal(metrics.gutterOverflows, false, `${label}: gutter overflows`);
}

/// Clicks once per expected size rather than until the button disables, so a
/// button that stops stepping or never disables fails here instead of
/// clicking until CI times out.
async function stepThrough(page, direction, sizes) {
const disabled = `${direction}Disabled`;
for (const [index, size] of sizes.entries()) {
await page.click(`#editor-font-${direction}`);
const metrics = await editorMetrics(page);
assert.equal(metrics.sizes[0], size, `${direction} to ${size}`);
assertLayersAgree(metrics, `${direction} ${size}`);
assert.equal(
metrics[disabled],
index === sizes.length - 1,
`${direction} at ${size}: disabled only at the end of the ladder`,
);
assert.equal(
metrics.focused,
`editor-font-${direction}`,
`${direction} at ${size}: focus stays on the button`,
);
}
}

test("every offered code font size keeps the editor layers in line, and is remembered", async (t) => {
if (!browser) return t.skip("playwright chromium unavailable");
const page = await browser.newPage();
try {
await page.goto(`${base}/interview.html`, {
waitUntil: "domcontentloaded",
});
await openEditor(page);
await fillLongBuffer(page);

const initial = await editorMetrics(page);
assert.equal(initial.sizes[0], "14px", "0.875rem unless changed");
assert.equal(initial.smallerDisabled, false);
assert.equal(initial.largerDisabled, false);
assertLayersAgree(initial, "default");

await stepThrough(page, "larger", ["16px", "18px", "20px", "24px"]);

await page.reload({ waitUntil: "domcontentloaded" });
Comment thread
ColtenOuO marked this conversation as resolved.
await openEditor(page);
await fillLongBuffer(page);
const returning = await editorMetrics(page);
assert.equal(returning.sizes[0], "24px", "the choice is remembered");
assert.equal(returning.largerDisabled, true);

await stepThrough(page, "smaller", [
"20px",
"18px",
"16px",
"14px",
"12px",
]);

await page.click("#editor-font-larger");
const back = await editorMetrics(page);
assert.equal(back.sizes[0], "14px");
assert.equal(back.stored, null, "the default is stored as no preference");
} finally {
await page.close();
}
});

test("a stored size the page does not offer falls back to the default", async (t) => {
if (!browser) return t.skip("playwright chromium unavailable");
const page = await browser.newPage();
try {
await page.addInitScript(() =>
localStorage.setItem("codetrial:editorFontSize", "9"),
);
await page.goto(`${base}/interview.html`, {
waitUntil: "domcontentloaded",
});
await openEditor(page);
const metrics = await editorMetrics(page);
assert.equal(metrics.sizes[0], "14px");
assertLayersAgree(metrics, "fallback");
} finally {
await page.close();
}
});
24 changes: 24 additions & 0 deletions web/interview.html
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,30 @@ <h1 id="problem-title">Loading interview...</h1>
Java
</button>
</div>
<div
class="font-size-control"
role="group"
aria-label="Code font size"
>
<button
id="editor-font-smaller"
class="font-size-button"
type="button"
title="Smaller code"
aria-label="Smaller code"
>
A-
</button>
<button
id="editor-font-larger"
class="font-size-button"
type="button"
title="Larger code"
aria-label="Larger code"
>
A+
</button>
</div>
<p class="compile-disclosure">
C, C++ and Java runs are sent to Compiler Explorer.
</p>
Expand Down
53 changes: 53 additions & 0 deletions web/interview.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,12 @@ const CODE_PUBLISH_DEBOUNCE_MS = 300;
/// it is about. Measured against the hint text, not chosen round.
const FRAMEWORK_HINT_MS = 12000;
const HIDE_EXAMPLES_KEY = "codetrial:hideExamples";
const EDITOR_FONT_SIZE_KEY = "codetrial:editorFontSize";
/// In rem. A fixed ladder rather than a free number, so a stored value is
/// either one of these or ignored, and every size the page can show is one the
/// browser check has seen keep the three editor layers in line.
const EDITOR_FONT_SIZES = ["0.75", "0.875", "1", "1.125", "1.25", "1.5"];
const DEFAULT_EDITOR_FONT_SIZE = "0.875";

// From /runtime-config.js, which is the only thing allowed to name what the
// server does. A literal here would be a second answer to "does this server
Expand Down Expand Up @@ -360,6 +366,9 @@ const nodes = {
editor: document.querySelector("#editor"),
editorHighlight: document.querySelector("#editor-highlight"),
editorLines: document.querySelector("#editor-lines"),
editorStack: document.querySelector(".editor-stack"),
editorFontSmaller: document.querySelector("#editor-font-smaller"),
editorFontLarger: document.querySelector("#editor-font-larger"),
compileDisclosure: document.querySelector(".compile-disclosure"),
run: document.querySelector("#run-tests"),
candidateCaseInput: document.querySelector("#candidate-case-input"),
Expand Down Expand Up @@ -436,6 +445,7 @@ async function init() {
state.transcript = createTranscriptView(document, nodes.transcriptPanel);
renderRuntimeConfig();
nodes.hideExamples.checked = readStored(HIDE_EXAMPLES_KEY) === "1";
applyEditorFontSize(readStored(EDITOR_FONT_SIZE_KEY));
renderProblem();
applyLanguages(null);
setLanguage("python");
Expand Down Expand Up @@ -640,6 +650,23 @@ function bindEvents() {
setLanguage(button.dataset.language),
);
}
for (const [button, step] of [
[nodes.editorFontSmaller, -1],
[nodes.editorFontLarger, 1],
]) {
button.addEventListener("click", () => {
const index = EDITOR_FONT_SIZES.indexOf(editorFontSize()) + step;
const size = EDITOR_FONT_SIZES[index];
if (!size) return;
applyEditorFontSize(size);
// The default is stored as no preference, so a later change of default
// reaches whoever never moved off it.
writeStored(
EDITOR_FONT_SIZE_KEY,
size === DEFAULT_EDITOR_FONT_SIZE ? "" : size,
);
});
}
// The overlay does not scroll on its own; it follows the textarea.
nodes.editor.addEventListener("scroll", () => {
nodes.editorHighlight.scrollTop = nodes.editor.scrollTop;
Expand Down Expand Up @@ -2797,6 +2824,32 @@ function paintEditor() {
}
}

/// Sets one custom property that the textarea, the highlight overlay and the
/// gutter all read, rather than a font size on each: three writes could leave
/// them disagreeing for a frame, or for good if one were missed, and the caret
/// then sits apart from the glyphs it edits.
function applyEditorFontSize(stored) {
const size = EDITOR_FONT_SIZES.includes(stored)
? stored
: DEFAULT_EDITOR_FONT_SIZE;
nodes.editorStack.style.setProperty("--editor-font-size", `${size}rem`);
const index = EDITOR_FONT_SIZES.indexOf(size);
// aria-disabled rather than disabled: disabling the button just pressed
// drops keyboard focus to <body>, and the click handler already ignores a
// step past either end.
nodes.editorFontSmaller.setAttribute("aria-disabled", String(index === 0));
nodes.editorFontLarger.setAttribute(
"aria-disabled",
String(index === EDITOR_FONT_SIZES.length - 1),
);
}

function editorFontSize() {
return nodes.editorStack.style
.getPropertyValue("--editor-font-size")
.replace(/rem$/, "");
}

function currentCode() {
return nodes.editor.value;
}
Expand Down
20 changes: 15 additions & 5 deletions web/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,8 @@ p {

/* A tab the judge cannot run, dimmed rather than removed: the row stays the
same five tabs everywhere, and the title says which one is out and why. */
.language-button:disabled {
.language-button:disabled,
.font-size-button[aria-disabled="true"] {
opacity: 0.6;
cursor: not-allowed;
}
Expand Down Expand Up @@ -456,6 +457,7 @@ p {
.difficulty-row,
.editor-toolbar,
.language-tabs,
.font-size-control,
.report-header,
.report-actions,
.history-header {
Expand Down Expand Up @@ -640,6 +642,7 @@ p {
.pill-button,
.tab-button,
.language-button,
.font-size-button,
.run-button {
border: 1px solid var(--hairline);
border-radius: 999px;
Expand Down Expand Up @@ -753,6 +756,7 @@ p {
.pill-button,
.tab-button,
.language-button,
.font-size-button,
.run-button {
padding: 0.4rem 0.75rem;
cursor: pointer;
Expand Down Expand Up @@ -868,6 +872,12 @@ p {
}

.editor-stack {
/* Set from interview.js when the candidate picks a size. The gutter scales
with it, or a three-digit line number overflows it at the largest size,
but never drops below 3.25rem, or a four-digit one is clipped at the
smallest. */
--editor-font-size: 0.875rem;
--editor-gutter: max(3.25rem, calc(var(--editor-font-size) * 3.72));
position: relative;
flex: 1;
min-height: 25rem;
Expand All @@ -879,12 +889,12 @@ p {
#editor,
#editor-highlight {
position: absolute;
inset: 0 0 0 3.25rem;
inset: 0 0 0 var(--editor-gutter);
margin: 0;
border: 0;
padding: 1rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
font-size: var(--editor-font-size);
line-height: 1.5;
tab-size: 4;
/* Not pre-wrap: the gutter numbers logical lines, so a wrapped line would
Expand All @@ -902,15 +912,15 @@ p {
#editor-lines {
position: absolute;
inset: 0 auto 0 0;
width: 3.25rem;
width: var(--editor-gutter);
margin: 0;
padding: 1rem 0.5rem;
overflow: hidden;
color: var(--muted);
background: #181816;
border-right: 1px solid var(--hairline);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.875rem;
font-size: var(--editor-font-size);
line-height: 1.5;
text-align: right;
user-select: none;
Expand Down