diff --git a/scripts/browser-check.cjs b/scripts/browser-check.cjs index 4047e9509..16351d715 100644 --- a/scripts/browser-check.cjs +++ b/scripts/browser-check.cjs @@ -69,6 +69,20 @@ function scenarioTitle(problemId) { return scenario(problemId).title; } +async function selectLanguage(page, language) { + const values = { + "Python 3": "python", + Python: "python", + JavaScript: "javascript", + C: "c", + "C++": "cpp", + Java: "java", + }; + await page + .locator("#language-select") + .selectOption(values[language] ?? language); +} + function interviewUrl(problemId, mode) { const surface = mode ? `&mode=${mode}` : ""; return `${process.env.BASE_URL}/interview?problem=${scenario(problemId).page}&duration=20${surface}`; @@ -309,7 +323,7 @@ async function checkEditorNewlines(page) { expected, caret = expected.length, } of cases) { - await page.getByRole("button", { name: language, exact: true }).click(); + await selectLanguage(page, language); await editor.fill(value); await editor.evaluate( (node, range) => node.setSelectionRange(...range), @@ -352,10 +366,8 @@ async function checkEditorNewlines(page) { assert.equal(await editor.inputValue(), expected, `${name}: redo`); const otherLanguage = language === "Python 3" ? "JavaScript" : "Python 3"; - await page - .getByRole("button", { name: otherLanguage, exact: true }) - .click(); - await page.getByRole("button", { name: language, exact: true }).click(); + await selectLanguage(page, otherLanguage); + await selectLanguage(page, language); assert.equal( await editor.inputValue(), expected, @@ -1159,7 +1171,7 @@ async function isolateRustAgent( // global set to "" and asserts every compiled language reports itself not // wired up. Neither has a load order to lose. if (compilerExplorerMock) { - await page.getByRole("button", { name: "C", exact: true }).click(); + await selectLanguage(page, "C"); await page.getByLabel("Code editor").fill(`#include int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { int* out = malloc(sizeof(int) * 2); @@ -1189,7 +1201,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "C++" }).click(); + await selectLanguage(page, "C++"); await page.getByLabel("Code editor").fill(`#include using namespace std; class BidLedger { @@ -1221,7 +1233,7 @@ public: }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "Java", exact: true }).click(); + await selectLanguage(page, "Java"); await page.getByLabel("Code editor").fill(`class OrderedCursor { private final ArrayDeque stack = new ArrayDeque<>(); public OrderedCursor(TreeNode root) { pushLeft(root); } @@ -1243,7 +1255,7 @@ public: return; } if (compilerExplorerBaseUrl !== "__default__") { - await page.getByRole("button", { name: "C++" }).click(); + await selectLanguage(page, "C++"); await page.getByLabel("Code editor").fill(`class Solution { public: vector matchDisputedCharge(vector& nums, int target) { @@ -1263,7 +1275,7 @@ public: .waitFor({ timeout: 120000 }); return; } - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor").fill(`let i = 0; function matchDisputedCharge() { return spec.cases[i++].expected; @@ -1286,7 +1298,7 @@ function matchDisputedCharge() { } `); await runAndExpectPassing(PROBLEM_IDS.twoSum); - await page.getByRole("button", { name: "C++" }).click(); + await selectLanguage(page, "C++"); await page.getByLabel("Code editor").fill(`class Solution { public: vector matchDisputedCharge(vector& nums, int target) { @@ -1319,7 +1331,7 @@ public: .locator("#results-body pre") .filter({ hasText: /Compilation failed|expected|error/i }) .waitFor({ timeout: 120000 }); - await page.getByRole("button", { name: "C", exact: true }).click(); + await selectLanguage(page, "C"); await page.getByLabel("Code editor").fill(`#include int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { int* out = malloc(sizeof(int) * 2); @@ -1338,7 +1350,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { } `); await runAndExpectPassing(PROBLEM_IDS.twoSum, 120000); - await page.getByRole("button", { name: "Python" }).click(); + await selectLanguage(page, "Python"); await page.getByLabel("Code editor").fill(`class Solution: def matchDisputedCharge(self, nums, target): seen = {} @@ -1365,7 +1377,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function spliceReadings(nums1, m, nums2, n) { let write = m + n - 1; @@ -1393,7 +1405,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function capRepeatsAtTwo(nums) { let write = 0; @@ -1436,7 +1448,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function interleaveEvents(list1, list2) { const dummy = new ListNode(); @@ -1468,7 +1480,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function snapshotOutline(head) { if (!head) return null; @@ -1494,7 +1506,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function wrapPlaylistTail(head, k) { if (!head || !head.next) return head; @@ -1527,7 +1539,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor").fill(`function mirrorLayout(root) { if (!root) return null; const left = mirrorLayout(root.left); @@ -1549,7 +1561,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function rebuildFromDumps(preorder, inorder) { const positions = new Map(inorder.map((value, index) => [value, index])); @@ -1579,7 +1591,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function linkRowNeighbors(root) { let level = root; @@ -1608,7 +1620,7 @@ int* matchDisputedCharge(int* nums, int numsSize, int target, int* returnSize) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`var OrderedCursor = function(root) { this.stack = []; @@ -1645,7 +1657,7 @@ OrderedCursor.prototype.hasNext = function() { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function nearestSharedApprover(root, p, q) { if (!root || root === p || root === q) return root; @@ -1668,7 +1680,7 @@ OrderedCursor.prototype.hasNext = function() { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function serpentineSweep(root) { if (!root) return []; @@ -1704,7 +1716,7 @@ OrderedCursor.prototype.hasNext = function() { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function indexOrderingHolds(root) { function valid(node, low, high) { @@ -1728,7 +1740,7 @@ OrderedCursor.prototype.hasNext = function() { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function replicateTopology(node) { if (!node) return null; @@ -1756,7 +1768,7 @@ OrderedCursor.prototype.hasNext = function() { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function planMigrationOrder(migrationCount, dependencies) { const graph = Array.from({ length: migrationCount }, () => []); @@ -1793,7 +1805,7 @@ OrderedCursor.prototype.hasNext = function() { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor").fill(`var CommandIndex = function() { this.children = new Map(); this.word = false; @@ -1839,7 +1851,7 @@ CommandIndex.prototype.find = function(text) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`var PatternLexicon = function() { this.children = new Map(); @@ -1884,7 +1896,7 @@ PatternLexicon.prototype.search = function(word) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function listPalletLoadouts(candidates, target) { candidates.sort((a, b) => a - b); @@ -1917,7 +1929,7 @@ PatternLexicon.prototype.search = function(word) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function allStartupOrders(nums) { const results = []; @@ -1952,7 +1964,7 @@ PatternLexicon.prototype.search = function(word) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function wellNestedSequences(n) { const results = []; @@ -1981,7 +1993,7 @@ PatternLexicon.prototype.search = function(word) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor").fill(`function countSafeLayouts(n) { let count = 0; const cols = new Set(); @@ -2020,7 +2032,7 @@ PatternLexicon.prototype.search = function(word) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function canTraceWord(board, word) { function dfs(row, col, index) { @@ -2057,7 +2069,7 @@ PatternLexicon.prototype.search = function(word) { }) .waitFor(); await page.getByText("Offline", { exact: true }).waitFor(); - await page.getByRole("button", { name: "JavaScript" }).click(); + await selectLanguage(page, "JavaScript"); await page.getByLabel("Code editor") .fill(`function buildBalancedIndex(nums) { function build(left, right) { diff --git a/tests/browser/candidate-cases.test.js b/tests/browser/candidate-cases.test.js index 274c43dca..4072c657e 100644 --- a/tests/browser/candidate-cases.test.js +++ b/tests/browser/candidate-cases.test.js @@ -112,8 +112,8 @@ test("candidate cases run when session storage cannot be written", async (t) => ); assert.equal(await page.locator("#candidate-case-input").inputValue(), ""); + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { - document.querySelector('[data-language="javascript"]').click(); document.querySelector("#run-tests").click(); }); await page.waitForFunction(() => @@ -145,9 +145,9 @@ test("candidate cases run when session storage cannot be reached", async (t) => waitUntil: "domcontentloaded", }); await candidateCasesReady(page); + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { document.querySelector("#candidate-case-input").value = "[[2,7,11,15],9]"; - document.querySelector('[data-language="javascript"]').click(); document.querySelector("#run-tests").click(); }); await page.waitForFunction(() => @@ -234,7 +234,7 @@ test("adding then running during judge load shares one addition", async (t) => { // test is holding. Retry the click instead and let the guard report its // own readiness, because `addCandidateCase` disables the button before its // first await, so a click that took is proof `bindEvents` has run. That - // function is synchronous, so the language tabs and the run button are + // function is synchronous, so the language selector and the run button are // bound by then too. await page.waitForFunction(() => { const add = document.querySelector("#candidate-case-add"); @@ -243,8 +243,8 @@ test("adding then running during judge load shares one addition", async (t) => { add.click(); return add.disabled; }); + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { - document.querySelector('[data-language="javascript"]').click(); document.querySelector("#run-tests").click(); }); releaseJudge(); @@ -310,9 +310,9 @@ test("a malformed candidate case stops the run it was typed into", async (t) => // what they typed. The reason is asserted to be the one the add path // wrote, because `runTests` used to recover it by reading the status line // back out, so rewording that line changed which runs were blocked. + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { document.querySelector("#candidate-case-input").value = "not json"; - document.querySelector('[data-language="javascript"]').click(); document.querySelector("#run-tests").click(); }); await page.waitForFunction(() => { @@ -367,9 +367,9 @@ test("a sixth candidate case is refused without blocking the run", async (t) => ); } + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { document.querySelector("#candidate-case-input").value = "[[9,9,9,9],18]"; - document.querySelector('[data-language="javascript"]').click(); document.querySelector("#run-tests").click(); }); await page.waitForFunction(() => @@ -541,10 +541,10 @@ test("a case cannot be removed while tests are running", async (t) => { () => document.querySelector("#candidate-case-list").children.length === 2, ); + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { document.querySelector("#candidate-case").open = true; document.querySelector("#candidate-case-input").value = "[[2,7],9]"; - document.querySelector('[data-language="javascript"]').click(); document.querySelector("#run-tests").click(); }); // A removal re-renders the list, so the rows still being there alongside @@ -608,9 +608,12 @@ async function holdJudge(page) { } /// Clicks a tab even when it is disabled, and answers whether it took. -async function clickSelects(tab) { - await tab.evaluate((button) => button.click()); - return tab.evaluate((button) => button.classList.contains("selected")); +async function selectLanguage(page, language) { + const select = page.locator("#language-select"); + const option = page.locator(`#language-select option[value="${language}"]`); + if (await option.isDisabled()) return false; + await select.selectOption(language); + return (await select.inputValue()) === language; } async function withHeldRequestedC(body) { @@ -649,21 +652,34 @@ test("disabled compiled runners cannot be selected while the judge loads", async () => document.querySelector('[data-language="c"]').disabled, ); for (const language of COMPILED_LANGUAGES) { - const tab = page.locator(`[data-language="${language}"]`); - assert.equal(await tab.isDisabled(), true); - assert.match(await tab.getAttribute("title"), /disabled by this server/); - assert.equal(await clickSelects(tab), false); + const option = page.locator( + `#language-select option[value="${language}"]`, + ); + assert.equal(await option.isDisabled(), true); + assert.match( + await option.textContent(), + /unavailable: disabled by server/, + ); + assert.match( + await option.getAttribute("title"), + /disabled by this server/, + ); + assert.equal(await selectLanguage(page, language), false); } releaseJudge(); await candidateCasesReady(page); for (const language of ["python", "javascript"]) { - const tab = page.locator(`[data-language="${language}"]`); - assert.equal(await tab.isDisabled(), false); - assert.equal(await clickSelects(tab), true); + const option = page.locator( + `#language-select option[value="${language}"]`, + ); + assert.equal(await option.isDisabled(), false); + assert.equal(await selectLanguage(page, language), true); } for (const language of COMPILED_LANGUAGES) { assert.equal( - await page.locator(`[data-language="${language}"]`).isDisabled(), + await page + .locator(`#language-select option[value="${language}"]`) + .isDisabled(), true, ); } @@ -686,31 +702,35 @@ test("C stays unavailable until a function-style judge has loaded", async (t) => const page = await browser.newPage(); let releaseJudge = () => {}; try { + const c = page.locator('#language-select option[value="c"]'); releaseJudge = await holdJudge(page); await page.goto(`${enabled.base}/interview.html?problem=${problem}`, { waitUntil: "domcontentloaded", }); - const c = page.locator('[data-language="c"]'); await page.waitForFunction( () => document.querySelector('[data-language="c"]').disabled, ); + assert.match(await c.textContent(), /unavailable: waiting for judge/); assert.match( await c.getAttribute("title"), - /only after function-style tests load/, + /function-style tests load/, ); - assert.equal(await clickSelects(c), false); + assert.equal(await selectLanguage(page, "c"), false); releaseJudge(); await candidateCasesReady(page); assert.equal(await c.isDisabled(), classStyle); - if (classStyle) - assert.match( - await c.getAttribute("title"), - /only builds function judges/, - ); - else assert.equal(await clickSelects(c), true); + if (classStyle) { + assert.match(await c.textContent(), /unavailable: class problem/); + assert.match(await c.getAttribute("title"), /class/); + } else { + assert.equal(await selectLanguage(page, "c"), true); + assert.equal(await c.getAttribute("title"), ""); + } for (const language of ["python", "javascript", "cpp", "java"]) { assert.equal( - await page.locator(`[data-language="${language}"]`).isDisabled(), + await page + .locator(`#language-select option[value="${language}"]`) + .isDisabled(), false, ); } @@ -910,14 +930,10 @@ test("candidate cases are preserved on page reload", async (t) => { test("a requested C tab is restored after its judge loads", async (t) => { if (!browser) return t.skip("playwright chromium unavailable"); await withHeldRequestedC(async (page, releaseJudge) => { - const c = page.locator('[data-language="c"]'); - assert.match( - await page.locator('[data-language="python"]').getAttribute("class"), - /\bselected\b/, - ); + assert.equal(await page.locator("#language-select").inputValue(), "python"); releaseJudge(); await candidateCasesReady(page); - assert.match(await c.getAttribute("class"), /\bselected\b/); + assert.equal(await page.locator("#language-select").inputValue(), "c"); }); }); @@ -931,10 +947,7 @@ test("editing before a requested C judge loads keeps Python", async (t) => { }, edited); releaseJudge(); await candidateCasesReady(page); - assert.match( - await page.locator('[data-language="python"]').getAttribute("class"), - /\bselected\b/, - ); + assert.equal(await page.locator("#language-select").inputValue(), "python"); assert.equal(await page.locator("#editor").inputValue(), edited); }); }); @@ -942,11 +955,13 @@ test("editing before a requested C judge loads keeps Python", async (t) => { test("choosing JavaScript before a requested C judge loads keeps JavaScript", async (t) => { if (!browser) return t.skip("playwright chromium unavailable"); await withHeldRequestedC(async (page, releaseJudge) => { - const javascript = page.locator('[data-language="javascript"]'); - assert.equal(await clickSelects(javascript), true); + assert.equal(await selectLanguage(page, "javascript"), true); releaseJudge(); await candidateCasesReady(page); - assert.match(await javascript.getAttribute("class"), /\bselected\b/); + assert.equal( + await page.locator("#language-select").inputValue(), + "javascript", + ); }); }); @@ -957,10 +972,7 @@ test("ending before a requested C judge loads keeps Python", async (t) => { await page.locator("#ending-overlay").waitFor({ state: "visible" }); releaseJudge(); await candidateCasesReady(page); - assert.match( - await page.locator('[data-language="python"]').getAttribute("class"), - /\bselected\b/, - ); + assert.equal(await page.locator("#language-select").inputValue(), "python"); }); }); @@ -973,9 +985,6 @@ test("running before a requested C judge loads keeps Python", async (t) => { ); releaseJudge(); await candidateCasesReady(page); - assert.match( - await page.locator('[data-language="python"]').getAttribute("class"), - /\bselected\b/, - ); + assert.equal(await page.locator("#language-select").inputValue(), "python"); }); }); diff --git a/tests/browser/comment-shortcut.test.js b/tests/browser/comment-shortcut.test.js index da03efc35..6ab486081 100644 --- a/tests/browser/comment-shortcut.test.js +++ b/tests/browser/comment-shortcut.test.js @@ -118,7 +118,7 @@ test("the comment marker follows the language tab", async (t) => { ["javascript", "//"], ["python", "#"], ]) { - await page.locator(`[data-language="${language}"]`).click(); + await page.locator("#language-select").selectOption(language); await setBuffer(page, "x", 0); await page.keyboard.press("Control+/"); assert.equal(await buffer(page), `${marker} x`, language); diff --git a/tests/browser/compiler-explorer.test.js b/tests/browser/compiler-explorer.test.js index 474c6130a..5b5cb9ae8 100644 --- a/tests/browser/compiler-explorer.test.js +++ b/tests/browser/compiler-explorer.test.js @@ -12,6 +12,7 @@ import { compilerType, generateHarness, HARNESS_SIGNATURE_NOTE, + harnessAvailability, harnessGap, harnessLineOffset, languagesFor, @@ -1605,6 +1606,10 @@ test("a class-style judge withholds C, and says why", () => { languagesFor(judges["lru-cache"]), ALL_LANGUAGES.filter((one) => one !== "c"), ); + assert.deepEqual(harnessAvailability("c", judges["lru-cache"]), { + message: harnessGap("c", judges["lru-cache"]), + reason: "class problem", + }); assert.match( harnessGap("c", judges["lru-cache"]), /only builds function judges/, @@ -1619,6 +1624,10 @@ test("C is offered only once a function-style judge is known", () => { languagesFor(null), ALL_LANGUAGES.filter((one) => one !== "c"), ); + assert.deepEqual(harnessAvailability("c", null), { + message: harnessGap("c", null), + reason: "waiting for judge", + }); assert.match(harnessGap("c", null), /only after function-style tests load/); }); @@ -1631,6 +1640,10 @@ test("disabled compiled runners withhold their languages for every judge", () => for (const spec of [null, judges["two-sum"], judges["lru-cache"]]) { assert.deepEqual(languagesFor(spec), ["python", "javascript"]); for (const language of COMPILED_LANGUAGES) { + assert.deepEqual(harnessAvailability(language, spec), { + message: harnessGap(language, spec), + reason: "disabled by server", + }); assert.match(harnessGap(language, spec), /disabled by this server/); } const available = diff --git a/tests/browser/dom-contract.test.js b/tests/browser/dom-contract.test.js index ee7f86914..38176b976 100644 --- a/tests/browser/dom-contract.test.js +++ b/tests/browser/dom-contract.test.js @@ -321,7 +321,7 @@ test("the interview page keeps the structure the script drives", () => { assert.match( page, /data-language="python"/, - "the language tabs drive setLanguage", + "the language selector drives setLanguage", ); assert.match( page, diff --git a/tests/browser/editor-pipeline.test.js b/tests/browser/editor-pipeline.test.js index 64344adf4..b4dacd659 100644 --- a/tests/browser/editor-pipeline.test.js +++ b/tests/browser/editor-pipeline.test.js @@ -107,9 +107,9 @@ test("the interview restores an offered language from the URL", async (t) => { if (!ctx) return; const { page, errors } = ctx; try { - assert.match( - await page.locator('[data-language="javascript"]').getAttribute("class"), - /\bselected\b/, + assert.equal( + await page.locator("#language-select").inputValue(), + "javascript", ); assert.deepEqual(errors, []); } finally { @@ -122,10 +122,7 @@ test("the interview keeps Python for an unavailable saved language", async (t) = if (!ctx) return; const { page, errors } = ctx; try { - assert.match( - await page.locator('[data-language="python"]').getAttribute("class"), - /\bselected\b/, - ); + assert.equal(await page.locator("#language-select").inputValue(), "python"); assert.deepEqual(errors, []); } finally { await page.close(); @@ -301,8 +298,8 @@ test("nested matching ignores quoted brackets and tracks programmatic selection" const { page, errors } = ctx; const code = '{ call("}", items[0]); /* ] */ }'; try { + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { - document.querySelector('[data-language="javascript"]').click(); document.querySelector("#editor").focus(); }); await page.keyboard.press("ControlOrMeta+A"); @@ -389,8 +386,8 @@ test("caret and focus changes reuse ranges until code or language changes", asyn if (!ctx) return; const { page, errors } = ctx; try { + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(async () => { - document.querySelector('[data-language="javascript"]').click(); await (await import("/syntax-parser.js")).prepareLanguage("javascript"); document.querySelector("#editor").focus(); }); @@ -428,8 +425,8 @@ test("caret and focus changes reuse ranges until code or language changes", asyn await page.locator(".matching-bracket").first().waitFor(); await page.evaluate(() => new Promise(requestAnimationFrame)); assert.equal(await page.evaluate(() => window.overlayPaints), 1); + await page.locator("#language-select").selectOption("python"); await page.evaluate(async () => { - document.querySelector('[data-language="python"]').click(); await (await import("/syntax-parser.js")).prepareLanguage("python"); document.querySelector("#editor").focus(); }); @@ -504,8 +501,8 @@ test("a loaded grammar replaces cached fallback ranges without an edit", async ( const { page, errors } = ctx; const code = "class A {} /[()]/.test(value);"; try { + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { - document.querySelector('[data-language="javascript"]').click(); document.querySelector("#editor").focus(); }); await page.keyboard.press("ControlOrMeta+A"); @@ -555,8 +552,8 @@ test("a parse cut off by its time budget is retried without an edit", async (t) if (!ctx) return; const { page, errors } = ctx; try { + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(async () => { - document.querySelector('[data-language="javascript"]').click(); await (await import("/syntax-parser.js")).prepareLanguage("javascript"); document.querySelector("#editor").focus(); }); @@ -661,9 +658,7 @@ test("disabled auto indentation retains parsed bracket highlighting", async (t) if (!ctx) return; const { page, errors } = ctx; try { - await page.evaluate(() => - document.querySelector('[data-language="javascript"]').click(), - ); + await page.locator("#language-select").selectOption("javascript"); await page.waitForFunction(async () => { const { parseSyntax } = await import("/syntax-parser.js"); const tree = parseSyntax("", "javascript"); @@ -721,7 +716,7 @@ test("Jim captions survive editor navigation and language switches", async (t) = await page.evaluate(() => import("/interview.js")); await closeSetupCheck(page); - await page.locator('[data-language="javascript"]').click(); + await page.locator("#language-select").selectOption("javascript"); await editor.fill(code); await page.locator("#problem-tab").click(); assert.equal( @@ -773,11 +768,11 @@ test("Jim captions survive editor navigation and language switches", async (t) = }); await assertJim("moving the caret must preserve Jim"); - const python = page.locator('[data-language="python"]'); - await python.click(); + const languageSelect = page.locator("#language-select"); + await languageSelect.selectOption("python"); assert.equal( - await python.evaluate((button) => button.classList.contains("selected")), - true, + await languageSelect.inputValue(), + "python", "the language switch must run", ); assert.notEqual( @@ -787,7 +782,7 @@ test("Jim captions survive editor navigation and language switches", async (t) = ); await assertJim("switching languages must preserve Jim"); - await page.locator('[data-language="javascript"]').click(); + await page.locator("#language-select").selectOption("javascript"); assert.equal(await editor.inputValue(), code); await assertJim("returning to the saved buffer must preserve Jim"); @@ -882,15 +877,11 @@ test("disabled syntax-aware editor features invoke neither classifier nor parser await page.keyboard.press("Enter"); const python = (await caretState(page)).value; assert.equal(python, " if ready:\n"); - await page.evaluate(() => - document.querySelector('[data-language="javascript"]').click(), - ); + await page.locator("#language-select").selectOption("javascript"); await setCode(page, "if (ready) {"); await page.keyboard.press("Enter"); assert.equal((await caretState(page)).value, "if (ready) {\n"); - await page.evaluate(() => - document.querySelector('[data-language="python"]').click(), - ); + await page.locator("#language-select").selectOption("python"); assert.equal((await caretState(page)).value, python); assert.equal(await page.locator("#editor-lines").textContent(), "1\n2"); assert.deepEqual(ctx.parserRequests, []); @@ -982,11 +973,14 @@ test("a new Jim turn starts at the top of the caption bar", async (t) => { async ({ text, id }) => { const captions = await import("/captions.js"); captions.updateCaptions("interviewer", text, id, { final: true }); - await new Promise((resolve) => requestAnimationFrame(resolve)); + const node = document.querySelector("#captions-text"); + if (node.textContent === `[Jim]: ${text.slice(0, 8)}`) { + for (let i = 0; i < Math.ceil(text.length / 8); i++) + captions.paceInterviewerCaption(); + } }, { text, id }, ); - await revealCaption(page, text); }; const scrollDown = async () => { @@ -1145,10 +1139,10 @@ for (const { name, before, start = before.length } of cppLiteralBracketCases) { if (!ctx) return; const { page, errors } = ctx; try { - await page.evaluate(() => - document.querySelector('[data-language="cpp"]').click(), + await page.locator("#language-select").selectOption("cpp"); + await page.waitForFunction( + () => document.querySelector("#language-select").value === "cpp", ); - await page.waitForSelector('[data-language="cpp"].selected'); await setCode(page, before, start); await page.keyboard.type(opener); assert.deepEqual(await caretState(page), { @@ -1202,12 +1196,12 @@ for (const parserReady of [false, true]) { // The same text is a comment in C++ and JS, but division in Python. // Replacing the buffer after switching also catches stale token ranges. for (const language of ["cpp", "python", "javascript"]) { - await page.evaluate( + await page.locator("#language-select").selectOption(language); + await page.waitForFunction( (language) => - document.querySelector(`[data-language="${language}"]`).click(), + document.querySelector("#language-select").value === language, language, ); - await page.waitForSelector(`[data-language="${language}"].selected`); if (parserReady) await waitForParser(page, language); const before = "// note "; await setCode(page, before); diff --git a/tests/browser/interview-layout.test.js b/tests/browser/interview-layout.test.js index 2b7f6065f..4282d0cc3 100644 --- a/tests/browser/interview-layout.test.js +++ b/tests/browser/interview-layout.test.js @@ -193,7 +193,7 @@ test("Run tests shares one toolbar row with the languages on a laptop", async (t return box.top + box.height / 2; }; return { - language: middle('.language-button[data-language="python"]'), + language: middle("#language-select"), run: middle("#run-tests"), theme: middle("#editor-theme-toggle"), }; diff --git a/tests/browser/interview-scroll.test.js b/tests/browser/interview-scroll.test.js index 1c1144a65..7d6f93a2d 100644 --- a/tests/browser/interview-scroll.test.js +++ b/tests/browser/interview-scroll.test.js @@ -286,7 +286,8 @@ test("a short interview viewport can scroll to the test results", async (t) => { await openInterview(page); // Locator clicks scroll clipped ancestors programmatically, which a // candidate's mouse wheel cannot do for overflow: hidden. - await page.mouse.move(1260, 180); + const panel = await page.locator(".editor-panel").boundingBox(); + await page.mouse.move(panel.x + panel.width - 8, panel.y + 12); await page.mouse.wheel(0, 2000); await page.waitForFunction( () => diff --git a/tests/browser/run-shortcut.test.js b/tests/browser/run-shortcut.test.js index 0e4fd1af8..9630828e5 100644 --- a/tests/browser/run-shortcut.test.js +++ b/tests/browser/run-shortcut.test.js @@ -80,8 +80,8 @@ async function interviewPage( await page.waitForFunction( () => document.querySelector("#candidate-case-input").placeholder !== "", ); + await page.locator("#language-select").selectOption("javascript"); await page.evaluate((started) => { - document.querySelector('[data-language="javascript"]').click(); if (started) document.querySelector("#audio-check").hidden = true; globalThis.runs = 0; const run = document.querySelector("#run-tests"); @@ -116,9 +116,11 @@ test("disabled execution blocks button and shortcut runs across language switche assert.equal(await page.locator("#candidate-case").isVisible(), false); assert.equal(await page.locator("#results-toggle").isVisible(), false); for (const language of ["python", "javascript", "c", "cpp", "java"]) { - const button = page.locator(`[data-language="${language}"]`); - assert.equal(await button.isDisabled(), false, language); - await button.evaluate((button) => button.click()); + const option = page.locator( + `#language-select option[value="${language}"]`, + ); + assert.equal(await option.isDisabled(), false, language); + await page.locator("#language-select").selectOption(language); assert.equal( await page.locator("#run-tests").isDisabled(), true, @@ -161,18 +163,25 @@ test("disabled execution keeps C unavailable for class problems", async (t) => { if (!ctx) return; const { page, errors } = ctx; try { - const c = page.locator('[data-language="c"]'); - await page.waitForFunction(() => - document.querySelector('[data-language="c"]').title.includes("class"), + const c = page.locator('#language-select option[value="c"]'); + await page.waitForFunction( + () => + document.querySelector('#language-select option[value="c"]').disabled, ); assert.equal(await c.isDisabled(), true); - const before = await page.locator("#editor").inputValue(); - await c.evaluate((button) => button.click()); - assert.equal(await page.locator("#editor").inputValue(), before); + assert.match(await c.textContent(), /unavailable/); + await page.locator("#language-select").selectOption("python"); + assert.equal(await page.locator("#language-select").inputValue(), "python"); + assert.match( + await page.locator("#editor").inputValue(), + /^class EventQueue:/, + ); for (const language of ["python", "javascript", "cpp", "java"]) { - const button = page.locator(`[data-language="${language}"]`); - assert.equal(await button.isDisabled(), false, language); - await button.click(); + const option = page.locator( + `#language-select option[value="${language}"]`, + ); + assert.equal(await option.isDisabled(), false, language); + await page.locator("#language-select").selectOption(language); const code = await page.locator("#editor").inputValue(); assert.notEqual(code, "undefined", language); assert.ok(code.trim(), language); @@ -212,6 +221,26 @@ test("offline reports apply execution preferences only to editor interviews", as } }); +test("the language selector stays on the submitted language in a report", async (t) => { + const ctx = await interviewPage(t); + if (!ctx) return; + const { page, errors } = ctx; + try { + await page.evaluate(() => document.querySelector("#end").click()); + await page.locator("#report-modal").waitFor({ state: "visible" }); + const code = await page.locator("#editor").inputValue(); + await page.locator("#language-select").selectOption("python"); + assert.equal( + await page.locator("#language-select").inputValue(), + "javascript", + ); + assert.equal(await page.locator("#editor").inputValue(), code); + assert.deepEqual(errors, []); + } finally { + await page.close(); + } +}); + test("a disabled-execution interview sends its choice with the token request", async (t) => { const ctx = await interviewPage(t, { started: false, diff --git a/tests/browser/syntax-parser-browser.test.js b/tests/browser/syntax-parser-browser.test.js index 90d13844f..62fc790bb 100644 --- a/tests/browser/syntax-parser-browser.test.js +++ b/tests/browser/syntax-parser-browser.test.js @@ -53,11 +53,10 @@ test("an interview loads only selected grammars and Enter works during a switch" await download; await route.continue(); }); - const select = (language) => - page.evaluate((language) => { - document.querySelector(`[data-language="${language}"]`).click(); - document.querySelector("#editor").focus(); - }, language); + const select = async (language) => { + await page.locator("#language-select").selectOption(language); + await page.locator("#editor").focus(); + }; const setCode = (value) => page.evaluate(async (value) => { const editor = document.querySelector("#editor"); @@ -82,7 +81,12 @@ test("an interview loads only selected grammars and Enter works during a switch" }); await closeSetupCheck(page); // C becomes selectable only after the judge callback has run. - await page.waitForSelector('[data-language="c"]:not([disabled])'); + await page.waitForFunction(() => { + const option = document.querySelector( + '#language-select [data-language="c"]', + ); + return option && !option.disabled; + }); await page.waitForSelector("#editor:not([disabled])"); await page.evaluate(async () => { const { prepareLanguage } = await import("/syntax-parser.js"); @@ -224,11 +228,11 @@ test("a grammar completing after a switch does not repaint the active language", await closeSetupCheck(page); await page.waitForSelector("#editor:not([disabled])"); const code = 'text = """\n/*\n"""\nif True:'; + await page.locator("#language-select").selectOption("javascript"); + await page.locator("#language-select").selectOption("python"); await page.evaluate(async (code) => { const { prepareLanguage } = await import("/syntax-parser.js"); await prepareLanguage("python"); - document.querySelector('[data-language="javascript"]').click(); - document.querySelector('[data-language="python"]').click(); await prepareLanguage("python"); const editor = document.querySelector("#editor"); editor.value = code; @@ -266,10 +270,7 @@ test("a grammar completing after a switch does not repaint the active language", await page.locator("#editor-highlight .tok-string").textContent(), '"""\n/*\n"""', ); - assert.match( - await page.locator('[data-language="python"]').getAttribute("class"), - /\bselected\b/, - ); + assert.equal(await page.locator("#language-select").inputValue(), "python"); } finally { release(); await page.close(); @@ -289,8 +290,8 @@ test("a failed grammar download leaves highlighting, Enter and undo usable", asy await closeSetupCheck(page); await page.waitForSelector("#editor:not([disabled])"); const code = "const text = `\n/*\n`;\n if (ready) {"; + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(async (code) => { - document.querySelector('[data-language="javascript"]').click(); const { prepareLanguage } = await import("/syntax-parser.js"); await prepareLanguage("javascript").catch(() => {}); const editor = document.querySelector("#editor"); @@ -343,7 +344,12 @@ test("highlighting and Enter share comment and string regions in every language" waitUntil: "domcontentloaded", }); await closeSetupCheck(page); - await page.waitForSelector('[data-language="c"]:not([disabled])'); + await page.waitForFunction(() => { + const option = document.querySelector( + '#language-select [data-language="c"]', + ); + return option && !option.disabled; + }); for (const [language, declaration, literal, comment] of [ ["python", "text = ", '"""\n/*\n"""', "# if ready:"], ["javascript", "const text = ", "`\n/*\n`", "/*\n if (ready) {"], @@ -352,9 +358,9 @@ test("highlighting and Enter share comment and string regions in every language" ["java", "String text = ", '"""\n/*\n"""', "/*\n if (ready) {"], ]) { const code = declaration + literal + ";\n " + comment; + await page.locator("#language-select").selectOption(language); await page.evaluate( async ({ code, language }) => { - document.querySelector(`[data-language="${language}"]`).click(); const { prepareLanguage } = await import("/syntax-parser.js"); await prepareLanguage(language); const editor = document.querySelector("#editor"); diff --git a/tests/browser/test-results-filter.test.js b/tests/browser/test-results-filter.test.js index 33d86faba..712feb93c 100644 --- a/tests/browser/test-results-filter.test.js +++ b/tests/browser/test-results-filter.test.js @@ -45,9 +45,9 @@ async function openInterview(t, { clock = false } = {}) { await page.waitForFunction( () => document.querySelector("#candidate-case-input").placeholder !== "", ); + await page.locator("#language-select").selectOption("javascript"); await page.evaluate(() => { document.querySelector("#audio-check").hidden = true; - document.querySelector('[data-language="javascript"]').click(); const post = Worker.prototype.postMessage; globalThis.testRuns = 0; Worker.prototype.postMessage = function (...args) { diff --git a/web/compiler-explorer.js b/web/compiler-explorer.js index abbf3b19d..1cf952113 100644 --- a/web/compiler-explorer.js +++ b/web/compiler-explorer.js @@ -113,37 +113,52 @@ export const compiledTestsEnabled = () => globalThis.CODETRIAL_COMPILER_EXPLORER_ENABLED !== false && Boolean(compilerExplorerBaseUrl()); -/// Why this judge cannot be run in this language, in a sentence a candidate can -/// read, or null when it can. The tab row disables from it and shows it as the -/// tooltip, so the reason a tab is dead cannot drift from the rule that killed -/// it; a second withheld pair is a second case here rather than a second place -/// to remember. +/// Why this judge cannot be run, in a sentence the candidate can read, and +/// the short reason shown in the language menu. Keeping both beside the rule +/// that withholds a language prevents its explanation and label from drifting. /// -/// C has no classes, so `generateHarness` refuses a class judge for it and a +/// C has no classes, so generateHarness refuses a class judge for it and a /// candidate would otherwise find that out by writing a whole solution and /// pressing run. -export function harnessGap(language, spec, execution = true) { +export function harnessAvailability(language, spec, execution = true) { if ( execution && !compiledTestsEnabled() && COMPILED_LANGUAGES.includes(language) ) { - return "Compiled language tests are disabled by this server. Choose Python or JavaScript to run tests."; + return { + message: + "Compiled language tests are disabled by this server. Choose Python or JavaScript to run tests.", + reason: "disabled by server", + }; } if (language === "c" && !spec) { - return "C is available only after function-style tests load. Choose another language or reload to retry."; + return { + message: + "C is available only after function-style tests load. Choose another language or reload to retry.", + reason: "waiting for judge", + }; } if (language === "c" && spec.kind === "class") { - return "This problem asks for a class, and the C harness only builds function judges."; + return { + message: + "This problem asks for a class, and the C harness only builds function judges.", + reason: "class problem", + }; } return null; } +/// The runner needs the candidate-facing explanation without the menu label. +export function harnessGap(language, spec, execution = true) { + return harnessAvailability(language, spec, execution)?.message ?? null; +} + /// Withhold C until the judge proves it is function-style, so a slow fetch /// cannot let a candidate start a class problem in an untestable language. export function languagesFor(spec, execution = true) { return ALL_LANGUAGES.filter( - (language) => !harnessGap(language, spec, execution), + (language) => !harnessAvailability(language, spec, execution), ); } diff --git a/web/interview.html b/web/interview.html index 896250e30..fad8439d1 100644 --- a/web/interview.html +++ b/web/interview.html @@ -181,31 +181,21 @@

Loading interview...

-
-