diff --git a/.github/workflows/build.sh b/.github/workflows/build.sh index 5569b290..6a0909e9 100755 --- a/.github/workflows/build.sh +++ b/.github/workflows/build.sh @@ -43,9 +43,6 @@ export MDBOOK_OUTPUT__PANDOC__DISABLED=false mdbook build -d "$dest_dir" -# Disable the redbox button in built versions of the course -echo '// Disabled in published builds, see build.sh' > "${dest_dir}/html/theme/redbox.js" - mv "$dest_dir/pandoc/pdf/comprehensive-rust.pdf" "$dest_dir/html/" (cd "$dest_dir/exerciser" && zip --recurse-paths ../html/comprehensive-rust-exercises.zip comprehensive-rust-exercises/) diff --git a/GEMINI.md b/GEMINI.md index ff912a2d..7c5b620e 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -171,3 +171,86 @@ its tasks correctly. snippet, treat it as a self-contained program. Do not assume it shares a scope or context with other snippets in the same file unless the surrounding text explicitly states otherwise. + +## Interacting with the `mdbook` Theme + +The `mdbook` theme has several interactive elements. Here's how to interact with +them: + +- **Sidebar Toggle:** The sidebar can be opened and closed by clicking the + "hamburger" button in the top-left of the body text. This button has the ID + `sidebar-toggle`. You can use the following JavaScript to toggle the sidebar: + + ```javascript + const button = document.getElementById("sidebar-toggle"); + button.click(); + ``` + +## WebdriverIO Testing + +This project uses WebdriverIO for browser-based integration tests. Here are some +key findings about the test environment: + +### Test Environments + +The `tests/` directory contains two primary configurations: + +- `npm test` (runs `wdio.conf.ts`): This is the standard for self-contained + integration tests. It uses `@wdio/static-server-service` to create a temporary + web server on port 8080. +- `npm run test-mdbook` (runs `wdio.conf-mdbook.ts`): This is for testing + against a live `mdbook serve` instance, which typically runs on port 3000. + +It is important to use the standard `npm test` command for most test development +to ensure the tests are self-contained. + +### Writing Stable Tests + +Tests can be flaky if they don't correctly handle the asynchronous nature of the +web browser and the test environment's state management. + +- **State Leakage Between Tests:** Despite what the WebdriverIO documentation + might suggest, `browser.url()` is not always sufficient to guarantee a clean + slate between tests. Lingering state, such as inline CSS styles applied by + JavaScript, can leak from one test into the next, causing unexpected failures. + The most effective solution found for this project is to add + `await browser.refresh();` to the `beforeEach` hook. This forces a full page + reload that properly clears the old state. + +- **Race Conditions with Dynamic Elements:** Many elements in this project are + created dynamically by JavaScript after the initial page load. If a test tries + to access an element immediately after navigation, it may fail because the + script hasn't finished running and the element doesn't exist in the DOM yet. + This creates a race condition. To prevent this, always use + `await element.waitForExist()` to ensure the element is present before trying + to interact with it or assert its state (e.g., `toBeDisplayed()`). + +### Handling Redirects + +`mdbook` uses a redirect map defined in `book.toml` under the +`[output.html.redirect]` section. When writing tests, it is crucial to use the +final, non-redirecting URL for navigation. Navigating to a URL that is a +redirect will cause the browser to follow it, but this process can strip URL +query parameters, leading to test failures for features that depend on them. + +### Running and Debugging Tests + +To run a single test file, use the `--spec` flag with the a string matching the +file name: + +```bash +npm test -- --spec redbox +``` + +To check for flakiness, you can repeat a test multiple times using the +`--repeat` flag: + +```bash +npm test -- --spec redbox --repeat 100 +``` + +Use `--mochaOpts.grep` to run a single test within a file: + +```bash +npm test -- --spec redbox --mochaOpts.grep "should be hidden by default" +``` diff --git a/book.toml b/book.toml index aa678207..948de554 100644 --- a/book.toml +++ b/book.toml @@ -60,6 +60,7 @@ urlcolor = "red" smart-punctuation = true additional-js = [ "theme/speaker-notes.js", + "theme/redbox.js", ] additional-css = [ "theme/css/svgbob.css", diff --git a/src/running-the-course.md b/src/running-the-course.md index b5788434..c0713e3e 100644 --- a/src/running-the-course.md +++ b/src/running-the-course.md @@ -55,6 +55,7 @@ better. Your students are also very welcome to [send us feedback][2]! [1]: https://github.com/google/comprehensive-rust/discussions/86 [2]: https://github.com/google/comprehensive-rust/discussions/100 [3]: https://github.com/google/comprehensive-rust#building +[red-box]: ?show-red-box=true
@@ -69,6 +70,11 @@ better. Your students are also very welcome to [send us feedback][2]! - **Familiarize yourself with `mdbook`:** The course is presented using `mdbook`. Knowing how to navigate, search, and use its features will make the presentation smoother. +- **Slice size helper:** Press Ctrl + Alt + B + to toggle a visual guide showing the amount of space available when + presenting. Expect any content outside of the red box to be hidden initially. + Use this as a guide when editing slides. You can also + [enable it via this link][red-box]. ### Creating a Good Learning Environment diff --git a/tests/src/redbox.test.ts b/tests/src/redbox.test.ts new file mode 100644 index 00000000..ec2e8eeb --- /dev/null +++ b/tests/src/redbox.test.ts @@ -0,0 +1,98 @@ +import { describe, it } from "mocha"; +import { expect, browser } from "@wdio/globals"; + +describe("Red Box", () => { + const redBox = () => $("#aspect-ratio-helper"); + const redBoxButton = () => $("#turn-off-red-box"); + + beforeEach(async () => { + await browser.url("/hello-world.html"); + await browser.execute(() => sessionStorage.clear()); + // Clear any lingering state (like inline styles) from previous + // tests. Reading https://webdriver.io/docs/api/browser/url, + // this should not be necessary, but tests fail without it. + await browser.refresh(); + }); + + it("should be hidden by default", async () => { + await expect(redBox()).not.toBeDisplayed(); + }); + + describe("Keyboard Shortcut", () => { + it("should show the red box when toggled on", async () => { + await browser.toggleRedBox(); + await expect(redBox()).toBeDisplayed(); + await expect(redBoxButton()).toBeDisplayed(); + }); + + it("should hide the red box when toggled off", async () => { + // Toggle on first + await browser.toggleRedBox(); + await expect(redBox()).toBeDisplayed(); + + // Then toggle off + await browser.toggleRedBox(); + await expect(redBox()).not.toBeDisplayed(); + }); + }); + + describe("URL Parameter", () => { + it("should show red box", async () => { + await browser.url("/hello-world.html?show-red-box=true"); + await expect(redBox()).toBeDisplayed(); + }); + + it("should override session storage", async () => { + // Set session storage first to ensure the URL parameter takes precedence. + await browser.execute(() => sessionStorage.setItem("showRedBox", "true")); + await browser.url("/hello-world.html?show-red-box=false"); + await expect(redBox()).not.toBeDisplayed(); + }); + }); + + describe("Hide Button", () => { + it("should hide the red box when clicked", async () => { + await browser.toggleRedBox(); + await expect(redBox()).toBeDisplayed(); + + await (await redBoxButton()).click(); + await expect(redBox()).not.toBeDisplayed(); + }); + }); + + describe("Session Storage", () => { + it("should persist being shown after a reload", async () => { + await browser.toggleRedBox(); + await expect(redBox()).toBeDisplayed(); + + await browser.refresh(); + + await expect(redBox()).toBeDisplayed(); + }); + + it("should persist being hidden after a reload", async () => { + await browser.toggleRedBox(); // turn on + await browser.toggleRedBox(); // turn off + await expect(redBox()).not.toBeDisplayed(); + + // Explicitly check that storage is cleared before reloading + const storage = await browser.execute(() => + sessionStorage.getItem("showRedBox"), + ); + expect(storage).toBeNull(); + + await browser.refresh(); + await expect(redBox()).not.toBeDisplayed(); + }); + }); + + describe("Interactions", () => { + it("should be able to be hidden with the keyboard after being shown with the URL", async () => { + await browser.url("/hello-world.html?show-red-box=true"); + await expect(redBox()).toBeDisplayed(); + + await browser.toggleRedBox(); + await expect(redBox()).not.toBeDisplayed(); + }); + }); +}); diff --git a/tests/src/speaker-notes.test.ts b/tests/src/speaker-notes.test.ts index 0b5debf0..765b5492 100644 --- a/tests/src/speaker-notes.test.ts +++ b/tests/src/speaker-notes.test.ts @@ -3,7 +3,17 @@ import { $, expect, browser } from "@wdio/globals"; describe("speaker-notes", () => { beforeEach(async () => { - await browser.url("/"); + await browser.url("/welcome-day-1.html"); + await browser.refresh(); + }); + + afterEach(async () => { + const handles = await browser.getWindowHandles(); + if (handles.length > 1) { + await browser.switchToWindow(handles[1]); + await browser.closeWindow(); + await browser.switchToWindow(handles[0]); + } }); it("contains summary with heading and button", async () => { @@ -17,7 +27,7 @@ describe("speaker-notes", () => { const details$ = await $("details"); const button$ = await $("details summary .pop-out"); await expect(details$).toBeDisplayed(); - button$.scrollIntoView(); + await button$.scrollIntoView(); await button$.click(); await expect(details$).not.toBeDisplayed(); @@ -28,4 +38,16 @@ describe("speaker-notes", () => { expect.stringContaining("#speaker-notes-open"), ); }); + + it("should not show the red box in the speaker notes window", async () => { + const button$ = await $("details summary .pop-out"); + await button$.scrollIntoView(); + await button$.click(); + + const handles = await browser.getWindowHandles(); + await browser.switchToWindow(handles[1]); + + const redBox = await $("#aspect-ratio-helper"); + await expect(redBox).not.toExist(); + }); }); diff --git a/tests/wdio.conf.ts b/tests/wdio.conf.ts index 6a7347f7..9642f87d 100644 --- a/tests/wdio.conf.ts +++ b/tests/wdio.conf.ts @@ -213,8 +213,37 @@ export const config: WebdriverIO.Config = { * @param {Array.} specs List of spec file paths that are to be run * @param {object} browser instance of created browser/device session */ - before: function (capabilities, specs) { - browser.setWindowSize(2560, 1440); + before: async function (capabilities, specs) { + await browser.setWindowSize(2560, 1440); + + /** + * Adds a custom `browser.toggleRedBox()` command. + * + * This command is necessary to reliably test the red box toggle + * functionality. A direct `browser.keys()` call proved to be + * flaky, causing intermittent test failures. This custom command + * will wait for the UI to reflect the state change, thus + * eliminating race conditions. + */ + browser.addCommand("toggleRedBox", async function () { + const redBox = await $("#aspect-ratio-helper"); + const initialVisibility = await redBox.isDisplayed(); + + // Perform the toggle action. + await browser.keys(["Control", "Alt", "b"]); + + // Wait until the visibility state has changed. + await browser.waitUntil( + async function () { + const currentVisibility = await redBox.isDisplayed(); + return currentVisibility !== initialVisibility; + }, + { + timeout: 5000, + timeoutMsg: `Red box display state did not toggle after 5s. Initial state: ${initialVisibility}`, + }, + ); + }); }, /** * Runs before a WebdriverIO command gets executed. diff --git a/theme/css/redbox.css b/theme/css/redbox.css index 633ed37b..eb363b32 100644 --- a/theme/css/redbox.css +++ b/theme/css/redbox.css @@ -8,7 +8,8 @@ div#aspect-ratio-helper { } div#aspect-ratio-helper div { - outline: 3px dashed red; + position: relative; + outline: 2em solid rgba(255, 0, 0, 0.2); margin: 0 auto; /* At this width, the theme fonts are readable in a 16 person conference room. If the browser is wider, the @@ -19,6 +20,16 @@ div#aspect-ratio-helper div { aspect-ratio: 16/8.5; } -#instructor-menu-list { - margin-left: 55px; +#turn-off-red-box { + position: absolute; + bottom: 10px; + right: 10px; + z-index: 10000; + padding: 10px; + background-color: #f44336; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + pointer-events: auto; } diff --git a/theme/instructor-menu.js b/theme/instructor-menu.js deleted file mode 100644 index 267b2000..00000000 --- a/theme/instructor-menu.js +++ /dev/null @@ -1,71 +0,0 @@ -(function handleInstructor() { - function handleInstructorMenu() { - let leftButtons = document.getElementsByClassName("left-buttons")[0]; - let instructorMenu = document.createElement("button"); - let instructorMenuList = document.createElement("ul"); - let redBoxItem = document.createElement("li"); - let redBoxButton = document.createElement("button"); - let playgroundStateItem = document.createElement("li"); - let playgroundStateButton = document.createElement("button"); - - leftButtons.insertBefore(instructorMenu, leftButtons.lastChild); - leftButtons.insertBefore(instructorMenuList, leftButtons.lastChild); - instructorMenuList.insertBefore(redBoxItem, instructorMenuList.lastChild); - instructorMenuList.insertBefore( - playgroundStateItem, - instructorMenuList.lastChild, - ); - redBoxItem.insertBefore(redBoxButton, redBoxItem.lastChild); - playgroundStateItem.insertBefore( - playgroundStateButton, - playgroundStateItem.lastChild, - ); - - instructorMenu.title = "Utilities for course instructors"; - instructorMenu.innerHTML = - ''; - redBoxButton.innerHTML = "aspect-ratio box"; - redBoxButton.title = - "Outline the area that fits on one screen while teaching the course."; - playgroundStateButton.innerHTML = "reset all playgrounds"; - playgroundStateButton.title = - "Reset code in all playgrounds to its original value."; - - instructorMenu.className = "icon-button"; - instructorMenuList.className = "theme-popup"; - redBoxButton.className = "theme"; - playgroundStateButton.className = "theme"; - instructorMenuList.style.display = "none"; - - instructorMenuList.role = "menu"; - redBoxItem.role = "none"; - playgroundStateItem.role = "none"; - redBoxButton.role = "menuitem"; - playgroundStateButton.role = "menuitem"; - - redBoxButton.id = "redbox"; - instructorMenuList.id = "instructor-menu-list"; - playgroundStateButton.id = "playground-state"; - - instructorMenu.addEventListener("click", () => { - if (instructorMenuList.style.display === "none") { - instructorMenuList.style.display = "block"; - } else { - instructorMenuList.style.display = "none"; - } - }); - - document.addEventListener("click", (e) => { - if (!instructorMenu.contains(e.target)) { - instructorMenuList.style.display = "none"; - } - }); - } - handleInstructorMenu(); - var redBoxButton = document.getElementById("redbox"); - var playgroundStateButton = document.getElementById("playground-state"); - redBoxButton.addEventListener("click", () => window.redboxButtonClicked()); - playgroundStateButton.addEventListener("click", () => - window.resetPlaygroundsClicked(), - ); -})(); diff --git a/theme/redbox.js b/theme/redbox.js index 754baffe..ffb08de5 100644 --- a/theme/redbox.js +++ b/theme/redbox.js @@ -1,29 +1,61 @@ -(function redBoxButton() { - // Create a new div element - var newDiv = document.createElement("div"); - // Set the id attribute of the new div - newDiv.id = "aspect-ratio-helper"; - // Create a nested div inside the new div - var nestedDiv = document.createElement("div"); - // Append the nested div to the new div - newDiv.appendChild(nestedDiv, newDiv.firstChild); - // Get the parent element where you want to append the new div - var parentElement = document.body; // Change this to your desired parent element - // Append the new div to the parent element - parentElement.insertBefore(newDiv, parentElement.firstChild); - //Default hiding the redbox - document.getElementById("aspect-ratio-helper").style.display = "none"; -})(); - -//Create a function to button to perform on click action. -function redboxButtonClicked() { - var hideShowButton = document.getElementById("redbox"); - if (document.getElementById("aspect-ratio-helper").style.display === "none") { - document.getElementById("aspect-ratio-helper").style.display = "block"; - hideShowButton.innerHTML = "aspect-ratio box"; - } else { - document.getElementById("aspect-ratio-helper").style.display = "none"; - hideShowButton.innerHTML = "aspect-ratio box"; +(function () { + if (window.location.hash === "#speaker-notes-open") { + return; } -} -window.redboxButtonClicked = redboxButtonClicked; + + const redBox = document.createElement("div"); + redBox.id = "aspect-ratio-helper"; + redBox.style.display = "none"; // Initially hidden + + const nestedDiv = document.createElement("div"); + redBox.appendChild(nestedDiv); + + const turnOffButton = document.createElement("button"); + turnOffButton.id = "turn-off-red-box"; + turnOffButton.textContent = "Hide Red Box"; + nestedDiv.appendChild(turnOffButton); + + document.body.prepend(redBox); + + const storageKey = "showRedBox"; + + const turnOff = () => { + redBox.style.display = "none"; + sessionStorage.removeItem(storageKey); + }; + + const turnOn = () => { + sessionStorage.setItem(storageKey, "true"); + redBox.style.display = "block"; + }; + + const toggleRedBox = () => { + if (redBox.style.display === "none") { + turnOn(); + } else { + turnOff(); + } + }; + + document.addEventListener("keydown", (event) => { + // Toggle the red box with Ctrl+Alt+B + if (event.ctrlKey && event.altKey && event.key === "b") { + event.preventDefault(); + toggleRedBox(); + } + }); + + turnOffButton.addEventListener("click", turnOff); + + // Check initial state from URL parameter or session storage + const searchParams = new URLSearchParams(window.location.search); + const showRedBoxParam = searchParams.get("show-red-box"); + + if (showRedBoxParam === "true") { + turnOn(); + } else if (showRedBoxParam === "false") { + turnOff(); + } else if (sessionStorage.getItem(storageKey) === "true") { + turnOn(); + } +})();