├── api └── VERSION ├── static ├── .gitignore ├── favicon.ico ├── UI_Sans.woff2 ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── apple-touch-icon.png ├── web-app-manifest-192x192.png ├── web-app-manifest-512x512.png ├── main.scss ├── site.webmanifest ├── _theme.scss ├── _autoRefresh.js ├── _control.js ├── favicon.svg ├── _base.scss ├── _variables.scss ├── _lightMode.scss ├── _normalize.scss ├── _login.scss ├── _sections.js ├── main.js ├── _logs.js ├── _media.scss └── _update.js ├── requirements.in ├── .coveragerc ├── .gitignore ├── dev_requirements.in ├── .env.example ├── css_selectors.py ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── coverage.yml │ ├── python-test.yml │ ├── badges.yml │ ├── release.yml │ └── codeql.yml ├── SECURITY.md ├── LICENSE ├── uninstall.sh ├── release.sh ├── CONTRIBUTING.md ├── minimize.sh ├── requirements.txt ├── config.ini.example ├── tests ├── handle_functions │ ├── test_handle_modal.py │ ├── test_handle_page.py │ ├── test_handle_loading_issue.py │ ├── test_handle_login_and_clear.py │ └── test_handle_retry.py ├── handler_functions │ ├── test_usage_handler.py │ ├── test_api_handler.py │ ├── test_restart_handler.py │ ├── test_handler_functions.py │ └── test_process_handler.py ├── monitoring │ └── test_flask.py ├── test_check_functions.py └── test_logging.py ├── dev_requirements.txt ├── templates └── login.html ├── logging_config.py └── conftest.py /api/VERSION: -------------------------------------------------------------------------------- 1 | 2.4.3 2 | -------------------------------------------------------------------------------- /static/.gitignore: -------------------------------------------------------------------------------- 1 | *.map 2 | *.config -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Samuel1698/fakeViewport/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/UI_Sans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Samuel1698/fakeViewport/HEAD/static/UI_Sans.woff2 -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Samuel1698/fakeViewport/HEAD/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Samuel1698/fakeViewport/HEAD/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Samuel1698/fakeViewport/HEAD/static/favicon-96x96.png -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | python-dotenv 2 | selenium 3 | webdriver-manager 4 | psutil 5 | uptime 6 | Flask 7 | flask-cors -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Samuel1698/fakeViewport/HEAD/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/web-app-manifest-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Samuel1698/fakeViewport/HEAD/static/web-app-manifest-192x192.png -------------------------------------------------------------------------------- /static/web-app-manifest-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Samuel1698/fakeViewport/HEAD/static/web-app-manifest-512x512.png -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | 3 | exclude_lines = 4 | if __name__ == .__main__.: 5 | if "pytest" in sys.modules 6 | if HEADLESS: -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | config.ini 3 | nohup.out 4 | .vscode/ 5 | logs/ 6 | api/sst.txt 7 | api/status.txt 8 | api/.pause 9 | venv/ 10 | __pycache__/ 11 | .coverage 12 | TODO.md 13 | *.config -------------------------------------------------------------------------------- /dev_requirements.in: -------------------------------------------------------------------------------- 1 | python-dotenv 2 | selenium 3 | webdriver-manager 4 | psutil 5 | uptime 6 | flask 7 | flask-cors 8 | pytest 9 | pytest-mock 10 | pip-tools 11 | pytest-cov 12 | codecov -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | USERNAME=YourLocalUsername 2 | PASSWORD=YourLocalPassword 3 | URL=http://192.168.100.100/protect/dashboard/multiviewurl 4 | # Optional Keys. If present, cannot be left empty 5 | FLASK_RUN_HOST=0.0.0.0 6 | FLASK_RUN_PORT=5000 7 | # SECRET= -------------------------------------------------------------------------------- /static/main.scss: -------------------------------------------------------------------------------- 1 | // Base styles 2 | @import '_normalize'; 3 | @import '_base'; 4 | // Light theme toggle 5 | @import '_theme'; 6 | @import '_lightMode'; 7 | // Pages 8 | @import '_login'; 9 | @import '_index'; 10 | // Media queries 11 | @import '_media'; -------------------------------------------------------------------------------- /css_selectors.py: -------------------------------------------------------------------------------- 1 | # CSS Selectors 2 | CSS_FULLSCREEN_PARENT = "div[class*='LiveviewControls__ButtonGroup']" 3 | CSS_FULLSCREEN_BUTTON = ":nth-child(2) > button" 4 | CSS_LOADING_DOTS = "div[class*='TimedDotsLoader']" 5 | CSS_LIVEVIEW_WRAPPER = "div[class*='liveview__ViewportsWrapper']" 6 | CSS_PLAYER_OPTIONS = ["aeugT", "dzRoNo"] 7 | CSS_CURSOR = ["hMbAUy"] 8 | CSS_CLOSE_BUTTON = "button[class*='closeButton']" -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Viewport Dashboard", 3 | "short_name": "Viewport", 4 | "icons": [ 5 | { 6 | "src": "/static/web-app-manifest-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png", 9 | "purpose": "maskable" 10 | }, 11 | { 12 | "src": "/static/web-app-manifest-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png", 15 | "purpose": "maskable" 16 | } 17 | ], 18 | "theme_color": "#ffffff", 19 | "background_color": "#ffffff", 20 | "display": "standalone" 21 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: Samuel1698 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 2.x.x | :white_check_mark: | 8 | | <2.0.0 | :x: | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | If you discover any security vulnerabilities, please submit an issue or create a pull request with a fix. 13 | 14 | **Note:** This is a personal project that I maintain in my free time. I'm committed to keeping it updated and secure for as long as I'm able to maintain it. Your understanding and contributions are appreciated. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: Samuel1698 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Screenshots** 14 | If applicable, add screenshots to help explain your problem. 15 | 16 | **Logs** 17 | If applicable, add the relevant log output from the log file. 18 | 19 | **(please complete the following information):** 20 | - OS: [e.g. Linux Mint] 21 | - Browser [e.g. chrome, firefox] 22 | - Device [e.g. Dell Wyse Thin Client 5070] 23 | -------------------------------------------------------------------------------- /static/_theme.scss: -------------------------------------------------------------------------------- 1 | @use '_variables' as *; 2 | @use "sass:map"; 3 | @use 'sass:color'; 4 | // -------------------------------------------------- 5 | // Theme Swapping 6 | // -------------------------------------------------- 7 | #themeToggle { 8 | background-color: transparent; 9 | border: none; 10 | border-radius: $border-radius; 11 | padding: 8px; 12 | cursor: pointer; 13 | transition: background-color $transition-speed; 14 | color: color("text"); 15 | @include hover { 16 | color: color("blue"); 17 | } 18 | svg { 19 | width: 24px; 20 | height: 24px; 21 | transition: fill $transition-speed; 22 | } 23 | .light-icon { 24 | display: none; 25 | } 26 | .dark-icon { 27 | display: block; 28 | } 29 | [data-theme="light"] & { 30 | .light-icon { 31 | display: block; 32 | } 33 | .dark-icon { 34 | display: none; 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Samuel G. Muñoz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /static/_autoRefresh.js: -------------------------------------------------------------------------------- 1 | import { loadStatus } from "./_device.js"; 2 | import { loadDeviceData } from "./_device.js"; 3 | import { configCache } from "./_device.js"; 4 | import { CACHE_TTL } from "./_update.js"; 5 | 6 | // --------------------------------------------------------------------------- 7 | // one timer for everything 8 | // --------------------------------------------------------------------------- 9 | let timer = null; 10 | 11 | const RATE = { 12 | status: 5_000, 13 | device: 5_000, 14 | config: CACHE_TTL, 15 | desktop: 5_000, // merged Status+Device view 16 | }; 17 | 18 | function tick(key) { 19 | switch (key) { 20 | case "status": 21 | loadStatus(); 22 | break; 23 | case "device": 24 | loadDeviceData(); 25 | break; 26 | case "config": 27 | configCache.get(); 28 | break; 29 | case "desktop": // desktop == status + device 30 | loadStatus(); 31 | loadDeviceData(); 32 | break; 33 | } 34 | } 35 | 36 | export function scheduleRefresh(key, { immediate = true } = {}) { 37 | clearInterval(timer); 38 | const rate = RATE[key]; 39 | if (!rate) return; 40 | if (immediate) tick(key); 41 | timer = setInterval(() => tick(key), rate); 42 | } 43 | -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | RED='\e[0;31m' 3 | GREEN='\e[0;32m' 4 | YELLOW='\e[1;33m' 5 | CYAN='\e[36m' 6 | NC='\e[0m' 7 | echo -e "${YELLOW}===== FakeViewport Uninstall =====${NC}" 8 | echo -ne "\n${YELLOW}This will uninstall the script and related files. Continue? (y/n):${NC} " 9 | read -r confirm 10 | if [[ ! "$confirm" =~ ^[Yy]([Ee][Ss])?$ ]]; then 11 | echo -e "${GREEN}Aborted.${NC}" 12 | exit 0 13 | fi 14 | 15 | # Remove desktop shortcut 16 | desktop_file="$HOME/Desktop/Viewport.desktop" 17 | [ -f "$desktop_file" ] && rm "$desktop_file" && echo -e "${GREEN}✓ Desktop shortcut removed.${NC}" 18 | 19 | # Remove alias from ~/.bashrc or ~/.zshrc 20 | for file in ~/.bashrc ~/.zshrc; do 21 | if [ -f "$file" ] && grep -q "alias viewport=" "$file"; then 22 | sed -i '/alias viewport=/d' "$file" 23 | echo -e "${GREEN}✓ Removed 'viewport' alias from ${file}${NC}" 24 | fi 25 | done 26 | # Remove cron job 27 | tmp_cron=$(mktemp) 28 | crontab -l 2>/dev/null | grep -v "viewport.py" > "$tmp_cron" 29 | crontab "$tmp_cron" 30 | rm "$tmp_cron" 31 | echo -e "${GREEN}✓ Cron job removed.${NC}" 32 | 33 | find . -mindepth 1 -maxdepth 1 \ 34 | ! -name ".env" \ 35 | -exec rm -rf {} + 36 | 37 | echo -e "${GREEN}✓ All project files removed except .env${NC}" 38 | 39 | echo -e "${YELLOW}✓ Uninstall complete. You may want to manually delete .env file if desired.${NC}" 40 | echo -e "\n${YELLOW}Run this command to remove the alias from your shell: ${NC}" 41 | echo -e "${CYAN} unalias viewport${NC}" -------------------------------------------------------------------------------- /static/_control.js: -------------------------------------------------------------------------------- 1 | import { stopLogsAutoRefresh, startLogsAutoRefresh } from "./_logs.js"; 2 | import { isDesktopView } from "./_sections.js"; 3 | 4 | // send control and update inline message 5 | export async function control(action) { 6 | const msgEls = document.querySelectorAll(".statusMessage span"); 7 | msgEls.forEach((el) => { 8 | el.textContent = ""; 9 | el.classList.remove("Green", "Red"); 10 | }); 11 | try { 12 | const res = await fetch(`/api/control/${action}`, { method: "POST" }); 13 | const js = await res.json(); 14 | 15 | if (js.status === "ok") { 16 | msgEls.forEach((el) => { 17 | el.textContent = "✓ " + js.message; 18 | el.classList.add("Green"); 19 | }); 20 | } else { 21 | msgEls.forEach((el) => { 22 | el.textContent = "✗ " + js.message; 23 | el.classList.add("Red"); 24 | }); 25 | } 26 | if (isDesktopView() && action != "quit"){ 27 | stopLogsAutoRefresh(); 28 | startLogsAutoRefresh(1_000); 29 | } 30 | // reset the message after 5 seconds 31 | setTimeout(() => { 32 | msgEls.forEach((el) => { 33 | el.textContent = ""; 34 | el.classList.remove("Green", "Red"); 35 | if (isDesktopView() && action != "quit") { 36 | stopLogsAutoRefresh(); 37 | startLogsAutoRefresh(); 38 | } 39 | }); 40 | }, 15_000); 41 | } catch (e) { 42 | msgEls.forEach((el) => { 43 | el.textContent = "✗ " + e; 44 | el.classList.add("Red"); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | # grab the tag (or fallback to commit SHA) 5 | VERSION=$(git describe --tags --abbrev=0 2>/dev/null || git rev-parse --short HEAD) 6 | OUTDIR=dist 7 | mkdir -p "$OUTDIR" 8 | 9 | # Full repo 10 | git archive \ 11 | --format=tar.gz \ 12 | --prefix="viewport ${VERSION}/" \ 13 | -o "${OUTDIR}/viewport-${VERSION}-full.tar.gz" \ 14 | HEAD 15 | 16 | # Minimal: only runtime files 17 | git archive \ 18 | --format=tar.gz \ 19 | --prefix="viewport ${VERSION}/" \ 20 | -o "${OUTDIR}/viewport-${VERSION}-minimal.tar.gz" \ 21 | HEAD \ 22 | viewport.py \ 23 | monitoring.py \ 24 | update.py \ 25 | logging_config.py \ 26 | validate_config.py \ 27 | css_selectors.py \ 28 | setup.sh \ 29 | minimize.sh \ 30 | uninstall.sh \ 31 | requirements.txt \ 32 | api/VERSION \ 33 | templates/ \ 34 | static/main-min.js \ 35 | static/marked-min.js \ 36 | static/main-min.css \ 37 | static/favicon* \ 38 | static/*woff2 \ 39 | static/*.png \ 40 | static/site* \ 41 | config.ini.example \ 42 | .env.example 43 | 44 | # Bare-bones: viewport.py + deps 45 | git archive \ 46 | --format=tar.gz \ 47 | --prefix="viewport ${VERSION}/" \ 48 | -o "${OUTDIR}/viewport-${VERSION}-no-api.tar.gz" \ 49 | HEAD \ 50 | viewport.py \ 51 | logging_config.py \ 52 | validate_config.py \ 53 | css_selectors.py \ 54 | setup.sh \ 55 | minimize.sh \ 56 | uninstall.sh \ 57 | requirements.txt \ 58 | api/VERSION \ 59 | config.ini.example \ 60 | .env.example 61 | 62 | echo "Created:" 63 | ls -1 "${OUTDIR}" 64 | -------------------------------------------------------------------------------- /static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for considering contributing to this project! All contributions (bug reports, feature requests, documentation, code improvements) are welcome. 4 | 5 | ## How to Contribute 6 | 7 | 1. **Fork** the repository on GitHub 8 | 2. **Clone** your fork locally 9 | 3. Create a **new branch** for your changes (`git checkout -b your-feature-branch`) 10 | 4. **Commit** your changes (see commit message guidelines below) 11 | 5. **Push** to your fork (`git push origin your-feature-branch`) 12 | 6. Open a **Pull Request** against the main branch 13 | 14 | ## Code Style 15 | 16 | - Follow [PEP 8](https://www.python.org/dev/peps/pep-0008/) style guidelines 17 | - Use DOCSTRINGS on any new tests/functions 18 | 19 | ## Commit Messages 20 | 21 | - Use present tense ("Add feature" not "Added feature") 22 | - Keep the subject line under 50 characters 23 | - Include a more detailed body when necessary 24 | - Reference issues with `#123` when applicable 25 | - Use [Conventional Commits](https://gist.github.com/qoomon/5dfcdf8eec66a051ecd85625518cfd13) 26 | 27 | ## Pull Requests 28 | 29 | - Keep PRs focused on a single feature/bugfix 30 | - Include a clear description of changes 31 | - Reference related issues 32 | - Update documentation if needed 33 | 34 | ## Development Setup 35 | 36 | 1. Run the `setup` script with `dev` as an argument: `./setup.sh dev` 37 | 2. Activate the virtual environment 38 | 39 | ```bash 40 | source venv/bin/activate 41 | ``` 42 | 43 | 3. Run `pytest` locally before submitting a pull request 44 | 45 | 4. Check the code coverage 46 | 47 | ```shell 48 | pytest --cov --cov-branch --cov-report=term-missing 49 | ``` 50 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: CI / Test Coverage 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | coverage: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 2 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: '3.x' 22 | 23 | - name: Install dependencies 24 | run: pip install -r dev_requirements.txt 25 | 26 | # Create a valid config.ini so viewport.py import passes 27 | - name: Write out config.ini 28 | run: | 29 | cat << 'EOF' > config.ini 30 | [General] 31 | SLEEP_TIME = 300 32 | WAIT_TIME = 30 33 | MAX_RETRIES = 5 34 | 35 | [Logging] 36 | LOG_FILE = true 37 | LOG_CONSOLE = true 38 | DEBUG_LOGGING=False 39 | ERROR_LOGGING = false 40 | LOG_DAYS = 7 41 | LOG_INTERVAL = 60 42 | 43 | [API] 44 | USE_API = false 45 | EOF 46 | 47 | # Create a minimal .env so viewport.py import passes 48 | - name: Write out .env 49 | run: | 50 | cat << 'EOF' > .env 51 | USERNAME=testuser 52 | PASSWORD=testpass 53 | URL=http://example.com 54 | EOF 55 | 56 | - name: Run tests 57 | run: pytest --cov --cov-branch --cov-report=xml 58 | 59 | - name: Upload results to Codecov 60 | uses: codecov/codecov-action@v5 61 | with: 62 | token: ${{ secrets.CODECOV_TOKEN }} 63 | files: ./coverage.xml 64 | fail_ci_if_error: true -------------------------------------------------------------------------------- /.github/workflows/python-test.yml: -------------------------------------------------------------------------------- 1 | name: Python Tests 2 | 3 | on: 4 | push: 5 | branches: [ "main", "snapshot" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: '37 1 * * 1' 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Check out repository 15 | uses: actions/checkout@v4 16 | 17 | # Create a valid config.ini so viewport.py import passes 18 | - name: Write out config.ini 19 | run: | 20 | cat << 'EOF' > config.ini 21 | [General] 22 | SLEEP_TIME = 300 23 | WAIT_TIME = 30 24 | MAX_RETRIES = 5 25 | 26 | [Logging] 27 | LOG_FILE = true 28 | LOG_CONSOLE = true 29 | DEBUG_LOGGING=False 30 | ERROR_LOGGING = false 31 | LOG_DAYS = 7 32 | LOG_INTERVAL = 60 33 | 34 | [API] 35 | USE_API = false 36 | EOF 37 | 38 | # Create a minimal .env so viewport.py import passes 39 | - name: Write out .env 40 | run: | 41 | cat << 'EOF' > .env 42 | USERNAME=testuser 43 | PASSWORD=testpass 44 | URL=http://example.com 45 | EOF 46 | 47 | - name: Set up Python 48 | uses: actions/setup-python@v4 49 | with: 50 | python-version: '3' 51 | 52 | - name: Install Dependencies 53 | run: | 54 | python -m pip install --upgrade pip 55 | if [ -f dev_requirements.txt ]; then pip install -r dev_requirements.txt; fi 56 | 57 | - name: Cache pip 58 | uses: actions/cache@v3 59 | with: 60 | path: ~/.cache/pip 61 | key: ${{ runner.os }}-pip-${{ hashFiles('**/dev_requirements.txt') }} 62 | restore-keys: | 63 | ${{ runner.os }}-pip- 64 | 65 | - name: Run pytest 66 | run: pytest 67 | -------------------------------------------------------------------------------- /minimize.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RED='\e[0;31m' 4 | GREEN='\e[0;32m' 5 | YELLOW='\e[1;33m' 6 | NC='\e[0m' 7 | 8 | echo -e "${YELLOW}===== FakeViewport Minimize =====${NC}" 9 | 10 | if [[ "$1" =~ ^(-f|--force)$ ]]; then 11 | confirm="y" 12 | else 13 | echo -ne "\n${YELLOW}This will delete all development files and tests. Continue? (y/n):${NC} " 14 | read -r confirm 15 | fi 16 | 17 | if [[ ! "$confirm" =~ ^[Yy]([Ee][Ss])?$ ]]; then 18 | echo -e "${GREEN}Aborted.${NC}" 19 | exit 0 20 | fi 21 | 22 | echo -e "${GREEN}Removing development files...${NC}" 23 | 24 | # Function to remove files and print a green dot 25 | remove_and_dot() { 26 | for target in "$@"; do 27 | if [ -e "$target" ]; then 28 | rm -rf "$target" 29 | echo -ne "${GREEN}.${NC}" 30 | fi 31 | done 32 | } 33 | 34 | # Remove known paths 35 | remove_and_dot tests/ .github/ conftest.py requirements.in 36 | remove_and_dot .pytest_cache/ .mypy_cache/ __pycache__/ 37 | 38 | # Remove *.md 39 | find . -type f -name "*.md" -not -path "./venv/*" -print0 | while IFS= read -r -d '' file; do 40 | rm -f "$file" && echo -ne "${GREEN}.${NC}" 41 | done 42 | 43 | # Remove dev_* files 44 | find . -type f -name "dev_*" -not -path "./venv/*" -print0 | while IFS= read -r -d '' file; do 45 | rm -f "$file" && echo -ne "${GREEN}.${NC}" 46 | done 47 | 48 | # Remove *.coveragerc and *release.sh 49 | find . -type f \( -name "*.coveragerc" -o -name "release.sh" \) -not -path "./venv/*" -print0 | while IFS= read -r -d '' file; do 50 | rm -f "$file" && echo -ne "${GREEN}.${NC}" 51 | done 52 | 53 | # Delete static/*.scss and static/main.js 54 | find ./static -type f \( -name "*.scss" -o -name "main.js" -o -name "_*.js" \) -print0 | while IFS= read -r -d '' file; do 55 | rm -f "$file" && echo -ne "${GREEN}.${NC}" 56 | done 57 | 58 | echo -e "\n${GREEN}Minimization complete.${NC}" 59 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=- --strip-extras requirements.in > requirements.txt 6 | # 7 | attrs==25.3.0 8 | # via 9 | # outcome 10 | # trio 11 | blinker==1.9.0 12 | # via flask 13 | certifi==2025.4.26 14 | # via 15 | # requests 16 | # selenium 17 | charset-normalizer==3.4.2 18 | # via requests 19 | click==8.1.8 20 | # via flask 21 | flask==3.1.0 22 | # via 23 | # -r requirements.in 24 | # flask-cors 25 | flask-cors==5.0.1 26 | # via -r requirements.in 27 | h11==0.16.0 28 | # via wsproto 29 | idna==3.10 30 | # via 31 | # requests 32 | # trio 33 | itsdangerous==2.2.0 34 | # via flask 35 | jinja2==3.1.6 36 | # via flask 37 | markupsafe==3.0.2 38 | # via 39 | # jinja2 40 | # werkzeug 41 | outcome==1.3.0.post0 42 | # via 43 | # trio 44 | # trio-websocket 45 | packaging==25.0 46 | # via webdriver-manager 47 | psutil==7.0.0 48 | # via -r requirements.in 49 | pysocks==1.7.1 50 | # via urllib3 51 | python-dotenv==1.1.0 52 | # via 53 | # -r requirements.in 54 | # webdriver-manager 55 | requests==2.32.3 56 | # via webdriver-manager 57 | selenium==4.32.0 58 | # via -r requirements.in 59 | sniffio==1.3.1 60 | # via trio 61 | sortedcontainers==2.4.0 62 | # via trio 63 | trio==0.30.0 64 | # via 65 | # selenium 66 | # trio-websocket 67 | trio-websocket==0.12.2 68 | # via selenium 69 | typing-extensions==4.13.2 70 | # via selenium 71 | uptime==3.0.1 72 | # via -r requirements.in 73 | urllib3==2.4.0 74 | # via 75 | # requests 76 | # selenium 77 | webdriver-manager==4.0.2 78 | # via -r requirements.in 79 | websocket-client==1.8.0 80 | # via selenium 81 | werkzeug==3.1.3 82 | # via 83 | # flask 84 | # flask-cors 85 | wsproto==1.2.0 86 | # via trio-websocket 87 | -------------------------------------------------------------------------------- /config.ini.example: -------------------------------------------------------------------------------- 1 | [General] 2 | # Time (seconds) between health checks or page refreshes. Don’t set too low unless troubleshooting. 3 | SLEEP_TIME=300 4 | 5 | # Max seconds to wait for page elements (loads, camera feeds, buttons) before error. 6 | WAIT_TIME=30 7 | 8 | # Number of retry attempts on failures (page reloads → browser restart → script restart). Minimum 3. 9 | MAX_RETRIES=3 10 | 11 | # Comma-separated 24h times (HH:MM) for automatic script restart (optional failsafe). 12 | # RESTART_TIMES=11:00, 23:00 13 | [Browser] 14 | # Optional: Custom profile directory (omit to use chrome's default) 15 | # • Go to chrome://version and copy "Profile Path" without the trailing "Default" 16 | # BROWSER_PROFILE_PATH=/home/your-user/.config/google-chrome/ 17 | # BROWSER_PROFILE_PATH=/home/your-user/.config/chromium/ 18 | # • Go to about:support for firefox and copy the "Profile Folder" without the trailing "Default" 19 | # BROWSER_PROFILE_PATH=/home/your-user/.mozilla/firefox/ 20 | # BROWSER_PROFILE_PATH=/home/your-user/snap/firefox/common/.mozilla/firefox/ 21 | 22 | # Optional: Executable Path 23 | # • Go to chrome://version and copy "Command Line" without the trailing "--flags" 24 | # BROWSER_BINARY=/usr/lib/chromium/chromium 25 | BROWSER_BINARY=/usr/bin/google-chrome-stable 26 | # • Go to about:support and copy "Application Binary" without the trailing "--flags" 27 | # BROWSER_BINARY=/usr/lib/firefox-esr/firefox-esr 28 | # BROWSER_BINARY=/snap/firefox/6103/usr/lib/firefox/firefox 29 | 30 | # Run in headless mode (True/False). Useful only for testing. 31 | # HEADLESS=True 32 | [Logging] 33 | # Enable writing to logfile and/or console. 34 | LOG_FILE=True 35 | LOG_CONSOLE=True 36 | 37 | # Use DEBUG level instead of INFO. 38 | DEBUG_LOGGING=False 39 | 40 | # On errors, raise exception (more verbose) and/or capture screenshot. 41 | ERROR_LOGGING=False 42 | ERROR_PRTSCR=False 43 | 44 | # Retain this many days of logs (and error screenshots). 45 | LOG_DAYS=7 46 | 47 | # Minutes between status log entries. Defaults to 60. 48 | LOG_INTERVAL=60 49 | 50 | [API] 51 | # Enable built-in monitoring API (False to disable). 52 | USE_API=False -------------------------------------------------------------------------------- /.github/workflows/badges.yml: -------------------------------------------------------------------------------- 1 | name: Update README Badges 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | refresh-badges: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Remove SNAPSHOT Warning and Replace Badges 22 | uses: actions/github-script@v6 23 | with: 24 | github-token: ${{ secrets.GITHUB_TOKEN }} 25 | script: | 26 | const fs = require('fs'); 27 | const file = 'README.md'; 28 | let md = fs.readFileSync(file, 'utf8'); 29 | md = md.replace( 30 | /## SNAPSHOT[\s\S]*?for a stable version of the code\.\n+---\s*/m, 31 | '' 32 | ); 33 | 34 | const badges = ` 35 | [![Python](https://github.com/Samuel1698/fakeViewport/actions/workflows/python-test.yml/badge.svg)](https://github.com/Samuel1698/fakeViewport/actions/workflows/python-test.yml) 36 | [![CodeQL](https://github.com/Samuel1698/fakeViewport/actions/workflows/codeql.yml/badge.svg)](https://github.com/Samuel1698/fakeViewport/actions/workflows/codeql.yml) 37 | [![codecov](https://codecov.io/github/Samuel1698/fakeViewport/graph/badge.svg?token=mPKJSAYXH5)](https://codecov.io/github/Samuel1698/fakeViewport) 38 | `; 39 | 40 | const regionRe = /[\s\S]*?/gm; 41 | md = md.replace( 42 | regionRe, 43 | `\n${badges}\n` 44 | ); 45 | fs.writeFileSync(file, md); 46 | 47 | - name: Commit & push changes 48 | run: | 49 | git config user.name "github-actions[bot]" 50 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 51 | git add README.md 52 | git commit -m "docs: update README" || echo "No changes to commit" 53 | git push -------------------------------------------------------------------------------- /static/_base.scss: -------------------------------------------------------------------------------- 1 | @use '_variables' as *; 2 | @use "sass:map"; 3 | @use 'sass:color'; 4 | // -------------------------------------------------- 5 | // Base Styles 6 | // -------------------------------------------------- 7 | html { 8 | transition: background-color $transition-speed, color $transition-speed; 9 | max-width: 100%; 10 | overflow-x: hidden; 11 | overflow-y: auto; 12 | } 13 | body { 14 | min-width: 320px; 15 | margin-top: 5rem; 16 | background-color: color("background"); 17 | color: color("text"); 18 | transition: background-color $transition-speed, color $transition-speed; 19 | } 20 | @font-face { 21 | font-family: "UI Sans"; 22 | src: url("../static/UI_Sans.woff2") format("woff2"); 23 | font-weight: normal; 24 | font-display: fallback; 25 | } 26 | html, body { 27 | margin: 0; 28 | padding: 0; 29 | font-family: "UI Sans", Arial, sans-serif; 30 | -webkit-font-smoothing: antialiased; 31 | -moz-osx-font-smoothing: grayscale; 32 | } 33 | img:focus-visible { 34 | margin: -2px; 35 | padding: 2px; 36 | outline: 5px auto -webkit-focus-ring-color; 37 | outline: 5px auto Highlight; 38 | outline: 5px auto color("red"); 39 | } 40 | /* prettier-ignore */ 41 | a:focus-visible, div:focus-visible, section:focus-visible, li:focus-visible, input:focus-visible, [type="button"]:focus-visible, [type="reset"]:focus-visible, [type="submit"]:focus-visible, button:focus-visible { 42 | position: relative; 43 | outline: none; 44 | &::after { 45 | content: ""; 46 | position: absolute; 47 | top: -2px; 48 | left: -2px; 49 | right: -2px; 50 | bottom: -2px; 51 | outline: 5px auto -webkit-focus-ring-color; 52 | outline: 5px auto Highlight; 53 | outline: 5px auto color("red"); 54 | z-index: 9999; 55 | } 56 | } 57 | button { 58 | border: none; 59 | } 60 | .Green { 61 | color: color("green"); 62 | } 63 | .Red { 64 | color: color("red"); 65 | } 66 | .Blue { 67 | color: color("blue"); 68 | } 69 | .Yellow { 70 | color: color("yellow"); 71 | } 72 | .up { 73 | color: color("up"); 74 | fill: color("up"); 75 | } 76 | .down { 77 | color: color("down"); 78 | fill: color("down"); 79 | } -------------------------------------------------------------------------------- /tests/handle_functions/test_handle_modal.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock, patch 3 | from selenium.common.exceptions import TimeoutException 4 | import viewport 5 | 6 | @pytest.fixture 7 | def mock_driver(): 8 | return MagicMock() 9 | 10 | def test_handle_modal_success(mock_driver): 11 | with patch("viewport.WebDriverWait") as MockWait, \ 12 | patch("viewport.ActionChains") as MockActions: 13 | # Simulate modal presence 14 | mock_driver.execute_script.return_value = True 15 | 16 | # Setup mocks for wait and actions 17 | mock_button = MagicMock() 18 | MockWait.return_value.until.side_effect = [ 19 | mock_button, # wait for close button 20 | True # wait for modal to disappear 21 | ] 22 | mock_action_chain = MagicMock() 23 | MockActions.return_value = mock_action_chain 24 | 25 | assert viewport.handle_modal(mock_driver) is True 26 | mock_action_chain.click.assert_called() 27 | 28 | def test_handle_modal_no_modal(mock_driver): 29 | mock_driver.execute_script.return_value = False 30 | assert viewport.handle_modal(mock_driver) is False 31 | 32 | def test_handle_modal_no_close_button(mock_driver): 33 | mock_driver.execute_script.return_value = True 34 | with patch("viewport.WebDriverWait") as MockWait: 35 | MockWait.return_value.until.side_effect = TimeoutException 36 | assert viewport.handle_modal(mock_driver) is False 37 | 38 | def test_handle_modal_close_button_but_modal_does_not_disappear(mock_driver): 39 | with patch("viewport.WebDriverWait") as MockWait, \ 40 | patch("viewport.ActionChains"): 41 | mock_driver.execute_script.return_value = True 42 | mock_button = MagicMock() 43 | MockWait.return_value.until.side_effect = [ 44 | mock_button, # close button found 45 | TimeoutException() # modal doesn't disappear 46 | ] 47 | assert viewport.handle_modal(mock_driver) is False 48 | 49 | def test_handle_modal_unexpected_exception(mock_driver): 50 | mock_driver.execute_script.side_effect = Exception("unexpected") 51 | assert viewport.handle_modal(mock_driver) is False 52 | -------------------------------------------------------------------------------- /tests/handler_functions/test_usage_handler.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import viewport 3 | from types import SimpleNamespace 4 | from unittest.mock import patch 5 | # import your helper; adjust the path if needed 6 | from tests.handler_functions.test_process_handler import _make_proc 7 | 8 | @pytest.mark.parametrize("match_str, proc_specs, exp_cpu, exp_mem", [ 9 | ( 10 | "viewport", 11 | [ 12 | # pid, cmdline, cpu, mem 13 | (1, "viewport.py", 3.0, 1000), 14 | (2, ["python", "viewport"], 2.0, 2000), 15 | (3, "other.py", 5.0, 3000), 16 | ], 17 | 5.0, 3000 18 | ), 19 | ( 20 | "chrome", 21 | [ 22 | (1, "chrome", 1.0, 100), 23 | (2, ["chromedriver"], 2.0, 200), 24 | (3, "unrelated", 9.0, 900), 25 | ], 26 | 3.0, 300 27 | ), 28 | ( 29 | "chromium", 30 | [ 31 | (1, "chromium", 1.0, 100), 32 | (2, ["chromiumdriver"], 2.0, 200), 33 | (3, "unrelated", 9.0, 900), 34 | ], 35 | 3.0, 300 36 | ), 37 | ]) 38 | def test_usage_handler(match_str, proc_specs, exp_cpu, exp_mem, monkeypatch): 39 | # build fake procs 40 | procs = [] 41 | for pid, cmdline, cpu, mem in proc_specs: 42 | proc = _make_proc(pid, cmdline) 43 | proc.cpu_percent.return_value = cpu 44 | proc.memory_info.return_value = SimpleNamespace(rss=mem) 45 | procs.append(proc) 46 | 47 | # patch psutil.process_iter 48 | monkeypatch.setattr(viewport.psutil, "process_iter", lambda attrs: procs) 49 | 50 | cpu, mem = viewport.usage_handler(match_str) 51 | assert cpu == pytest.approx(exp_cpu) 52 | assert mem == exp_mem 53 | 54 | @patch("viewport.psutil.process_iter") 55 | def test_usage_handler_ignores_exceptions(mock_process_iter): 56 | # one good process 57 | good = _make_proc(1, ["target_app"]) 58 | good.cpu_percent.return_value = 10.0 59 | good.memory_info.return_value = SimpleNamespace(rss=100000) 60 | 61 | # one broken process 62 | bad = _make_proc(2, ["target_app"]) 63 | bad.cpu_percent.side_effect = Exception("denied") 64 | bad.memory_info.return_value = SimpleNamespace(rss=50000) 65 | 66 | mock_process_iter.return_value = [good, bad] 67 | 68 | cpu, mem = viewport.usage_handler("target") 69 | assert cpu == pytest.approx(10.0) 70 | assert mem == 100000 71 | -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.12 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=- --strip-extras dev_requirements.in > dev_requirements.txt 6 | # 7 | attrs==25.3.0 8 | # via 9 | # outcome 10 | # trio 11 | blinker==1.9.0 12 | # via flask 13 | build==1.2.2.post1 14 | # via pip-tools 15 | certifi==2025.4.26 16 | # via 17 | # requests 18 | # selenium 19 | charset-normalizer==3.4.2 20 | # via requests 21 | click==8.2.0 22 | # via 23 | # flask 24 | # pip-tools 25 | codecov==2.1.13 26 | # via -r dev_requirements.in 27 | coverage==7.8.0 28 | # via 29 | # codecov 30 | # pytest-cov 31 | flask==3.1.0 32 | # via 33 | # -r dev_requirements.in 34 | # flask-cors 35 | flask-cors==5.0.1 36 | # via -r dev_requirements.in 37 | h11==0.16.0 38 | # via wsproto 39 | idna==3.10 40 | # via 41 | # requests 42 | # trio 43 | iniconfig==2.1.0 44 | # via pytest 45 | itsdangerous==2.2.0 46 | # via flask 47 | jinja2==3.1.6 48 | # via flask 49 | markupsafe==3.0.2 50 | # via 51 | # jinja2 52 | # werkzeug 53 | outcome==1.3.0.post0 54 | # via 55 | # trio 56 | # trio-websocket 57 | packaging==25.0 58 | # via 59 | # build 60 | # pytest 61 | # webdriver-manager 62 | pip-tools==7.4.1 63 | # via -r dev_requirements.in 64 | pluggy==1.5.0 65 | # via pytest 66 | psutil==7.0.0 67 | # via -r dev_requirements.in 68 | pyproject-hooks==1.2.0 69 | # via 70 | # build 71 | # pip-tools 72 | pysocks==1.7.1 73 | # via urllib3 74 | pytest==8.3.5 75 | # via 76 | # -r dev_requirements.in 77 | # pytest-cov 78 | # pytest-mock 79 | pytest-cov==6.1.1 80 | # via -r dev_requirements.in 81 | pytest-mock==3.14.0 82 | # via -r dev_requirements.in 83 | python-dotenv==1.1.0 84 | # via 85 | # -r dev_requirements.in 86 | # webdriver-manager 87 | requests==2.32.3 88 | # via 89 | # codecov 90 | # webdriver-manager 91 | selenium==4.32.0 92 | # via -r dev_requirements.in 93 | sniffio==1.3.1 94 | # via trio 95 | sortedcontainers==2.4.0 96 | # via trio 97 | trio==0.30.0 98 | # via 99 | # selenium 100 | # trio-websocket 101 | trio-websocket==0.12.2 102 | # via selenium 103 | typing-extensions==4.13.2 104 | # via selenium 105 | uptime==3.0.1 106 | # via -r dev_requirements.in 107 | urllib3==2.4.0 108 | # via 109 | # requests 110 | # selenium 111 | webdriver-manager==4.0.2 112 | # via -r dev_requirements.in 113 | websocket-client==1.8.0 114 | # via selenium 115 | werkzeug==3.1.3 116 | # via 117 | # flask 118 | # flask-cors 119 | wheel==0.45.1 120 | # via pip-tools 121 | wsproto==1.2.0 122 | # via trio-websocket 123 | 124 | # The following packages are considered to be unsafe in a requirements file: 125 | # pip 126 | # setuptools 127 | -------------------------------------------------------------------------------- /static/_variables.scss: -------------------------------------------------------------------------------- 1 | @use "sass:map"; 2 | @use "sass:color"; 3 | 4 | $base-palette: ( 5 | background: #131416, 6 | text: #dee0e3, 7 | blue: #147aff, 8 | red: #FF556D, 9 | green: #36be5f, 10 | yellow: #EFC368, 11 | down: #5baed1, 12 | up: #8e63b0, 13 | search: #1c1d1f, 14 | border: #282b2f, 15 | diminished: #282b2f, 16 | box: #1c1e21, 17 | gitCode: #2e363e, 18 | gitText: #d1d7e0, 19 | ); 20 | $light-palette: ( 21 | background: #ffffff, 22 | text: #50565e, 23 | bText: #dee0e3, 24 | blue: #006fff, 25 | bBlue: #147aff, 26 | red: #ee383b, 27 | green: #38cc65, 28 | yellow: #f3a424, 29 | bYellow: #EFC368, 30 | down: #5baed1, 31 | up: #8e63b0, 32 | search: #efeff0, 33 | border: #eff1f2, 34 | diminished: #f4f5f6, 35 | bDiminished:#282b2f, 36 | box: #ffffff, 37 | gitCode: #f0f1f2, 38 | gitText: #1f2328, 39 | ); 40 | @mixin define-color($name, $color) { 41 | --#{$name}-h: #{hue($color)}; 42 | --#{$name}-s: #{saturation($color)}; 43 | --#{$name}-l: #{lightness($color)}; 44 | --#{$name}-a: #{alpha($color)}; 45 | } 46 | :root { 47 | // Default dark theme 48 | &[data-theme="dark"], 49 | &:not([data-theme]) { // Fallback to dark if no theme specified 50 | @each $name, $col in $base-palette { 51 | @include define-color($name, $col); 52 | } 53 | } 54 | 55 | // Light theme 56 | &[data-theme="light"] { 57 | @each $name, $col in $light-palette { 58 | @include define-color($name, $col); 59 | } 60 | } 61 | @media (prefers-color-scheme: light) { 62 | &:not([data-theme]) { 63 | @each $name, $col in $light-palette { 64 | @include define-color($name, $col); 65 | } 66 | } 67 | } 68 | } 69 | @function color( 70 | $name, 71 | $hue: 0deg, 72 | $saturation: 0%, 73 | $lightness: 0%, 74 | $alpha: 0 75 | ) { 76 | @return hsla( 77 | calc(var(--#{$name}-h) + #{$hue}), 78 | calc(var(--#{$name}-s) + #{$saturation}), 79 | calc(var(--#{$name}-l) + #{$lightness}), 80 | calc(var(--#{$name}-a) + #{$alpha}) 81 | ); 82 | } 83 | 84 | $header: 50px; 85 | $border-radius: 8px; 86 | $transition-speed: 0.3s; 87 | $small: 40rem; // 640px 88 | $medium: 58.75rem; // 940px 89 | $big: 77.5rem; // 1240px 90 | $orbit: circle(50vmax at 50% 50%); 91 | 92 | @mixin hover { 93 | &:not([disabled]):hover { 94 | @content; 95 | } 96 | &:focus-visible { 97 | @content; 98 | } 99 | } 100 | @mixin shadow { 101 | /* prettier-ignore */ 102 | box-shadow: 103 | 0 1px 1px hsl(0deg 0% 0% / 0.075), 104 | 0 2px 2px hsl(0deg 0% 0% / 0.075), 105 | 0 4px 4px hsl(0deg 0% 0% / 0.075), 106 | 0 8px 8px hsl(0deg 0% 0% / 0.075), 107 | 0 16px 16px hsl(0deg 0% 0% / 0.075) 108 | ; 109 | } 110 | @mixin respond-above($breakpoint) { 111 | @media (min-width: $breakpoint) { 112 | @content; 113 | } 114 | } -------------------------------------------------------------------------------- /static/_lightMode.scss: -------------------------------------------------------------------------------- 1 | @use '_variables' as *; 2 | @use "sass:map"; 3 | @use 'sass:color'; 4 | 5 | // Light theme overrides 6 | html[data-theme="light"]{ 7 | #login { 8 | background-color: color("box"); 9 | } 10 | #login .wrapper { 11 | border: none; 12 | } 13 | #login section { 14 | background-color: transparent; 15 | input[type="password"]{ 16 | background-color: color("search"); 17 | } 18 | } 19 | #login button[type="submit"] { 20 | color: color("bText"); 21 | background-color: color("blue"); 22 | @include hover { 23 | color: color("bText", $lightness: 10%); 24 | background-color: color("blue", $lightness: 10%); 25 | } 26 | } 27 | button { 28 | border: 2px solid color("border"); 29 | margin-bottom: -2px; 30 | } 31 | header button { 32 | border: none; 33 | margin-bottom: 0; 34 | } 35 | .wrapper { 36 | border: 2px solid color("border"); 37 | } 38 | section#navigation { 39 | .log-controls { 40 | border: 2px solid color("border"); 41 | border-bottom: 2px solid color("background"); 42 | margin-bottom: -2px; 43 | @include hover { 44 | background-color: color("search", $lightness: -2%); 45 | } 46 | } 47 | .button-group, .refresh { 48 | border: 2px solid color("border"); 49 | border-bottom: 2px solid color("background"); 50 | margin-bottom: -2px; 51 | z-index: 1; 52 | button { 53 | border: 0; 54 | margin-bottom: unset; 55 | @include hover { 56 | color: color("text", $lightness: 10%); 57 | } 58 | } 59 | button[aria-selected="true"] { 60 | @include hover { 61 | color: color("blue"); 62 | } 63 | } 64 | } 65 | } 66 | section.display #controls button{ 67 | background-color: color("diminished"); 68 | @include hover { 69 | &:not([disabled]) { 70 | color: color("text", $lightness: 10%); 71 | background-color: color("background"); 72 | } 73 | } 74 | } 75 | section#update .wrapper .headingGroup { 76 | border-width: 2px; 77 | } 78 | div.group section#logs button.hide-panel svg { 79 | border-width: 2px; 80 | } 81 | .tooltip .tooltip-text { 82 | .Blue { 83 | color: color("bBlue"); 84 | } 85 | .Yellow { 86 | color: color("bYellow"); 87 | } 88 | background-color: color("bDiminished", $lightness: -5%); 89 | color: color("bText"); 90 | &::after { 91 | border-color: color("bDiminished", $lightness: -5%) transparent transparent transparent; 92 | } 93 | } 94 | } 95 | 96 | // Light theme media queries 97 | html[data-theme="light"]{ 98 | @include respond-above($small) { 99 | #login section { 100 | background-color: color("box"); 101 | } 102 | #login .wrapper { 103 | border: 2px solid color("border"); 104 | border-radius: $border-radius; 105 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 106 | } 107 | section#logs .container button.expand-button { 108 | border: none; 109 | margin-bottom: 0; 110 | } 111 | } 112 | @include respond-above($medium){ 113 | section#navigation { 114 | border: none; 115 | border-right: 2px solid color("border"); 116 | .log-controls { 117 | margin-bottom: 0; 118 | border: none; 119 | } 120 | .button-group, .refresh { 121 | margin-bottom: 0; 122 | border: none; 123 | } 124 | } 125 | button.hide-panel{ 126 | border: none; 127 | } 128 | } 129 | } -------------------------------------------------------------------------------- /static/_normalize.scss: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | // Customized by me 3 | html { 4 | line-height: 1.15; 5 | -webkit-text-size-adjust: 100%; 6 | } 7 | body { 8 | margin: 0; 9 | font-size: 0; 10 | & > * { 11 | font-size: initial; 12 | } 13 | } 14 | main { 15 | display: block; 16 | } 17 | h1 { 18 | font-size: 2em; 19 | margin: 0.67em 0; 20 | } 21 | ul { 22 | margin: 0; 23 | padding: 0; 24 | list-style: none; 25 | } 26 | hr { 27 | -webkit-box-sizing: content-box; 28 | box-sizing: content-box; 29 | height: 0; 30 | overflow: visible; 31 | } 32 | pre { 33 | font-family: monospace, monospace; 34 | font-size: 1em; 35 | } 36 | a { 37 | background-color: transparent; 38 | text-decoration: none; 39 | color: initial; 40 | } 41 | abbr[title] { 42 | border-bottom: none; 43 | text-decoration: underline; 44 | -webkit-text-decoration: underline dotted; 45 | text-decoration: underline dotted; 46 | } 47 | b, 48 | strong { 49 | font-weight: bolder; 50 | } 51 | /* prettier-ignore */ 52 | code, kbd, samp { 53 | font-family: monospace, monospace; 54 | font-size: 1em; 55 | } 56 | small { 57 | font-size: 80%; 58 | } 59 | /* prettier-ignore */ 60 | sub, sup { 61 | font-size: 75%; 62 | line-height: 0; 63 | position: relative; 64 | vertical-align: baseline; 65 | } 66 | sub { 67 | bottom: -0.25em; 68 | } 69 | sup { 70 | top: -0.5em; 71 | } 72 | img { 73 | border-style: none; 74 | vertical-align: text-bottom; 75 | } 76 | /* prettier-ignore */ 77 | button, input, optgroup, select, textarea { 78 | font-family: inherit; 79 | font-size: 100%; 80 | line-height: 1.15; 81 | margin: 0; 82 | } 83 | /* prettier-ignore */ 84 | button, input { 85 | overflow: visible; 86 | } 87 | /* prettier-ignore */ 88 | button, select { 89 | text-transform: none; 90 | } 91 | [type="button"], 92 | [type="reset"], 93 | [type="submit"], 94 | button { 95 | -webkit-appearance: button; 96 | } 97 | [type="button"]::-moz-focus-inner, 98 | [type="reset"]::-moz-focus-inner, 99 | [type="submit"]::-moz-focus-inner, 100 | button::-moz-focus-inner { 101 | border-style: none; 102 | padding: 0; 103 | } 104 | [type="button"]:-moz-focusring, 105 | [type="reset"]:-moz-focusring, 106 | [type="submit"]:-moz-focusring, 107 | button:-moz-focusring { 108 | outline: 1px dotted ButtonText; 109 | } 110 | fieldset { 111 | padding: 0.35em 0.75em 0.625em; 112 | } 113 | legend { 114 | -webkit-box-sizing: border-box; 115 | box-sizing: border-box; 116 | color: inherit; 117 | display: table; 118 | max-width: 100%; 119 | padding: 0; 120 | white-space: normal; 121 | } 122 | progress { 123 | vertical-align: baseline; 124 | } 125 | textarea { 126 | overflow: auto; 127 | } 128 | [type="checkbox"], 129 | [type="radio"] { 130 | -webkit-box-sizing: border-box; 131 | box-sizing: border-box; 132 | padding: 0; 133 | } 134 | [type="number"]::-webkit-inner-spin-button, 135 | [type="number"]::-webkit-outer-spin-button { 136 | height: auto; 137 | } 138 | [type="search"] { 139 | -webkit-appearance: textfield; 140 | outline-offset: -2px; 141 | } 142 | [type="search"]::-webkit-search-decoration { 143 | -webkit-appearance: none; 144 | } 145 | ::-webkit-file-upload-button { 146 | -webkit-appearance: button; 147 | font: inherit; 148 | } 149 | details { 150 | display: block; 151 | } 152 | summary { 153 | display: list-item; 154 | } 155 | template { 156 | display: none; 157 | } 158 | [hidden] { 159 | display: none; 160 | } 161 | input::-webkit-inner-spin-button, 162 | input::-webkit-outer-spin-button { 163 | -webkit-appearance: none; /* Hide default arrows */ 164 | margin: 0; /* Remove default margin */ 165 | } 166 | input { 167 | -moz-appearance: textfield; /* Hide default arrows */ 168 | } -------------------------------------------------------------------------------- /templates/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Login 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 23 | 28 | 29 |
30 |
31 | 33 | 36 | 39 | 40 |

41 | Viewport Control 42 | 51 |

52 |
53 | 54 | 55 | Forgot Password? 56 | 57 |
58 |
59 | 60 | 61 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: 📦 Build Releases 2 | permissions: 3 | contents: write 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | slice-and-upload: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | # Checkout full history so we can push later 15 | - name: Checkout 16 | uses: actions/checkout@v3 17 | with: 18 | fetch-depth: 0 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | # Bump api/VERSION and push to the branch the tag was cut from 22 | - name: Update VERSION file and push 23 | env: 24 | TAG: ${{ github.event.release.tag_name }} # e.g. v2.3.3 25 | BRANCH: ${{ github.event.release.target_commitish }} # e.g. main 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | run: | 28 | git fetch origin "$BRANCH" 29 | git checkout "$BRANCH" 30 | 31 | echo "${TAG#v}" > api/VERSION # strip leading 'v' 32 | 33 | git config user.name "github-actions[bot]" 34 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 35 | git add api/VERSION 36 | git commit -m "docs: bump VERSION to ${TAG}" 37 | git push origin "$BRANCH" 38 | 39 | # Build release bundles 40 | - name: Build bundles 41 | run: | 42 | chmod +x ./release.sh 43 | ./release.sh 44 | 45 | # Publish the tarballs attached by release.sh 46 | - name: Publish assets 47 | uses: softprops/action-gh-release@v1 48 | with: 49 | files: dist/*.tar.gz 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | update-snapshot: 54 | needs: slice-and-upload # only run if release job succeeded 55 | runs-on: ubuntu-latest 56 | 57 | steps: 58 | # Checkout snapshot branch with credentials 59 | - name: Checkout snapshot branch 60 | uses: actions/checkout@v3 61 | with: 62 | ref: snapshot 63 | fetch-depth: 0 64 | token: ${{ secrets.GITHUB_TOKEN }} 65 | 66 | # Merge the just-updated $default_branch → snapshot 67 | - name: Merge main into snapshot 68 | env: 69 | BRANCH: ${{ github.event.repository.default_branch }} # usually 'main' 70 | run: | 71 | git config user.name "github-actions[bot]" 72 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 73 | git fetch origin "$BRANCH" 74 | git merge --no-ff "origin/$BRANCH" -m "merge: $BRANCH → snapshot" 75 | git push origin snapshot 76 | 77 | # Rewrite README badge + add warning banner 78 | - name: Replace badges and add SNAPSHOT warning 79 | uses: actions/github-script@v6 80 | with: 81 | github-token: ${{ secrets.GITHUB_TOKEN }} 82 | script: | 83 | const fs = require('fs'); 84 | const file = 'README.md'; 85 | let md = fs.readFileSync(file, 'utf8'); 86 | 87 | const warning = `## SNAPSHOT 88 | 89 | You are currently seeing the snapshot branch. This is where I make rapid changes and experiment with new code. If this branch is ahead of main, it is most likely broken. 90 | Check the [latest release](https://github.com/Samuel1698/fakeViewport/releases) or go to [main](https://github.com/Samuel1698/fakeViewport/tree/main) for a stable version of the code. 91 | 92 | --- 93 | 94 | `; 95 | 96 | const badges = ` 97 | [![Python](https://github.com/Samuel1698/fakeViewport/actions/workflows/python-test.yml/badge.svg?branch=snapshot)](https://github.com/Samuel1698/fakeViewport/actions/workflows/python-test.yml) 98 | `; 99 | 100 | const regionRe = /[\s\S]*?/gm; 101 | md = md.replace(regionRe, `\n${badges}\n`); 102 | 103 | if (!md.includes('# SNAPSHOT')) { 104 | const endBadges = ''; 105 | const parts = md.split(endBadges); 106 | if (parts.length >= 2) { 107 | md = parts[0] + endBadges + '\n\n' + warning + parts[1]; 108 | } 109 | } 110 | 111 | fs.writeFileSync(file, md); 112 | 113 | # Commit README changes (if any) 114 | - name: Commit & push snapshot updates 115 | run: | 116 | git add README.md 117 | git commit -m "docs: update README" || echo "No changes to commit" 118 | git push origin snapshot 119 | -------------------------------------------------------------------------------- /static/_login.scss: -------------------------------------------------------------------------------- 1 | @use '_variables' as *; 2 | @use "sass:map"; 3 | @use 'sass:color'; 4 | // -------------------------------------------------- 5 | // Login Page Styles 6 | // -------------------------------------------------- 7 | #login { 8 | background-color: color("diminished"); 9 | display: flex; 10 | position: relative; 11 | height: 100vh; 12 | width: 100%; 13 | top: 0px; 14 | display: flex; 15 | align-items: center; 16 | h1 { 17 | font-size: 1.5rem; 18 | color: color("text"); 19 | margin-bottom: 1rem; 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | } 24 | section { 25 | //mobile 26 | width: 100%; 27 | margin-left: 0px; 28 | padding: 32px 32px 64px; 29 | height: 100%; 30 | // Shared 31 | background-color: transparent; 32 | max-height: 90%; 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | justify-content: center; 37 | position: absolute; 38 | border-radius: $border-radius; 39 | box-sizing: border-box; 40 | h1 { 41 | font-size: 1.6rem; 42 | margin-top: 0; 43 | margin-bottom: 48px; 44 | } 45 | form { 46 | display: flex; 47 | flex-direction: column; 48 | align-items: flex-start; 49 | label { 50 | display: block; 51 | margin-bottom: 0.5rem; 52 | font-weight: bold; 53 | } 54 | input[type="password"] { 55 | background-color: color("diminished", $lightness: 10%); 56 | color: color("text"); 57 | width: 100%; 58 | padding: 0.75rem; 59 | border: 2px solid color("border"); 60 | border-radius: $border-radius; 61 | margin-bottom: 1rem; 62 | box-sizing: border-box; 63 | } 64 | // Forgot Password 65 | span.tooltip { 66 | font-size: 12px; 67 | display: inline-block; 68 | color: color("blue"); 69 | border-bottom: 2px solid; 70 | margin-bottom: -2px; 71 | cursor: pointer; 72 | @include hover { 73 | color: color("yellow"); 74 | } 75 | .tooltip-text { 76 | font-size: 1rem; 77 | } 78 | } 79 | // Log In 80 | button[type="submit"] { 81 | margin: 2rem auto; 82 | width: 150px; 83 | padding: 0.75rem; 84 | background-color: color("blue"); 85 | color: color("text"); 86 | border-radius: $border-radius; 87 | cursor: pointer; 88 | transition: background $transition-speed; 89 | @include hover { 90 | background-color: color("blue", $lightness: -10%); 91 | } 92 | } 93 | } 94 | } 95 | .tooltip .tooltip-text { 96 | left: 133%; 97 | right: unset; 98 | transform: translateX(-50%); 99 | border-bottom-right-radius: $border-radius; 100 | &::after{ 101 | right: 68%; 102 | } 103 | } 104 | .hero-bg { 105 | display: none; // Hide by default 106 | position: fixed; 107 | top: 0; 108 | left: 0; 109 | width: 100%; 110 | height: 100%; 111 | z-index: -1; 112 | background: linear-gradient( 113 | 135deg, 114 | color("blue", $alpha: -0.9) 0%, 115 | color("up", $alpha: -0.9) 50%, 116 | color("down", $alpha: -0.9) 100% 117 | ); 118 | } 119 | .blob { 120 | position: absolute; 121 | width: 40vmax; 122 | height: 40vmax; 123 | pointer-events: none; 124 | opacity: 0.1; 125 | &.blob-tl { 126 | animation: orbit-tl 32s linear infinite; 127 | offset-path: $orbit; 128 | } 129 | &.blob-br { 130 | animation: orbit-br 32s linear infinite; 131 | animation-delay: -12s; 132 | offset-path: $orbit; 133 | } 134 | } 135 | @keyframes orbit-tl { 136 | from { 137 | offset-distance: 10%; 138 | } 139 | to { 140 | offset-distance: 100%; 141 | } 142 | } 143 | @keyframes orbit-br { 144 | from { 145 | offset-distance: 100%; 146 | } 147 | to { 148 | offset-distance: 0%; 149 | } 150 | } 151 | @keyframes blob-spin { 152 | from { 153 | transform: rotate(0deg); 154 | } 155 | to { 156 | transform: rotate(360deg); 157 | } 158 | } 159 | } 160 | 161 | @include respond-above($small) { 162 | // Slightly larger form and controls layout for tablets 163 | #login { 164 | background-color: color("background"); 165 | section { 166 | background-color: color("diminished"); 167 | width: 402px; 168 | margin-left: 10%; 169 | padding: 40px 64px 64px; 170 | height: 597px; 171 | } 172 | .hero-bg { 173 | display: block; 174 | } 175 | .tooltip .tooltip-text { 176 | left: 50%; 177 | &::after{ 178 | right: 50%; 179 | } 180 | } 181 | } 182 | } -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '37 1 * * 1' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | permissions: 32 | # required for all workflows 33 | security-events: write 34 | 35 | # required to fetch internal or private CodeQL packs 36 | packages: read 37 | 38 | # only required for workflows in private repositories 39 | actions: read 40 | contents: read 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: python 47 | build-mode: none 48 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 49 | # Use `c-cpp` to analyze code written in C, C++ or both 50 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 51 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 52 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 53 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 54 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 55 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | 60 | # Add any setup steps before running the `github/codeql-action/init` action. 61 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 62 | # or others). This is typically only required for manual builds. 63 | # - name: Setup runtime (example) 64 | # uses: actions/setup-example@v1 65 | 66 | # Initializes the CodeQL tools for scanning. 67 | - name: Initialize CodeQL 68 | uses: github/codeql-action/init@v3 69 | with: 70 | languages: ${{ matrix.language }} 71 | build-mode: ${{ matrix.build-mode }} 72 | # If you wish to specify custom queries, you can do so here or in a config file. 73 | # By default, queries listed here will override any specified in a config file. 74 | # Prefix the list here with "+" to use these queries and those in the config file. 75 | 76 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 77 | # queries: security-extended,security-and-quality 78 | 79 | # If the analyze step fails for one of the languages you are analyzing with 80 | # "We were unable to automatically build your code", modify the matrix above 81 | # to set the build mode to "manual" for that language. Then modify this step 82 | # to build your code. 83 | # ℹ️ Command-line programs to run using the OS shell. 84 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 85 | - if: matrix.build-mode == 'manual' 86 | shell: bash 87 | run: | 88 | echo 'If you are using a "manual" build mode for one or more of the' \ 89 | 'languages you are analyzing, replace this with the commands to build' \ 90 | 'your code, for example:' 91 | echo ' make bootstrap' 92 | echo ' make release' 93 | exit 1 94 | 95 | - name: Perform CodeQL Analysis 96 | uses: github/codeql-action/analyze@v3 97 | with: 98 | category: "/language:${{matrix.language}}" 99 | -------------------------------------------------------------------------------- /logging_config.py: -------------------------------------------------------------------------------- 1 | import logging, re 2 | from logging.handlers import TimedRotatingFileHandler 3 | from datetime import time as dtime 4 | from pathlib import Path 5 | 6 | class ColoredFormatter(logging.Formatter): 7 | RED = '\033[0;31m' 8 | GREEN = '\033[0;32m' 9 | YELLOW = '\033[1;33m' 10 | CYAN = '\033[36m' 11 | NC = '\033[0m' 12 | 13 | def format(self, record): 14 | # Apply color based on level 15 | if record.levelno == logging.ERROR: 16 | color = self.RED 17 | elif record.levelno == logging.WARNING: 18 | color = self.YELLOW 19 | elif record.levelno == logging.INFO: 20 | color = self.GREEN 21 | elif record.levelno == logging.DEBUG: 22 | color = self.CYAN 23 | else: 24 | color = self.NC 25 | record.msg = f"{color}{record.msg}{self.NC}" 26 | return super().format(record) 27 | 28 | def clean_flask_message(record): 29 | """ 30 | Strip the 'IP - - [timestamp] ' preamble that Werkzeug adds, leaving 31 | the actual HTTP line intact. 32 | """ 33 | if record.name.startswith(("werkzeug", "flask")): 34 | # record.msg looks like: 35 | # 127.0.0.1 - - [11/Jun/2025 16:37:01] "GET /api/status HTTP/1.1" 200 - 36 | record.msg = re.sub( 37 | r'^[0-9a-fA-F:.]+ - - \[[^\]]+\]\s*', # IPv4 *or* IPv6, once 38 | '', 39 | record.getMessage(), 40 | count=1, 41 | flags=re.ASCII, 42 | ).strip() 43 | record.args = () # safety; Werkzeug doesn’t use %-style args 44 | return True 45 | 46 | # Filter to remove ANSI color codes 47 | def remove_ansi_codes(record): 48 | ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') 49 | if record.msg: record.msg = ansi_escape.sub('', str(record.msg)) 50 | return True 51 | 52 | def configure_logging( 53 | log_file_path: str, 54 | log_file: bool, 55 | log_console: bool, 56 | log_days: int = 7, 57 | Debug_logging: bool = False 58 | ) -> logging.Logger: 59 | """ 60 | Configure the root logger with: 61 | • a TimedRotatingFileHandler (if log_file is True) 62 | • optional console output (if log_console is True) 63 | 64 | Args: 65 | log_file_path: full path to the logfile (e.g. "/root/logs/viewport.log") 66 | log_file: if True, enable file logging 67 | log_console: if True, enable colored console output 68 | log_days: how many days' worth of dated backups to keep 69 | Debug_logging: if True, set level to DEBUG; otherwise INFO 70 | """ 71 | logger = logging.getLogger() 72 | level = logging.DEBUG if Debug_logging else logging.INFO 73 | logger.setLevel(level) 74 | logger.propagate = False 75 | # If a handler for this file already exists, reuse it and exit. 76 | for h in logger.handlers: 77 | if isinstance(h, TimedRotatingFileHandler) and \ 78 | Path(h.baseFilename) == Path(log_file_path): 79 | return logger 80 | # File formatter: [YYYY‐MM‐DD HH:MM:SS] [LEVEL] message 81 | file_fmt = logging.Formatter(f'[%(asctime)s] [%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') 82 | 83 | if log_file: 84 | file_handler = TimedRotatingFileHandler( 85 | filename = log_file_path, 86 | when = "midnight", 87 | interval = 1, 88 | backupCount = log_days, 89 | encoding = "utf-8", 90 | utc = False, 91 | atTime = dtime(0, 0), 92 | ) 93 | file_handler.addFilter(remove_ansi_codes) 94 | # If the handler writes to viewport.log, keep monitoring noise out 95 | if Path(log_file_path).name == "viewport.log": 96 | viewport_filter = ( 97 | lambda rec: not ( 98 | rec.name.startswith(("werkzeug", "flask", "monitoring")) 99 | or "GET /api/" in rec.getMessage() # safety-net for stray prints 100 | ) 101 | ) 102 | file_handler.addFilter(viewport_filter) 103 | if Path(log_file_path).name == "monitoring.log": 104 | file_handler.addFilter(lambda rec: rec.name.startswith( 105 | ("monitoring", "werkzeug", "flask"))) 106 | file_handler.addFilter(clean_flask_message) 107 | file_handler.setLevel(logger.level) 108 | file_handler.setFormatter(file_fmt) 109 | logger.addHandler(file_handler) 110 | 111 | if log_console: 112 | console_handler = logging.StreamHandler() 113 | console_handler.setLevel(logger.level) 114 | console_handler.setFormatter( 115 | ColoredFormatter('[%(asctime)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S') 116 | ) 117 | logger.addHandler(console_handler) 118 | 119 | return logger -------------------------------------------------------------------------------- /tests/handle_functions/test_handle_page.py: -------------------------------------------------------------------------------- 1 | import viewport 2 | from unittest.mock import MagicMock, patch, call 3 | 4 | # --------------------------------------------------------------------------- # 5 | # Tests for handle_page function 6 | # --------------------------------------------------------------------------- # 7 | @patch("viewport.handle_elements") 8 | @patch("viewport.check_for_title") 9 | @patch("viewport.time.sleep", return_value=None) 10 | @patch("viewport.logging.info") 11 | def test_handle_page_dashboard_short_circuits( 12 | mock_log_info, mock_sleep, mock_check, mock_handle_elements 13 | ): 14 | driver = MagicMock(title="Dashboard Home") 15 | viewport.WAIT_TIME = 1 16 | mock_check.return_value = None 17 | 18 | ret = viewport.handle_page(driver) 19 | 20 | mock_check.assert_called_once_with(driver) 21 | mock_handle_elements.assert_called_once_with(driver) 22 | assert ret is True 23 | 24 | @patch("viewport.log_error") 25 | @patch("viewport.api_status") 26 | @patch("viewport.check_for_title") 27 | @patch("viewport.time.time") 28 | @patch("viewport.time.sleep", return_value=None) 29 | def test_handle_page_timeout_logs_and_returns_false( 30 | mock_sleep, mock_time, mock_check, mock_api_status, mock_log_error 31 | ): 32 | driver = MagicMock(title="Something Else") 33 | viewport.WAIT_TIME = 1 34 | 35 | # simulate time() so that after first check we immediately exceed WAIT_TIME*2 36 | mock_time.side_effect = [0, 3] 37 | ret = viewport.handle_page(driver) 38 | 39 | expected_log_error = "Unexpected page loaded. The page title is: Something Else" 40 | args, _ = mock_log_error.call_args 41 | assert args[0] == expected_log_error 42 | 43 | mock_api_status.assert_called_with("Error Loading Page Something Else") 44 | assert ret is False 45 | 46 | # --------------------------------------------------------------------------- # 47 | # Covers the "Ubiquiti Account" / "UniFi OS" login‐page branch when login fails 48 | # --------------------------------------------------------------------------- # 49 | @patch("viewport.handle_login", return_value=False) 50 | @patch("viewport.logging.info") 51 | @patch("viewport.check_for_title") 52 | @patch("viewport.time.sleep", return_value=None) 53 | def test_handle_page_login_page_fails( 54 | mock_sleep, 55 | mock_check_for_title, 56 | mock_log_info, 57 | mock_handle_login, 58 | ): 59 | # Arrange: driver.title indicates the login screen 60 | driver = MagicMock(title="Ubiquiti Account - please sign in") 61 | viewport.WAIT_TIME = 1 # so timeout logic won't kick in before our branch 62 | 63 | # Act 64 | result = viewport.handle_page(driver) 65 | 66 | # Assert: we tried to wait for title first 67 | mock_check_for_title.assert_called_once_with(driver) 68 | 69 | # We logged the login‐page message 70 | mock_log_info.assert_called_once_with("Log-in page found. Inputting credentials...") 71 | 72 | # Since handle_login returned False, handle_page should return False 73 | assert result is False 74 | 75 | # --------------------------------------------------------------------------- # 76 | # Covers looping until title becomes Dashboard, then hits the final sleep(3) 77 | # --------------------------------------------------------------------------- # 78 | @patch("viewport.handle_elements") 79 | @patch("viewport.handle_pause_banner") 80 | @patch("viewport.check_for_title") 81 | @patch("viewport.time.sleep", return_value=None) 82 | def test_handle_page_loops_then_dashboard(mock_sleep, mock_check_for_title, mock_banner, mock_handle_elements): 83 | # Arrange: simulate driver.title changing over iterations 84 | titles = ["Loading...", "Still Loading", "Dashboard | Protect"] 85 | class DummyDriver: 86 | def __init__(self, titles): 87 | self._titles = titles 88 | self._idx = -1 89 | @property 90 | def title(self): 91 | self._idx += 1 92 | # once past the list, stay at the last title 93 | return self._titles[min(self._idx, len(self._titles) - 1)] 94 | 95 | driver = DummyDriver(titles) 96 | # Make WAIT_TIME large enough so we never hit the timeout 97 | viewport.WAIT_TIME = 10 98 | # Act 99 | result = viewport.handle_page(driver) 100 | 101 | # Assert we returned True 102 | assert result is True 103 | 104 | # check_for_title should have been called once before the loop 105 | mock_check_for_title.assert_called_once_with(driver) 106 | 107 | # handle_elements should run exactly once when we hit Dashboard 108 | mock_handle_elements.assert_called_once_with(driver) 109 | mock_banner.assert_called_once_with(driver) 110 | # We expect two outer sleep(3) calls: 111 | # 1) after "Loading..." iteration 112 | # 2) after "Still Loading" iteration 113 | # Inner sleep call gets a different MagicMock ID 114 | assert mock_sleep.call_count == 2 115 | assert mock_sleep.call_args_list == [call(3), call(3)] -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import logging, logging.handlers, shutil, pytest, inspect 2 | from pathlib import Path 3 | import viewport, monitoring 4 | from validate_config import AppConfig 5 | 6 | # --------------------------------------------------------------------------- # 7 | # Guard against rogue deletes – session scope, uses tmp_path_factory 8 | # --------------------------------------------------------------------------- # 9 | @pytest.fixture(autouse=True, scope="session") 10 | def hard_delete_guard(tmp_path_factory): 11 | safe_root = tmp_path_factory.getbasetemp().parents[0].resolve() 12 | real_rmtree = shutil.rmtree 13 | def guarded(path, *a, **kw): 14 | path = Path(path).resolve() 15 | if not str(path).startswith(str(safe_root)): 16 | raise RuntimeError(f"Refusing to delete outside {safe_root}: {path}") 17 | else: pass 18 | return real_rmtree(path, *a, **kw) 19 | mp = pytest.MonkeyPatch() 20 | mp.setattr("viewport.shutil.rmtree", guarded, raising=True) 21 | yield 22 | mp.undo() 23 | 24 | # --------------------------------------------------------------------------- # 25 | # Redirect logging 26 | # --------------------------------------------------------------------------- # 27 | @pytest.fixture(autouse=True, scope="session") 28 | def isolate_logging(tmp_path_factory): 29 | log_dir = tmp_path_factory.mktemp("logs") 30 | real_trfh = logging.handlers.TimedRotatingFileHandler 31 | 32 | class _PatchedTRFH(real_trfh): 33 | def __init__(self, *_, **kw): 34 | # Try to discover the function-scoped tmp_path on the call stack 35 | stack_tmp = next( 36 | (f.frame.f_locals["tmp_path"] 37 | for f in inspect.stack() 38 | if "tmp_path" in f.frame.f_locals), 39 | None, 40 | ) 41 | target_dir = Path(stack_tmp or log_dir) 42 | 43 | # Forward only keyword args – drop the caller’s positional filename 44 | super().__init__(target_dir / "test.log", **kw) 45 | 46 | mp = pytest.MonkeyPatch() 47 | mp.setattr(logging.handlers, "TimedRotatingFileHandler", _PatchedTRFH) 48 | if hasattr(viewport, "logs_dir"): mp.setattr(viewport, "logs_dir", log_dir, raising=False) 49 | root = logging.getLogger() 50 | for h in list(root.handlers): 51 | root.removeHandler(h) 52 | 53 | viewport.configure_logging( 54 | log_file_path=str(log_dir / "test.log"), 55 | log_file=True, log_console=False, 56 | log_days=7, Debug_logging=True, 57 | ) 58 | yield 59 | mp.undo() 60 | 61 | # --------------------------------------------------------------------------- # 62 | # Dummy AppConfig – session scope, use tmp_path_factory 63 | # --------------------------------------------------------------------------- # 64 | @pytest.fixture(autouse=True, scope="session") 65 | def provide_dummy_config(tmp_path_factory): 66 | data_dir = tmp_path_factory.mktemp("data") 67 | cfg = AppConfig( 68 | # timeouts / sleeps / retries 69 | SLEEP_TIME=60, 70 | WAIT_TIME=30, 71 | MAX_RETRIES=3, 72 | RESTART_TIMES=[], 73 | # browser config 74 | BROWSER_PROFILE_PATH="", 75 | BROWSER_BINARY="", 76 | HEADLESS=False, 77 | BROWSER="", 78 | # logging config 79 | LOG_FILE_FLAG=False, 80 | LOG_CONSOLE=False, 81 | DEBUG_LOGGING=False, 82 | ERROR_LOGGING=False, 83 | ERROR_PRTSCR=False, 84 | LOG_DAYS=7, 85 | LOG_INTERVAL=60, 86 | # API & creds 87 | API=False, 88 | CONTROL_TOKEN="", 89 | username="user", 90 | password="pass", 91 | url="http://example.com", 92 | host="", 93 | port="", 94 | # file paths 95 | mon_file=str(data_dir / "monitoring.log"), 96 | log_file=str(data_dir / "viewport.log"), 97 | sst_file=data_dir / "sst.txt", 98 | status_file=data_dir / "status.txt", 99 | restart_file=data_dir / ".restart", 100 | pause_file= data_dir / ".pause", 101 | ) 102 | # Save the real config browser since it changes based on other variables 103 | mp = pytest.MonkeyPatch() 104 | for mod in (viewport, monitoring): 105 | def _cfg(*_a, **_kw): 106 | # make sure any per-test monkey-patches bleed through 107 | cfg.sst_file = getattr(viewport, "sst_file", cfg.sst_file) 108 | cfg.status_file = getattr(viewport, "status_file", cfg.status_file) 109 | cfg.pause_file = getattr(viewport, "pause_file", cfg.pause_file) 110 | cfg.restart_file = getattr(viewport, "restart_file", cfg.restart_file) 111 | return cfg 112 | mp.setattr(mod, "validate_config", _cfg, raising=False) 113 | mp.setattr(mod, "cfg", cfg, raising=False) 114 | for k, v in vars(cfg).items(): 115 | mp.setattr(mod, k, v, raising=False) 116 | 117 | yield cfg 118 | mp.undo() -------------------------------------------------------------------------------- /tests/handler_functions/test_api_handler.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | import pytest 3 | import viewport 4 | 5 | @pytest.fixture(autouse=True) 6 | def disable_external_side_effects(monkeypatch): 7 | # never actually sleep 8 | monkeypatch.setattr(viewport.time, "sleep", lambda *args, **kwargs: None) 9 | # never actually fork a process 10 | monkeypatch.setattr(viewport.subprocess, "Popen", lambda *args, **kwargs: None) 11 | 12 | # --------------------------------------------------------------------------- # 13 | # Test api_handler function 14 | # --------------------------------------------------------------------------- # 15 | class _FakeProcess: 16 | # Mimics the handful of attributes/behaviors api_handler relies on 17 | def __init__(self, *, exit_code=None, stdout_lines=None, stderr_lines=None): 18 | self._exit_code = exit_code # value returned by poll() 19 | self.returncode = exit_code # surfaced in the error msg 20 | self.stdout = stdout_lines or [] # iterable for Thread‑target 21 | self.stderr = stderr_lines or [] 22 | 23 | def poll(self): 24 | return self._exit_code 25 | 26 | 27 | class _DummyThread: 28 | # Replaces threading.Thread – runs the target *synchronously* 29 | started = 0 30 | 31 | def __init__(self, *, target=None, args=(), daemon=None): 32 | self._target = target 33 | self._args = args 34 | 35 | def start(self): 36 | _DummyThread.started += 1 37 | if self._target: self._target(*self._args) 38 | 39 | def _common_patches(monkeypatch): 40 | monkeypatch.setattr(viewport, "api_status", lambda *a, **k: None) 41 | 42 | import threading as _t 43 | monkeypatch.setattr(_t, "Thread", _DummyThread) 44 | monkeypatch.setattr(viewport.threading, "Thread", _DummyThread) 45 | 46 | _DummyThread.started = 0 47 | def test_api_handler_already_running(monkeypatch): 48 | _common_patches(monkeypatch) 49 | # process_handler reports “monitoring.py” is alive 50 | monkeypatch.setattr(viewport, "process_handler", lambda *a, **k: True) 51 | 52 | # fail fast if api_handler ever tried to spawn a process 53 | monkeypatch.setattr( 54 | viewport.subprocess, 55 | "Popen", 56 | lambda *a, **k: pytest.fail("Popen must not be invoked when API is up"), 57 | ) 58 | 59 | assert viewport.api_handler(standalone=True) is True 60 | assert _DummyThread.started == 0 61 | 62 | def test_api_handler_starts_successfully(monkeypatch): 63 | _common_patches(monkeypatch) 64 | monkeypatch.setattr(viewport, "process_handler", lambda *a, **k: False) 65 | 66 | fake_proc = _FakeProcess( 67 | exit_code=None, # .poll() → None ⇒ still running 68 | stdout_lines=[ 69 | "Serving Flask app\n", # skipped by filter_output 70 | "Press CTRL+C to quit\n", # skipped 71 | "Custom log line\n", # logged 72 | ], 73 | stderr_lines=["Some warning\n"], 74 | ) 75 | monkeypatch.setattr(viewport.subprocess, "Popen", lambda *a, **k: fake_proc) 76 | 77 | assert viewport.api_handler(standalone=True) is True 78 | # two filter threads (stdout + stderr) should have executed 79 | assert _DummyThread.started == 2 80 | 81 | def test_api_handler_process_exits_early(monkeypatch): 82 | _common_patches(monkeypatch) 83 | monkeypatch.setattr(viewport, "process_handler", lambda *a, **k: False) 84 | 85 | fake_proc = _FakeProcess(exit_code=1) # .poll() → 1 triggers RuntimeError 86 | monkeypatch.setattr(viewport.subprocess, "Popen", lambda *a, **k: fake_proc) 87 | 88 | assert viewport.api_handler(standalone=True) is False 89 | assert _DummyThread.started == 2 # threads still started 90 | 91 | def test_api_handler_popen_raises(monkeypatch): 92 | _common_patches(monkeypatch) 93 | monkeypatch.setattr(viewport, "process_handler", lambda *a, **k: False) 94 | 95 | def _boom(*_a, **_kw): 96 | raise OSError("simulated failure") 97 | 98 | monkeypatch.setattr(viewport.subprocess, "Popen", _boom) 99 | 100 | assert viewport.api_handler() is False 101 | # Nothing spawned ⇒ no threads 102 | assert _DummyThread.started == 0 103 | 104 | def test_api_handler_embedded_starts_without_threads(monkeypatch): 105 | """ 106 | Exercise the `else: pass` branch (stand-alone = False). 107 | The child process is 'running', so the function should 108 | return True and spawn zero DummyThreads. 109 | """ 110 | monkeypatch.setattr(viewport, "process_handler", lambda *a, **k: False) 111 | 112 | # dummy Popen that looks healthy 113 | class _DummyPopen: 114 | def __init__(self, *a, **k): 115 | self.stdout = None 116 | self.stderr = None 117 | self.returncode = None 118 | 119 | def poll(self): 120 | return None 121 | 122 | fake_proc = SimpleNamespace( 123 | Popen=_DummyPopen, 124 | PIPE=object(), 125 | DEVNULL=object(), 126 | ) 127 | monkeypatch.setattr(viewport, "subprocess", fake_proc) 128 | 129 | monkeypatch.setattr(viewport.threading, "Thread", _DummyThread) 130 | 131 | # run & assert 132 | assert viewport.api_handler() is True # standalone defaults to False 133 | assert _DummyThread.started == 0 # else-branch spawns none -------------------------------------------------------------------------------- /tests/monitoring/test_flask.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import types 3 | import monitoring 4 | from unittest.mock import patch, MagicMock 5 | 6 | class DummyApp: 7 | def __init__(self): 8 | self.run_called = False 9 | self.run_args = None 10 | 11 | def run(self, host, port): 12 | self.run_called = True 13 | self.run_args = (host, port) 14 | 15 | def test_main_with_valid_config(monkeypatch): 16 | # Stub out process_handler so that it always returns False (i.e. "no existing process") 17 | monkeypatch.setattr(monitoring, "process_handler", lambda name, action: False) 18 | 19 | fake_cfg = types.SimpleNamespace(host="1.2.3.4", port=2500) 20 | monkeypatch.setattr(monitoring, "validate_config", lambda *a, **k: fake_cfg) 21 | 22 | dummy = DummyApp() 23 | monkeypatch.setattr(monitoring, "create_app", lambda: dummy) 24 | 25 | # Should not raise, and should call DummyApp.run(host, port) 26 | monitoring.main() 27 | 28 | assert dummy.run_called is True 29 | assert dummy.run_args == ("1.2.3.4", 2500) 30 | 31 | def test_main_with_missing_host_port(monkeypatch): 32 | # Stub process_handler again 33 | monkeypatch.setattr(monitoring, "process_handler", lambda name, action: False) 34 | 35 | # host and port both falsey 36 | fake_cfg = types.SimpleNamespace(host="", port=0) 37 | monkeypatch.setattr(monitoring, "validate_config", lambda *a, **k: fake_cfg) 38 | 39 | dummy = DummyApp() 40 | monkeypatch.setattr(monitoring, "create_app", lambda: dummy) 41 | 42 | # Should still not raise; run_args should default to (None, None) 43 | monitoring.main() 44 | 45 | assert dummy.run_args == (None, None) 46 | 47 | def test_main_config_validation_failure(monkeypatch): 48 | # In this case, validate_config() raises SystemExit before process_handler is ever called. 49 | # We still stub process_handler (unused) for consistency. 50 | monkeypatch.setattr(monitoring, "process_handler", lambda name, action: False) 51 | 52 | def _bad_validate(*a, **k): 53 | raise SystemExit(1) 54 | monkeypatch.setattr(monitoring, "validate_config", _bad_validate) 55 | 56 | # We never get as far as create_app() 57 | with pytest.raises(SystemExit) as exc: 58 | monitoring.main() 59 | assert exc.value.code == 1 60 | 61 | def test_main_app_creation_failure(monkeypatch): 62 | # Stub process_handler so main() doesn’t hang on real process checks 63 | monkeypatch.setattr(monitoring, "process_handler", lambda name, action: False) 64 | 65 | fake_cfg = types.SimpleNamespace(host="x", port=1) 66 | monkeypatch.setattr(monitoring, "validate_config", lambda *a, **k: fake_cfg) 67 | 68 | def _broken_create(): 69 | raise RuntimeError("boom at create_app") 70 | monkeypatch.setattr(monitoring, "create_app", _broken_create) 71 | 72 | with pytest.raises(RuntimeError) as exc: 73 | monitoring.main() 74 | assert "boom at create_app" in str(exc.value) 75 | 76 | def test_main_app_run_failure(monkeypatch): 77 | # Stub process_handler again 78 | monkeypatch.setattr(monitoring, "process_handler", lambda name, action: False) 79 | 80 | fake_cfg = types.SimpleNamespace(host="h", port=2) 81 | monkeypatch.setattr(monitoring, "validate_config", lambda *a, **k: fake_cfg) 82 | 83 | class BadApp: 84 | def run(self, host, port): 85 | raise IOError("boom at run") 86 | 87 | monkeypatch.setattr(monitoring, "create_app", lambda: BadApp()) 88 | 89 | with pytest.raises(IOError) as exc: 90 | monitoring.main() 91 | assert "boom at run" in str(exc.value) 92 | 93 | def test_main_process_handler_true(monkeypatch): 94 | # When process_handler("monitoring.py", action='check') returns True, 95 | # main() should call time.sleep(3), then process_handler(..., action='kill'), 96 | # and finally start the Flask app using create_app().run(). 97 | recorded = {"check_called": False, "kill_called": False} 98 | fake_cfg = types.SimpleNamespace(host="h", port=2) 99 | monkeypatch.setattr(monitoring, "validate_config", lambda *a, **k: fake_cfg) 100 | # Stub process_handler: first call (action='check') → True, second call (action='kill') → record kill 101 | def fake_process_handler(name, action): 102 | if action == "check": 103 | recorded["check_called"] = True 104 | return True 105 | elif action == "kill": 106 | recorded["kill_called"] = True 107 | return True 108 | else: pass 109 | monkeypatch.setattr(monitoring, "process_handler", fake_process_handler) 110 | 111 | # Stub monitoring.time.sleep so it does not actually sleep 112 | monkeypatch.setattr(monitoring.time, "sleep", lambda seconds: None) 113 | 114 | dummy_app = DummyApp() 115 | monkeypatch.setattr(monitoring, "create_app", lambda: dummy_app) 116 | 117 | # Call main() (validate_config is already patched by the autouse fixture) 118 | monitoring.main() 119 | 120 | # Assert that process_handler was called with action="check" and action="kill" 121 | assert recorded["check_called"] is True, "process_handler(check) was not called" 122 | assert recorded["kill_called"] is True, "process_handler(kill) was not called" 123 | 124 | # Assert that create_app().run(...) was invoked using the host and port from patch_validate_config 125 | assert dummy_app.run_called is True, "create_app().run() was not invoked" 126 | assert dummy_app.run_args == (monitoring.host, monitoring.port) 127 | _ = fake_process_handler("monitoring.py", action="noop") -------------------------------------------------------------------------------- /tests/handle_functions/test_handle_loading_issue.py: -------------------------------------------------------------------------------- 1 | import time as real_time 2 | import pytest 3 | from selenium.common.exceptions import TimeoutException 4 | import viewport 5 | from unittest.mock import MagicMock, patch 6 | 7 | # --------------------------------------------------------------------------- # 8 | # Tests for handle_loading_issue function 9 | # --------------------------------------------------------------------------- # 10 | @patch("viewport.WebDriverWait") 11 | @patch("viewport.time.sleep", return_value=None) 12 | @patch("viewport.log_error") 13 | @patch("viewport.logging.info") 14 | @patch("viewport.api_status") 15 | @patch("viewport.handle_page") 16 | @patch("viewport.time.time", side_effect=[0, 16]) 17 | def test_handle_loading_issue_persists_and_refreshes( 18 | mock_time, mock_handle_page, mock_api_status, mock_log_info, mock_log_error, mock_sleep, mock_wdw 19 | ): 20 | driver = MagicMock() 21 | viewport.CSS_LOADING_DOTS = ".dots" 22 | viewport.SLEEP_TIME = 10 23 | 24 | # force WebDriverWait(...).until(...) to always return truthy 25 | fake_wait = MagicMock() 26 | fake_wait.until.return_value = True 27 | mock_wdw.return_value = fake_wait 28 | 29 | # first time we record start_time = 0, second time time.time() - start_time >= 15 30 | viewport.time.time = mock_time 31 | mock_handle_page.return_value = True 32 | 33 | viewport.handle_loading_issue(driver) 34 | 35 | expected_log_error = "Video feed trouble persisting for 15 seconds, refreshing the page." 36 | args, _ = mock_log_error.call_args 37 | assert args[0] == expected_log_error 38 | driver.refresh.assert_called_once() 39 | mock_api_status.assert_called_once_with("Loading Issue Detected") 40 | # since handle_page returned True, we do not log Error Reloading nor sleep(SLEEP_TIME) 41 | 42 | @patch("viewport.WebDriverWait") 43 | @patch("viewport.time.sleep", return_value=None) 44 | @patch("viewport.log_error") 45 | @patch("viewport.logging.info") 46 | @patch("viewport.api_status") 47 | def test_handle_loading_issue_no_persistence( 48 | mock_api_status, mock_log_info, mock_log_error, mock_sleep, mock_wdw 49 | ): 50 | driver = MagicMock() 51 | viewport.CSS_LOADING_DOTS = ".dots" 52 | 53 | # Make every .until() call raise TimeoutException 54 | fake_wait = MagicMock() 55 | fake_wait.until.side_effect = TimeoutException 56 | mock_wdw.return_value = fake_wait 57 | 58 | # Run — it should loop 30× without ever logging or refreshing 59 | viewport.handle_loading_issue(driver) 60 | 61 | mock_log_error.assert_not_called() 62 | driver.refresh.assert_not_called() 63 | 64 | @patch("viewport.log_error") 65 | @patch("viewport.time.sleep", return_value=None) 66 | def test_handle_loading_issue_inspection_error_raises(mock_sleep, mock_log_error): 67 | driver = MagicMock() 68 | viewport.CSS_LOADING_DOTS = ".dots" 69 | 70 | # Simulate driver.find_elements throwing 71 | driver.find_elements.side_effect = Exception("boom") 72 | 73 | with pytest.raises(Exception) as excinfo: 74 | viewport.handle_loading_issue(driver) 75 | 76 | # It should have logged the error 77 | expected_log_error = "Error checking loading dots: " 78 | args, _ = mock_log_error.call_args 79 | assert args[0] == expected_log_error 80 | 81 | # And the original exception should bubble out 82 | assert "boom" in str(excinfo.value) 83 | 84 | # --------------------------------------------------------------------------- # 85 | # Case: loading persists → refresh → handle_page returns False 86 | # Should hit the "Unexpected page loaded after refresh..." branch 87 | # --------------------------------------------------------------------------- # 88 | @patch("viewport.time.sleep", return_value=None) 89 | @patch("viewport.api_status") 90 | @patch("viewport.log_error") 91 | @patch("viewport.handle_page", return_value=False) 92 | def test_handle_loading_issue_refresh_then_handle_page_fails( 93 | mock_handle_page, mock_log_error, mock_api_status, mock_sleep 94 | ): 95 | driver = MagicMock() 96 | viewport.CSS_LOADING_DOTS = ".dots" 97 | viewport.SLEEP_TIME = 7 # arbitrary 98 | 99 | # Simulate .find_elements always returning something 100 | driver.find_elements.return_value = [MagicMock()] 101 | 102 | # time.time: first call t0=0, second call t1=16 to exceed 15s threshold 103 | t0_t1 = [0, 16] 104 | def fake_time(): 105 | return t0_t1.pop(0) 106 | patcher = patch("viewport.time.time", side_effect=fake_time) 107 | patcher.start() 108 | 109 | # Act 110 | viewport.handle_loading_issue(driver) 111 | 112 | patcher.stop() 113 | 114 | # First log_error for 15s persistence 115 | first = mock_log_error.call_args_list[0][0][0] 116 | assert first == "Video feed trouble persisting for 15 seconds, refreshing the page." 117 | 118 | # api_status for detection 119 | mock_api_status.assert_any_call("Loading Issue Detected") 120 | 121 | # driver.refresh and initial sleep(5) 122 | driver.refresh.assert_called_once() 123 | mock_sleep.assert_any_call(5) 124 | 125 | # Because handle_page returned False, we hit the reload-error branch: 126 | second = mock_log_error.call_args_list[1][0][0] 127 | assert second == "Unexpected page loaded after refresh. Waiting before retrying..." 128 | mock_api_status.assert_any_call("Error Reloading") 129 | 130 | # And we waited SLEEP_TIME afterward 131 | mock_sleep.assert_any_call(viewport.SLEEP_TIME) 132 | 133 | # --------------------------------------------------------------------------- # 134 | # Case: loading appears then clears immediately → reset timer branch 135 | # Should never refresh or log anything 136 | # --------------------------------------------------------------------------- # 137 | @patch("viewport.time.sleep", return_value=None) 138 | @patch("viewport.log_error") 139 | @patch("viewport.api_status") 140 | def test_handle_loading_issue_clears_loading_resets_timer( 141 | mock_api_status, mock_log_error, mock_sleep 142 | ): 143 | driver = MagicMock() 144 | viewport.CSS_LOADING_DOTS = ".dots" 145 | 146 | # find_elements: first iteration returns non-empty, second returns empty 147 | driver.find_elements.side_effect = [ 148 | [MagicMock()], # trouble starts 149 | [], # clears immediately 150 | ] + [[]] * 28 # rest of the loops 151 | 152 | # time.time shouldn't matter here, but patch to keep signature 153 | patcher = patch("viewport.time.time", return_value=real_time.time()) 154 | patcher.start() 155 | 156 | # Act 157 | viewport.handle_loading_issue(driver) 158 | 159 | patcher.stop() 160 | 161 | # No refresh, no errors, no api calls 162 | driver.refresh.assert_not_called() 163 | mock_log_error.assert_not_called() 164 | mock_api_status.assert_not_called() -------------------------------------------------------------------------------- /static/_sections.js: -------------------------------------------------------------------------------- 1 | // Export these if needed elsewhere 2 | export const sections = { 3 | status: document.getElementById("status"), 4 | device: document.getElementById("device"), 5 | config: document.getElementById("config"), 6 | logs: document.getElementById("logs"), 7 | updateBanner: document.getElementById("update"), 8 | }; 9 | 10 | export const buttons = { 11 | status: document.getElementById("statusBtn"), 12 | device: document.getElementById("deviceBtn"), 13 | config: document.getElementById("configBtn"), 14 | logs: document.getElementById("logsBtn"), 15 | updateBanner: document.getElementById("updateBtn"), 16 | refreshButton: document.getElementById("refreshButton"), 17 | logInput: document.querySelector("#navigation .log-controls"), 18 | }; 19 | 20 | export const controls = document.getElementById("controls"); 21 | const groupDiv = document.querySelector(".group"); 22 | 23 | import { fetchAndDisplayLogs } from "./_logs.js"; 24 | import { activeTab, loadInfo, setActiveTab } from "./_device.js"; 25 | import { showChangelog } from "./_update.js"; 26 | import { scheduleRefresh } from "./_autoRefresh.js"; 27 | 28 | // Check if screen is in "desktop" mode (combined status/device/logs view) 29 | export function isDesktopView() { 30 | return window.matchMedia("(min-width: 58.75rem)").matches; 31 | } 32 | 33 | // Handle responsive changes 34 | function handleResponsiveChange() { 35 | const currentSection = Object.entries(buttons).find( 36 | ([_, btn]) => btn.getAttribute("aria-selected") === "true" 37 | )?.[0]; 38 | 39 | if (isDesktopView()) { 40 | // Desktop view logic 41 | if (currentSection === "config" || currentSection === "updateBanner") { 42 | // Hide group div when config/update is selected in desktop view 43 | groupDiv.setAttribute("hidden", "true"); 44 | } else { 45 | // On first load: 46 | // Show group div and ensure status is selected with all relevant elements visible 47 | groupDiv.removeAttribute("hidden"); 48 | // Force toggle status so that the scheduledRefresh can start for all relevant sections 49 | toggleSection("status"); 50 | // Ensure device and logs sections are visible in desktop view 51 | displayFullStatus(); 52 | } 53 | } else { 54 | // Mobile view logic 55 | groupDiv.removeAttribute("hidden"); 56 | 57 | // Only auto-select status if we're coming from desktop view with status selected 58 | // and not if config/update was selected 59 | if (currentSection === "status") { 60 | toggleSection("status"); 61 | } 62 | } 63 | } 64 | 65 | // Initialize media query listener 66 | function initResponsiveListener() { 67 | const mediaQuery = window.matchMedia("(min-width: 58.75rem)"); 68 | mediaQuery.addEventListener("change", handleResponsiveChange); 69 | handleResponsiveChange(); // Run once on init 70 | } 71 | 72 | // Shows status view on desktop site 73 | function displayFullStatus(){ 74 | sections.status.removeAttribute("hidden"); 75 | sections.device.removeAttribute("hidden"); 76 | sections.logs.removeAttribute("hidden"); 77 | buttons.logInput.removeAttribute("hidden"); 78 | } 79 | 80 | export function toggleSection(buttonId) { 81 | // Handle responsive behavior - if desktop view and trying to select a merged section, 82 | // ensure status is selected instead 83 | if (isDesktopView() && (buttonId === "device" || buttonId === "logs")) { 84 | buttonId = "status"; 85 | } 86 | 87 | // Hide all sections first 88 | Object.values(sections).forEach((section) => { 89 | section.setAttribute("hidden", ""); 90 | }); 91 | 92 | // Show the selected section 93 | sections[buttonId].removeAttribute("hidden"); 94 | 95 | // Update aria-selected for all buttons 96 | Object.entries(buttons).forEach(([id, button]) => { 97 | button.setAttribute("aria-selected", id === buttonId ? "true" : "false"); 98 | }); 99 | 100 | // Hide refresh button unless "status" or "device" or "config" 101 | if (buttonId === "status" || buttonId === "device" || buttonId === "config") { 102 | buttons.refreshButton.removeAttribute("hidden"); 103 | } else { 104 | buttons.refreshButton.setAttribute("hidden", "true"); 105 | } 106 | 107 | // Hide the control buttons 108 | if (!(buttonId === "status" || buttonId === "device")) { 109 | controls.setAttribute("hidden", "true"); 110 | } else { 111 | controls.removeAttribute("hidden"); 112 | } 113 | 114 | // Hide the log input button 115 | if (buttonId === "logs") { 116 | buttons.logInput.removeAttribute("hidden"); 117 | } else { 118 | buttons.logInput.setAttribute("hidden", "true"); 119 | } 120 | 121 | // Handle group div visibility for desktop view 122 | if (isDesktopView()) { 123 | if (buttonId === "config" || buttonId === "updateBanner") { 124 | groupDiv.setAttribute("hidden", "true"); 125 | } else { 126 | // On button clicks 127 | groupDiv.removeAttribute("hidden"); 128 | displayFullStatus(); 129 | } 130 | } else { 131 | groupDiv.removeAttribute("hidden"); 132 | } 133 | const refreshKey = isDesktopView() 134 | ? (["status","device","logs"].includes(buttonId) ? "desktop" : buttonId) 135 | : buttonId; 136 | scheduleRefresh(refreshKey, { immediate: false }); 137 | } 138 | 139 | // Initialize section functionality 140 | export function initSections() { 141 | // Initial state 142 | sections.status.removeAttribute("hidden"); 143 | sections.device.setAttribute("hidden", ""); 144 | sections.logs.setAttribute("hidden", ""); 145 | sections.updateBanner.setAttribute("hidden", ""); 146 | 147 | // Set initial aria-selected 148 | buttons.status.setAttribute("aria-selected", "true"); 149 | buttons.device.setAttribute("aria-selected", "false"); 150 | buttons.logs.setAttribute("aria-selected", "false"); 151 | buttons.updateBanner.setAttribute("aria-selected", "false"); 152 | 153 | // Show refresh button initially since status is default 154 | buttons.refreshButton.removeAttribute("hidden"); 155 | 156 | // Add click handlers 157 | buttons.status.addEventListener("click", () => { 158 | toggleSection("status"); 159 | setActiveTab("status"); 160 | }); 161 | buttons.device.addEventListener("click", () => { 162 | toggleSection(isDesktopView() ? "status" : "device"); 163 | setActiveTab("device"); 164 | }); 165 | buttons.config.addEventListener("click", () => { 166 | toggleSection("config"); 167 | setActiveTab("config"); 168 | }); 169 | buttons.logs.addEventListener("click", async () => { 170 | toggleSection(isDesktopView() ? "status" : "logs"); 171 | await fetchAndDisplayLogs(); 172 | }); 173 | 174 | buttons.updateBanner.addEventListener("click", () => { 175 | toggleSection("updateBanner"); 176 | showChangelog(); 177 | }); 178 | 179 | buttons.refreshButton.addEventListener("click", () => { 180 | loadInfo({ forceRefreshConfig: true }); 181 | if (isDesktopView() && activeTab === "status") fetchAndDisplayLogs(); 182 | buttons.refreshButton.classList.add("refreshing"); 183 | setTimeout(() => { 184 | buttons.refreshButton.classList.remove("refreshing"); 185 | }, 1000); 186 | }); 187 | 188 | // Initialize responsive behavior 189 | initResponsiveListener(); 190 | } 191 | -------------------------------------------------------------------------------- /static/main.js: -------------------------------------------------------------------------------- 1 | import { initLogs, startLogsAutoRefresh } from "./_logs.js"; 2 | import { scheduleRefresh } from "./_autoRefresh.js"; 3 | import { loadStatus, loadDeviceData, configCache } from "./_device.js"; 4 | import { checkForUpdate, CACHE_TTL, initUpdateButton } from "./_update.js"; 5 | import { control } from "./_control.js"; 6 | import { initSections, isDesktopView } from "./_sections.js"; 7 | 8 | document.addEventListener("DOMContentLoaded", async () => { 9 | // Light Theme toggle 10 | document.getElementById("themeToggle").addEventListener("click", () => { 11 | const html = document.documentElement; 12 | const currentTheme = html.getAttribute("data-theme"); 13 | const newTheme = currentTheme === "light" ? "dark" : "light"; 14 | 15 | html.setAttribute("data-theme", newTheme); 16 | localStorage.setItem("theme", newTheme); 17 | }); 18 | // Check for saved theme preference 19 | if (typeof window !== "undefined") { 20 | const savedTheme = localStorage.getItem("theme"); 21 | if (savedTheme) { 22 | document.documentElement.setAttribute("data-theme", savedTheme); 23 | } 24 | } 25 | function initTooltips() { 26 | const tooltipElements = document.querySelectorAll("[data-tooltip]"); 27 | tooltipElements.forEach((element) => { 28 | if (element.parentElement?.classList.contains("tooltip")) { 29 | return; // already initialised 30 | } 31 | const tooltipText = element.getAttribute("data-tooltip"); 32 | const elementClasses = element.className.split(' ').filter(c => c); // Get all classes from child element 33 | 34 | // Split and handle multiple | characters 35 | const parts = tooltipText 36 | .split("|") 37 | .map((s) => s.trim()) 38 | .filter(Boolean); 39 | 40 | // Create tooltip container 41 | const tooltipDiv = document.createElement("div"); 42 | tooltipDiv.className = "tooltip-text"; 43 | tooltipDiv.setAttribute("role", "tooltip"); // Accessibility 44 | 45 | // Process first part (always shown in Blue) 46 | if (parts.length > 0) { 47 | const line1 = document.createElement("span"); 48 | line1.className = "Blue"; 49 | line1.textContent = parts[0]; 50 | tooltipDiv.appendChild(line1); 51 | } 52 | 53 | // Process remaining parts 54 | for (let i = 1; i < parts.length; i++) { 55 | // Add line break before each additional part 56 | tooltipDiv.appendChild(document.createElement("br")); 57 | const line = document.createElement("span"); 58 | line.textContent = parts[i]; 59 | if (i == parts.length - 1 && parts.length > 2) { 60 | line.className = "Yellow"; 61 | } 62 | tooltipDiv.appendChild(line); 63 | } 64 | 65 | // Handle case where there was no | character 66 | if (parts.length === 1) { 67 | tooltipDiv.querySelector(".Blue").style.display = "block"; 68 | } 69 | 70 | // Create wrapper and insert into DOM 71 | const wrapper = document.createElement("span"); 72 | wrapper.className = "tooltip"; 73 | 74 | // Add all classes from the child element to the wrapper 75 | elementClasses.forEach(className => { 76 | if (className !== 'tooltip-trigger') { // Skip if it's the class we're about to add 77 | wrapper.classList.add(className); 78 | } 79 | }); 80 | 81 | element.parentNode.insertBefore(wrapper, element); 82 | 83 | // Prepare the trigger element 84 | element.classList.add("tooltip-trigger"); 85 | element.setAttribute("tabindex", "-1"); // Prevent focus outline 86 | element.setAttribute("aria-describedby", `tooltip-${Date.now()}`); 87 | tooltipDiv.id = element.getAttribute("aria-describedby"); 88 | 89 | wrapper.appendChild(element); 90 | wrapper.appendChild(tooltipDiv); 91 | 92 | // Mobile touch handling 93 | element.addEventListener("touchstart", (e) => { 94 | document.querySelectorAll(".tooltip-trigger").forEach((t) => { 95 | if (t !== element) t.classList.remove("active"); 96 | }); 97 | element.classList.toggle("active"); 98 | }); 99 | }); 100 | 101 | // Close tooltips when tapping elsewhere 102 | document.addEventListener("touchstart", (e) => { 103 | if (!e.target.closest(".tooltip-trigger")) { 104 | document.querySelectorAll(".tooltip-trigger").forEach((el) => { 105 | el.classList.remove("active"); 106 | }); 107 | } 108 | }); 109 | } 110 | 111 | initTooltips(); 112 | 113 | const isLoginPage = 114 | window.location.pathname.includes("login.html") || 115 | window.location.pathname === "/login" || 116 | document.getElementById("login"); 117 | 118 | if (isLoginPage) return; 119 | // Initialize all components 120 | initSections(); 121 | await Promise.all([ 122 | loadStatus(), 123 | loadDeviceData(), 124 | configCache.get(true), 125 | ]); 126 | initLogs(); 127 | // Check for update last 128 | checkForUpdate(); 129 | initUpdateButton(); 130 | startLogsAutoRefresh(); 131 | setInterval(checkForUpdate, CACHE_TTL); 132 | scheduleRefresh(isDesktopView() ? "desktop" : "status", { immediate: false }); 133 | // Control buttons 134 | const controls = document.getElementById("controls"); 135 | const parentTooltip = controls.parentElement; 136 | const buttons = controls.querySelectorAll("button"); 137 | const COOLDOWN_TIME = 15_000; 138 | 139 | // Store the original tooltip for restoration 140 | const originalTooltip = controls.getAttribute("data-tooltip"); 141 | 142 | buttons.forEach((btn) => { 143 | btn.addEventListener("click", async () => { 144 | // Disable all buttons and add visual feedback 145 | buttons.forEach((b) => { 146 | b.setAttribute("disabled", ""); 147 | b.classList.add("processing"); 148 | }); 149 | // Update tooltip to show action is processing 150 | controls.setAttribute("data-tooltip", "Processing command..."); 151 | parentTooltip.classList.add("show"); 152 | try { 153 | await control(btn.dataset.action, btn); 154 | } catch (error) { 155 | console.error("Control action failed:", error); 156 | // Update tooltip to show error 157 | controls.setAttribute( 158 | "data-tooltip", 159 | "Action failed. " + (error.message || "") 160 | ); 161 | } finally { 162 | // Re-enable buttons after cooldown 163 | setTimeout(() => { 164 | buttons.forEach((b) => { 165 | b.removeAttribute("disabled"); 166 | b.classList.remove("processing"); 167 | }); 168 | parentTooltip.classList.remove("show"); 169 | // Restore original tooltip 170 | controls.setAttribute("data-tooltip", originalTooltip); 171 | }, COOLDOWN_TIME); 172 | } 173 | }); 174 | }); 175 | 176 | const hidePanelButton = document.querySelector("button.hide-panel"); 177 | const statusDevice = document.querySelector(".status-device"); 178 | const logsSection = document.getElementById("logs"); 179 | 180 | if (hidePanelButton && statusDevice) { 181 | hidePanelButton.addEventListener("click", () => { 182 | statusDevice.classList.toggle("contracted"); 183 | logsSection.classList.toggle("expanded"); 184 | logsSection.parentElement.classList.toggle("expanded"); 185 | // Update aria-expanded attribute for accessibility 186 | const isContracted = statusDevice.classList.contains("contracted"); 187 | hidePanelButton.setAttribute("aria-expanded", isContracted); 188 | 189 | // Update tooltip text based on state 190 | hidePanelButton.parentElement.querySelector(".tooltip-text span").textContent = isContracted 191 | ? "Show Panel" 192 | : "Hide Panel"; 193 | 194 | const svg = hidePanelButton.parentElement.querySelector("svg"); 195 | if (svg) { 196 | svg.classList = isContracted ? "rotated" : ""; 197 | } 198 | }); 199 | 200 | // Add keyboard support 201 | hidePanelButton.addEventListener("keydown", (e) => { 202 | if (e.key === "Enter" || e.key === " ") { 203 | e.preventDefault(); 204 | hidePanelButton.click(); 205 | } 206 | }); 207 | } 208 | }); 209 | -------------------------------------------------------------------------------- /tests/handler_functions/test_restart_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import subprocess 4 | import pytest 5 | from types import SimpleNamespace 6 | from unittest.mock import MagicMock, patch 7 | import viewport 8 | 9 | # --------------------------------------------------------------------------- # 10 | # Helpers and Fixtures 11 | # --------------------------------------------------------------------------- # 12 | class DummyDriver: 13 | def __init__(self): 14 | self.quit = MagicMock() 15 | @pytest.fixture(autouse=True) 16 | def isolate_sst(tmp_path, monkeypatch): 17 | # redirect every test’s sst_file into tmp_path/ 18 | fake = tmp_path / "sst.txt" 19 | fake.write_text("2025-01-01 00:00:00.000000") # or leave empty 20 | monkeypatch.setattr(viewport, "sst_file", fake) 21 | @pytest.fixture 22 | def patch_time_and_paths(monkeypatch): 23 | # No actual sleep 24 | monkeypatch.setattr(viewport.time, "sleep", lambda s: None) 25 | # Make script_path reproducible 26 | monkeypatch.setattr(viewport.os.path, "realpath", lambda p: "script.py") 27 | # Control python executable and argv 28 | monkeypatch.setattr(sys, "executable", "/usr/bin/py") 29 | monkeypatch.setattr(sys, "argv", ["script.py"]) 30 | 31 | @pytest.fixture 32 | def patch_args_and_api(monkeypatch): 33 | # Stub out argument helpers 34 | monkeypatch.setattr(viewport, "args_helper", lambda: "ARGS") 35 | monkeypatch.setattr(viewport, "args_child_handler", lambda args, drop_flags: ["--child"]) 36 | # Spy on api_status 37 | monkeypatch.setattr(viewport, "api_status", MagicMock()) 38 | 39 | # --------------------------------------------------------------------------- # 40 | # Tests for restart_handler 41 | # --------------------------------------------------------------------------- # 42 | @pytest.mark.parametrize("initial_argv, driver_present, expected_flags", [ 43 | # No flags ⇒ no extra flags 44 | (["viewport.py"], False, []), 45 | # background flags ⇒ preserved 46 | (["viewport.py", "-b"], False, ["--background"]), 47 | (["viewport.py", "--background"], False, ["--background"]), 48 | (["viewport.py", "--backg"], False, ["--background"]), 49 | # restart flags ⇒ removed 50 | (["viewport.py", "-r"], False, []), 51 | (["viewport.py", "--restart"], False, []), 52 | (["viewport.py", "--rest"], False, []), 53 | # driver present ⇒ quit() 54 | (["viewport.py", "--restart"], True, []), 55 | ]) 56 | @patch("viewport.sys.exit") 57 | @patch("viewport.subprocess.Popen") 58 | @patch("viewport.time.sleep", return_value=None) 59 | @patch("viewport.api_status") 60 | def test_restart_handler( 61 | mock_api_status, 62 | mock_sleep, 63 | mock_popen, 64 | mock_exit, 65 | initial_argv, 66 | driver_present, 67 | expected_flags 68 | ): 69 | # Arrange: set up sys.argv and optional driver 70 | viewport.sys.argv = list(initial_argv) 71 | driver = MagicMock() if driver_present else None 72 | 73 | # Act 74 | viewport.restart_handler(driver) 75 | 76 | # Assert: status update and sleep 77 | mock_api_status.assert_called_once_with("Restarting script...") 78 | mock_sleep.assert_called_once_with(2) 79 | 80 | # Assert: driver.quit() only if driver was passed 81 | if driver_present: 82 | driver.quit.assert_called_once() 83 | else: 84 | # ensure we didn't mistakenly call .quit() 85 | assert not getattr(driver, "quit", MagicMock()).called 86 | 87 | # Assert: Popen called exactly once with correct args 88 | expected_cmd = [sys.executable, viewport.__file__] + expected_flags 89 | mock_popen.assert_called_once_with( 90 | expected_cmd, 91 | stdin=subprocess.DEVNULL, 92 | stdout=subprocess.DEVNULL, 93 | stderr=subprocess.DEVNULL, 94 | close_fds=True, 95 | start_new_session=True, 96 | ) 97 | 98 | # Assert: parent exits with code 0 99 | mock_exit.assert_called_once_with(0) 100 | 101 | # --------------------------------------------------------------------------- # 102 | # Error‐flow: make subprocess.Popen throw ⇒ log_error, api_status, clear_sst, sys.exit(1) 103 | # --------------------------------------------------------------------------- # 104 | @patch("viewport.api_status") 105 | @patch("viewport.time.sleep", return_value=None) 106 | @patch("viewport.args_child_handler", side_effect=RuntimeError("boom")) 107 | @patch("viewport.args_helper", return_value=SimpleNamespace()) 108 | @patch("viewport.clear_sst") 109 | @patch("viewport.log_error") 110 | @patch("viewport.subprocess.Popen") 111 | @patch("os.path.realpath", return_value="/fake/script.py") 112 | def test_restart_handler_exception( 113 | mock_realpath, 114 | mock_popen, 115 | mock_log_error, 116 | mock_clear_sst, 117 | mock_args_helper, 118 | mock_args_child, 119 | mock_sleep, 120 | mock_api_status, 121 | ): 122 | # make Popen never even get called because args_child_handler blows up 123 | mock_popen.side_effect = AssertionError("should not reach popen") 124 | with pytest.raises(SystemExit) as exc: 125 | viewport.restart_handler(None) 126 | 127 | # exit code should be 1 128 | assert exc.value.code == 1 129 | 130 | # log_error should have been called with our exception 131 | assert mock_log_error.call_count == 1 132 | args, kwargs = mock_log_error.call_args 133 | assert "Error during restart process:" in args[0] 134 | assert isinstance(args[1], RuntimeError) 135 | 136 | # API should have been called to report the failure 137 | mock_api_status.assert_any_call("Error Restarting, exiting...") 138 | 139 | # clear_sst must have run and slept 2 seconds 140 | mock_clear_sst.assert_called_once() 141 | mock_sleep.assert_called_once_with(2) 142 | # no popen on error 143 | mock_popen.assert_not_called() 144 | 145 | def test_restart_handler_exec_replace_failure(monkeypatch, 146 | patch_time_and_paths, 147 | patch_args_and_api): 148 | # Simulate interactive terminal 149 | monkeypatch.setattr(sys.stdout, "isatty", lambda: True) 150 | 151 | # Make execv throw, so we exercise the exception branch 152 | def bad_execv(exe, argv): 153 | raise RuntimeError("execv failed") 154 | monkeypatch.setattr(os, "execv", bad_execv) 155 | 156 | # Spy on log_error and clear_sst 157 | fake_log_error = MagicMock() 158 | fake_clear_sst = MagicMock() 159 | monkeypatch.setattr(viewport, "log_error", fake_log_error) 160 | monkeypatch.setattr(viewport, "clear_sst", fake_clear_sst) 161 | 162 | driver = DummyDriver() 163 | with pytest.raises(SystemExit) as se: 164 | viewport.restart_handler(driver) 165 | 166 | # It should exit with code 1 167 | assert se.value.code == 1 168 | 169 | # It should have shut down the driver 170 | driver.quit.assert_called_once() 171 | 172 | # And logged the error, updated the API, and cleared SST 173 | fake_log_error.assert_called_once() 174 | viewport.api_status.assert_called_with("Error Restarting, exiting...") 175 | fake_clear_sst.assert_called_once() 176 | 177 | def test_restart_handler_detach(monkeypatch, 178 | patch_time_and_paths, 179 | patch_args_and_api): 180 | # Simulate background (no TTY) 181 | monkeypatch.setattr(sys.stdout, "isatty", lambda: False) 182 | 183 | fake_popen = MagicMock() 184 | monkeypatch.setattr(subprocess, "Popen", fake_popen) 185 | monkeypatch.setattr(sys, "exit", lambda code=0: (_ for _ in ()).throw(SystemExit(code))) 186 | driver = DummyDriver() 187 | with pytest.raises(SystemExit) as se: 188 | viewport.restart_handler(driver) 189 | 190 | # detach path always exits 0 191 | assert se.value.code == 0 192 | driver.quit.assert_called_once() 193 | viewport.api_status.assert_called_with("Restarting script...") 194 | fake_popen.assert_called_once() 195 | args, kwargs = fake_popen.call_args 196 | assert args[0] == ["/usr/bin/py", "script.py", "--child"] 197 | assert kwargs["stdin"] == subprocess.DEVNULL 198 | assert kwargs["stdout"] == subprocess.DEVNULL 199 | assert kwargs["stderr"] == subprocess.DEVNULL 200 | assert kwargs["close_fds"] is True 201 | assert kwargs["start_new_session"] is True -------------------------------------------------------------------------------- /static/_logs.js: -------------------------------------------------------------------------------- 1 | import { fetchJSON } from "./_device.js"; 2 | 3 | // Store the last used limit value 4 | let logRefreshInterval; 5 | let lastLogControlsInteraction = 0; 6 | let lastLogLimit = 50; 7 | const MAX_AUTO_SCROLL_LOGS = 100; 8 | const INTERACTION_PAUSE_MS = 2_500; 9 | 10 | // Helper function for logs 11 | export function startLogsAutoRefresh(interval=5_000) { 12 | if (logRefreshInterval) return; // already running 13 | 14 | logRefreshInterval = setInterval(() => { 15 | if (shouldRefreshLogs()) fetchAndDisplayLogs(lastLogLimit); 16 | }, interval); 17 | } 18 | export function stopLogsAutoRefresh() { 19 | clearInterval(logRefreshInterval); 20 | logRefreshInterval = null; 21 | } 22 | function togglePauseIndicator(paused) { 23 | const badge = document.getElementById("logsPaused"); 24 | if (!badge) return; 25 | badge.hidden = !paused; 26 | } 27 | function shouldRefreshLogs() { 28 | const logsSection = document.getElementById("logs"); 29 | const logOutput = document.getElementById("logOutput"); 30 | const visible = !!logsSection && !logsSection.hasAttribute("hidden"); 31 | 32 | const atBottom = 33 | logOutput.scrollHeight - logOutput.scrollTop - logOutput.clientHeight < 40; 34 | 35 | const interactedRecently = 36 | Date.now() - lastLogControlsInteraction < INTERACTION_PAUSE_MS; 37 | 38 | // Show ⏸ badge whenever *either* condition blocks auto-refresh 39 | togglePauseIndicator(!atBottom || interactedRecently); 40 | 41 | return visible && atBottom && !interactedRecently; 42 | } 43 | 44 | export function colorLogEntry(logText, element) { 45 | const entry = element || document.createElement("div"); 46 | let displayText = logText.trim(); 47 | entry.classList.remove("Green", "Blue", "Yellow", "Red"); 48 | 49 | // Convert log text to lowercase once for all comparisons 50 | const lowerLogText = displayText.toLowerCase(); 51 | if ( 52 | // Success 53 | lowerLogText.includes("healthy") || 54 | lowerLogText.includes("resumed") || 55 | lowerLogText.includes("reloaded") || 56 | lowerLogText.includes("fullscreen activated") || 57 | lowerLogText.includes("saved") || 58 | lowerLogText.includes("gracefully shutting down") || 59 | lowerLogText.includes("already running") || 60 | lowerLogText.includes("successfully updated") || 61 | lowerLogText.includes("no errors found") || 62 | lowerLogText.includes("started") 63 | ) { 64 | entry.classList.add("Green"); 65 | } else if ( 66 | // Actions that raise an eyebrow 67 | lowerLogText.includes("[warning]") || 68 | lowerLogText.includes("=====") || 69 | lowerLogText.includes("chromedriver ") || 70 | lowerLogText.includes("geckodriver ") || 71 | lowerLogText.includes("response is 200") || 72 | lowerLogText.includes("WebDriver version") || 73 | lowerLogText.includes("download new driver") || 74 | lowerLogText.includes("version") || 75 | lowerLogText.includes("getting latest") || 76 | lowerLogText.includes("^^") || 77 | lowerLogText.includes("get ") 78 | ) { 79 | entry.classList.add("Yellow"); 80 | } else if ( 81 | // Normal actions 82 | lowerLogText.includes("[info]") 83 | ) { 84 | entry.classList.add("Blue"); 85 | } else { 86 | // Errors and exceptions 87 | entry.classList.add("Red"); 88 | } 89 | // If an existing element was passed, trim timestamp and log level 90 | if (element) { 91 | // Match timestamp followed by log level (e.g., "2023-01-01 12:00:00 [INFO] ") 92 | const prefixMatch = displayText.match(/^.*?\[(INFO|ERROR|WARNING|DEBUG)\]\s*/); 93 | if (prefixMatch) { 94 | displayText = displayText.substring(prefixMatch[0].length); 95 | } 96 | } 97 | entry.textContent = displayText; 98 | return entry; 99 | } 100 | // Main logs functionality 101 | export async function fetchAndDisplayLogs(limit, scroll=true) { 102 | const logCountSpan = document.getElementById("logCount"); 103 | const logLimitInput = document.getElementById("logLimit"); 104 | const logOutput = document.getElementById("logOutput"); 105 | 106 | // Use provided limit, last used limit, or default 50 107 | const newLimit = limit !== undefined ? limit : lastLogLimit; 108 | 109 | // Sanitize the input 110 | const sanitizedLimit = Math.max(10, Math.min(1000, parseInt(newLimit) || 50)); 111 | 112 | // Update the stored value 113 | lastLogLimit = sanitizedLimit; 114 | 115 | // Update the input and display 116 | logLimitInput.value = sanitizedLimit; 117 | logCountSpan.textContent = sanitizedLimit; 118 | 119 | const res = await fetchJSON(`/api/logs?limit=${sanitizedLimit}`); 120 | if (res?.data?.logs) { 121 | // Clear the log output container 122 | logOutput.innerHTML = ""; 123 | 124 | // Convert the log array to individual div elements 125 | res.data.logs.forEach((logText) => { 126 | const logEntry = colorLogEntry(logText); 127 | logEntry.classList.add("log-entry"); 128 | logOutput.appendChild(logEntry); 129 | }); 130 | } 131 | // Auto-scroll to bottom if logs are below threshold 132 | if (scroll && (logOutput.children.length <= MAX_AUTO_SCROLL_LOGS)) { 133 | logOutput.scrollTop = logOutput.scrollHeight; 134 | } 135 | } 136 | 137 | // Initialize logs functionality 138 | export function initLogs() { 139 | const searchLogsButton = document.getElementById("searchLogs"); 140 | const logLimitInput = document.getElementById("logLimit"); 141 | const logControls = document.querySelector(".log-controls"); 142 | 143 | // Set up event listeners 144 | document.querySelectorAll(".custom-spinner-btn").forEach((btn) => { 145 | btn.addEventListener("click", () => { 146 | const step = parseInt(logLimitInput.step) || 10; 147 | let currentValue = parseInt(logLimitInput.value) || lastLogLimit; 148 | 149 | if (btn.dataset.action === "increment") { 150 | currentValue = Math.min(1000, currentValue + step); 151 | } else { 152 | currentValue = Math.max(10, currentValue - step); 153 | } 154 | 155 | logLimitInput.value = currentValue; 156 | logLimitInput.dispatchEvent(new Event("input")); 157 | }); 158 | }); 159 | 160 | searchLogsButton.addEventListener("click", async () => { 161 | await fetchAndDisplayLogs(logLimitInput.value); 162 | }); 163 | 164 | logLimitInput.addEventListener("keypress", async (e) => { 165 | if (e.key === "Enter") { 166 | await fetchAndDisplayLogs(logLimitInput.value); 167 | } 168 | }); 169 | 170 | logLimitInput.addEventListener("blur", () => { 171 | let value = parseInt(logLimitInput.value) || lastLogLimit; 172 | value = Math.max(10, Math.min(1000, value)); 173 | logLimitInput.value = value; 174 | document.getElementById("logCount").textContent = value; 175 | lastLogLimit = value; // Update stored value 176 | }); 177 | 178 | logLimitInput.addEventListener("input", () => { 179 | let value = parseInt(logLimitInput.value) || lastLogLimit; 180 | document.getElementById("logCount").textContent = Math.min(1000, value); 181 | }); 182 | 183 | if (logControls) { 184 | // Any keypress, mouse click, or touch counts as “interaction” 185 | ['input','keydown','mousedown','touchstart'].forEach(evt => 186 | logControls.addEventListener(evt, () => { 187 | lastLogControlsInteraction = Date.now(); // reset the timer 188 | togglePauseIndicator(true); // show ⏸ immediately 189 | }) 190 | ); 191 | } 192 | // Pause on scroll 193 | document.getElementById("logOutput").addEventListener("scroll", () => 194 | togglePauseIndicator(!shouldRefreshLogs()) 195 | ); 196 | // Add expand button functionality 197 | const expandButton = document.getElementById("expandLogs"); 198 | const logsSection = document.getElementById("logs"); 199 | 200 | if (expandButton && logsSection) { 201 | expandButton.addEventListener("click", () => { 202 | logsSection.classList.toggle("expanded"); 203 | 204 | // Update aria-expanded attribute for accessibility 205 | const isExpanded = logsSection.classList.contains("expanded"); 206 | expandButton.setAttribute("aria-expanded", isExpanded); 207 | 208 | // Update button label 209 | expandButton.setAttribute( 210 | "aria-label", 211 | isExpanded ? "Collapse logs" : "Expand logs" 212 | ); 213 | expandButton.addEventListener("keydown", (e) => { 214 | if (e.key === "Enter" || e.key === " ") { 215 | e.preventDefault(); 216 | expandButton.click(); 217 | } 218 | }); 219 | }); 220 | } 221 | // Initial load with stored value 222 | fetchAndDisplayLogs(lastLogLimit); 223 | } -------------------------------------------------------------------------------- /tests/handler_functions/test_handler_functions.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import pytest 4 | import viewport 5 | from datetime import time 6 | import time 7 | from unittest.mock import MagicMock, patch, call 8 | 9 | @pytest.fixture(autouse=True) 10 | def isolate_sst(tmp_path, monkeypatch): 11 | # redirect every test’s sst_file into tmp_path/ 12 | fake = tmp_path / "sst.txt" 13 | fake.write_text("2025-01-01 00:00:00.000000") # or leave empty 14 | monkeypatch.setattr(viewport, "sst_file", fake) 15 | monkeypatch.setattr(viewport, "BROWSER", "chrome") 16 | # --------------------------------------------------------------------------- # 17 | # Test for Signal Handler 18 | # --------------------------------------------------------------------------- # 19 | @patch("viewport.logging") 20 | @patch("viewport.api_status") 21 | @patch("viewport.os._exit") 22 | def test_signal_handler_calls_exit(mock__exit, mock_api_status, mock_logging): 23 | mock_driver = MagicMock() 24 | 25 | # Call the signal handler manually 26 | viewport.signal_handler(signum=2, frame=None, driver=mock_driver) 27 | 28 | # Assertions 29 | mock_driver.quit.assert_called_once() 30 | mock_logging.info.assert_any_call(f"Gracefully shutting down chrome.") 31 | mock_logging.info.assert_any_call("Gracefully shutting down script instance.") 32 | mock_api_status.assert_called_once_with("Stopped") 33 | mock__exit.assert_called_once_with(0) 34 | 35 | @patch("viewport.logging") 36 | @patch("viewport.api_status") 37 | @patch("viewport.os._exit") 38 | def test_signal_handler_quit_exception(mock__exit, mock_api_status, mock_logging): 39 | mock_driver = MagicMock() 40 | mock_driver.quit.side_effect = RuntimeError("cannot quit") 41 | 42 | viewport.signal_handler(signum=2, frame=None, driver=mock_driver) 43 | 44 | mock_driver.quit.assert_called_once() 45 | mock_logging.info.assert_any_call(f"Gracefully shutting down {viewport.BROWSER}.") 46 | mock_logging.info.assert_any_call("Gracefully shutting down script instance.") 47 | mock_api_status.assert_called_once_with("Stopped") 48 | mock__exit.assert_called_once_with(0) 49 | 50 | # --------------------------------------------------------------------------- # 51 | # Tests for screenshot handler 52 | # --------------------------------------------------------------------------- # 53 | @pytest.mark.parametrize("file_ages_days, expected_deleted", [ 54 | ([10, 5, 1], ["screenshot_0.png", "screenshot_1.png"]), # 10 and 5 days old, delete if cutoff is 2 55 | ([1, 0.5], []), # recent files, none deleted 56 | ]) 57 | def test_screenshot_handler(tmp_path, file_ages_days, expected_deleted, monkeypatch): 58 | # Arrange 59 | max_age_days = 2 60 | now = time.time() 61 | 62 | created_files = [] 63 | for i, age in enumerate(file_ages_days): 64 | file = tmp_path / f"screenshot_{i}.png" 65 | file.write_text("dummy") 66 | os.utime(file, (now - age * 86400, now - age * 86400)) 67 | created_files.append(file) 68 | 69 | mock_info = MagicMock() 70 | mock_api_status = MagicMock() 71 | mock_log_error = MagicMock() 72 | 73 | monkeypatch.setattr(logging, "info", mock_info) 74 | monkeypatch.setattr(viewport, "api_status", mock_api_status) 75 | monkeypatch.setattr(viewport, "log_error", mock_log_error) 76 | 77 | # Act 78 | viewport.screenshot_handler(tmp_path, max_age_days) 79 | 80 | # Assert 81 | deleted_names = [f.name for f in created_files if not f.exists()] 82 | assert sorted(deleted_names) == sorted(expected_deleted) 83 | assert mock_info.call_count == len(expected_deleted) 84 | assert mock_api_status.call_count == len(expected_deleted) 85 | mock_log_error.assert_not_called() 86 | def test_screenshot_handler_unlink_raises(tmp_path, monkeypatch): 87 | import time 88 | from pathlib import Path 89 | 90 | # Arrange 91 | file = tmp_path / "screenshot_fail.png" 92 | file.write_text("dummy") 93 | os.utime(file, (time.time() - 10 * 86400, time.time() - 10 * 86400)) # definitely old 94 | 95 | mock_info = MagicMock() 96 | mock_api_status = MagicMock() 97 | mock_log_error = MagicMock() 98 | 99 | class BadFile: 100 | def __init__(self, path): 101 | self._path = path 102 | self.name = path.name 103 | def stat(self): 104 | return type('stat', (), {'st_mtime': time.time() - 10 * 86400})() 105 | def unlink(self): 106 | raise OSError("unlink failed") 107 | 108 | monkeypatch.setattr(logging, "info", mock_info) 109 | monkeypatch.setattr(viewport, "api_status", mock_api_status) 110 | monkeypatch.setattr(viewport, "log_error", mock_log_error) 111 | monkeypatch.setattr(Path, "glob", lambda self, pattern: [BadFile(file)] if pattern == "screenshot_*.png" else []) 112 | 113 | # Act 114 | viewport.screenshot_handler(tmp_path, max_age_days=2) 115 | 116 | # Assert 117 | mock_info.assert_not_called() 118 | mock_api_status.assert_not_called() 119 | mock_log_error.assert_called_once() 120 | assert "unlink failed" in str(mock_log_error.call_args[0][1]) 121 | # --------------------------------------------------------------------------- # 122 | # Tests for browser_restart_handler 123 | # --------------------------------------------------------------------------- # 124 | @pytest.mark.parametrize( 125 | "chrome_exc, check_exc, handle_page_ret, " 126 | "should_sleep, should_feed_ok, should_return, " 127 | "should_log_err, should_raise, expected_api_calls", 128 | [ 129 | # Success, handle_page=True 130 | ( 131 | None, None, True, 132 | True, True, True, 133 | False, False, 134 | [call(f"Restarting chrome"), call("Feed Healthy")], 135 | ), 136 | # Success, handle_page=False 137 | ( 138 | None, None, False, 139 | False, False, True, 140 | False, False, 141 | [call(f"Restarting chrome")], 142 | ), 143 | # browser_handler throws 144 | ( 145 | Exception("boom"), None, None, 146 | False, False, False, 147 | True, True, 148 | [call(f"Restarting chrome"), call(f"Error Killing chrome")], 149 | ), 150 | # check_for_title throws 151 | ( 152 | None, Exception("oops"), None, 153 | False, False, False, 154 | True, True, 155 | [call(f"Restarting chrome"), call(f"Error Killing chrome")], 156 | ), 157 | ] 158 | ) 159 | @patch("viewport.time.sleep", return_value=None) 160 | @patch("viewport.logging.info") 161 | @patch("viewport.api_status") 162 | @patch("viewport.log_error") 163 | @patch("viewport.handle_page") 164 | @patch("viewport.check_for_title") 165 | @patch("viewport.browser_handler") 166 | def test_browser_restart_handler( 167 | mock_browser_handler, 168 | mock_check_for_title, 169 | mock_handle_page, 170 | mock_log_error, 171 | mock_api_status, 172 | mock_log_info, 173 | mock_sleep, 174 | chrome_exc, 175 | check_exc, 176 | handle_page_ret, 177 | should_sleep, 178 | should_feed_ok, 179 | should_return, 180 | should_log_err, 181 | should_raise, 182 | expected_api_calls, 183 | ): 184 | url = "http://example.com" 185 | fake_driver = MagicMock() 186 | 187 | # wire up browser_handler 188 | if chrome_exc: 189 | mock_browser_handler.side_effect = chrome_exc 190 | else: 191 | mock_browser_handler.return_value = fake_driver 192 | 193 | # wire up check_for_title 194 | if check_exc: 195 | mock_check_for_title.side_effect = check_exc 196 | 197 | # wire up handle_page 198 | mock_handle_page.return_value = handle_page_ret 199 | 200 | # Act 201 | if should_raise: 202 | with pytest.raises(Exception): 203 | viewport.browser_restart_handler(url) 204 | else: 205 | result = viewport.browser_restart_handler(url) 206 | 207 | # Always start by logging & api_status "Restarting BROWSER" 208 | mock_log_info.assert_any_call(f"Restarting chrome...") 209 | mock_api_status.assert_any_call(f"Restarting chrome") 210 | 211 | # Check the full sequence of api_status calls 212 | assert mock_api_status.call_args_list == expected_api_calls 213 | 214 | # Sleep only when handle_page returned True and no exception 215 | assert mock_sleep.called == should_sleep 216 | 217 | # "Page successfully reloaded." only when handle_page was True and no exception 218 | if should_feed_ok: 219 | mock_log_info.assert_any_call("Page successfully reloaded.") 220 | else: 221 | assert not any("Page successfully reloaded." in args[0][0] 222 | for args in mock_log_info.call_args_list) 223 | 224 | # Return driver only on full success 225 | if not should_raise and should_return: 226 | assert result is fake_driver 227 | 228 | # log_error only on exception paths 229 | assert mock_log_error.called == should_log_err 230 | -------------------------------------------------------------------------------- /tests/test_check_functions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import MagicMock, PropertyMock, patch 3 | from selenium.common.exceptions import TimeoutException, WebDriverException 4 | import viewport 5 | 6 | # --------------------------------------------------------------------------- # 7 | # Fixtures 8 | # --------------------------------------------------------------------------- # 9 | @pytest.fixture 10 | def mock_driver(): 11 | return MagicMock() 12 | 13 | @pytest.fixture(autouse=True) 14 | def mock_common(mocker): 15 | patches = { 16 | "wait": mocker.patch("viewport.WebDriverWait"), 17 | "api_status": mocker.patch("viewport.api_status"), 18 | "log_error": mocker.patch("viewport.log_error"), 19 | "logging": mocker.patch("viewport.logging"), 20 | } 21 | return patches 22 | 23 | # --------------------------------------------------------------------------- # 24 | # Test: check_crash 25 | # --------------------------------------------------------------------------- # 26 | @pytest.mark.parametrize( 27 | "page_source, expected", 28 | [ 29 | # Page contains the "Aw, Snap!" crash banner 30 | ("…Aw, Snap! Something broke…", True), 31 | # Page contains "Tab Crashed" text 32 | ("
Error: Tab Crashed while loading
", True), 33 | # No crash indicators present 34 | ("All systems operational.", False), 35 | # Partial match of "Aw, Snap" without the exclamation 36 | ("

Aw, Snap this is just text

", False), 37 | # Partial match of "Crashed" without full phrase 38 | ("The process crashed unexpectedly", False), 39 | ], 40 | ids=[ 41 | "contains_aw_snap", 42 | "contains_tab_crashed", 43 | "no_crash", 44 | "partial_aw_snap", 45 | "partial_crashed", 46 | ] 47 | ) 48 | def test_check_crash(page_source, expected): 49 | # Create a dummy driver with a configurable page_source 50 | class DummyDriver: 51 | pass 52 | 53 | driver = DummyDriver() 54 | driver.page_source = page_source 55 | 56 | # Assert that check_crash returns the expected boolean 57 | assert viewport.check_crash(driver) is expected 58 | 59 | # --------------------------------------------------------------------------- # 60 | # Test: check_driver should return True on success, otherwise raise 61 | # --------------------------------------------------------------------------- # 62 | @pytest.mark.parametrize( 63 | "title_value, side_effect, expected_exception", 64 | [ 65 | # Normal title ⇒ returns True 66 | ("Mock Title", None, None), 67 | # Selenium failure ⇒ should propagate WebDriverException 68 | (None, WebDriverException, WebDriverException), 69 | # Other error ⇒ should propagate generic Exception 70 | (None, Exception, Exception), 71 | ], 72 | ids=[ 73 | "valid_title", 74 | "webdriver_exception", 75 | "generic_exception", 76 | ] 77 | ) 78 | def test_check_driver(mock_driver, title_value, side_effect, expected_exception): 79 | # Arrange: either stub driver.title to return a value or raise 80 | if side_effect: 81 | type(mock_driver).title = PropertyMock(side_effect=side_effect) 82 | else: 83 | mock_driver.title = title_value 84 | 85 | # Act & Assert 86 | if expected_exception: 87 | with pytest.raises(expected_exception): 88 | viewport.check_driver(mock_driver) 89 | else: 90 | assert viewport.check_driver(mock_driver) is True 91 | 92 | # --------------------------------------------------------------------------- # 93 | # Test: check_for_title 94 | # --------------------------------------------------------------------------- # 95 | @pytest.mark.parametrize( 96 | "side_effect, title, expected_result, expected_log_error, expected_api_status", 97 | [ 98 | (None, "Test Page", True, None, "Loaded page: 'Test Page'"), 99 | (TimeoutException, "Missing Page", False, "Timed out waiting for the title 'Missing Page' to load.", "Timed Out Waiting for Title 'Missing Page'"), 100 | (WebDriverException, "Test Page", False, "Tab Crashed.", "Tab Crashed"), 101 | ], 102 | ids=[ 103 | "successful_load", 104 | "timeout_waiting_for_title", 105 | "webdriver_crash", 106 | ] 107 | ) 108 | def test_check_for_title(mock_driver, mock_common, side_effect, title, expected_result, expected_log_error, expected_api_status): 109 | if side_effect: 110 | mock_common["wait"].return_value.until.side_effect = side_effect 111 | else: 112 | mock_common["wait"].return_value.until.return_value = True 113 | 114 | result = viewport.check_for_title(mock_driver, title=title) 115 | 116 | assert result is expected_result 117 | 118 | if expected_log_error: 119 | args, _ = mock_common["log_error"].call_args 120 | assert args[0] == expected_log_error 121 | else: 122 | mock_common["log_error"].assert_not_called() 123 | 124 | mock_common["api_status"].assert_called_with(expected_api_status) 125 | 126 | if expected_result and title: 127 | mock_common["logging"].info.assert_called_with(f"Loaded page: '{title}'") 128 | 129 | def test_check_for_title_no_title_given(mock_driver, mock_common): 130 | mock_common["wait"].return_value.until.return_value = True 131 | 132 | result = viewport.check_for_title(mock_driver) 133 | 134 | assert result is True 135 | mock_common["log_error"].assert_not_called() 136 | mock_common["api_status"].assert_not_called() 137 | 138 | def test_check_for_title_no_title_timeout(mock_driver, mock_common): 139 | # Arrange: no title ⇒ wait.until raises TimeoutException 140 | mock_common["wait"].return_value.until.side_effect = TimeoutException("no title") 141 | 142 | # Act 143 | result = viewport.check_for_title(mock_driver) 144 | 145 | # Assert 146 | assert result is False 147 | 148 | # The error message for empty‐title timeout must be logged 149 | mock_common["log_error"].assert_called_once_with( 150 | "Timed out waiting for the page title to not be empty.", 151 | mock_common["wait"].return_value.until.side_effect, 152 | mock_driver, 153 | ) 154 | 155 | # And the API gets the "Page Timed Out" status 156 | mock_common["api_status"].assert_called_once_with("Page Timed Out") 157 | 158 | # No generic logging.info should have been called 159 | mock_common["logging"].info.assert_not_called() 160 | 161 | def test_check_for_title_generic_exception(mock_driver, mock_common): 162 | # Arrange: wait.until raises a generic Exception (not TimeoutException/WebDriverException) 163 | generic_exc = Exception("something went wrong") 164 | mock_common["wait"].return_value.until.side_effect = generic_exc 165 | 166 | # Act 167 | result = viewport.check_for_title(mock_driver, title="MyTitle") 168 | 169 | # Assert 170 | assert result is False 171 | 172 | # log_error should be called with the formatted message, the exception, and the driver 173 | mock_common["log_error"].assert_called_once_with( 174 | "Error while waiting for title 'MyTitle': ", 175 | generic_exc, 176 | mock_driver, 177 | ) 178 | 179 | # api_status should be called with the corresponding error status 180 | mock_common["api_status"].assert_called_once_with("Error Waiting for Title 'MyTitle'") 181 | 182 | # No info‐level logging on this path 183 | mock_common["logging"].info.assert_not_called() 184 | 185 | # --------------------------------------------------------------------------- # 186 | # Test: check_unable_to_stream 187 | # --------------------------------------------------------------------------- # 188 | @pytest.mark.parametrize( 189 | "script_result, side_effect, expected_result, expect_log_error, expect_api_status", 190 | [ 191 | (["mock-element"], None, True, False, False), # Element found 192 | ([], None, False, False, False), # No element found 193 | (None, WebDriverException, False, True, True), # WebDriver crash 194 | (None, Exception("Some JS error"), False, True, True), # Other JS error 195 | ], 196 | ids=[ 197 | "element_found", 198 | "no_element_found", 199 | "webdriver_crash", 200 | "generic_js_error", 201 | ] 202 | ) 203 | @patch("viewport.api_status") 204 | @patch("viewport.log_error") 205 | def test_check_unable_to_stream(mock_log_error, mock_api_status, script_result, side_effect, expected_result, expect_log_error, expect_api_status): 206 | mock_driver = MagicMock() 207 | 208 | if side_effect: 209 | mock_driver.execute_script.side_effect = side_effect 210 | else: 211 | mock_driver.execute_script.return_value = script_result 212 | 213 | result = viewport.check_unable_to_stream(mock_driver) 214 | 215 | assert result is expected_result 216 | 217 | if expect_log_error: 218 | mock_log_error.assert_called() 219 | else: 220 | mock_log_error.assert_not_called() 221 | 222 | if expect_api_status: 223 | mock_api_status.assert_called() 224 | else: 225 | mock_api_status.assert_not_called() -------------------------------------------------------------------------------- /tests/test_logging.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import logging, datetime, os, sys, re 3 | from pathlib import Path 4 | from logging.handlers import TimedRotatingFileHandler 5 | from logging_config import configure_logging, ColoredFormatter 6 | # --------------------------------------------------------------------------- # 7 | # Override conftest's autouse isolate_logging 8 | # --------------------------------------------------------------------------- # 9 | @pytest.fixture(autouse=True) 10 | def isolate_logging_override(): 11 | # no-op: prevents conftest.py from reconfiguring logging for these tests 12 | yield 13 | 14 | # --------------------------------------------------------------------------- # 15 | # Clear root handlers before each test 16 | # --------------------------------------------------------------------------- # 17 | @pytest.fixture(autouse=True) 18 | def clear_root_handlers(): 19 | root = logging.getLogger() 20 | saved = list(root.handlers) 21 | root.handlers.clear() 22 | yield 23 | root.handlers[:] = saved 24 | 25 | # --------------------------------------------------------------------------- # 26 | # Boilerplate configuration check 27 | # --------------------------------------------------------------------------- # 28 | def test_configure_logging_file_and_console(tmp_path): 29 | log_path = tmp_path / "app.log" 30 | logger = configure_logging( 31 | log_file_path=str(log_path), 32 | log_file=True, 33 | log_console=True, 34 | log_days=5, 35 | Debug_logging=False 36 | ) 37 | 38 | # Root logger returned, level INFO 39 | assert logger is logging.getLogger() 40 | assert logger.level == logging.INFO 41 | 42 | # File handler present 43 | file_handlers = [ 44 | h for h in logger.handlers 45 | if isinstance(h, TimedRotatingFileHandler) 46 | ] 47 | assert file_handlers, "Expected a TimedRotatingFileHandler" 48 | assert log_path.exists() 49 | 50 | # Console handler present (StreamHandler but NOT a file handler) 51 | # pick out the StreamHandler we added (writing to stderr) 52 | console_handlers = [ 53 | h for h in logger.handlers 54 | if isinstance(h, logging.StreamHandler) 55 | and getattr(h, "stream", None) is sys.stderr 56 | ] 57 | assert len(console_handlers) == 1 58 | assert isinstance(console_handlers[0].formatter, ColoredFormatter) 59 | 60 | def test_configure_logging_debug_mode_only_file(tmp_path): 61 | log_path = tmp_path / "debug.log" 62 | logger = configure_logging( 63 | log_file_path=str(log_path), 64 | log_file=True, 65 | log_console=False, 66 | log_days=2, 67 | Debug_logging=True 68 | ) 69 | 70 | assert logger.level == logging.DEBUG 71 | 72 | # file handler still present 73 | assert any(isinstance(h, TimedRotatingFileHandler) for h in logger.handlers) 74 | # but no StreamHandler writing to stderr 75 | assert not any( 76 | isinstance(h, logging.StreamHandler) 77 | and getattr(h, "stream", None) is sys.stderr 78 | for h in logger.handlers 79 | ) 80 | 81 | def test_configure_logging_only_console(tmp_path): 82 | log_path = tmp_path / "unused.log" 83 | logger = configure_logging( 84 | log_file_path=str(log_path), 85 | log_file=False, 86 | log_console=True, 87 | ) 88 | 89 | assert logger.level == logging.INFO 90 | 91 | # no file handlers at all 92 | assert not any(isinstance(h, TimedRotatingFileHandler) 93 | for h in logger.handlers) 94 | 95 | # exactly one StreamHandler writing to stderr 96 | console_handlers = [ 97 | h for h in logger.handlers 98 | if isinstance(h, logging.StreamHandler) 99 | and getattr(h, "stream", None) is sys.stderr 100 | ] 101 | assert len(console_handlers) == 1 102 | 103 | @pytest.mark.parametrize("level, expected_color", [ 104 | (logging.ERROR, ColoredFormatter.RED), 105 | (logging.WARNING, ColoredFormatter.YELLOW), 106 | (logging.INFO, ColoredFormatter.GREEN), 107 | (logging.DEBUG, ColoredFormatter.CYAN), 108 | (12345, ColoredFormatter.NC), 109 | ]) 110 | def test_colored_formatter_all_branches(level, expected_color): 111 | fmt = ColoredFormatter("%(message)s") 112 | rec = logging.LogRecord( 113 | name="test", 114 | level=level, 115 | pathname=__file__, 116 | lineno=1, 117 | msg="Payload", 118 | args=(), 119 | exc_info=None 120 | ) 121 | out = fmt.format(rec) 122 | assert out.startswith(expected_color) 123 | assert out.endswith(ColoredFormatter.NC) 124 | assert "Payload" in out 125 | 126 | # --------------------------------------------------------------------------- # 127 | # Test log rotation 128 | # --------------------------------------------------------------------------- # 129 | def test_date_only_rotation_culls_old_backups(tmp_path): 130 | """ 131 | Create “test.log.YYYY-MM-DD” files by hand and verify the handler 132 | deletes the oldest if there are more than 3 days of backups. 133 | """ 134 | # Build a handler exactly as done in configure_logging() 135 | handler = TimedRotatingFileHandler( 136 | filename = str(tmp_path / "test.log"), 137 | when = "midnight", 138 | interval = 1, 139 | backupCount = 2, 140 | encoding = "utf-8", 141 | utc = False, 142 | atTime = datetime.time(0, 0), 143 | ) 144 | # Create five “date” backups in arbitrary order: 145 | for d in ["2025-05-03", "2025-05-01", "2025-05-05", "2025-05-02", "2025-05-04"]: 146 | (tmp_path / f"test.log.{d}").write_text("") 147 | 148 | # Ask which files would be deleted 149 | to_delete = handler.getFilesToDelete() 150 | filenames_to_delete = sorted(Path(p).name for p in to_delete) 151 | assert filenames_to_delete == ["test.log.2025-05-01", "test.log.2025-05-02", "test.log.2025-05-03"] 152 | 153 | # Simulate removing them and verify only the newest 3 remain 154 | for p in to_delete: 155 | os.remove(p) 156 | 157 | remaining = sorted(p.name for p in tmp_path.iterdir() if p.name.startswith("test.log")) 158 | assert remaining == [ 159 | "test.log", 160 | "test.log.2025-05-04", 161 | "test.log.2025-05-05", 162 | ] 163 | 164 | # --------------------------------------------------------------------------- # 165 | # Re-use-existing-handler branch coverage 166 | # --------------------------------------------------------------------------- # 167 | def test_configure_logging_reuses_existing_handler(tmp_path): 168 | """ 169 | Calling configure_logging() twice with the *same* path must not add a second 170 | TimedRotatingFileHandler - the early-return branch should trigger. 171 | """ 172 | log_file = tmp_path / "viewport.log" 173 | 174 | first = configure_logging( 175 | log_file_path=str(log_file), 176 | log_file=True, 177 | log_console=False, 178 | log_days=1, 179 | Debug_logging=False, 180 | ) 181 | handlers_before = [h for h in first.handlers if isinstance(h, TimedRotatingFileHandler)] 182 | assert len(handlers_before) == 1 183 | 184 | second = configure_logging( 185 | log_file_path=str(log_file), 186 | log_file=True, 187 | log_console=False, 188 | log_days=1, 189 | Debug_logging=False, 190 | ) 191 | handlers_after = [h for h in second.handlers if isinstance(h, TimedRotatingFileHandler)] 192 | assert second is first # same logger object 193 | assert handlers_after == handlers_before # same single handler 194 | 195 | # tidy up to avoid bleed-through 196 | for h in list(first.handlers): 197 | first.removeHandler(h) 198 | h.close() 199 | 200 | # --------------------------------------------------------------------------- # 201 | # End-to-end routing & filter behaviour 202 | # --------------------------------------------------------------------------- # 203 | def test_viewport_and_monitoring_logs_routed_and_filtered(tmp_path): 204 | """ 205 | • viewport.log gets *root/update* messages, not monitoring/werkzeug 206 | • monitoring.log gets monitoring + cleaned werkzeug lines 207 | • ANSI codes, IP addresses, and timestamps are stripped in monitoring.log 208 | """ 209 | vp_log = tmp_path / "viewport.log" 210 | mon_log = tmp_path / "monitoring.log" 211 | 212 | # viewport sets up first – root handler 213 | configure_logging( 214 | log_file_path=str(vp_log), 215 | log_file=True, 216 | log_console=False, 217 | log_days=1, 218 | Debug_logging=False, 219 | ) 220 | # monitoring adds its own handler without wiping the first 221 | configure_logging( 222 | log_file_path=str(mon_log), 223 | log_file=True, 224 | log_console=False, 225 | log_days=1, 226 | Debug_logging=False, 227 | ) 228 | 229 | # emit one message for each logger type 230 | logging.info("ROOT message") 231 | logging.getLogger("monitoring").info("MON message") 232 | raw_access = "\x1b[0;32m127.0.0.1 - - [01/Jan/2025 00:00:00] \"GET /api/test HTTP/1.1\" 200 -\x1b[0m" 233 | logging.getLogger("werkzeug").info(raw_access) 234 | 235 | # flush all handlers so files are written 236 | for h in logging.getLogger().handlers: 237 | h.flush() 238 | 239 | vp_text = vp_log.read_text() 240 | mon_text = mon_log.read_text() 241 | 242 | # viewport.log expectations 243 | assert "ROOT message" in vp_text 244 | assert "MON message" not in vp_text 245 | assert "/api/test" not in vp_text 246 | 247 | # monitoring.log expectations 248 | assert "MON message" in mon_text 249 | assert "ROOT message" not in mon_text 250 | assert "\"GET /api/test HTTP/1.1\" 200" in mon_text # cleaned access line 251 | assert "\x1b[" not in mon_text # no ANSI codes 252 | assert not "127.0.0.1 - - [01/Jan/2025 00:00:00]" in mon_text 253 | # no IP + timestamp clutter 254 | assert not re.search(r"\d+\.\d+\.\d+\.\d+ - - \[", mon_text) 255 | -------------------------------------------------------------------------------- /static/_media.scss: -------------------------------------------------------------------------------- 1 | @use '_variables' as *; 2 | @use "sass:map"; 3 | @use 'sass:color'; 4 | 5 | // -------------------------------------------------- 6 | // Responsive Adjustments 7 | // -------------------------------------------------- 8 | @include respond-above($small) { 9 | // Slightly larger form and controls layout for tablets 10 | section { 11 | margin-right: auto; 12 | margin-left: auto; 13 | max-width: 600px; 14 | .wrapper { 15 | margin-right: auto; 16 | margin-left: auto; 17 | border-radius: $border-radius; 18 | border-top-left-radius: 0; 19 | border-top-right-radius: 0; 20 | } 21 | } 22 | section#navigation { 23 | button { 24 | min-width: 75px; 25 | span { 26 | margin-left: 4px; 27 | display: inline-block; 28 | } 29 | } 30 | } 31 | section.display .wrapper { 32 | border-top-right-radius: 0; 33 | } 34 | // Allow resize 35 | section#logs { 36 | transition: all 0.3s ease; 37 | overflow: hidden; 38 | &.expanded { 39 | max-width: 900px; 40 | border-top-left-radius: $border-radius; 41 | border-top-right-radius: $border-radius; 42 | .wrapper button.expand-button { 43 | svg { 44 | transform: rotate(90deg); 45 | } 46 | } 47 | } 48 | // Contains title and button 49 | .container { 50 | display: flex; 51 | align-items: center; 52 | button.expand-button { 53 | display: inline-block; 54 | background: none; 55 | color: color("text"); 56 | cursor: pointer; 57 | padding: 8px; 58 | line-height: 0; // Forces svg to be centered on button 59 | margin-left: auto; // Push to right 60 | @include hover { 61 | color: color("blue"); 62 | } 63 | svg { 64 | width: 32px; 65 | height: 32px; 66 | transition: transform 0.6s ease; 67 | } 68 | } 69 | } 70 | } 71 | } 72 | @include respond-above($medium) { 73 | $gap: 16px; 74 | $width: 20rem; 75 | header { 76 | font-size: 18px; 77 | margin-bottom: 0; 78 | background-color: color("diminished"); 79 | h1 { 80 | color: color("text", $lightness: -25%); 81 | } 82 | } 83 | section { 84 | vertical-align: top; 85 | margin: 0; 86 | .wrapper { 87 | border: 1px solid color("border"); 88 | background-color: color("background"); 89 | border-radius: $border-radius; 90 | } 91 | } 92 | section#navigation { 93 | display: inline-flex; 94 | flex-direction: column; 95 | align-items: flex-start; 96 | justify-content: unset; 97 | box-sizing: border-box; 98 | // 100vh -header -1px for overflow prevention 99 | height: calc(100vh - $header - 1px); 100 | width: $header; 101 | justify-content: space-between; 102 | max-width: unset; 103 | border: none; 104 | border-right: 1px solid color("border"); 105 | padding-top: $gap; 106 | .button-group { 107 | flex-direction: column; 108 | align-items: stretch; 109 | border-radius: 0; 110 | background-color: color("background"); 111 | } 112 | button { 113 | justify-content: flex-start; 114 | min-width: unset; 115 | span { 116 | display: none; 117 | } 118 | &[aria-selected=true] { 119 | background-color: transparent; 120 | } 121 | @include hover { 122 | &:not([disabled]) { 123 | background-color: transparent; 124 | } 125 | svg { 126 | color: color("blue"); 127 | } 128 | } 129 | } 130 | 131 | button#deviceBtn, button#logsBtn { 132 | display: none; 133 | } 134 | button.refresh { 135 | padding: 0.75rem; 136 | margin-left: unset; 137 | border-radius: unset; 138 | background-color: transparent; 139 | } 140 | .log-controls { 141 | position: absolute; 142 | right: calc($gap + 1rem); 143 | top: calc($header + $gap + 1rem); 144 | height: 2.5rem; 145 | border-radius: $border-radius; 146 | } 147 | } 148 | div.group { 149 | display: inline-flex; 150 | &[hidden]{display:none;} 151 | vertical-align: top; 152 | width: calc(100% - $header); 153 | box-sizing: border-box; 154 | overflow: hidden; 155 | padding: $gap; 156 | gap: $gap; 157 | &.expanded { 158 | overflow: initial; 159 | gap: unset; 160 | } 161 | section { 162 | box-sizing: border-box; 163 | max-height: 40vh; 164 | width: $width; 165 | } 166 | .status-device { 167 | transition: transform 250ms ease-in-out; 168 | display: flex; 169 | flex-direction: column; 170 | position: relative; 171 | section { 172 | margin-bottom: $gap; 173 | } 174 | .tooltip { 175 | margin-top: auto; 176 | } 177 | #controls { 178 | margin: 0; 179 | button { 180 | margin-bottom: 0; 181 | } 182 | } 183 | .statusMessage { 184 | position: absolute; 185 | bottom: 60px; 186 | left: 0; 187 | right: 0; 188 | } 189 | &.contracted { 190 | width: 0; 191 | max-width: 0; 192 | opacity: 0; 193 | transform: translateX(-100%); 194 | } 195 | } 196 | section#status { 197 | ul li:last-child{ 198 | display: none; 199 | } 200 | } 201 | section#logs { 202 | position: relative; 203 | max-height: unset; 204 | max-width: unset; 205 | // 100% - Header - Padding 206 | height: calc(100vh - $header - (2 * $gap)); 207 | width: 100%; 208 | overflow: initial; 209 | .tooltip.hide-panel { 210 | position: absolute; 211 | left: 0; 212 | transform: translate(-50%, -50%); 213 | top: 25%; 214 | } 215 | button.hide-panel { 216 | display: inline-block; // Overrides [hidden] 217 | background-color: transparent; 218 | padding: 8px; 219 | line-height: 0; 220 | cursor: pointer; 221 | color: color("text"); 222 | svg { 223 | transition: all 0.3s ease-in-out; 224 | width: 24px; 225 | height: 24px; 226 | background-color: color("background"); 227 | border-radius: $border-radius; 228 | border: 1px solid color("border"); 229 | &.rotated { 230 | transform: rotate(180deg); 231 | } 232 | } 233 | @include hover { 234 | color: color("blue"); 235 | .tooltip .tooltip-text { 236 | visibility: visible; 237 | opacity: 1; 238 | } 239 | } 240 | } 241 | // Undo cascading effect of disabling tooltip for control buttons 242 | .tooltip:not([disabled]) { 243 | @include hover { 244 | .tooltip-text{ 245 | visibility: visible; 246 | opacity: 1; 247 | } 248 | } 249 | } 250 | .wrapper { 251 | height: 100%; 252 | box-sizing: border-box; 253 | h2 { 254 | margin-top: 0.5rem; 255 | font-size: 1.3rem; 256 | } 257 | button.expand-button { 258 | display: none; 259 | } 260 | div#logOutput { 261 | max-height: unset; 262 | height: calc(100% - 60px); 263 | } 264 | } 265 | } 266 | } 267 | section#config { 268 | display: inline-grid; 269 | grid-template-columns: 22rem minmax(22rem, 700px); 270 | padding: $gap; 271 | gap: $gap; 272 | max-width: unset; 273 | width: calc(100% - $header); 274 | box-sizing: border-box; 275 | &[hidden]{display:none;} 276 | ul:not(.status) { 277 | display: flex; 278 | flex-direction: column; 279 | justify-content: space-between; 280 | } 281 | .wrapper { 282 | grid-column: 1; 283 | margin: 0; 284 | box-sizing: border-box; 285 | border-radius: $border-radius; 286 | &[hidden] { 287 | display: block; 288 | grid-column: 2; 289 | ul.status { 290 | font-size: 110%; 291 | font-weight: bold; 292 | justify-content: space-between; 293 | } 294 | } 295 | &:nth-child(1), &:nth-child(2){ 296 | grid-row: 1; 297 | } 298 | &:nth-child(3), &:nth-child(4){ 299 | grid-row: 2; 300 | ul { 301 | height: 100%; 302 | } 303 | } 304 | &:nth-child(5), &:nth-child(6){ 305 | grid-row: 3; 306 | } 307 | } 308 | .tooltip { 309 | @include hover { 310 | .tooltip-text { 311 | span, br { 312 | display: none; 313 | } 314 | span.Blue { 315 | display: inline-block; 316 | } 317 | } 318 | } 319 | } 320 | } 321 | section#update { 322 | display: inline-flex; 323 | &[hidden]{display:none;} 324 | padding: $gap; 325 | vertical-align: top; 326 | height: calc(100vh - $header); 327 | width: calc(100% - $header); 328 | max-width: unset; 329 | box-sizing: border-box; 330 | font-size: 14px; 331 | overflow: hidden; 332 | .wrapper { 333 | overflow-y: auto; 334 | .headingGroup ul { 335 | margin-bottom: 0; 336 | } 337 | } 338 | // div.changelog { 339 | // div.headingWrapper { 340 | // display: grid; 341 | // grid-template-columns: repeat(2, 1fr); 342 | // div.headingGroup { 343 | // display: flex; 344 | // flex-direction: column; 345 | // } 346 | // div.headingGroup:last-child:nth-child(odd) { 347 | // grid-column: span 2; 348 | // } 349 | // } 350 | // } 351 | } 352 | .tooltip .tooltip-text { 353 | left: 50%; 354 | right: unset; 355 | transform: translateX(-50%); 356 | border-bottom-right-radius: $border-radius; 357 | &::after{ 358 | right: 50%; 359 | } 360 | } 361 | } 362 | @include respond-above($big){ 363 | $width: 28.125rem; 364 | div.group section { 365 | // 450px 366 | width: $width; 367 | } 368 | section#config { 369 | grid-template-columns: $width minmax($width, 700px); 370 | } 371 | } -------------------------------------------------------------------------------- /tests/handle_functions/test_handle_login_and_clear.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import viewport 3 | from unittest.mock import MagicMock, patch, call 4 | from selenium.common.exceptions import TimeoutException 5 | from selenium.webdriver.common.keys import Keys 6 | 7 | # --------------------------------------------------------------------------- # 8 | # Tests for handle_login function 9 | # --------------------------------------------------------------------------- # 10 | @pytest.mark.parametrize("exc, expect_ret, expect_error_msg, expect_api", [ 11 | (None, True, None, None), 12 | (Exception("oops"), False, "Error during login: ", "Error Logging In"), 13 | ]) 14 | @patch("viewport.browser_restart_handler") 15 | @patch("viewport.api_status") 16 | @patch("viewport.log_error") 17 | @patch("viewport.time.sleep", return_value=None) 18 | @patch("viewport.handle_clear") 19 | @patch("viewport.WebDriverWait") 20 | def test_handle_login( 21 | mock_wdw, mock_handle_clear, mock_sleep, mock_log_error, mock_api_status, mock_restart, 22 | exc, expect_ret, expect_error_msg, expect_api 23 | ): 24 | driver = MagicMock() 25 | viewport.WAIT_TIME = 1 26 | viewport.username = "u" 27 | viewport.password = "p" 28 | viewport.url = "http://example.com" 29 | # check_for_title returns True only on success path 30 | with patch("viewport.check_for_title", return_value= True if exc is None else None) as mock_check: 31 | # build three calls: username field, password field, submit button 32 | username_el = MagicMock() 33 | password_el = MagicMock() 34 | submit_el = MagicMock() 35 | wd = MagicMock() 36 | if exc is None: 37 | wd.until.side_effect = [username_el, password_el, submit_el] 38 | else: 39 | wd.until.side_effect = exc 40 | mock_wdw.return_value = wd 41 | 42 | result = viewport.handle_login(driver) 43 | 44 | if exc is None: 45 | username_el.send_keys.assert_called_once_with("u") 46 | password_el.send_keys.assert_called_once_with("p") 47 | submit_el.click.assert_called_once() 48 | mock_check.assert_called_with(driver, "Dashboard") 49 | assert result is True 50 | else: 51 | mock_log_error.assert_called() 52 | mock_api_status.assert_called_with(expect_api) 53 | assert result is False 54 | 55 | @patch("viewport.handle_clear") 56 | def test_handle_login_trust_prompt_not_found(mock_handle_clear, monkeypatch): 57 | driver = MagicMock() 58 | 59 | # Stub credentials 60 | monkeypatch.setattr(viewport, "username", "testuser") 61 | monkeypatch.setattr(viewport, "password", "testpass") 62 | 63 | # Prepare fake fields 64 | username_field = MagicMock() 65 | password_field = MagicMock() 66 | submit_button = MagicMock() 67 | 68 | # WebDriverWait.until should return username, password, submit, then raise TimeoutException 69 | seq = [username_field, password_field, submit_button] 70 | def fake_until(cond): 71 | if seq: 72 | return seq.pop(0) 73 | raise TimeoutException() 74 | monkeypatch.setattr(viewport, "WebDriverWait", lambda drv, t: MagicMock(until=fake_until)) 75 | 76 | # check_for_title returns False on first call, True on second 77 | calls = [] 78 | def fake_check(drv, title="Dashboard"): 79 | calls.append(title) 80 | return False if len(calls) == 1 else True 81 | monkeypatch.setattr(viewport, "check_for_title", fake_check) 82 | 83 | # No real sleeping 84 | monkeypatch.setattr(viewport.time, "sleep", lambda s: None) 85 | 86 | # Act 87 | result = viewport.handle_login(driver) 88 | 89 | # Assert 90 | assert result is True 91 | 92 | # Credentials were entered 93 | username_field.send_keys.assert_called_once_with("testuser") 94 | password_field.send_keys.assert_called_once_with("testpass") 95 | 96 | # Login button was clicked 97 | submit_button.click.assert_called_once() 98 | 99 | # check_for_title was called twice (before and after trust‐block) 100 | assert len(calls) == 2 101 | 102 | @patch("viewport.handle_clear") 103 | def test_handle_login_with_trust_device(mock_handle_clear, monkeypatch): 104 | driver = MagicMock() 105 | 106 | # stub credentials 107 | monkeypatch.setattr(viewport, "username", "testuser") 108 | monkeypatch.setattr(viewport, "password", "testpass") 109 | 110 | # create the elements returned by WebDriverWait.until() 111 | username_el = MagicMock() 112 | password_el = MagicMock() 113 | submit_btn = MagicMock() 114 | trust_span = MagicMock() 115 | trust_button = MagicMock() 116 | 117 | # When we do trust_span.find_element(...), return our fake button 118 | trust_span.find_element.return_value = trust_button 119 | 120 | # WebDriverWait.until should return username, password, submit, then trust_span 121 | seq = [username_el, password_el, submit_btn, trust_span] 122 | def fake_until(cond): 123 | return seq.pop(0) 124 | monkeypatch.setattr( 125 | viewport, 126 | "WebDriverWait", 127 | lambda drv, t: MagicMock(until=fake_until) 128 | ) 129 | 130 | # check_for_title: first call False (login not yet complete), 131 | # second call True (after trusting device) 132 | calls = {"n": 0} 133 | def fake_check_for_title(drv, title="Dashboard"): 134 | calls["n"] += 1 135 | return calls["n"] > 1 136 | monkeypatch.setattr(viewport, "check_for_title", fake_check_for_title) 137 | 138 | # Spy on time.sleep so we can assert the 1s pause after clicking 139 | sleep_calls = [] 140 | monkeypatch.setattr(viewport.time, "sleep", lambda s: sleep_calls.append(s)) 141 | 142 | # Stub out error handlers to avoid side effects 143 | monkeypatch.setattr(viewport, "log_error", MagicMock()) 144 | monkeypatch.setattr(viewport, "api_status", MagicMock()) 145 | 146 | # Act 147 | result = viewport.handle_login(driver) 148 | 149 | # Assert 150 | assert result is True 151 | 152 | # Credentials flow 153 | username_el.send_keys.assert_called_once_with("testuser") 154 | password_el.send_keys.assert_called_once_with("testpass") 155 | submit_btn.click.assert_called_once() 156 | 157 | # Trust‐this‐device flow 158 | trust_span.find_element.assert_called_once_with( 159 | viewport.By.XPATH, 160 | "./ancestor::button" 161 | ) 162 | trust_button.click.assert_called_once() 163 | 164 | # We should sleep 1 second after clicking "Trust This Device" 165 | assert 1 in sleep_calls 166 | 167 | @patch("viewport.time.sleep", return_value=None) 168 | @patch("viewport.handle_clear") 169 | @patch("viewport.WebDriverWait") 170 | def test_handle_login_uses_force_clear(mock_wdw, mock_handle_clear, mock_sleep, monkeypatch): 171 | driver = MagicMock() 172 | 173 | # fake elements returned by WebDriverWait.until() 174 | user_el, pass_el, submit_el = MagicMock(), MagicMock(), MagicMock() 175 | mock_wdw.return_value.until.side_effect = [user_el, pass_el, submit_el] 176 | 177 | # stub creds 178 | monkeypatch.setattr(viewport, "username", "demoUser") 179 | monkeypatch.setattr(viewport, "password", "demoPass") 180 | viewport.WAIT_TIME = 1 181 | 182 | # make title check succeed 183 | with patch("viewport.check_for_title", return_value=True): 184 | assert viewport.handle_login(driver) is True 185 | 186 | # handle_clear should be called for BOTH elements, in order 187 | mock_handle_clear.assert_has_calls( 188 | [call(driver, user_el), call(driver, pass_el)] 189 | ) 190 | 191 | # --------------------------------------------------------------------------- # 192 | # Tests for the handle_clear function 193 | # --------------------------------------------------------------------------- # 194 | class DummyField: 195 | def __init__(self): 196 | self.value = "savedPASS" 197 | self.clear_called = False 198 | self.send_keys_calls = [] 199 | 200 | def clear(self): 201 | # Selenium’s .clear() 202 | self.value = "" 203 | self.clear_called = True 204 | 205 | def send_keys(self, *args): 206 | # capture all send_keys calls 207 | self.send_keys_calls.append(args) 208 | 209 | class DummyDriver: 210 | def __init__(self): 211 | self.script_calls = [] 212 | 213 | def execute_script(self, script, element): 214 | self.script_calls.append(script) 215 | 216 | def test_handle_clear_removes_prefilled_value(): 217 | drv = DummyDriver() 218 | field = DummyField() 219 | 220 | # Sanity: value starts non-empty 221 | assert field.value == "savedPASS" 222 | 223 | # Act 224 | viewport.handle_clear(drv, field) 225 | 226 | # Assert the input is now empty 227 | assert field.value == "" 228 | 229 | # And each clearing step ran 230 | assert field.clear_called is True 231 | assert any("value = ''" in s for s in drv.script_calls) 232 | assert (Keys.CONTROL, "a", Keys.DELETE) in field.send_keys_calls 233 | 234 | @pytest.mark.parametrize("fail_step", ["clear", "execute_script", "send_keys"]) 235 | def test_handle_clear_swallows_exceptions(fail_step): 236 | # When any internal step inside handle_clear raises, 237 | # the helper must swallow the exception and exit silently. 238 | drv = MagicMock() 239 | field = MagicMock() 240 | 241 | # Configure which step should raise 242 | if fail_step == "clear": 243 | field.clear.side_effect = Exception("boom") 244 | else: 245 | field.clear.return_value = None 246 | 247 | if fail_step == "execute_script": 248 | drv.execute_script.side_effect = Exception("boom") 249 | else: 250 | drv.execute_script.return_value = None 251 | 252 | if fail_step == "send_keys": 253 | field.send_keys.side_effect = Exception("boom") 254 | else: 255 | field.send_keys.return_value = None 256 | 257 | # Should NOT raise, regardless of which step failed 258 | viewport.handle_clear(drv, field) 259 | 260 | # Assertions: the failing step ran, later steps (if any) did not 261 | if fail_step == "clear": 262 | field.clear.assert_called_once() 263 | drv.execute_script.assert_not_called() 264 | field.send_keys.assert_not_called() 265 | elif fail_step == "execute_script": 266 | field.clear.assert_called_once() 267 | drv.execute_script.assert_called_once() 268 | field.send_keys.assert_not_called() 269 | else: # fail_step == "send_keys" 270 | field.clear.assert_called_once() 271 | drv.execute_script.assert_called_once() 272 | field.send_keys.assert_called_once() -------------------------------------------------------------------------------- /static/_update.js: -------------------------------------------------------------------------------- 1 | import { fetchJSON } from "./_device.js"; 2 | export const CACHE_TTL = 60 * 15 * 1000; // 15 minutes 3 | let updateCache = { 4 | timestamp: 0, 5 | data: null, // { current, latest, changelog, releaseUrl } 6 | }; 7 | // ----------------------------------------------------------------------------- 8 | // Helper function 9 | // ----------------------------------------------------------------------------- 10 | function cmpVersions(a, b) { 11 | const pa = a.split(".").map(Number), 12 | pb = b.split(".").map(Number); 13 | for (let i = 0, n = Math.max(pa.length, pb.length); i < n; i++) { 14 | const x = pa[i] || 0, 15 | y = pb[i] || 0; 16 | if (x > y) return 1; 17 | if (x < y) return -1; 18 | } 19 | return 0; 20 | } 21 | // Fetch both /api/update and /api/update/changelog in parallel, 22 | // cache for an hour, return { current, latest, changelog, releaseUrl } 23 | export async function loadUpdateData() { 24 | const now = Date.now(); 25 | if (updateCache.data && now - updateCache.timestamp < CACHE_TTL) { 26 | return updateCache.data; 27 | } 28 | 29 | // parallel fetch 30 | const [verRes, logRes] = await Promise.all([ 31 | fetchJSON("/api/update"), 32 | fetch("/api/update/changelog").then((r) => r.json()), 33 | ]); 34 | 35 | if (!verRes?.data) { 36 | throw new Error("Failed to fetch version info"); 37 | } 38 | if (logRes.status !== "ok") { 39 | throw new Error("Failed to fetch changelog"); 40 | } 41 | 42 | const { current, latest } = verRes.data; 43 | const { changelog, release_url: releaseUrl } = logRes.data; 44 | 45 | updateCache = { 46 | timestamp: now, 47 | data: { current, latest, changelog, releaseUrl }, 48 | }; 49 | return updateCache.data; 50 | } 51 | // Just reads from the cache populated by checkForUpdate() 52 | // and populates your modal. 53 | export function showChangelog() { 54 | const info = updateCache.data; 55 | if (!info) { 56 | console.error("No update data; did you call checkForUpdate()?"); 57 | return; 58 | } 59 | const { latest, changelog, releaseUrl } = info; 60 | 61 | const title = document.querySelector("#update h2"); 62 | if (latest.includes("failed-to-fetch")) { 63 | title.textContent = "Failed to Fetch Changelog"; 64 | } else { 65 | title.textContent = `Release v${latest}`; 66 | } 67 | 68 | const changelogBody = document.getElementById("changelog-body"); 69 | changelogBody.innerHTML = marked.parse(changelog); 70 | 71 | // Create a container for all heading groups 72 | const groupsContainer = document.createElement("div"); 73 | groupsContainer.className = "headingWrapper"; 74 | 75 | // Get all elements in the changelog body 76 | const allElements = Array.from(changelogBody.children); 77 | 78 | // Find the point where H3s start (after title/summary) 79 | const firstHeadingIndex = allElements.findIndex((el) => el.tagName === "H3"); 80 | 81 | // Insert the groups container after the intro content 82 | if (firstHeadingIndex >= 0) { 83 | // Move all non-heading intro elements to before the container 84 | const introElements = allElements.slice(0, firstHeadingIndex); 85 | 86 | // Insert groups container 87 | changelogBody.insertBefore(groupsContainer, allElements[firstHeadingIndex]); 88 | 89 | // Process each heading section 90 | const headings = allElements 91 | .slice(firstHeadingIndex) 92 | .filter((el) => el.tagName === "H3"); 93 | 94 | headings.forEach((heading, index) => { 95 | // Create heading group wrapper 96 | const wrapper = document.createElement("div"); 97 | wrapper.className = "headingGroup"; 98 | 99 | // Collect section elements 100 | const sectionElements = [heading]; 101 | let nextElement = heading.nextElementSibling; 102 | 103 | while (nextElement && nextElement.tagName !== "H3") { 104 | sectionElements.push(nextElement); 105 | nextElement = nextElement.nextElementSibling; 106 | } 107 | 108 | // Move elements to wrapper 109 | sectionElements.forEach((el) => wrapper.appendChild(el)); 110 | 111 | // Add to groups container 112 | groupsContainer.appendChild(wrapper); 113 | }); 114 | } 115 | document.getElementById("changelog-link").href = releaseUrl; 116 | document.getElementById("update").removeAttribute("hidden"); 117 | } 118 | // ----------------------------------------------------------------------------- 119 | // Send command to apply update 120 | // ----------------------------------------------------------------------------- 121 | export async function applyUpdate(btn) { 122 | const updateMessage = document.querySelector("#updateMessage span"); 123 | const originalBtnText = btn.querySelector("span").textContent; 124 | const originalBtnDisabled = btn.disabled; 125 | 126 | // Reset message state 127 | updateMessage.textContent = ""; 128 | updateMessage.className = ""; 129 | btn.disabled = true; 130 | updateMessage.textContent = "Fetching Update..."; 131 | updateMessage.classList.add("Green"); 132 | try { 133 | // First check versions before attempting update 134 | const { current, latest } = await loadUpdateData(); 135 | if (cmpVersions(latest, current) <= 0) { 136 | updateMessage.textContent = "✓ Your system is already up to date"; 137 | updateMessage.classList.add("Green"); 138 | btn.querySelector("span").textContent = "Up to date"; 139 | btn.disabled = true; // Keep button disabled 140 | 141 | setTimeout(() => { 142 | updateMessage.textContent = ""; 143 | updateMessage.className = ""; 144 | }, 5000); 145 | return; 146 | } 147 | updateMessage.textContent = "Fetching Update..."; 148 | updateMessage.classList.add("Green"); 149 | 150 | // First API call - apply update 151 | const updateResponse = await fetch("/api/update/apply", { method: "POST" }); 152 | 153 | if (!updateResponse.ok) { 154 | throw new Error(`Update failed with status ${updateResponse.status}`); 155 | } 156 | 157 | const updateData = await updateResponse.json(); 158 | const outcome = updateData?.data?.outcome || updateData?.outcome; 159 | 160 | // Handle different outcome cases 161 | if (outcome === "already-current") { 162 | updateMessage.textContent = "✓ Your system is already up to date"; 163 | updateMessage.classList.remove("Red"); 164 | updateMessage.classList.add("Green"); 165 | btn.querySelector("span").textContent = "Up to date"; 166 | setTimeout(() => { 167 | btn.querySelector("span").textContent = originalBtnText; 168 | btn.disabled = originalBtnDisabled; 169 | updateMessage.textContent = ""; 170 | updateMessage.className = ""; 171 | }, 10_000); 172 | return; 173 | } 174 | 175 | // Handle successful updates 176 | if (outcome.startsWith("updated-to-")) { 177 | updateMessage.textContent = 178 | "✓ Update successful, preparing to restart..."; 179 | updateMessage.classList.remove("Red"); 180 | updateMessage.classList.add("Green"); 181 | 182 | // Start restart sequence 183 | try { 184 | const [restartResponse, selfRestartResponse] = await Promise.all([ 185 | fetch(`/api/control/restart`, { method: "POST" }), 186 | fetch(`/api/self/restart`, { method: "POST" }), 187 | ]); 188 | 189 | if (!restartResponse.ok || !selfRestartResponse.ok) { 190 | throw new Error("Restart commands failed"); 191 | } 192 | 193 | const [restartData, selfRestartData] = await Promise.all([ 194 | restartResponse.json(), 195 | selfRestartResponse.json(), 196 | ]); 197 | 198 | if (restartData.status === "ok" && selfRestartData.status === "ok") { 199 | updateMessage.textContent = "✓ System restarting..."; 200 | setTimeout(() => location.reload(), 5000); 201 | } else { 202 | updateMessage.textContent = 203 | "✓ Update complete - please restart manually"; 204 | btn.querySelector("span").textContent = "Restart required"; 205 | } 206 | } catch (restartError) { 207 | updateMessage.textContent = 208 | "✓ Update complete - automatic restart failed"; 209 | btn.querySelector("span").textContent = "Restart required"; 210 | console.error("Restart failed:", restartError); 211 | } 212 | } 213 | // Handle failure case 214 | else if (outcome === "update-failed") { 215 | throw new Error("Update process failed"); 216 | } 217 | // Unknown response 218 | else { 219 | throw new Error("Unexpected update response"); 220 | } 221 | } catch (error) { 222 | console.error("Update failed:", error); 223 | updateMessage.classList.remove("Green"); 224 | updateMessage.classList.add("Red"); 225 | 226 | // More specific error messages 227 | if (error.message.includes("Failed to fetch")) { 228 | updateMessage.textContent = 229 | "✗ Network error - please check your connection"; 230 | } else if (error.message.includes("Update process failed")) { 231 | updateMessage.textContent = "✗ Update failed - please try again"; 232 | } else { 233 | updateMessage.textContent = "✗ Update error - please check logs"; 234 | } 235 | 236 | // Revert button state 237 | btn.querySelector("span").textContent = "Retry"; 238 | btn.disabled = false; 239 | } 240 | } 241 | // Call on page-load (and once per hour via setInterval) 242 | // to reveal the banner if latest > current. 243 | export async function checkForUpdate() { 244 | try { 245 | const { current, latest } = await loadUpdateData(); 246 | const banner = document.getElementById("updateBtn"); 247 | const updateButton = document.querySelector( 248 | '#update button[type="submit"]' 249 | ); 250 | 251 | if (cmpVersions(latest, current) <= 0) { 252 | // Hide banner and disable button if already up-to-date 253 | if (banner) banner.setAttribute("hidden", ""); 254 | if (updateButton) { 255 | updateButton.disabled = true; 256 | updateButton.querySelector("span").textContent = "Up to date"; 257 | } 258 | return; 259 | } 260 | 261 | // Show banner and enable button if update available 262 | if (banner) banner.removeAttribute("hidden"); 263 | if (updateButton) { 264 | updateButton.disabled = false; 265 | updateButton.querySelector("span").textContent = "Apply Update"; 266 | } 267 | } catch (err) { 268 | console.error("Update check failed:", err); 269 | } 270 | } 271 | // Initialize the update button 272 | export function initUpdateButton() { 273 | const pushUpdate = document.querySelector('#update button[type="submit"]'); 274 | if (pushUpdate) { 275 | pushUpdate.addEventListener("click", () => applyUpdate(pushUpdate)); 276 | // Set initial state 277 | pushUpdate.disabled = true; 278 | pushUpdate.querySelector("span").textContent = "Checking..."; 279 | } 280 | } -------------------------------------------------------------------------------- /tests/handle_functions/test_handle_retry.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import viewport 3 | from unittest.mock import MagicMock, patch, call 4 | from selenium.common.exceptions import WebDriverException 5 | # --------------------------------------------------------------------------- # 6 | # Tests for handle_retry function 7 | # --------------------------------------------------------------------------- # 8 | @pytest.mark.parametrize("attempt,title,check_driver_ok,login_ok,fullscreen_ok,expected_calls", [ 9 | # normal reload path 10 | (0, "Some Other", True, None, None, ["Attempting to load page from URL.", "Page successfully reloaded."]), 11 | # login-page path 12 | (0, "Ubiquiti Account", True, True, False, ["Log-in page found. Inputting credentials...",]), 13 | ]) 14 | @patch("viewport.restart_handler") 15 | @patch("viewport.browser_restart_handler", return_value="NEW_DRIVER") 16 | @patch("viewport.handle_fullscreen_button", return_value=False) 17 | @patch("viewport.handle_login") 18 | @patch("viewport.handle_page", return_value=True) 19 | @patch("viewport.check_driver") 20 | @patch("viewport.api_status") 21 | @patch("viewport.logging.info") 22 | @patch("viewport.time.sleep", return_value=None) 23 | def test_handle_retry_basic_paths( 24 | mock_sleep, mock_log_info, mock_api_status, mock_check_driver, 25 | mock_handle_page, mock_handle_login, mock_handle_fs, mock_chrome_restart, 26 | mock_restart, attempt, title, check_driver_ok, login_ok, fullscreen_ok, expected_calls 27 | ): 28 | driver = MagicMock(title=title) 29 | url = "http://example.com" 30 | max_retries = 3 31 | 32 | mock_check_driver.return_value = check_driver_ok 33 | mock_handle_login.return_value = login_ok 34 | 35 | ret = viewport.handle_retry(driver, url, attempt, max_retries) 36 | 37 | # verify log/info calls for expected branch messages 38 | for msg in expected_calls: 39 | assert any(msg in args[0] for args in mock_log_info.call_args_list) 40 | 41 | # on login case, ensure handle_login + feed‐healthy 42 | if "Log-in page" in expected_calls[0]: 43 | mock_handle_login.assert_called_once_with(driver) 44 | mock_api_status.assert_any_call("Feed Healthy") 45 | 46 | # on normal reload, ensure driver.get called 47 | if "Attempting to load page" in expected_calls[0]: 48 | driver.get.assert_called_once_with(url) 49 | mock_api_status.assert_any_call("Feed Healthy") 50 | mock_sleep.assert_called() 51 | # --------------------------------------------------------------------------- # 52 | # max_retries − 1 branch 53 | # --------------------------------------------------------------------------- # 54 | @patch("viewport.api_status") 55 | @patch("viewport.logging.info") 56 | @patch("viewport.browser_restart_handler", return_value="CH_RESTARTED") 57 | @patch("viewport.check_driver", return_value=True) 58 | def test_handle_retry_final_before_restart( 59 | mock_check_driver, 60 | mock_chrome_restart, 61 | mock_log_info, 62 | mock_api_status, 63 | ): 64 | driver = MagicMock(title="Whatever") 65 | url = "u" 66 | 67 | # attempt == max_retries−1 → should call browser_restart_handler and return its value 68 | result = viewport.handle_retry(driver, url, attempt=2, max_retries=3) 69 | 70 | mock_chrome_restart.assert_called_once_with("u") 71 | assert result == "CH_RESTARTED" 72 | # --------------------------------------------------------------------------- # 73 | # max_retries branch 74 | # --------------------------------------------------------------------------- # 75 | @patch("viewport.restart_handler") 76 | @patch("viewport.logging.warning") 77 | @patch("viewport.api_status") 78 | def test_handle_retry_max_retries_calls_restart(mock_api, mock_warning, mock_restart): 79 | driver = MagicMock() 80 | url = "u" 81 | # attempt == max_retries triggers restart_handler 82 | viewport.handle_retry(driver, url, attempt=3, max_retries=3) 83 | mock_warning.assert_any_call("Max Attempts reached, restarting script...") 84 | mock_api.assert_called_with("Max Attempts Reached, restarting script") 85 | mock_restart.assert_called_once_with(driver) 86 | # --------------------------------------------------------------------------- # 87 | # handle_retry triggers browser_restart_handler when driver has crashed 88 | # --------------------------------------------------------------------------- # 89 | @patch("viewport.browser_restart_handler", return_value=MagicMock(title="Dashboard Home")) 90 | @patch("viewport.handle_fullscreen_button", return_value=True) 91 | @patch("viewport.handle_login", return_value=True) 92 | @patch("viewport.handle_page", return_value=True) 93 | @patch("viewport.check_driver", return_value=False) # simulate crash 94 | @patch("viewport.api_status") 95 | @patch("viewport.logging.warning") 96 | @patch("viewport.logging.info") 97 | @patch("viewport.time.sleep", return_value=None) 98 | def test_handle_retry_detects_driver_crash_and_restarts( 99 | mock_sleep, 100 | mock_log_info, 101 | mock_log_warning, 102 | mock_api_status, 103 | mock_check_driver, 104 | mock_handle_page, 105 | mock_handle_login, 106 | mock_fs_btn, 107 | mock_chrome_restart, 108 | ): 109 | driver = MagicMock(title="Old Title") 110 | url = "http://example.com" 111 | 112 | result = viewport.handle_retry(driver, url, attempt=0, max_retries=3) 113 | 114 | # we should have warned about a crash... 115 | mock_log_warning.assert_any_call("WebDriver crashed.") 116 | # ...and then invoked browser_restart_handler(url) 117 | mock_chrome_restart.assert_called_once_with(url) 118 | 119 | new_driver = mock_chrome_restart.return_value 120 | # new driver should be used to reload the page 121 | new_driver.get.assert_called_once_with(url) 122 | # and we should have reported “Feed Healthy” 123 | mock_api_status.assert_called_with("Feed Healthy") 124 | # finally, the returned driver is the new one 125 | assert result is new_driver 126 | 127 | # --------------------------------------------------------------------------- # 128 | # InvalidSessionIdException path 129 | # --------------------------------------------------------------------------- # 130 | @patch("viewport.log_error") 131 | @patch("viewport.api_status") 132 | @patch("viewport.restart_handler") 133 | @patch("viewport.check_driver", side_effect=viewport.InvalidSessionIdException("bad session")) 134 | def test_handle_retry_invalid_session( 135 | mock_check_driver, mock_restart, mock_api_status, mock_log_error 136 | ): 137 | driver = MagicMock(title="Dashboard") 138 | url = "http://x" 139 | attempt = 1 140 | max_retries = 5 141 | 142 | result = viewport.handle_retry(driver, url, attempt=attempt, max_retries=max_retries) 143 | 144 | # api_status should first log the retry, then the branch 145 | assert mock_api_status.call_args_list == [ 146 | call(f"Retrying: {attempt} of {max_retries}"), 147 | call("Restarting Program"), 148 | ] 149 | 150 | # log_error called once with our InvalidSessionIdException 151 | err_call = mock_log_error.call_args[0] 152 | assert err_call[0] == f"{viewport.BROWSER} session is invalid. Restarting the program." 153 | assert isinstance(err_call[1], viewport.InvalidSessionIdException) 154 | 155 | # restart_handler must be invoked with the original driver 156 | mock_restart.assert_called_once_with(driver) 157 | 158 | # return value is whatever restart_handler returned (None by default) 159 | assert result is driver 160 | 161 | # --------------------------------------------------------------------------- # 162 | # WebDriverException path 163 | # --------------------------------------------------------------------------- # 164 | @patch("viewport.log_error") 165 | @patch("viewport.api_status") 166 | @patch("viewport.browser_restart_handler", return_value="new-driver") 167 | @patch("viewport.check_driver", return_value=True) 168 | def test_handle_retry_webdriver_exception( 169 | mock_check, mock_browser_restart, mock_api_status, mock_log_error 170 | ): 171 | driver = MagicMock(title="Dashboard") 172 | driver.get.side_effect = WebDriverException("tab died") 173 | url = "http://x" 174 | attempt = 0 175 | max_retries = 3 176 | 177 | result = viewport.handle_retry(driver, url, attempt=attempt, max_retries=max_retries) 178 | 179 | # api_status first logs retry, then "Tab Crashed" 180 | assert mock_api_status.call_args_list == [ 181 | call(f"Retrying: {attempt} of {max_retries}"), 182 | call("Tab Crashed"), 183 | ] 184 | 185 | # log_error called once with the WebDriverException 186 | err_call = mock_log_error.call_args[0] 187 | assert err_call[0] == f"Tab Crashed. Restarting {viewport.BROWSER}..." 188 | assert isinstance(err_call[1], WebDriverException) 189 | 190 | # browser_restart_handler should be called once with the URL 191 | mock_browser_restart.assert_called_once_with(url) 192 | # and return value is what it returned 193 | assert result == "new-driver" 194 | 195 | # --------------------------------------------------------------------------- # 196 | # Generic Exception path 197 | # --------------------------------------------------------------------------- # 198 | @patch("viewport.log_error") 199 | @patch("viewport.api_status") 200 | @patch("viewport.check_driver", return_value=True) 201 | def test_handle_retry_generic_exception( 202 | mock_check, mock_api_status, mock_log_error 203 | ): 204 | driver = MagicMock(title="Dashboard") 205 | url = "http://x" 206 | attempt = 0 207 | max_retries = 2 208 | 209 | # Patch handle_page to throw something unexpected 210 | with patch.object(viewport, "handle_page", side_effect=ValueError("oops")): 211 | result = viewport.handle_retry(driver, url, attempt=attempt, max_retries=max_retries) 212 | 213 | # api_status should first log retry, then "Error refreshing" 214 | assert mock_api_status.call_args_list == [ 215 | call(f"Retrying: {attempt} of {max_retries}"), 216 | call("Error refreshing"), 217 | ] 218 | 219 | # log_error should be called once with our ValueError 220 | err_call = mock_log_error.call_args[0] 221 | assert err_call[0] == "Error while handling retry logic: " 222 | assert isinstance(err_call[1], ValueError) 223 | assert err_call[2] is driver 224 | 225 | # since this is attempt < max_retries-1, we return the original driver 226 | assert result is driver 227 | 228 | # --------------------------------------------------------------------------- # 229 | # Page Failure path 230 | # --------------------------------------------------------------------------- # 231 | @patch("viewport.api_status") 232 | @patch("viewport.logging.warning") 233 | @patch("viewport.logging.info") 234 | @patch("viewport.handle_page", return_value=False) 235 | @patch("viewport.check_driver", return_value=True) 236 | def test_handle_retry_page_failure( 237 | mock_check_driver, 238 | mock_handle_page, 239 | mock_log_info, 240 | mock_log_warning, 241 | mock_api_status, 242 | ): 243 | driver = MagicMock(title="Whatever") 244 | url = "http://example.com" 245 | attempt = 1 246 | max_retries = 3 247 | 248 | # Act 249 | result = viewport.handle_retry(driver, url, attempt=attempt, max_retries=max_retries) 250 | 251 | # Assert: info-logging for failed reload 252 | mock_log_warning.assert_any_call("Couldn't reload page.") 253 | 254 | # Assert: warning-logging for skipping fullscreen / healthy-feed 255 | mock_log_warning.assert_any_call( 256 | "Page reload failed; skipping fullscreen and healthy-feed status." 257 | ) 258 | 259 | # Assert: api_status called for retry then unhealthy feed 260 | assert mock_api_status.call_args_list == [ 261 | call(f"Retrying: {attempt} of {max_retries}"), 262 | call("Couldn't verify feed"), 263 | ] 264 | 265 | # And our driver should just be returned unchanged 266 | assert result is driver -------------------------------------------------------------------------------- /tests/handler_functions/test_process_handler.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | import signal 3 | import pytest 4 | import viewport 5 | from types import SimpleNamespace 6 | from unittest.mock import MagicMock, patch, call 7 | 8 | # --------------------------------------------------------------------------- # 9 | # helper to build a fake psutil.Process‐like object 10 | # --------------------------------------------------------------------------- # 11 | def _make_proc(pid, cmdline, uids=None, name=None, cpu: float = 0.0, mem: int = 0): 12 | # pid: process ID 13 | # cmdline: either a list of strings or a single binary name 14 | # uids: optionally a SimpleNamespace(real=) for ownership filtering 15 | # name: override process.info['name'] 16 | # cpu: what cpu_percent(interval) should return 17 | # mem: what memory_info().rss should return 18 | 19 | # Normalize cmdline to list and derive default name 20 | if isinstance(cmdline, str): 21 | cmd = [cmdline] 22 | else: 23 | cmd = list(cmdline) if cmdline is not None else [] 24 | default_name = cmd[0] if cmd else f"proc{pid}" 25 | proc_name = name or default_name 26 | 27 | # Build info dict 28 | info = { 29 | "pid": pid, 30 | "cmdline": cmd, 31 | "uids": uids or SimpleNamespace(real=1000), 32 | "name": proc_name, 33 | } 34 | 35 | # Create the MagicMock and attach methods 36 | proc = MagicMock() 37 | proc.info = info 38 | proc.cpu_percent.return_value = cpu 39 | proc.memory_info.return_value = MagicMock(rss=mem) 40 | return proc 41 | 42 | # --------------------------------------------------------------------------- # 43 | # Test for Process Handler 44 | # --------------------------------------------------------------------------- # 45 | @pytest.mark.parametrize( 46 | "proc_list, current_pid, name, action, expected_result, expected_kill_calls, expected_api_calls, expected_log_info", 47 | [ 48 | # Process Running 49 | # Current PID, Name to, Check, Exists? 50 | # Expected Kill Calls, Expected API Calls 51 | 52 | # No process, 'viewport', check, Not Present 53 | ( 54 | [], 55 | 100, "viewport.py", "check", False, 56 | [], [], [] 57 | ), 58 | 59 | # Only Self Viewport Process running 60 | # 'viewport', 'check', Should be False 61 | ( 62 | [_make_proc(100, ["viewport.py"])], 63 | 100, "viewport.py", "check", False, 64 | [], [], [] 65 | ), 66 | 67 | # Different Viewport Process running 68 | # 'viewport', 'check', Should be True 69 | ( 70 | [_make_proc(1, ["python", "viewport.py"])], 71 | 100, "viewport.py", "check", True, 72 | [], [], [] 73 | ), 74 | 75 | # Different commandline process with argument 76 | # 'viewport', 'check', Should be True 77 | ( 78 | [_make_proc(10, ["/usr/bin/viewport.py", "--foo"])], 79 | 0, "viewport.py", "check", True, 80 | [], [], [] 81 | ), 82 | 83 | # No Process Running = None to kill 84 | # 'viewport', 'kill', Should be False 85 | ( 86 | [], 87 | 200, "viewport.py", "kill", False, 88 | [], [], [] 89 | ), 90 | 91 | # Only Self Viewport Process Runnning = Do not Kill 92 | # 'viewport', 'kill', Should be False 93 | ( 94 | [_make_proc(200, ["viewport.py"])], 95 | 200, "viewport.py", "kill", False, 96 | [], [], [] 97 | ), 98 | # Monitoring process runnning under filename that includes viewport = Do not Kill 99 | # 'viewport', 'kill', Should be False 100 | ( 101 | [_make_proc(200, ["home/viewport/monitoring.py"])], 102 | 200, "viewport.py", "kill", False, 103 | [], [], [] 104 | ), 105 | # Chrome proccess checks to kill firefox = Do not Kill 106 | # 'chrome', 'kill', Should be False 107 | ( 108 | [_make_proc(200, ["firefox"])], 109 | 100, "chrome", "kill", False, 110 | [], [], [] 111 | ), 112 | # Chrome Processes (2, 3) running in backgrond 113 | # 'chrome', 'kill', If killed should return False 114 | # Process 2 and 3 gets SIGTERM, API Call should be: 115 | ( 116 | [_make_proc(2, ["chrome"]), 117 | _make_proc(3, ["chrome"])], 118 | 999, "chrome", "kill", False, 119 | [(2, signal.SIGKILL), (3, signal.SIGKILL)], ["Killed process 'chrome'"], ["Killed process 'chrome' with PIDs: 2, 3"] 120 | ), 121 | 122 | # Chromium Process (2, 3) running in backgrond 123 | # 'chromium', 'kill', If killed should return False 124 | # Process 2 and 3 get SIGKILL, API Call should be: 125 | ( 126 | [_make_proc(2, ["/usr/lib/chromium/chromium"]), 127 | _make_proc(3, ["chromium"])], 128 | 999, "chromium", "kill", False, 129 | [(2, signal.SIGKILL), (3, signal.SIGKILL)], ["Killed process 'chromium'"], ["Killed process 'chromium' with PIDs: 2, 3"] 130 | ), 131 | 132 | # Multiple viewport instances running in background, separate from current instance 133 | # 'viewport', 'kill', If killed should return False 134 | # Process 2 and 3 get SIGKILL, API Call should be: 135 | ( 136 | [_make_proc(2, ["viewport/viewport.py"]), 137 | _make_proc(3, ["viewport.py"]), 138 | _make_proc(4, ["other"])], 139 | 999, "viewport.py", "kill", False, 140 | [(2, signal.SIGKILL), (3, signal.SIGKILL)], ["Killed process 'viewport.py'"], ["Killed process 'viewport.py' with PIDs: 2, 3"] 141 | ), 142 | # One other viewport instance running in background 143 | # 'viewport', 'kill', If killed should return False 144 | # Process 2, API Call should be: 145 | ( 146 | [_make_proc(2, ["viewport.py"]), 147 | _make_proc(3, ["other"])], 148 | 999, "viewport.py", "kill", False, 149 | [(2, signal.SIGKILL)], ["Killed process 'viewport.py'"], ["Killed process 'viewport.py' with PIDs: 2"] 150 | ), 151 | # Firefox main + root-owned helper: should kill only the main (uid == me) 152 | ( 153 | [ 154 | # main firefox, owned by us 155 | _make_proc(2, ["firefox"], uids=SimpleNamespace(real=1000)), 156 | # helper, owned by root → should be ignored 157 | _make_proc(3, ["firefox"], uids=SimpleNamespace(real=0)), 158 | ], 159 | 999, "firefox", "kill", False, 160 | # only pid 2 gets SIGKILL 161 | [(2, signal.SIGKILL)], 162 | # api_status should be called with this message 163 | ["Killed process 'firefox'"], 164 | # logging.info with this 165 | ["Killed process 'firefox' with PIDs: 2"] 166 | ), 167 | ] 168 | ) 169 | @patch("viewport.logging.info") 170 | @patch("viewport.psutil.process_iter") 171 | @patch("viewport.os.geteuid") 172 | @patch("viewport.os.getpid") 173 | @patch("viewport.os.kill") 174 | @patch("viewport.api_status") 175 | @patch("viewport.time.sleep") 176 | def test_process_handler( 177 | mock_sleep, mock_api, mock_kill, mock_getpid, mock_geteuid, mock_iter, mock_log_info, 178 | proc_list, current_pid, name, action, 179 | expected_result, expected_kill_calls, expected_api_calls, expected_log_info 180 | ): 181 | # arrange 182 | mock_geteuid.return_value = 1000 183 | mock_iter.return_value = iter(proc_list) 184 | mock_getpid.return_value = current_pid 185 | 186 | # act 187 | result = viewport.process_handler(name, action=action) 188 | 189 | # assert return value 190 | assert result is expected_result 191 | 192 | # assert os.kill calls 193 | if expected_kill_calls: 194 | assert mock_kill.call_count == len(expected_kill_calls) 195 | for pid, sig in expected_kill_calls: 196 | mock_kill.assert_any_call(pid, sig) 197 | else: 198 | mock_kill.assert_not_called() 199 | 200 | # Assert logging.info calls 201 | if expected_log_info: 202 | for msg in expected_log_info: 203 | mock_log_info.assert_any_call(msg) 204 | else: 205 | mock_log_info.assert_not_called() 206 | # assert api_status calls 207 | if expected_api_calls: 208 | assert mock_api.call_args_list == [call(msg) for msg in expected_api_calls] 209 | else: 210 | mock_api.assert_not_called() 211 | 212 | # --------------------------------------------------------------------------- # 213 | # Cover the psutil.NoSuchProcess / AccessDenied path in the loop 214 | # --------------------------------------------------------------------------- # 215 | @patch("viewport.psutil.process_iter") 216 | @patch("viewport.time.sleep") 217 | def test_process_handler_ignores_uninspectable_procs(mock_sleep, mock_iter): 218 | class BadProc: 219 | @property 220 | def info(self): 221 | # simulate a process that disappears or denies access 222 | raise psutil.NoSuchProcess(pid=123) 223 | mock_iter.return_value = iter([BadProc()]) 224 | # should swallow and return False (no matches) 225 | assert viewport.process_handler("anything", action="check") is False 226 | 227 | # also cover AccessDenied 228 | class DeniedProc: 229 | @property 230 | def info(self): 231 | raise psutil.AccessDenied(pid=456) 232 | mock_iter.return_value = iter([DeniedProc()]) 233 | assert viewport.process_handler("anything", action="check") is False 234 | 235 | # --------------------------------------------------------------------------- # 236 | # Cover the ProcessLookupError inside the kill loop 237 | # --------------------------------------------------------------------------- # 238 | @patch("viewport.os.kill") 239 | @patch("viewport.os.getpid", return_value=0) 240 | @patch("viewport.os.geteuid", return_value=1000) 241 | @patch("viewport.psutil.process_iter") 242 | @patch("viewport.api_status") 243 | @patch("viewport.logging.info") 244 | @patch("viewport.logging.warning") 245 | @patch("viewport.time.sleep") 246 | def test_process_handler_kill_handles_processlookuperror( 247 | mock_sleep, mock_warn, mock_info, mock_api, mock_iter, 248 | mock_geteuid, mock_getpid, mock_kill 249 | ): 250 | # one matching process 251 | proc = MagicMock() 252 | proc.info = { 253 | "pid": 99, 254 | "name": "foo", 255 | "uids": SimpleNamespace(real=1000), 256 | "cmdline": ["foo"], 257 | } 258 | mock_iter.return_value = iter([proc]) 259 | 260 | # raise ProcessLookupError on kill 261 | mock_kill.side_effect = ProcessLookupError 262 | 263 | # kill action 264 | result = viewport.process_handler("foo", action="kill") 265 | assert result is False 266 | 267 | # ensure kill was attempted 268 | mock_kill.assert_called_once_with(99, signal.SIGKILL) 269 | # ensure warning logged 270 | mock_warn.assert_called_once_with("Process 99 already gone") 271 | # ensure we still log info about having 'killed' it 272 | mock_info.assert_called_once_with("Killed process 'foo' with PIDs: 99") 273 | # ensure API was still notified 274 | mock_api.assert_called_once_with("Killed process 'foo'") 275 | 276 | # --------------------------------------------------------------------------- # 277 | # Cover the catch-all Exception path 278 | # --------------------------------------------------------------------------- # 279 | @patch("viewport.api_status") 280 | @patch("viewport.log_error") 281 | @patch("viewport.os.geteuid", side_effect=RuntimeError("oh no")) 282 | def test_process_handler_catches_unexpected(mock_geteuid, mock_log_error, mock_api): 283 | # When get_euid blows up, we should hit the catch-all 284 | result = viewport.process_handler("myproc", action="check") 285 | assert result is False 286 | 287 | # log_error should be called with the right message and the exception 288 | mock_log_error.assert_called_once() 289 | msg, exc = mock_log_error.call_args[0] 290 | assert "Error while checking process 'myproc'" in msg 291 | assert isinstance(exc, RuntimeError) 292 | assert str(exc) == "oh no" 293 | 294 | # api_status should be notified too 295 | mock_api.assert_called_once_with("Error Checking Process 'myproc'") --------------------------------------------------------------------------------