diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 66278c66..d109a2ad 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -195,15 +195,14 @@ jobs: emscripten_wasm: runs-on: ${{ matrix.os }} + name: ${{ matrix.os}}-${{ matrix.notebook }}-${{ matrix.kernel }} strategy: fail-fast: false matrix: - include: - - name: ubu24 - os: ubuntu-24.04 - - name: osx15-arm - os: macos-15 + os: [ubuntu-24.04, macos-15] + notebook: [smallpt.ipynb, xeus-cpp-lite-demo.ipynb] + kernel: [C++17,C++20,C++23] steps: - uses: actions/checkout@v5 @@ -329,12 +328,69 @@ jobs: fi timeout-minutes: 4 - - name: Jupyter Lite integration + - name: Jupyter Lite integration test shell: bash -l {0} run: | + set -e micromamba create -n xeus-lite-host jupyterlite-core=0.6 jupyterlite-xeus -c conda-forge micromamba activate xeus-lite-host - jupyter lite build --XeusAddon.prefix=${{ env.PREFIX }} + if [[ "${{ matrix.os }}" == "macos"* ]]; then + brew install coreutils + export PATH="$HOMEBREW_PREFIX/opt/coreutils/libexec/gnubin:$PATH" + fi + export INPUT_TEXT="" + if [[ "${{ matrix.notebook }}" == "xeus-cpp-lite-demo.ipynb"* ]]; then + export INPUT_TEXT="--stdin Test_Name" + fi + timeout 900 jupyter lite serve --settings-overrides=overrides.json --XeusAddon.prefix=${{ env.PREFIX }} \ + --XeusAddon.mounts="${{ env.PREFIX }}/share/xeus-cpp/tagfiles:/share/xeus-cpp/tagfiles" \ + --XeusAddon.mounts="${{ env.PREFIX }}/etc/xeus-cpp/tags.d:/etc/xeus-cpp/tags.d" \ + --contents README.md \ + --contents notebooks/xeus-cpp-lite-demo.ipynb \ + --contents notebooks/smallpt.ipynb \ + --contents notebooks/images/marie.png \ + --contents notebooks/audio/audio.wav & + # There is a bug in nbdime after 3.2.0 where it will show the filenames as if there was a diff + # but there is no diff with the options chosen below (the latest doesn't show a diff, just the filenames with +++ + # and --- as if it was planning to show a diff. This only happens for xeus-cpp-lite-demo.ipynb. + python -m pip install nbdime==3.2.0 + python -m pip install selenium + # This sleep is to force enough time for the jupyter site to build before trying + # to run notebooks in it. If you try to run the notebooks before the website is + # ready the ci python script will crash saying ti cannot access the url + sleep 10 + echo "Running xeus-cpp in Jupter Lite in Chrome" + python -u scripts/automated-notebook-run-script.py --driver chrome --notebook ${{ matrix.notebook }} --kernel ${{ matrix.kernel }} $INPUT_TEXT + nbdiff notebooks/${{ matrix.notebook }} $HOME/Downloads/${{ matrix.notebook }} --ignore-id --ignore-metadata >> chrome_diff.txt + export CHROME_TESTS_RETURN_VALUE=$( [ -s chrome_diff.txt ] && echo 1 || echo 0 ) + echo "Running xeus-cpp in Jupter Lite in Firefox" + python -u scripts/automated-notebook-run-script.py --driver firefox --notebook ${{ matrix.notebook }} --kernel ${{ matrix.kernel }} $INPUT_TEXT + nbdiff notebooks/${{ matrix.notebook }} $HOME/Downloads/${{ matrix.notebook }} --ignore-id --ignore-metadata >> firefox_diff.txt + export FIREFOX_TESTS_RETURN_VALUE=$( [ -s firefox_diff.txt ] && echo 1 || echo 0 ) + rm $HOME/Downloads/${{ matrix.notebook }} + export SAFARI_TESTS_RETURN_VALUE=0 + touch safari_diff.txt + if [[ "${{ matrix.os }}" == "macos"* ]]; then + python -m pip install PyAutoGUI + python scripts/enable-downloads-safari-github-ci.py + echo "Running xeus-cpp in Jupter Lite in Safari" + python -u scripts/automated-notebook-run-script.py --driver safari --notebook ${{ matrix.notebook }} --kernel ${{ matrix.kernel }} $INPUT_TEXT + nbdiff notebooks/${{ matrix.notebook }} $HOME/Downloads/${{ matrix.notebook }} --ignore-id --ignore-metadata >> safari_diff.txt + export SAFARI_TESTS_RETURN_VALUE=$( [ -s safari_diff.txt ] && echo 1 || echo 0 ) + rm $HOME/Downloads/${{ matrix.notebook }} + fi + if [[ $SAFARI_TESTS_RETURN_VALUE -ne 0 || $FIREFOX_TESTS_RETURN_VALUE -ne 0 || $CHROME_TESTS_RETURN_VALUE -ne 0 ]]; then + if [[ "${{ matrix.os }}" == "macos"* ]]; then + echo "Diff Safari (blank means no diff)" + cat safari_diff.txt + fi + echo "Diff Firefox (blank means no diff)" + cat firefox_diff.txt + echo "Diff Chrome (blank means no diff)" + cat chrome_diff.txt + exit 1 + fi + timeout-minutes: 15 - name: Setup tmate session if: ${{ failure() && runner.debug }} diff --git a/notebooks/xeus-cpp-lite-demo.ipynb b/notebooks/xeus-cpp-lite-demo.ipynb index b32f7837..3f044e21 100644 --- a/notebooks/xeus-cpp-lite-demo.ipynb +++ b/notebooks/xeus-cpp-lite-demo.ipynb @@ -585,8 +585,14 @@ "metadata": { "trusted": true }, - "outputs": [], - "execution_count": null + "outputs": [ + { + "output_type": "stream", + "name": "stdin", + "text": " Test_Name\n" + } + ], + "execution_count": 29 }, { "id": "8ec65830-4cb5-4d01-a860-f6c46ac4f60f", @@ -595,8 +601,24 @@ "metadata": { "trusted": true }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": "Your name is Test_Name" + } + ], + "execution_count": 30 + }, + { + "id": "5d649107-9b69-42ad-b5ea-c50b3ee5add3", + "cell_type": "code", + "source": "", + "metadata": { + "trusted": true + }, "outputs": [], "execution_count": null } ] -} +} \ No newline at end of file diff --git a/overrides.json b/overrides.json new file mode 100644 index 00000000..6c67db51 --- /dev/null +++ b/overrides.json @@ -0,0 +1,14 @@ +{ + "@jupyterlab/notebook-extension:panel": { + "toolbar": [ + { + "name": "download", + "label": "Download", + "args": {}, + "command": "docmanager:download", + "icon": "ui-components:download", + "rank": 50 + } + ] + } +} diff --git a/scripts/automated-notebook-run-script.py b/scripts/automated-notebook-run-script.py new file mode 100644 index 00000000..5cd4b142 --- /dev/null +++ b/scripts/automated-notebook-run-script.py @@ -0,0 +1,328 @@ +import argparse +from selenium import webdriver +from selenium.webdriver.chrome.options import Options as ChromeOptions +from selenium.webdriver.firefox.options import Options as FirefoxOptions +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.by import By +from selenium.webdriver.common.keys import Keys +import time +import platform +import sys + + +def cell_is_waiting_for_input(driver): + dialog_selectors = [".jp-Stdin-input"] + + for selector in dialog_selectors: + try: + elems = driver.find_elements(By.CSS_SELECTOR, selector) + if any(elem.is_displayed() for elem in elems): + return True + except Exception: + pass + + return False + + +def main(): + parser = argparse.ArgumentParser(description="Run Selenium with a chosen driver") + parser.add_argument( + "--driver", + type=str, + default="chrome", + choices=["chrome", "firefox", "safari"], + help="Choose which WebDriver to use", + ) + parser.add_argument( + "--notebook", + type=str, + required=True, + help="Notebook to execute", + ) + parser.add_argument( + "--kernel", + type=str, + required=True, + help="Kernel to run notebook in", + ) + parser.add_argument( + "--stdin", + type=str, + help="Text to pass to standard input", + ) + + args = parser.parse_args() + URL = f"http://127.0.0.1:8000/lab/index.html?path={args.notebook}" + + # This will start the right driver depending on what + # driver option is chosen + if args.driver == "chrome": + options = ChromeOptions() + options.add_argument("--headless") + options.add_argument("--no-sandbox") + driver = webdriver.Chrome(options=options) + + elif args.driver == "firefox": + options = FirefoxOptions() + options.add_argument("--headless") + driver = webdriver.Firefox(options=options) + + elif args.driver == "safari": + driver = webdriver.Safari() + + wait = WebDriverWait(driver, 30) + actions = ActionChains(driver) + + # Open Jupyter Lite with the notebook requested + driver.get(URL) + + # Waiting for Jupyter Lite URL to finish loading + notebook_area = wait.until( + EC.presence_of_element_located((By.CSS_SELECTOR, ".jp-Notebook")) + ) + + time.sleep(1) + + notebook_area.click() + actions.context_click(notebook_area).pause(0.1).send_keys(Keys.DOWN * 9).pause(0.1).send_keys(Keys.ENTER).pause(0.1).perform() + + # Select Kernel based on input + kernel_button = driver.find_element( + By.CSS_SELECTOR, "jp-button.jp-Toolbar-kernelName.jp-ToolbarButtonComponent" + ) + driver.execute_script("arguments[0].click();", kernel_button) + driver.switch_to.active_element.send_keys(Keys.TAB) + time.sleep(1) + actions.send_keys(f"{args.kernel}").perform() + time.sleep(1) + actions.send_keys(Keys.TAB).perform() + time.sleep(1) + actions.send_keys(Keys.ENTER).perform() + time.sleep(1) + + # This will run all the cells of the chosen notebook + if args.driver == "chrome" or args.driver == "firefox": + print("Running Cells") + while True: + focused_cell = driver.find_element( + By.CSS_SELECTOR, ".jp-Notebook-cell.jp-mod-selected" + ) + editor_divs = focused_cell.find_elements( + By.CSS_SELECTOR, ".jp-InputArea-editor div" + ) + + cell_content = "".join( + div.get_attribute("textContent") for div in editor_divs + ).strip() + + if not cell_content: + print("Empty cell reached") + break + + if cell_is_waiting_for_input(driver): + print("Cell requesting input") + input_box = WebDriverWait(driver, 5).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, ".jp-Stdin-input") + ) + ) + input_box.click() + input_box.send_keys(f"{args.stdin}") + time.sleep(1) + input_box.send_keys(Keys.CONTROL, Keys.ENTER) + next_cell = focused_cell.find_element( + By.XPATH, + "following-sibling::div[contains(@class,'jp-Notebook-cell')][1]", + ) + driver.execute_script( + "arguments[0].scrollIntoView({block:'center'});", next_cell + ) + next_cell.click() + while True: + spans = driver.find_elements(By.CSS_SELECTOR, "span.jp-StatusBar-TextItem") + status_span = spans[2] + text = status_span.text + + if "Idle" in text: + break + time.sleep(0.01) + print(focused_cell.text) + focused_cell=next_cell + run_menu = wait.until( + EC.element_to_be_clickable((By.XPATH, "//li[normalize-space()='Run']")) + ) + actions.move_to_element(run_menu).pause(0.05).click().perform() + actions.send_keys(Keys.DOWN).send_keys(Keys.ENTER).pause(0.1).perform() + if not cell_is_waiting_for_input(driver): + while True: + spans = driver.find_elements(By.CSS_SELECTOR, "span.jp-StatusBar-TextItem") + status_span = spans[2] + text = status_span.text + + if "Idle" in text: + print(focused_cell.text) + break + time.sleep(0.01) + + elif args.driver == "safari": + print("Running all cells using Shift+Enter...") + while True: + focused_cell = driver.find_element( + By.CSS_SELECTOR, ".jp-Notebook-cell.jp-mod-selected" + ) + print(focused_cell.text) + + editor_divs = focused_cell.find_elements( + By.CSS_SELECTOR, ".jp-InputArea-editor div" + ) + cell_content = "".join([div.text for div in editor_divs]).strip() + + if not cell_content: + print("Empty cell reached") + break + + if cell_is_waiting_for_input(driver): + print("Cell requesting input") + input_box = WebDriverWait(driver, 5).until( + EC.visibility_of_element_located( + (By.CSS_SELECTOR, ".jp-Stdin-input") + ) + ) + input_box.click() + input_box.send_keys(f"{args.stdin}") + time.sleep(10) + input_box.send_keys(Keys.CONTROL, Keys.ENTER) + next_cell = focused_cell.find_element( + By.XPATH, + "following-sibling::div[contains(@class,'jp-Notebook-cell')][1]", + ) + driver.execute_script( + "arguments[0].scrollIntoView({block:'center'});", next_cell + ) + print(next_cell.text) + next_cell.click() + driver.execute_script( + """ + const evt = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter', + keyCode: 13, + which: 13, + shiftKey: true, + bubbles: true + }); + document.activeElement.dispatchEvent(evt); + """ + ) + + # Press Shift+Enter to run the cell + notebook_area.send_keys(Keys.SHIFT, Keys.ENTER) + time.sleep(1.0) + + # In case the notebook stalls during execution + # this makes it so the notebook moves onto the + # save stage after 600 seconds even if the kernel + # is still busy + timeout = 360 + start_time = time.time() + while True: + elapsed = time.time() - start_time + if elapsed > timeout: + print(f"Timeout reached ({elapsed:.1f} seconds). Stopping loop.") + sys.exit(1) + + spans = driver.find_elements(By.CSS_SELECTOR, "span.jp-StatusBar-TextItem") + status_span = spans[2] + text = status_span.text + + print(f"[{elapsed:.1f}s] Kernel status: {text}") + + if "Idle" in text: + print("Kernel is Idle. Stopping loop.") + break + + time.sleep(2) + + if args.driver == "chrome": + print("Saving notebook using command + s + enter.") + actions.send_keys(Keys.COMMAND, "s") + time.sleep(0.5) + + actions.send_keys(Keys.ENTER) + time.sleep(0.5) + + elif args.driver == "safari" or args.driver == "firefox": + print("Saving notebook using command + s + enter.") + notebook_area.send_keys(Keys.COMMAND, "s") + time.sleep(0.5) + + notebook_area.send_keys(Keys.ENTER) + time.sleep(0.5) + + # This downloads the notebook, so it can be compared + # to a reference notebook + print("Downloading notebook by clicking download button") + search_script = """ + function deepQuerySelector(root, selector) { + const walker = document.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: node => NodeFilter.FILTER_ACCEPT + }, + false + ); + + while (walker.nextNode()) { + let node = walker.currentNode; + + // Check if this node matches + if (node.matches && node.matches(selector)) { + return node; + } + + // If this element has a shadow root, search inside it + if (node.shadowRoot) { + const found = deepQuerySelector(node.shadowRoot, selector); + if (found) return found; + } + } + return null; + } + + return deepQuerySelector(document, "jp-button[data-command='docmanager:download']"); + """ + + download_button = driver.execute_script(search_script) + + time.sleep(2) + driver.execute_script( + """ + const el = arguments[0]; + + // Force element to be visible and focused + el.scrollIntoView({block: 'center', inline: 'center'}); + + // Dispatch real mouse events since Safari WebDriver ignores .click() on Web Components + ['pointerdown', 'mousedown', 'mouseup', 'click'].forEach(type => { + el.dispatchEvent(new MouseEvent(type, { + bubbles: true, + cancelable: true, + composed: true, // IMPORTANT for shadow DOM + view: window + })); + }); + """, + download_button, + ) + + time.sleep(2) + + # Close browser + driver.quit() + + +if __name__ == "__main__": + main() diff --git a/scripts/enable-downloads-safari-github-ci.py b/scripts/enable-downloads-safari-github-ci.py new file mode 100644 index 00000000..af1acff3 --- /dev/null +++ b/scripts/enable-downloads-safari-github-ci.py @@ -0,0 +1,33 @@ +import pyautogui +import time + +def main(): + # Click Safari icon + pyautogui.moveTo(150, 720, duration=1) + pyautogui.click() + time.sleep(1) + # Click Safari Menu + pyautogui.moveTo(60, 10, duration=1) + pyautogui.click() + time.sleep(1) + # Click Settings + pyautogui.moveTo(75, 102, duration=1) + pyautogui.click() + time.sleep(2.4) + # Click websites page of settings + pyautogui.moveTo(700, 240, duration=1) + pyautogui.click() + time.sleep(1.2) + # Click Downloads section of webpages page + pyautogui.moveTo(350, 630, duration=1) + pyautogui.click() + time.sleep(1.2) + # Change ask to allow + pyautogui.moveTo(950, 690, duration=1) + pyautogui.click() + pyautogui.moveTo(950, 670, duration=1) + pyautogui.click() + time.sleep(1.2) + +if __name__ == "__main__": + main()