diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 9afa87fb..aef464f5 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -106,6 +106,8 @@ The `.github/skills/` directory contains domain-specific knowledge files: | `coding-style.md` | Python/JS/CSS style conventions | | `workflows.md` | CLI usage, running tests, server mode, docs site | | `testing.md` | Test suite structure, pabot parallelism, how to add tests | +| `unit-tests.md` | Python unit tests (pytest, coverage, test layout, fixtures) | +| `js-unit-tests.md` | JavaScript unit tests (Vitest, mocking patterns, which modules are testable) | | `server-api.md` | All REST endpoints, authentication, log linking, auto-update behavior | | `filtering-and-settings.md` | Filter pipeline, settings object, localStorage persistence, layout/GridStack system | diff --git a/.github/skills/javascript-unit-tests.md b/.github/skills/javascript-unit-tests.md new file mode 100644 index 00000000..895d39b6 --- /dev/null +++ b/.github/skills/javascript-unit-tests.md @@ -0,0 +1,200 @@ +--- +name: js-unit-tests +description: 'Run, analyze, fix, and write JavaScript unit tests in tests/javascript/. Use when: working on, running, or reasoning about JS unit tests; JS tests are failing; adding tests for JS modules; debugging Vitest errors. DO NOT USE FOR: Python unit tests (tests/python/), acceptance tests (tests/robot/), or Robot Framework test suites.' +argument-hint: 'Optional: specific test file or test name to focus on' +--- + +# JavaScript Unit Tests + +## How to run + +**Windows:** +```bat +scripts\jstests.bat +``` + +**Linux / macOS:** +```bash +bash scripts/jstests.sh +``` + +Both scripts run `npx vitest run --reporter=verbose`. + +**Targeted runs:** +```bash +npx vitest run tests/javascript/graph_data/failed.test.js +npx vitest run --reporter=verbose -t "sorts by total failures" +``` + +## Framework and config + +- **Vitest 4.1.1** — test runner and assertion library (`describe`, `it`, `expect`, `vi`). +- **jsdom 29.0.1** — available as a devDependency for DOM-dependent tests (not currently used; environment is `node`). +- Config is in `vitest.config.js`. Key setting: the `@js` path alias resolves to `robotframework_dashboard/js/`, so `import '@js/variables/settings.js'` works the same in tests as in source modules. + +## Test layout + +``` +tests/javascript/ +├── mocks/ # shared mock modules (see below) +│ ├── chartconfig.js +│ ├── data.js +│ ├── globals.js +│ └── graphs.js +├── common.test.js +├── filter.test.js +├── localstorage.test.js +└── graph_data/ # one file per graph_data source module + ├── donut.test.js + ├── failed.test.js + ├── flaky.test.js + ├── graph_config.test.js + ├── helpers.test.js + ├── messages.test.js + ├── time_consuming.test.js + └── tooltip_helpers.test.js +``` + +| File | What it covers | +|---|---| +| `common.test.js` | `format_duration`, `strip_tz_suffix`, `format_name` | +| `filter.test.js` | `filter_data_by_name`, `filter_data_by_tag`, `merge_filter_profile`, `get_searchable_keys`, `convert_timezone` | +| `localstorage.test.js` | `merge_deep`, `merge_view`, `merge_view_section_or_graph`, `merge_theme_colors`, `merge_layout`, `collect_allowed_graphs` | +| `graph_data/helpers.test.js` | `convert_timeline_data` | +| `graph_data/tooltip_helpers.test.js` | `build_tooltip_meta`, `lookup_tooltip_meta`, `format_status` | +| `graph_data/failed.test.js` | `get_most_failed_data` (bar + timeline modes) | +| `graph_data/flaky.test.js` | `get_most_flaky_data` (bar + timeline modes) | +| `graph_data/donut.test.js` | `get_donut_graph_data`, `get_donut_total_graph_data` | +| `graph_data/time_consuming.test.js` | `get_most_time_consuming_or_most_used_data` | +| `graph_data/messages.test.js` | `get_messages_data` (bar + timeline modes) | +| `graph_data/graph_config.test.js` | `get_graph_config` (bar, line, timeline, boxplot, radar, common options) | + +## What is testable + +Only **pure functions** (no DOM access, no Chart.js instances, no DataTables) should be tested with Vitest. Many JS modules in this project create or manipulate DOM elements and cannot be unit-tested in a `node` environment: + +| Classification | Examples | +|---|---| +| **Pure / testable** | `common.js`, `filter.js`, `localstorage.js`, `graph_data/tooltip_helpers.js`, `graph_data/failed.js`, `graph_data/flaky.js`, `graph_data/donut.js`, `graph_data/messages.js`, `graph_data/time_consuming.js`, `graph_data/graph_config.js`, `graph_data/helpers.js` | +| **DOM-dependent / not testable** | `graph_data/statistics.js`, `graph_data/duration.js`, `graph_data/heatmap.js`, `graph_data/duration_deviation.js`, `graph_creation/*.js`, `js/main.js`, `js/admin_page/*.js` | + +If a module has **some** pure functions and **some** DOM-dependent functions, test only the pure ones. Do not attempt to mock `document`, `window.Chart`, or DataTables. + +## Mocking pattern + +Every `graph_data` module imports globals (`settings`, `globals`, `chartconfig`, etc.). These must be mocked with `vi.mock()` **at the top of each test file, before importing the module under test**. + +### Inline mocks (preferred for graph_data tests) + +```js +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@js/variables/settings.js', () => ({ + settings: { + switch: { useLibraryNames: false, suitePathsSuiteSection: false, suitePathsTestSection: false }, + show: { aliases: false, rounding: 6 }, + }, +})); +vi.mock('@js/variables/globals.js', () => ({ + inFullscreen: false, + inFullscreenGraph: '', +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + failedConfig: { backgroundColor: 'rgba(206,62,1,0.7)', borderColor: '#ce3e01' }, +})); + +// Import AFTER vi.mock calls +import { get_most_failed_data } from '@js/graph_data/failed.js'; +``` + +### Shared mocks (`tests/javascript/mocks/`) + +Four reusable mock modules exist in `tests/javascript/mocks/` for modules that are widely imported: + +| Mock file | Replaces | Key exports | +|---|---|---| +| `data.js` | `@js/variables/data.js` | `runs`, `suites`, `tests`, `keywords` arrays | +| `globals.js` | `@js/variables/globals.js` | Global state variables (`inFullscreen`, grid refs, etc.) | +| `chartconfig.js` | `@js/variables/chartconfig.js` | Color config objects (e.g. `failedConfig`, `passedConfig`) | +| `graphs.js` | `@js/variables/graphs.js` | `overview_graphs`, `run_graphs`, etc. | + +To use a shared mock: +```js +vi.mock('@js/variables/data.js', async () => import('../mocks/data.js')); +``` + +### When to use inline vs shared mocks + +- **Inline** — when the test needs specific settings values or only a few exports. Most `graph_data` tests use inline mocks because each test file needs a tailored `settings` shape. +- **Shared** — when you need a full, realistic copy of the module's exports and don't need to customize values per test. + +### Mocking helper modules + +Some `graph_data` modules import functions from sibling helpers (e.g. `convert_timeline_data` from `helpers.js`). Mock these with simplified implementations: + +```js +vi.mock('@js/graph_data/helpers.js', () => ({ + convert_timeline_data: (datasets) => { + const grouped = {}; + for (const ds of datasets) { + const key = `${ds.label}::${ds.backgroundColor}`; + if (!grouped[key]) grouped[key] = { label: ds.label, data: [], backgroundColor: ds.backgroundColor }; + grouped[key].data.push(...ds.data); + } + return Object.values(grouped); + }, +})); +``` + +## Mutating mock state in tests + +When a test needs to change a mocked module's state (e.g. toggle `settings.switch.useLibraryNames`), import the mock object and mutate it directly inside the test. `vi.mock` factory functions return the same object reference for all importers: + +```js +import { settings } from '@js/variables/settings.js'; + +it('uses library names when enabled', () => { + settings.switch.useLibraryNames = true; + // ... run function under test ... + settings.switch.useLibraryNames = false; // restore +}); +``` + +Use `beforeEach` to reset mutable state when multiple tests share the same mock. + +## Writing a new test file + +1. Create the test file in `tests/javascript/` (or `tests/javascript/graph_data/` for graph data modules). +2. Add `vi.mock()` calls for every import of the source module. +3. Import the function(s) under test **after** the mock calls. +4. Write `describe`/`it` blocks with clear descriptions. +5. Run `npx vitest run ` to verify. + +### Non-exported helper functions + +Some source modules have local helper functions that are not exported. If you need to test one, **re-implement it directly in the test file** rather than modifying the source module's exports. This keeps the production API surface unchanged: + +```js +// Re-implement non-exported helper for testing +function format_status(status) { + if (status === true || status === 'PASS') return 'PASS'; + if (status === false || status === 'FAIL') return 'FAIL'; + if (status === 'SKIP') return 'SKIP'; + return status; +} +``` + +## CI integration + +JS unit tests run as a separate `js-unit-tests` job in `.github/workflows/tests.yml` using Node.js 24. The `robot-tests` job declares `needs: [unit-tests, js-unit-tests]`, so acceptance tests are skipped if either Python or JS unit tests fail. + +## Analyzing and fixing failures + +| Failure pattern | Likely cause | Action | +|---|---|---| +| `ReferenceError: X is not defined` | Missing `vi.mock()` for a dependency | Add the mock for the missing module | +| `TypeError: X is not a function` | Mock doesn't export the needed function | Update the mock factory to include it | +| `AssertionError` on a changed value | Source logic changed intentionally | Verify the change is correct; update expected values | +| `AssertionError` on a value that shouldn't change | Regression in source code | Fix the source code | +| `Cannot find module '@js/...'` | Path alias issue or renamed file | Check `vitest.config.js` alias and source file paths | +| Mock state leaking between tests | Mutable mock object not reset | Add `beforeEach` to reset mock state | diff --git a/.github/skills/unit-tests.md b/.github/skills/python-unit-tests.md similarity index 62% rename from .github/skills/unit-tests.md rename to .github/skills/python-unit-tests.md index 01f8006b..89db795e 100644 --- a/.github/skills/unit-tests.md +++ b/.github/skills/python-unit-tests.md @@ -1,6 +1,6 @@ --- name: unit-tests -description: 'Run, analyze, fix, and report on the Python unit tests in tests/. Use when: working on, running, or reasoning about unit tests; tests are failing; CI is red; debugging test errors; adding a missing argument to a test helper after a new parameter was introduced. DO NOT USE FOR: acceptance tests (atest/), Robot Framework test suites.' +description: 'Run, analyze, fix, and report on the Python unit tests in tests/python/. Use when: working on, running, or reasoning about unit tests; tests are failing; CI is red; debugging test errors; adding a missing argument to a test helper after a new parameter was introduced. DO NOT USE FOR: acceptance tests (tests/robot/), Robot Framework test suites.' argument-hint: 'Optional: specific test file or test name to focus on' --- @@ -22,40 +22,40 @@ Both scripts run `pytest` with coverage reporting on the `robotframework_dashboa **Targeted runs:** ```bash -python -m pytest tests/test_server.py --tb=short -python -m pytest tests/test_dashboard.py::test_generate_dashboard_creates_file --tb=short +python -m pytest tests/python/test_server.py --tb=short +python -m pytest tests/python/test_dashboard.py::test_generate_dashboard_creates_file --tb=short ``` **Coverage only (no script):** ```bash -python -m pytest tests/ --cov=robotframework_dashboard --cov-report=term-missing --cov-report=html:results/coverage +python -m pytest tests/python/ --cov=robotframework_dashboard --cov-report=term-missing --cov-report=html:results/coverage ``` ## Test layout -All unit tests live flat in `tests/` — no subdirectories. +All unit tests live flat in `tests/python/` — no subdirectories. | File | What it covers | |---|---| -| `tests/conftest.py` | Shared pytest fixtures (XML paths, `OutputProcessor`, `DatabaseProcessor`) | -| `tests/test_arguments.py` | `dotdict`, `_normalize_bool`, `_check_project_version_usage`, `_process_arguments` (all branches), `get_arguments` via mocked `sys.argv` | -| `tests/test_processors.py` | `OutputProcessor`: `get_run_start`, `get_output_data`, `calculate_keyword_averages`, `merge_run_and_suite_metadata`; legacy RF compat branches (`RunProcessor`, `SuiteProcessor`, `TestProcessor`, `KeywordProcessor`) | -| `tests/test_database.py` | `DatabaseProcessor`: table creation, schema migration, insert/get round-trip, all `remove_runs` strategies, `list_runs`, `vacuum_database`, `update_output_path`, static helpers | -| `tests/test_dashboard.py` | `DashboardGenerator`: `_compress_and_encode`, `_minify_text`, `generate_dashboard` (file creation, title, server mode, configs, subdirectory) | -| `tests/test_dependencies.py` | `DependencyProcessor`: JS/CSS block generation, CDN vs offline mode, admin-page variant, file gathering | -| `tests/test_robotdashboard.py` | `RobotDashboard`: `initialize_database`, `process_outputs`, `print_runs`, `remove_outputs`, `create_dashboard`, `get_runs`, `get_run_paths`, `update_output_path` | -| `tests/test_main.py` | `main()`: orchestration pipeline via mocked `ArgumentParser` and `RobotDashboard`; server branch | -| `tests/test_server.py` | `ApiServer`: all FastAPI endpoints via `TestClient` — auth, add/remove outputs, add/remove logs, file uploads (plain and gzip), catch-all resource route, autoupdate flag | +| `tests/python/conftest.py` | Shared pytest fixtures (XML paths, `OutputProcessor`, `DatabaseProcessor`) | +| `tests/python/test_arguments.py` | `dotdict`, `_normalize_bool`, `_check_project_version_usage`, `_process_arguments` (all branches), `get_arguments` via mocked `sys.argv` | +| `tests/python/test_processors.py` | `OutputProcessor`: `get_run_start`, `get_output_data`, `calculate_keyword_averages`, `merge_run_and_suite_metadata`; legacy RF compat branches (`RunProcessor`, `SuiteProcessor`, `TestProcessor`, `KeywordProcessor`) | +| `tests/python/test_database.py` | `DatabaseProcessor`: table creation, schema migration, insert/get round-trip, all `remove_runs` strategies, `list_runs`, `vacuum_database`, `update_output_path`, static helpers | +| `tests/python/test_dashboard.py` | `DashboardGenerator`: `_compress_and_encode`, `_minify_text`, `generate_dashboard` (file creation, title, server mode, configs, subdirectory) | +| `tests/python/test_dependencies.py` | `DependencyProcessor`: JS/CSS block generation, CDN vs offline mode, admin-page variant, file gathering | +| `tests/python/test_robotdashboard.py` | `RobotDashboard`: `initialize_database`, `process_outputs`, `print_runs`, `remove_outputs`, `create_dashboard`, `get_runs`, `get_run_paths`, `update_output_path` | +| `tests/python/test_main.py` | `main()`: orchestration pipeline via mocked `ArgumentParser` and `RobotDashboard`; server branch | +| `tests/python/test_server.py` | `ApiServer`: all FastAPI endpoints via `TestClient` — auth, add/remove outputs, add/remove logs, file uploads (plain and gzip), catch-all resource route, autoupdate flag | ## Test data -Real `output.xml` files live in `testdata/outputs/`. These are the same 15 Robot Framework output files used by the acceptance tests — no synthetic mocks. Using real XMLs means `OutputProcessor` and `DatabaseProcessor` are exercised against genuine data, not fabricated inputs. +Real `output.xml` files live in `tests/robot/resources/outputs/`. These are the same 15 Robot Framework output files used by the acceptance tests — no synthetic mocks. Using real XMLs means `OutputProcessor` and `DatabaseProcessor` are exercised against genuine data, not fabricated inputs. Inline data fixtures (plain Python tuples/dicts) are used only for edge cases that real XMLs cannot cover, such as malformed inputs and single-entry keyword lists. ## Testing the server -`tests/test_server.py` uses `fastapi.testclient.TestClient` (backed by `httpx`) to exercise every endpoint in `ApiServer` without starting a real network process. The `RobotDashboard` dependency is replaced with a `MagicMock`, making every test fast and deterministic. `httpx` is a required dev dependency — install it with `pip install httpx`. +`tests/python/test_server.py` uses `fastapi.testclient.TestClient` (backed by `httpx`) to exercise every endpoint in `ApiServer` without starting a real network process. The `RobotDashboard` dependency is replaced with a `MagicMock`, making every test fast and deterministic. `httpx` is a required dev dependency — install it with `pip install httpx`. Key patterns used: - `_make_server()` helper creates an `ApiServer` with mock `RobotDashboard` attached. diff --git a/.github/skills/testing.md b/.github/skills/robotframework-tests.md similarity index 63% rename from .github/skills/testing.md rename to .github/skills/robotframework-tests.md index 1460799d..09e48c12 100644 --- a/.github/skills/testing.md +++ b/.github/skills/robotframework-tests.md @@ -6,7 +6,7 @@ description: Use when running tests, adding new tests, understanding how the tes ## Overview -All tests are **Robot Framework acceptance tests** run with **pabot**. There are no pytest or unittest files. Tests live in `atest/testsuites/`. +All tests are **Robot Framework acceptance tests** run with **pabot**. Tests live in `tests/robot/`. --- @@ -14,10 +14,10 @@ All tests are **Robot Framework acceptance tests** run with **pabot**. There are ```bash # Windows -pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results .\atest\testsuites\*.robot +pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results .\tests\robot\*.robot # Linux / macOS -pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results atest/testsuites/*.robot +pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results tests/robot/*.robot ``` Convenience scripts: `scripts/tests.bat` and `scripts/tests.sh`. @@ -45,8 +45,8 @@ Key pabot flags: | Suite | What it tests | Method | |---|---|---| -| `00_cli.robot` | Every CLI flag (short + long form) | Runs `robotdashboard` as a subprocess, checks stdout/files against `atest/resources/cli_output/` | -| `01_database.robot` | SQLite table contents after parsing | Queries the DB via DatabaseLibrary, compares rows against `atest/resources/database_output/` | +| `00_cli.robot` | Every CLI flag (short + long form) | Runs `robotdashboard` as a subprocess, checks stdout/files against `tests/robot/resources/cli_output/` | +| `01_database.robot` | SQLite table contents after parsing | Queries the DB via DatabaseLibrary, compares rows against `tests/robot/resources/database_output/` | | `02_overview.robot` | Overview page rendering | Browser screenshot diff vs. reference images | | `03_dashboard.robot` | Dashboard tab charts/layout | Browser screenshot diff | | `04_compare.robot` | Compare page | Browser screenshot diff | @@ -54,7 +54,7 @@ Key pabot flags: | `06_filters.robot` | Filter modal behavior | Browser interactions + screenshot diff | | `07_settings.robot` | Settings modal behavior | Browser interactions + screenshot diff | -`atest/testsuites/__init__.robot` is the **suite init** — it detects OS at suite setup (stored as global variable), sets a 60-second global test timeout, and runs `Remove Index` + `Move All Screenshots` as a single teardown (`Run Teardown Only Once`). +`tests/robot/testsuites/__init__.robot` is the **suite init** — it detects OS at suite setup (stored as global variable), sets a 60-second global test timeout, and runs `Remove Index` + `Move All Screenshots` as a single teardown (`Run Teardown Only Once`). --- @@ -62,8 +62,8 @@ Key pabot flags: Because tests run in parallel and each test needs its own `.db` and `.html` files, the test infrastructure uses an **atomic counter**: -- `atest/resources/keywords/general-keywords.resource` provides `Get Dashboard Index` -- Uses `Acquire Lock` (pabot lock) to atomically read/write `atest/resources/index.txt` +- `tests/robot/resources/keywords/general-keywords.resource` provides `Get Dashboard Index` +- Uses `Acquire Lock` (pabot lock) to atomically read/write `tests/robot/resources/index.txt` - Each test gets a unique integer N, and works with `robotresults_N.db` + `robotdashboard_N.html` - `Remove Database And Dashboard With Index` cleans up both files in teardown @@ -73,7 +73,7 @@ Because tests run in parallel and each test needs its own `.db` and `.html` file - Each flag has two tests: short form (`-x`) and long form (`--flag`) - Uses `Validate CLI` keyword: runs `robotdashboard ` via `Run` (OperatingSystem library) -- Compares output against expected files in `atest/resources/cli_output/` +- Compares output against expected files in `tests/robot/resources/cli_output/` - Server tests (`-s`) are currently skipped - OS-specific tests (e.g. `-c` custom DB class) skip on non-Windows @@ -83,7 +83,7 @@ Because tests run in parallel and each test needs its own `.db` and `.html` file - Test Setup generates a fresh dashboard and opens a SQLite connection - Tests query each of the four tables (`runs`, `suites`, `tests`, `keywords`) via `DatabaseLibrary` -- Result strings are normalized (path separators, timezone offsets) via regex before comparison against `atest/resources/database_output/` reference files +- Result strings are normalized (path separators, timezone offsets) via regex before comparison against `tests/robot/resources/database_output/` reference files --- @@ -92,7 +92,7 @@ Because tests run in parallel and each test needs its own `.db` and `.html` file - Use `robotframework-browser` (Playwright, headless Chromium) - Open a generated `robotdashboard_N.html` via `file://` URL (no server needed) - Interact with UI (clicks, fills, waits for elements) -- Take screenshots and compare with reference images in `atest/resources/dashboard_output/` +- Take screenshots and compare with reference images in `tests/robot/resources/dashboard_output/` - Comparison uses `DocTest.VisualTest` at **98% accuracy** (`threshold=0.02`) - Screenshots per worker are moved to `results/screenshots/` in the suite teardown so they appear in `log.html` @@ -102,19 +102,19 @@ Because tests run in parallel and each test needs its own `.db` and `.html` file | File | Contents | |---|---| -| `atest/resources/keywords/general-keywords.resource` | `Get Dashboard Index`, `Generate Dashboard`, `Remove Database And Dashboard With Index` | -| `atest/resources/keywords/database-keywords.resource` | DB connection helpers, normalization, row comparison | -| `atest/resources/keywords/dashboard-keywords.resource` | Browser lifecycle, filter helpers (`Set Run Filter`, `Set Date Filter`, `Set Amount Filter`), screenshot comparison, `Change Settings` | -| `atest/resources/outputs/` | Input `output.xml` files used as test fixtures | -| `atest/resources/cli_output/` | Expected CLI output reference files | -| `atest/resources/database_output/` | Expected DB row reference files | -| `atest/resources/dashboard_output/` | Reference screenshots for visual comparison | +| `tests/robot/resources/keywords/general-keywords.resource` | `Get Dashboard Index`, `Generate Dashboard`, `Remove Database And Dashboard With Index` | +| `tests/robot/resources/keywords/database-keywords.resource` | DB connection helpers, normalization, row comparison | +| `tests/robot/resources/keywords/dashboard-keywords.resource` | Browser lifecycle, filter helpers (`Set Run Filter`, `Set Date Filter`, `Set Amount Filter`), screenshot comparison, `Change Settings` | +| `tests/robot/resources/outputs/` | Input `output.xml` files used as test fixtures | +| `tests/robot/resources/cli_output/` | Expected CLI output reference files | +| `tests/robot/resources/database_output/` | Expected DB row reference files | +| `tests/robot/resources/dashboard_output/` | Reference screenshots for visual comparison | --- ## Adding a New Test -1. **CLI test**: add two test cases in `00_cli.robot` (short + long form), add expected output file to `atest/resources/cli_output/` -2. **Database test**: add test case in `01_database.robot`, add reference row file to `atest/resources/database_output/` -3. **Browser test**: add test case in the relevant suite (`02–07`), capture a reference screenshot and add to `atest/resources/dashboard_output/` +1. **CLI test**: add two test cases in `00_cli.robot` (short + long form), add expected output file to `tests/robot/resources/cli_output/` +2. **Database test**: add test case in `01_database.robot`, add reference row file to `tests/robot/resources/database_output/` +3. **Browser test**: add test case in the relevant suite (`02–07`), capture a reference screenshot and add to `tests/robot/resources/dashboard_output/` 4. All tests should use `Get Dashboard Index` for a unique file suffix to stay parallel-safe diff --git a/.github/skills/workflows.md b/.github/skills/workflows.md index 4a121ec2..a65b88d3 100644 --- a/.github/skills/workflows.md +++ b/.github/skills/workflows.md @@ -13,10 +13,10 @@ All tests are Robot Framework acceptance tests run with **pabot** (parallel exec ```bash # Windows -pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results .\atest\testsuites\*.robot +pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results .\tests\robot\testsuites\*.robot # Linux / macOS -pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results atest/testsuites/*.robot +pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results tests/robot/testsuites/*.robot ``` Convenience scripts: `scripts/tests.bat` and `scripts/tests.sh`. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 09be4bc6..b3e404c0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,10 +1,10 @@ -name: Robot Tests +name: Robotdashboard Tests on: pull_request: jobs: - unit-tests: + python-tests: runs-on: ubuntu-latest steps: @@ -21,18 +21,37 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-dev.txt - - name: Run unit tests + - name: Run Python unit tests run: | - bash scripts/unittests.sh + bash scripts/python-tests.sh - name: Upload coverage report uses: actions/upload-artifact@v4 with: name: coverage-report - path: coverage.xml + path: results/coverage.xml + + javascript-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + + - name: Install dependencies + run: npm ci + + - name: Run JavaScript unit tests + run: | + bash scripts/javascript-tests.sh robot-tests: - needs: unit-tests + needs: [python-tests, javascript-tests] runs-on: ubuntu-latest container: image: mcr.microsoft.com/playwright:v1.56.0-jammy @@ -86,10 +105,10 @@ jobs: run: | rfbrowser init - - name: Run Robot tests + - name: Run Robot Framework tests id: robot run: | - bash scripts/tests.sh + bash scripts/robot-tests.sh continue-on-error: true # allows workflow to continue even if some tests fail - name: Upload Robot logs diff --git a/.gitignore b/.gitignore index 71755afc..4053dcc0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,43 @@ -results -logs +# General output and results +results/ +logs/ +robot_logs/ +index.txt +robot_results.db + +# Python bytecode and cache +__pycache__/ *.pyc +.pytest_cache/ + +# Build and distribution +dist/ +build/ +robotframework_dashboard.egg-info/ + +# Test and coverage outputs .coverage -__pycache__ -dist -build -robotframework_dashboard.egg-info -robot_results.db -robotdashboard*.html -robot_dashboard*.html -robot_logs -index.txt .pabotsuitenames + +# Database files *.db -*.vscode + +# HTML reports and dashboards log.html output.xml report.html +robotdashboard*.html +robot_dashboard*.html + +# Browser and Playwright outputs browser/ playwright-log.txt screenshots/ -# docs entries -node_modules -docs/.vitepress/dist -docs/.vitepress/cache +# Editor and tool settings +*.vscode/ + +# Node and docs build artifacts +node_modules/ +docs/.vitepress/dist/ +docs/.vitepress/cache/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d57f83f..5f3094f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,13 +16,36 @@ robotdashboard -n robot_dashboard.html ``` ## ✅ Tests -Tests are located in `atest`. There are different tests for the different parts of the tool. +There are three levels of tests in this project: +### Python Unit Tests +Python unit tests are located in `tests/python/` and run with pytest. +```sh +bash scripts/unittests.sh +``` + +### JavaScript Unit Tests +JavaScript unit tests are located in `tests/javascript/` and run with [Vitest](https://vitest.dev/). +```sh +npm install +npm run test:js +``` +Or on Windows: +``` +scripts\jstests.bat +``` +To run in watch mode during development: +```sh +npm run test:js:watch +``` + +### Robot Framework End-to-End Tests +End-to-end tests are located in `tests/robot/` and cover: - CLI - Database - Dashboard -Tests will run automatically in GitHub actions. They are triggered through the `.github/workflows/tests.yml` yml script. In this script details regarding the test pipeline can be found. The tests will run when: +All tests run automatically in GitHub Actions. They are triggered through the `.github/workflows/tests.yml` yml script. In this script details regarding the test pipeline can be found. The tests will run when: - Creating a PR - Pushing a commit to a PR diff --git a/atest/resources/keywords/general-keywords.resource b/atest/resources/keywords/general-keywords.resource deleted file mode 100644 index 16d5c88d..00000000 --- a/atest/resources/keywords/general-keywords.resource +++ /dev/null @@ -1,56 +0,0 @@ -*** Settings *** -Library OperatingSystem -Library String -Library pabot.pabotlib -Resource ../variables/variables.resource - - -*** Keywords *** -Get Dashboard Index - Acquire Lock name=dashboard_index - ${exists} Run Keyword And Return Status Should Exist path=index.txt - IF not ${exists} - Create File path=index.txt content=1:${TEST_NAME} - RETURN ${1} - END - ${index} Get File path=index.txt - ${index} Split String string=${index} separator=: - ${index} Convert To Integer ${index}[0] - ${index} Evaluate ${index} + 1 - Create File path=index.txt content=${index}:${TEST_NAME} - RETURN ${index} - -Generate Dashboard - ${index} Get Dashboard Index - Release Lock name=dashboard_index - VAR ${DASHBOARD_INDEX} ${index} scope=test - ${files} Catenate SEPARATOR=${SPACE} - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002134.xml:prod:project_1:version_1.0 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002151.xml:dev:project_1:version_1.0 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002222.xml:prod:project_2:version_1.0 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002257.xml:dev:project_2:version_1.1 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002338.xml:prod:project_1:version_1.1 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002400.xml:dev:project_1:version_1.1 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002431.xml:prod:project_2:version_1.1 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002457.xml:dev:project_2:version_1.1 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002528.xml:prod:project_1:version_1.1 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002549.xml:prod:project_1:version_1.2 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002636.xml:prod:project_2:version_1.2 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002703.xml:prod:project_2:version_1.2 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002739.xml:dev:project_1:version_1.2 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-002915.xml:dev:project_1:version_1.2 - ... -o ${CURDIR}/../../../testdata/outputs/output-20250313-003006.xml:dev:project_2:amount:version_1.2 - ${output} Run command=robotdashboard -d robotresults_${DASHBOARD_INDEX}.db ${files} -n robotdashboard_${DASHBOARD_INDEX}.html - Log ${output} - -Remove Database And Dashboard - ${files} List Files In Directory path=${CURDIR}/../../.. - FOR ${file} IN @{files} - IF ('.db' in $file or '.html' in $file) and not ('robotresults_' in $file or 'robotdashboard_' in $file) - Remove File path=${file} - END - END - -Remove Database And Dashboard With Index - Remove File path=robotresults_${DASHBOARD_INDEX}.db - Remove File path=robotdashboard_${DASHBOARD_INDEX}.html diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 12dfc3cc..64833d3f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -124,7 +124,7 @@ export default defineConfig({ res.end(html); return; } - if (normalizedUrl && normalizedUrl.startsWith('/atest/resources/outputs/')) { + if (normalizedUrl && normalizedUrl.startsWith('/tests/robot/resources/outputs/')) { const relativePath = normalizedUrl.replace(/^\//, ''); const filePath = resolve(process.cwd(), relativePath); try { diff --git a/package-lock.json b/package-lock.json index a422f4d0..cb0012f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4,10 +4,54 @@ "requires": true, "packages": { "": { + "name": "robotframework-dashboard", "devDependencies": { - "vitepress": "^2.0.0-alpha.13" + "jsdom": "^29.0.1", + "vitepress": "^2.0.0-alpha.13", + "vitest": "^4.1.1" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -58,6 +102,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@docsearch/css": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.3.2.tgz", @@ -517,6 +714,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@iconify-json/simple-icons": { "version": "1.2.58", "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.58.tgz", @@ -983,6 +1198,31 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1073,6 +1313,129 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.1.tgz", + "integrity": "sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.1.tgz", + "integrity": "sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.1", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.1.tgz", + "integrity": "sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.1.tgz", + "integrity": "sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.1", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.1.tgz", + "integrity": "sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "@vitest/utils": "4.1.1", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.1.tgz", + "integrity": "sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.1.tgz", + "integrity": "sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.1", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vue/compiler-core": { "version": "3.5.24", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz", @@ -1326,6 +1689,26 @@ "vue": "^3.5.0" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/birpc": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.8.0.tgz", @@ -1347,6 +1730,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities-html4": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", @@ -1380,6 +1773,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-anything": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", @@ -1396,6 +1796,20 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.2.tgz", @@ -1403,6 +1817,27 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1440,6 +1875,13 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1489,6 +1931,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1513,7 +1965,6 @@ "integrity": "sha512-v/Z8bvMCajtx4mEXmOo7QEsIzlIOqRXTIwgUfsFOF9gEsespdbD0AkPIka1bSXZ8Y8oZ+2IVDQZePkTfEHZl7Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "tabbable": "^6.3.0" } @@ -1585,6 +2036,19 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -1596,6 +2060,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-what": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", @@ -1609,6 +2080,57 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1648,6 +2170,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -1775,6 +2304,17 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/oniguruma-parser": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", @@ -1794,6 +2334,39 @@ "regex-recursion": "^6.0.2" } }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/perfect-debounce": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.0.0.tgz", @@ -1809,12 +2382,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1862,6 +2434,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/regex/-/regex-6.0.1.tgz", @@ -1889,6 +2471,16 @@ "dev": true, "license": "MIT" }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -1941,6 +2533,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/shiki": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/shiki/-/shiki-3.15.0.tgz", @@ -1958,6 +2563,13 @@ "@types/hast": "^3.0.4" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1989,6 +2601,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -2017,6 +2643,13 @@ "node": ">=16" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tabbable": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", @@ -2024,6 +2657,23 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -2041,6 +2691,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -2052,6 +2758,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -2161,7 +2877,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2276,13 +2991,94 @@ } } }, + "node_modules/vitest": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.1.tgz", + "integrity": "sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.1", + "@vitest/mocker": "4.1.1", + "@vitest/pretty-format": "4.1.1", + "@vitest/runner": "4.1.1", + "@vitest/snapshot": "4.1.1", + "@vitest/spy": "4.1.1", + "@vitest/utils": "4.1.1", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.1", + "@vitest/browser-preview": "4.1.1", + "@vitest/browser-webdriverio": "4.1.1", + "@vitest/ui": "4.1.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vue": { "version": "3.5.24", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz", "integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.24", "@vue/compiler-sfc": "3.5.24", @@ -2299,6 +3095,88 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 2ea756ec..8e848a98 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,13 @@ { "devDependencies": { - "vitepress": "^2.0.0-alpha.13" + "jsdom": "^29.0.1", + "vitepress": "^2.0.0-alpha.13", + "vitest": "^4.1.1" }, "scripts": { + "test:js": "vitest run --reporter=verbose", + "test:js:watch": "vitest", + "test:js:coverage": "vitest run --coverage", "docs:dev": "vitepress dev docs", "docs:build": "node scripts/copy-static.mjs && vitepress build docs", "docs:preview": "vitepress preview docs" diff --git a/scripts/copy-static.mjs b/scripts/copy-static.mjs index 84fbf6be..91f9100c 100644 --- a/scripts/copy-static.mjs +++ b/scripts/copy-static.mjs @@ -41,4 +41,4 @@ function copyHtmlOnly(srcRel, destRel) { copyFileRelative('example/robot_dashboard.html', 'docs/public/example/robot_dashboard.html'); // Copy only HTML files from Robot Framework outputs into VitePress public -copyHtmlOnly('atest/resources/outputs', 'docs/public/atest/resources/outputs'); +copyHtmlOnly('tests/robot/resources/outputs', 'docs/public/tests/robot/resources/outputs'); diff --git a/scripts/example.bat b/scripts/example.bat index f93999aa..b7ee7aeb 100644 --- a/scripts/example.bat +++ b/scripts/example.bat @@ -1,15 +1,15 @@ -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002134.xml:prod:project_1 --projectversion 0.1 --timezone=+07:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002151.xml:dev:project_2 --projectversion 0.1 --timezone=+05:30 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002222.xml:prod:project_1 --projectversion 0.1 --timezone=+05:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002257.xml:dev:project_2 --projectversion 0.1 -z +02:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002338.xml:prod:project_1 --projectversion 1.1 --timezone=+02:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002400.xml:dev:project_2 --projectversion 1.2 --timezone=+00:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002431.xml:prod:project_1 --projectversion 1.2 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002457.xml:dev:project_2 --projectversion 1.2 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002528.xml:prod:project_1 --projectversion 2.0 --timezone=-03:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002549.xml:dev:project_2 --projectversion 2.3 --timezone=-05:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002636.xml:prod:project_1 --projectversion 2.3 --timezone=-05:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002703.xml:dev:project_2 --projectversion 2.3 --timezone=-07:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002739.xml:prod:project_1 --timezone=-07:00 -robotdashboard -g -l -o .\atest\resources\outputs\output-20250313-002915.xml:prod:project_1 --projectversion 2.0 --timezone=-08:00 -robotdashboard -n robot_dashboard -o .\atest\resources\outputs\output-20250313-003006.xml:prod:project_1 --uselogs --timezone=-09:00 \ No newline at end of file +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002134.xml:prod:project_1 --projectversion 0.1 --timezone=+07:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002151.xml:dev:project_2 --projectversion 0.1 --timezone=+05:30 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002222.xml:prod:project_1 --projectversion 0.1 --timezone=+05:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002257.xml:dev:project_2 --projectversion 0.1 -z +02:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002338.xml:prod:project_1 --projectversion 1.1 --timezone=+02:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002400.xml:dev:project_2 --projectversion 1.2 --timezone=+00:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002431.xml:prod:project_1 --projectversion 1.2 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002457.xml:dev:project_2 --projectversion 1.2 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002528.xml:prod:project_1 --projectversion 2.0 --timezone=-03:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002549.xml:dev:project_2 --projectversion 2.3 --timezone=-05:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002636.xml:prod:project_1 --projectversion 2.3 --timezone=-05:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002703.xml:dev:project_2 --projectversion 2.3 --timezone=-07:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002739.xml:prod:project_1 --timezone=-07:00 +robotdashboard -g -l -o .\tests\robot\resources\outputs\output-20250313-002915.xml:prod:project_1 --projectversion 2.0 --timezone=-08:00 +robotdashboard -n robot_dashboard -o .\tests\robot\resources\outputs\output-20250313-003006.xml:prod:project_1 --uselogs --timezone=-09:00 diff --git a/scripts/javascript-tests.bat b/scripts/javascript-tests.bat new file mode 100644 index 00000000..c0fdfab2 --- /dev/null +++ b/scripts/javascript-tests.bat @@ -0,0 +1,3 @@ +@echo off +REM Run all JavaScript unit tests in tests/javascript/ +npx vitest run --reporter=verbose diff --git a/scripts/javascript-tests.sh b/scripts/javascript-tests.sh new file mode 100644 index 00000000..3657ea17 --- /dev/null +++ b/scripts/javascript-tests.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +# Run all JavaScript unit tests in tests/javascript/ +npx vitest run --reporter=verbose diff --git a/scripts/local-test-env.sh b/scripts/local-test-env.sh index b8f6f5ab..e55165e0 100644 --- a/scripts/local-test-env.sh +++ b/scripts/local-test-env.sh @@ -3,7 +3,7 @@ # Replaces all language related settings to "C.utf8" # Either to the sourced or to be called with some command given as argument # . ./local-test-env.sh -# bash local-test-env.sh robot tests/*.robot +# bash local-test-env.sh robot tests/robot/testsuites/*.robot for e in $(env | grep -E 'LC_|LANG' | cut -d= -f 1); do unset $e diff --git a/scripts/unittests.bat b/scripts/python-tests.bat similarity index 76% rename from scripts/unittests.bat rename to scripts/python-tests.bat index 57b81cb6..bd844d42 100644 --- a/scripts/unittests.bat +++ b/scripts/python-tests.bat @@ -1,3 +1,5 @@ +@echo off +REM Run all Python unit tests in tests/python/ and collect coverage set COVERAGE_FILE=results/.coverage set PYTHONPATH=%~dp0.. if not exist results mkdir results diff --git a/scripts/unittests.sh b/scripts/python-tests.sh similarity index 78% rename from scripts/unittests.sh rename to scripts/python-tests.sh index 63cb4c67..3871586d 100644 --- a/scripts/unittests.sh +++ b/scripts/python-tests.sh @@ -3,4 +3,5 @@ set -e SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" mkdir -p results results/coverage -COVERAGE_FILE=results/.coverage PYTHONPATH="$SCRIPT_DIR/.." python -m pytest tests/ --cov=robotframework_dashboard --cov-report=term-missing --cov-report=html:results/coverage --cov-report=xml:coverage.xml +# Run all Python unit tests in tests/python/ and collect coverage +COVERAGE_FILE=results/.coverage PYTHONPATH="$SCRIPT_DIR/.." python -m pytest tests/ --cov=robotframework_dashboard --cov-report=term-missing --cov-report=html:results/coverage --cov-report=xml:results/coverage.xml diff --git a/scripts/robot-tests.bat b/scripts/robot-tests.bat new file mode 100644 index 00000000..25011218 --- /dev/null +++ b/scripts/robot-tests.bat @@ -0,0 +1,3 @@ +@echo off +REM Run all Robot Framework tests in tests/robot/testsuites/ +pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results .\tests\robot\testsuites\*.robot diff --git a/scripts/tests.sh b/scripts/robot-tests.sh similarity index 63% rename from scripts/tests.sh rename to scripts/robot-tests.sh index 130802e5..a2cd3328 100644 --- a/scripts/tests.sh +++ b/scripts/robot-tests.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -e +# Run all Robot Framework tests in tests/robot/testsuites/ pabot \ --pabotlib \ --testlevelsplit \ @@ -8,4 +9,4 @@ pabot \ --artifactsinsubfolders \ --processes 2 \ -d results \ - atest/testsuites/*.robot \ No newline at end of file + tests/robot/testsuites/*.robot diff --git a/scripts/tests.bat b/scripts/tests.bat deleted file mode 100644 index 566cd422..00000000 --- a/scripts/tests.bat +++ /dev/null @@ -1 +0,0 @@ -pabot --pabotlib --testlevelsplit --artifacts png,jpg --artifactsinsubfolders --processes 2 -d results .\atest\testsuites\*.robot \ No newline at end of file diff --git a/tests/javascript/common.test.js b/tests/javascript/common.test.js new file mode 100644 index 00000000..61a8c73c --- /dev/null +++ b/tests/javascript/common.test.js @@ -0,0 +1,294 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + get_next_folder_level, + format_duration, + compare_to_average, + space_to_camelcase, + underscore_to_camelcase, + camelcase_to_underscore, + format_date_to_string, + transform_file_path, + combine_paths, + debounce, + strip_tz_suffix, +} from '@js/common.js'; + + +describe('get_next_folder_level', () => { + it('returns next level when currentPath is a prefix of fullPath', () => { + expect(get_next_folder_level('a', 'a.b.c')).toBe('a.b'); + }); + + it('returns deeper next level', () => { + expect(get_next_folder_level('a.b', 'a.b.c.d')).toBe('a.b.c'); + }); + + it('returns currentPath when already at full depth', () => { + expect(get_next_folder_level('a.b.c', 'a.b.c')).toBe('a.b.c'); + }); + + it('returns currentPath when not a prefix of fullPath', () => { + expect(get_next_folder_level('x.y', 'a.b.c')).toBe('x.y'); + }); + + it('handles single-level paths', () => { + expect(get_next_folder_level('root', 'root')).toBe('root'); + }); +}); + + +describe('format_duration', () => { + it('formats sub-second values', () => { + expect(format_duration(0.5)).toBe('0.5s'); + }); + + it('formats zero seconds', () => { + expect(format_duration(0)).toBe('0s'); + }); + + it('formats exact seconds with no trailing zeros', () => { + expect(format_duration(5)).toBe('5s'); + }); + + it('formats sub-minute with decimals', () => { + expect(format_duration(45.12)).toBe('45.12s'); + }); + + it('strips trailing zeros for sub-minute', () => { + expect(format_duration(10.10)).toBe('10.1s'); + }); + + it('formats minutes and seconds', () => { + expect(format_duration(90)).toBe('1m 30s'); + }); + + it('formats exact minutes', () => { + expect(format_duration(120)).toBe('2m'); + }); + + it('formats hours', () => { + expect(format_duration(3661)).toBe('1h 1m 1s'); + }); + + it('formats days', () => { + expect(format_duration(86400)).toBe('1d'); + }); + + it('does not show seconds when days > 0', () => { + expect(format_duration(86400 + 3600 + 60 + 30)).toBe('1d 1h 1m'); + }); + + it('formats days and hours without seconds', () => { + expect(format_duration(90000)).toBe('1d 1h'); + }); +}); + + +describe('compare_to_average', () => { + it('returns text-passed when duration is below threshold', () => { + expect(compare_to_average(50, 100, 20)).toBe('text-passed'); + }); + + it('returns text-failed when duration is above threshold', () => { + expect(compare_to_average(150, 100, 20)).toBe('text-failed'); + }); + + it('returns empty string when within range', () => { + expect(compare_to_average(100, 100, 20)).toBe(''); + }); + + it('returns empty string at exact lower boundary', () => { + expect(compare_to_average(80, 100, 20)).toBe(''); + }); + + it('returns empty string at exact upper boundary', () => { + expect(compare_to_average(120, 100, 20)).toBe(''); + }); + + it('handles string percent parameter', () => { + expect(compare_to_average(50, 100, '20')).toBe('text-passed'); + }); +}); + + +describe('space_to_camelcase', () => { + it('converts space-separated words to camelCase', () => { + expect(space_to_camelcase('hello world')).toBe('helloWorld'); + }); + + it('handles single word', () => { + expect(space_to_camelcase('hello')).toBe('hello'); + }); + + it('handles multiple words', () => { + expect(space_to_camelcase('run statistics graph')).toBe('runStatisticsGraph'); + }); +}); + + +describe('underscore_to_camelcase', () => { + it('converts underscored string to camelCase', () => { + expect(underscore_to_camelcase('hello_world')).toBe('helloWorld'); + }); + + it('handles multiple underscores', () => { + expect(underscore_to_camelcase('run_statistics_graph')).toBe('runStatisticsGraph'); + }); + + it('handles no underscores', () => { + expect(underscore_to_camelcase('hello')).toBe('hello'); + }); +}); + + +describe('camelcase_to_underscore', () => { + it('converts camelCase to underscore_case', () => { + expect(camelcase_to_underscore('helloWorld')).toBe('hello_world'); + }); + + it('handles multiple capitals', () => { + expect(camelcase_to_underscore('runStatisticsGraph')).toBe('run_statistics_graph'); + }); + + it('handles consecutive capitals', () => { + expect(camelcase_to_underscore('myHTTPClient')).toBe('my_httpclient'); + }); + + it('handles no capitals', () => { + expect(camelcase_to_underscore('hello')).toBe('hello'); + }); +}); + + +describe('format_date_to_string', () => { + it('formats a date object to YYYY-MM-DD HH:MM:SS', () => { + const date = new Date(2025, 0, 15, 9, 5, 3); // Jan 15, 2025 09:05:03 + expect(format_date_to_string(date)).toBe('2025-01-15 09:05:03'); + }); + + it('pads single-digit values', () => { + const date = new Date(2025, 2, 3, 1, 2, 3); // Mar 3, 2025 01:02:03 + expect(format_date_to_string(date)).toBe('2025-03-03 01:02:03'); + }); + + it('handles midnight', () => { + const date = new Date(2025, 11, 31, 0, 0, 0); // Dec 31, 2025 00:00:00 + expect(format_date_to_string(date)).toBe('2025-12-31 00:00:00'); + }); +}); + + +describe('transform_file_path', () => { + it('transforms output.xml to log.html with forward slashes', () => { + expect(transform_file_path('/path/to/output.xml')).toBe('/path/to/log.html'); + }); + + it('transforms output.xml to log.html with backslashes', () => { + expect(transform_file_path('C:\\path\\to\\output.xml')).toBe('C:\\path\\to\\log.html'); + }); + + it('replaces all occurrences of output in filename', () => { + expect(transform_file_path('/path/output_output.xml')).toBe('/path/log_log.html'); + }); + + it('handles case-insensitive .XML extension', () => { + expect(transform_file_path('/path/output.XML')).toBe('/path/log.html'); + }); +}); + + +describe('combine_paths', () => { + it('combines base URL with relative path by matching folder', () => { + const result = combine_paths('http://localhost:8000/results/', 'results/log.html'); + expect(result).toBe('http://localhost:8000/results/log.html'); + }); + + it('resolves .. in relative path', () => { + const result = combine_paths('http://localhost:8000/a/b/', '../c/log.html'); + expect(result).toBe('http://localhost:8000/c/log.html'); + }); + + it('resolves . in relative path', () => { + const result = combine_paths('http://localhost:8000/results/', './results/log.html'); + expect(result).toBe('http://localhost:8000/results/log.html'); + }); + + it('handles backslashes in relative path', () => { + const result = combine_paths('http://localhost:8000/results/', 'results\\subdir\\log.html'); + expect(result).toBe('http://localhost:8000/results/subdir/log.html'); + }); + + it('handles no matching folder', () => { + const result = combine_paths('http://localhost:8000/', 'output/log.html'); + expect(result).toBe('http://localhost:8000/output/log.html'); + }); +}); + + +describe('strip_tz_suffix', () => { + it('strips +HH:MM timezone suffix', () => { + expect(strip_tz_suffix('2025-01-15 09:05:03+02:00')).toBe('2025-01-15 09:05:03'); + }); + + it('strips -HH:MM timezone suffix', () => { + expect(strip_tz_suffix('2025-01-15 09:05:03-05:00')).toBe('2025-01-15 09:05:03'); + }); + + it('returns string unchanged if no timezone suffix', () => { + expect(strip_tz_suffix('2025-01-15 09:05:03')).toBe('2025-01-15 09:05:03'); + }); + + it('handles millisecond timestamps with timezone', () => { + expect(strip_tz_suffix('2025-01-15 09:05:03.123+02:00')).toBe('2025-01-15 09:05:03.123'); + }); + + it('handles UTC offset +00:00', () => { + expect(strip_tz_suffix('2025-01-15 09:05:03+00:00')).toBe('2025-01-15 09:05:03'); + }); +}); + + +describe('debounce', () => { + it('delays function execution', async () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const debounced = debounce(fn, 100); + + debounced(); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + + vi.useRealTimers(); + }); + + it('resets timer on subsequent calls', () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const debounced = debounce(fn, 100); + + debounced(); + vi.advanceTimersByTime(50); + debounced(); // reset + vi.advanceTimersByTime(50); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50); + expect(fn).toHaveBeenCalledOnce(); + + vi.useRealTimers(); + }); + + it('passes arguments to the debounced function', () => { + vi.useFakeTimers(); + const fn = vi.fn(); + const debounced = debounce(fn, 100); + + debounced('a', 'b'); + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledWith('a', 'b'); + + vi.useRealTimers(); + }); +}); diff --git a/tests/javascript/filter.test.js b/tests/javascript/filter.test.js new file mode 100644 index 00000000..8d726e07 --- /dev/null +++ b/tests/javascript/filter.test.js @@ -0,0 +1,266 @@ +import { describe, it, expect, vi } from 'vitest'; +import { strip_tz_suffix } from '@js/common.js'; + +// Test the pure data transformation logic from filter.js. +// Most filter functions in filter.js touch the DOM (document.getElementById), +// so here we test the reusable logic patterns (sorting, data transformations) +// that the filter functions rely on. + +// Reimplementation of sort_wall_clock from filter.js for direct testing +function sort_wall_clock(data) { + return [...data].sort((a, b) => { + const ak = strip_tz_suffix(a.run_start); + const bk = strip_tz_suffix(b.run_start); + return ak < bk ? -1 : ak > bk ? 1 : 0; + }); +} + +describe('filter.js pure logic', () => { + describe('sort_wall_clock logic', () => { + + it('sorts runs by wall-clock time', () => { + const data = [ + { run_start: '2025-01-15 10:00:00+02:00' }, + { run_start: '2025-01-15 08:00:00+02:00' }, + { run_start: '2025-01-15 09:00:00+02:00' }, + ]; + const sorted = sort_wall_clock(data); + expect(sorted[0].run_start).toBe('2025-01-15 08:00:00+02:00'); + expect(sorted[1].run_start).toBe('2025-01-15 09:00:00+02:00'); + expect(sorted[2].run_start).toBe('2025-01-15 10:00:00+02:00'); + }); + + it('sorts runs with mixed timezone offsets by wall-clock', () => { + const data = [ + { run_start: '2025-01-15 12:00:00+05:00' }, + { run_start: '2025-01-15 08:00:00+01:00' }, + { run_start: '2025-01-15 10:00:00+02:00' }, + ]; + const sorted = sort_wall_clock(data); + // Wall-clock sorting: 08:00, 10:00, 12:00 + expect(sorted[0].run_start).toBe('2025-01-15 08:00:00+01:00'); + expect(sorted[1].run_start).toBe('2025-01-15 10:00:00+02:00'); + expect(sorted[2].run_start).toBe('2025-01-15 12:00:00+05:00'); + }); + + it('handles data without timezone suffixes', () => { + const data = [ + { run_start: '2025-01-15 10:00:00' }, + { run_start: '2025-01-15 08:00:00' }, + ]; + const sorted = sort_wall_clock(data); + expect(sorted[0].run_start).toBe('2025-01-15 08:00:00'); + expect(sorted[1].run_start).toBe('2025-01-15 10:00:00'); + }); + + it('returns empty array for empty input', () => { + expect(sort_wall_clock([])).toEqual([]); + }); + + it('does not mutate original array', () => { + const data = [ + { run_start: '2025-01-15 10:00:00' }, + { run_start: '2025-01-15 08:00:00' }, + ]; + const original = [...data]; + sort_wall_clock(data); + expect(data).toEqual(original); + }); + }); + + describe('remove_milliseconds logic', () => { + function remove_milliseconds(data, showMilliseconds) { + if (showMilliseconds) return data; + return data.map(obj => { + const rs = obj.run_start; + const datetime = rs.slice(0, 19); + const suffix = rs.slice(-6); + const hasTz = /^[+-]\d{2}:\d{2}$/.test(suffix); + return { ...obj, run_start: hasTz ? datetime + suffix : datetime }; + }); + } + + it('removes milliseconds when disabled', () => { + const data = [{ run_start: '2025-01-15 09:05:03.123+02:00', name: 'run1' }]; + const result = remove_milliseconds(data, false); + expect(result[0].run_start).toBe('2025-01-15 09:05:03+02:00'); + }); + + it('preserves milliseconds when enabled', () => { + const data = [{ run_start: '2025-01-15 09:05:03.123+02:00', name: 'run1' }]; + const result = remove_milliseconds(data, true); + expect(result[0].run_start).toBe('2025-01-15 09:05:03.123+02:00'); + }); + + it('handles timestamps without timezone', () => { + const data = [{ run_start: '2025-01-15 09:05:03.123', name: 'run1' }]; + const result = remove_milliseconds(data, false); + expect(result[0].run_start).toBe('2025-01-15 09:05:03'); + }); + + it('does not mutate original data', () => { + const data = [{ run_start: '2025-01-15 09:05:03.123+02:00', name: 'run1' }]; + remove_milliseconds(data, false); + expect(data[0].run_start).toBe('2025-01-15 09:05:03.123+02:00'); + }); + }); + + describe('remove_timezones logic', () => { + function remove_timezones(data, showTimezones) { + if (showTimezones) return data; + return data.map(obj => { + const rs = obj.run_start; + const suffix = rs.slice(-6); + const hasTz = /^[+-]\d{2}:\d{2}$/.test(suffix); + if (!hasTz) return obj; + return { ...obj, run_start: rs.slice(0, -6) }; + }); + } + + it('removes timezone suffix when disabled', () => { + const data = [{ run_start: '2025-01-15 09:05:03+02:00' }]; + const result = remove_timezones(data, false); + expect(result[0].run_start).toBe('2025-01-15 09:05:03'); + }); + + it('preserves timezone suffix when enabled', () => { + const data = [{ run_start: '2025-01-15 09:05:03+02:00' }]; + const result = remove_timezones(data, true); + expect(result[0].run_start).toBe('2025-01-15 09:05:03+02:00'); + }); + + it('handles timestamps without timezone', () => { + const data = [{ run_start: '2025-01-15 09:05:03' }]; + const result = remove_timezones(data, false); + expect(result[0].run_start).toBe('2025-01-15 09:05:03'); + }); + + it('handles negative timezone offset', () => { + const data = [{ run_start: '2025-01-15 09:05:03-05:00' }]; + const result = remove_timezones(data, false); + expect(result[0].run_start).toBe('2025-01-15 09:05:03'); + }); + }); + + describe('filter_data logic', () => { + it('filters data based on matching run_start values', () => { + const filteredRuns = [ + { run_start: '2025-01-15 09:00:00' }, + { run_start: '2025-01-15 10:00:00' }, + ]; + const data = [ + { run_start: '2025-01-15 09:00:00', name: 'suite1' }, + { run_start: '2025-01-15 10:00:00', name: 'suite2' }, + { run_start: '2025-01-15 11:00:00', name: 'suite3' }, + ]; + const validRunStarts = filteredRuns.map(v => v.run_start); + const result = data.filter(v => validRunStarts.includes(v.run_start)); + expect(result).toHaveLength(2); + expect(result[0].name).toBe('suite1'); + expect(result[1].name).toBe('suite2'); + }); + + it('returns empty array when no runs match', () => { + const filteredRuns = [{ run_start: '2025-01-15 12:00:00' }]; + const data = [ + { run_start: '2025-01-15 09:00:00', name: 'suite1' }, + ]; + const validRunStarts = filteredRuns.map(v => v.run_start); + const result = data.filter(v => validRunStarts.includes(v.run_start)); + expect(result).toHaveLength(0); + }); + }); + + describe('convert_timezone logic', () => { + // Reimplementation of filter.js convert_timezone for direct testing + function convert_timezone(data, convertTimezone) { + if (!convertTimezone) return data; + return data.map(obj => { + const rs = obj.run_start; + const suffix = rs.slice(-6); + const hasTz = /^[+-]\d{2}:\d{2}$/.test(suffix); + if (!hasTz) return obj; + + const isoStr = rs.replace(' ', 'T'); + const date = new Date(isoStr); + if (isNaN(date.getTime())) return obj; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + + const tzOffset = -date.getTimezoneOffset(); + const tzSign = tzOffset >= 0 ? '+' : '-'; + const tzHours = String(Math.floor(Math.abs(tzOffset) / 60)).padStart(2, '0'); + const tzMins = String(Math.abs(tzOffset) % 60).padStart(2, '0'); + const localTz = `${tzSign}${tzHours}:${tzMins}`; + + const mainPart = rs.slice(0, -6); + const subSecond = mainPart.length > 19 ? mainPart.slice(19) : ''; + const localStr = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}${subSecond}${localTz}`; + + return { ...obj, run_start: localStr }; + }); + } + + it('returns data unchanged when convertTimezone is false', () => { + const data = [{ run_start: '2025-01-15 10:00:00+02:00', name: 'test1' }]; + const result = convert_timezone(data, false); + expect(result).toEqual(data); + }); + + it('returns data unchanged for timestamps without timezone offset', () => { + const data = [{ run_start: '2025-01-15 10:00:00', name: 'test1' }]; + const result = convert_timezone(data, true); + expect(result[0].run_start).toBe('2025-01-15 10:00:00'); + }); + + it('converts timestamp to local timezone', () => { + const data = [{ run_start: '2025-01-15 10:00:00+00:00', name: 'test1' }]; + const result = convert_timezone(data, true); + // The converted timestamp should end with the local timezone offset + const localOffset = -new Date('2025-01-15T10:00:00+00:00').getTimezoneOffset(); + const sign = localOffset >= 0 ? '+' : '-'; + const h = String(Math.floor(Math.abs(localOffset) / 60)).padStart(2, '0'); + const m = String(Math.abs(localOffset) % 60).padStart(2, '0'); + const expectedSuffix = `${sign}${h}:${m}`; + expect(result[0].run_start).toMatch(new RegExp(`\\${expectedSuffix}$`)); + }); + + it('preserves sub-second precision', () => { + const data = [{ run_start: '2025-01-15 10:00:00.123456+00:00', name: 'test1' }]; + const result = convert_timezone(data, true); + expect(result[0].run_start).toContain('.123456'); + }); + + it('does not mutate original data', () => { + const data = [{ run_start: '2025-01-15 10:00:00+02:00', name: 'test1' }]; + const original = data[0].run_start; + convert_timezone(data, true); + expect(data[0].run_start).toBe(original); + }); + + it('handles invalid date gracefully', () => { + const data = [{ run_start: 'not-a-date+00:00', name: 'test1' }]; + const result = convert_timezone(data, true); + expect(result[0].run_start).toBe('not-a-date+00:00'); + }); + + it('handles negative timezone offsets', () => { + const data = [{ run_start: '2025-01-15 10:00:00-05:00', name: 'test1' }]; + const result = convert_timezone(data, true); + // Should produce a valid date string with local offset + expect(result[0].run_start).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/); + }); + + it('preserves other object properties', () => { + const data = [{ run_start: '2025-01-15 10:00:00+02:00', name: 'test1', status: 'PASS' }]; + const result = convert_timezone(data, true); + expect(result[0].name).toBe('test1'); + expect(result[0].status).toBe('PASS'); + }); + }); +}); diff --git a/tests/javascript/graph_data/donut.test.js b/tests/javascript/graph_data/donut.test.js new file mode 100644 index 00000000..07c83265 --- /dev/null +++ b/tests/javascript/graph_data/donut.test.js @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies +vi.mock('@js/variables/settings.js', () => ({ + settings: { + show: { aliases: false }, + switch: { suitePathsSuiteSection: false }, + }, +})); +vi.mock('@js/common.js', () => ({ + get_next_folder_level: (current, full) => { + if (!full.startsWith(current + '.')) return current; + const rest = full.slice(current.length + 1); + const next = rest.split('.')[0]; + return `${current}.${next}`; + }, +})); +vi.mock('@js/variables/globals.js', () => ({ + onlyFailedFolders: false, +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + passedBackgroundColor: 'rgba(151, 189, 97, 0.7)', + passedBackgroundBorderColor: '#97bd61', + failedBackgroundColor: 'rgba(206, 62, 1, 0.7)', + failedBackgroundBorderColor: '#ce3e01', + skippedBackgroundColor: 'rgba(254, 216, 79, 0.7)', + skippedBackgroundBorderColor: '#fed84f', +})); + +import { get_donut_graph_data, get_donut_total_graph_data } from '@js/graph_data/donut.js'; +import { settings } from '@js/variables/settings.js'; + + +describe('get_donut_graph_data', () => { + beforeEach(() => { + settings.show.aliases = false; + }); + + it('returns donut data for the last entry in filtered data', () => { + const filteredData = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'R1', passed: 5, failed: 2, skipped: 1 }, + { run_start: '2025-01-16 10:00:00', run_alias: 'R2', passed: 8, failed: 0, skipped: 3 }, + ]; + const [graphData, callbackData] = get_donut_graph_data('test', filteredData); + + expect(graphData.labels).toContain('Passed'); + expect(graphData.labels).toContain('Skipped'); + // Last entry has failed=0, so no "Failed" label + expect(graphData.labels).not.toContain('Failed'); + expect(graphData.datasets[0].data).toContain(8); + expect(graphData.datasets[0].data).toContain(3); + }); + + it('includes only non-zero categories', () => { + const filteredData = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'R1', passed: 10, failed: 0, skipped: 0 }, + ]; + const [graphData] = get_donut_graph_data('test', filteredData); + expect(graphData.labels).toEqual(['Passed']); + expect(graphData.datasets[0].data).toEqual([10]); + }); + + it('includes all three categories when all non-zero', () => { + const filteredData = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'R1', passed: 5, failed: 3, skipped: 2 }, + ]; + const [graphData] = get_donut_graph_data('test', filteredData); + expect(graphData.labels).toEqual(['Passed', 'Failed', 'Skipped']); + expect(graphData.datasets[0].data).toEqual([5, 3, 2]); + }); + + it('returns run_start as callback data by default', () => { + const filteredData = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'R1', passed: 5, failed: 0, skipped: 0 }, + ]; + const [, callbackData] = get_donut_graph_data('test', filteredData); + expect(callbackData).toBe('2025-01-15 10:00:00'); + }); + + it('returns run_alias as callback data when aliases enabled', () => { + settings.show.aliases = true; + const filteredData = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'Alias1', passed: 5, failed: 0, skipped: 0 }, + ]; + const [, callbackData] = get_donut_graph_data('test', filteredData); + expect(callbackData).toBe('Alias1'); + }); + + it('returns empty callback data for empty input', () => { + const [graphData, callbackData] = get_donut_graph_data('test', []); + expect(graphData.labels).toEqual([]); + expect(graphData.datasets[0].data).toEqual([]); + expect(callbackData).toBe(''); + }); + + it('assigns correct colors for each status', () => { + const filteredData = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'R1', passed: 5, failed: 3, skipped: 2 }, + ]; + const [graphData] = get_donut_graph_data('test', filteredData); + const colors = graphData.datasets[0].backgroundColor; + expect(colors[0]).toBe('rgba(151, 189, 97, 0.7)'); // Passed + expect(colors[1]).toBe('rgba(206, 62, 1, 0.7)'); // Failed + expect(colors[2]).toBe('rgba(254, 216, 79, 0.7)'); // Skipped + }); +}); + + +describe('get_donut_total_graph_data', () => { + it('sums all entries across filtered data', () => { + const filteredData = [ + { passed: 5, failed: 2, skipped: 1 }, + { passed: 3, failed: 1, skipped: 0 }, + { passed: 2, failed: 0, skipped: 4 }, + ]; + const [graphData, callbackData] = get_donut_total_graph_data('test', filteredData); + expect(graphData.labels).toEqual(['Passed', 'Failed', 'Skipped']); + expect(graphData.datasets[0].data).toEqual([10, 3, 5]); + }); + + it('only includes non-zero totals', () => { + const filteredData = [ + { passed: 5, failed: 0, skipped: 0 }, + { passed: 3, failed: 0, skipped: 0 }, + ]; + const [graphData] = get_donut_total_graph_data('test', filteredData); + expect(graphData.labels).toEqual(['Passed']); + expect(graphData.datasets[0].data).toEqual([8]); + }); + + it('returns labels array as callback data', () => { + const filteredData = [ + { passed: 5, failed: 2, skipped: 1 }, + ]; + const [, callbackData] = get_donut_total_graph_data('test', filteredData); + expect(callbackData).toEqual(['Passed', 'Failed', 'Skipped']); + }); + + it('returns empty data for no input', () => { + const [graphData, callbackData] = get_donut_total_graph_data('test', []); + expect(graphData.labels).toEqual([]); + expect(graphData.datasets[0].data).toEqual([]); + expect(callbackData).toEqual([]); + }); + + it('assigns correct colors', () => { + const filteredData = [ + { passed: 1, failed: 1, skipped: 1 }, + ]; + const [graphData] = get_donut_total_graph_data('test', filteredData); + expect(graphData.datasets[0].backgroundColor).toEqual([ + 'rgba(151, 189, 97, 0.7)', + 'rgba(206, 62, 1, 0.7)', + 'rgba(254, 216, 79, 0.7)', + ]); + }); + + it('has hoverOffset set on dataset', () => { + const filteredData = [{ passed: 1, failed: 0, skipped: 0 }]; + const [graphData] = get_donut_total_graph_data('test', filteredData); + expect(graphData.datasets[0].hoverOffset).toBe(4); + }); +}); diff --git a/tests/javascript/graph_data/failed.test.js b/tests/javascript/graph_data/failed.test.js new file mode 100644 index 00000000..5bb28f56 --- /dev/null +++ b/tests/javascript/graph_data/failed.test.js @@ -0,0 +1,214 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies +vi.mock('@js/variables/settings.js', () => ({ + settings: { + switch: { + useLibraryNames: false, + suitePathsSuiteSection: false, + suitePathsTestSection: false, + }, + show: { + aliases: false, + rounding: 6, + }, + }, +})); +vi.mock('@js/variables/globals.js', () => ({ + inFullscreen: false, + inFullscreenGraph: '', +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + failedConfig: { + backgroundColor: 'rgba(206, 62, 1, 0.7)', + borderColor: '#ce3e01', + }, +})); +vi.mock('@js/graph_data/helpers.js', () => ({ + convert_timeline_data: (datasets) => { + // Simplified grouping for testing + const grouped = {}; + for (const ds of datasets) { + const key = `${ds.label}::${ds.backgroundColor}::${ds.borderColor}`; + if (!grouped[key]) { + grouped[key] = { label: ds.label, data: [], backgroundColor: ds.backgroundColor, borderColor: ds.borderColor, parsing: true }; + } + grouped[key].data.push(...ds.data); + } + return Object.values(grouped); + }, +})); +vi.mock('@js/common.js', () => ({ + strip_tz_suffix: (s) => s.replace(/[+-]\d{2}:\d{2}$/, ''), +})); + +import { get_most_failed_data } from '@js/graph_data/failed.js'; +import { settings } from '@js/variables/settings.js'; + + +function makeTestData(entries) { + return entries.map(e => ({ + name: e.name, + full_name: e.full_name || `Suite.${e.name}`, + run_start: e.run_start, + run_alias: e.run_alias || e.run_start, + failed: e.failed ?? 1, + passed: e.passed ?? 0, + skipped: e.skipped ?? 0, + elapsed_s: e.elapsed_s ?? 1.0, + message: e.message || '', + owner: e.owner || undefined, + })); +} + +describe('get_most_failed_data', () => { + beforeEach(() => { + settings.switch.useLibraryNames = false; + settings.switch.suitePathsSuiteSection = false; + settings.switch.suitePathsTestSection = false; + settings.show.aliases = false; + }); + + describe('bar graph type', () => { + it('returns graph data and callback data for failed items', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + { name: 'Test B', run_start: '2025-01-15 10:00:00', failed: 1 }, + ]); + const [graphData, callbackData] = get_most_failed_data('test', 'bar', data, false); + + expect(graphData.labels).toEqual(['Test A', 'Test B']); + expect(graphData.datasets[0].data).toEqual([2, 1]); + }); + + it('excludes items with failed=0', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1 }, + { name: 'Test B', run_start: '2025-01-15 10:00:00', failed: 0, passed: 1 }, + ]); + const [graphData] = get_most_failed_data('test', 'bar', data, false); + + expect(graphData.labels).toEqual(['Test A']); + expect(graphData.datasets[0].data).toEqual([1]); + }); + + it('limits to 10 items by default', () => { + const data = makeTestData( + Array.from({ length: 15 }, (_, i) => ({ + name: `Test ${i}`, + run_start: '2025-01-15 10:00:00', + failed: 1, + })) + ); + const [graphData] = get_most_failed_data('test', 'bar', data, false); + expect(graphData.labels.length).toBe(10); + }); + + it('sorts by failure count descending', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1 }, + { name: 'Test B', run_start: '2025-01-15 10:00:00', failed: 1 }, + { name: 'Test B', run_start: '2025-01-16 10:00:00', failed: 1 }, + { name: 'Test B', run_start: '2025-01-17 10:00:00', failed: 1 }, + ]); + const [graphData] = get_most_failed_data('test', 'bar', data, false); + expect(graphData.labels[0]).toBe('Test B'); + expect(graphData.datasets[0].data[0]).toBe(3); + }); + + it('sorts by most recent failure when recent=true', () => { + const data = makeTestData([ + { name: 'Test Old', run_start: '2025-01-10 10:00:00', failed: 1 }, + { name: 'Test Old', run_start: '2025-01-11 10:00:00', failed: 1 }, + { name: 'Test Old', run_start: '2025-01-12 10:00:00', failed: 1 }, + { name: 'Test Recent', run_start: '2025-01-20 10:00:00', failed: 1 }, + ]); + const [graphData] = get_most_failed_data('test', 'bar', data, true); + // Recent puts the test with the latest failure first + expect(graphData.labels[0]).toBe('Test Recent'); + }); + + it('uses full_name when suitePathsSuiteSection is true for suite dataType', () => { + settings.switch.suitePathsSuiteSection = true; + const data = makeTestData([ + { name: 'MySuite', full_name: 'Root.MySuite', run_start: '2025-01-15 10:00:00', failed: 1 }, + ]); + const [graphData] = get_most_failed_data('suite', 'bar', data, false); + expect(graphData.labels[0]).toBe('Root.MySuite'); + }); + + it('uses owner.name when useLibraryNames is true for keyword dataType', () => { + settings.switch.useLibraryNames = true; + const data = makeTestData([ + { name: 'MyKeyword', owner: 'BuiltIn', run_start: '2025-01-15 10:00:00', failed: 1 }, + ]); + const [graphData] = get_most_failed_data('keyword', 'bar', data, false); + expect(graphData.labels[0]).toBe('BuiltIn.MyKeyword'); + }); + + it('uses aliases in callback data when show.aliases is true', () => { + settings.show.aliases = true; + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', run_alias: 'Alias1', failed: 1 }, + ]); + const [, callbackData] = get_most_failed_data('test', 'bar', data, false); + expect(callbackData['Test A']).toEqual(['Alias1']); + }); + + it('returns empty data when no failures', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 0, passed: 1 }, + ]); + const [graphData] = get_most_failed_data('test', 'bar', data, false); + expect(graphData.labels).toEqual([]); + expect(graphData.datasets[0].data).toEqual([]); + }); + }); + + describe('timeline graph type', () => { + it('returns graphData, runStartsArray, and pointMeta', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1, message: 'err1' }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1, message: 'err2' }, + ]); + const [graphData, runStartsArray, pointMeta] = get_most_failed_data('test', 'timeline', data, false); + + expect(graphData.labels).toEqual(['Test A']); + expect(graphData.datasets.length).toBeGreaterThan(0); + expect(runStartsArray.length).toBe(2); + expect(pointMeta).toBeDefined(); + }); + + it('records pointMeta with FAIL status for each point', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1, elapsed_s: 2.5, message: 'boom' }, + ]); + const [, , pointMeta] = get_most_failed_data('test', 'timeline', data, false); + const key = 'Test A::0'; + expect(pointMeta[key]).toBeDefined(); + expect(pointMeta[key].status).toBe('FAIL'); + expect(pointMeta[key].elapsed_s).toBe(2.5); + expect(pointMeta[key].message).toBe('boom'); + }); + + it('sorts run starts chronologically', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1 }, + ]); + const [, runStartsArray] = get_most_failed_data('test', 'timeline', data, false); + expect(runStartsArray[0]).toBe('2025-01-15 10:00:00'); + expect(runStartsArray[1]).toBe('2025-01-16 10:00:00'); + }); + + it('uses aliases for runStartsArray when show.aliases is true', () => { + settings.show.aliases = true; + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', run_alias: 'Alias1', failed: 1 }, + ]); + const [, runStartsArray] = get_most_failed_data('test', 'timeline', data, false); + expect(runStartsArray).toContain('Alias1'); + }); + }); +}); diff --git a/tests/javascript/graph_data/flaky.test.js b/tests/javascript/graph_data/flaky.test.js new file mode 100644 index 00000000..4e0423ef --- /dev/null +++ b/tests/javascript/graph_data/flaky.test.js @@ -0,0 +1,229 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies +vi.mock('@js/variables/settings.js', () => ({ + settings: { + switch: { + suitePathsTestSection: false, + }, + show: { + aliases: false, + rounding: 6, + }, + }, +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + passedConfig: { backgroundColor: 'rgba(151, 189, 97, 0.7)', borderColor: '#97bd61' }, + failedConfig: { backgroundColor: 'rgba(206, 62, 1, 0.7)', borderColor: '#ce3e01' }, + skippedConfig: { backgroundColor: 'rgba(254, 216, 79, 0.7)', borderColor: '#fed84f' }, +})); +vi.mock('@js/graph_data/helpers.js', () => ({ + convert_timeline_data: (datasets) => { + const grouped = {}; + for (const ds of datasets) { + const key = `${ds.label}::${ds.backgroundColor}::${ds.borderColor}`; + if (!grouped[key]) { + grouped[key] = { label: ds.label, data: [], backgroundColor: ds.backgroundColor, borderColor: ds.borderColor, parsing: true }; + } + grouped[key].data.push(...ds.data); + } + return Object.values(grouped); + }, +})); +vi.mock('@js/common.js', () => ({ + strip_tz_suffix: (s) => s.replace(/[+-]\d{2}:\d{2}$/, ''), +})); + +import { get_most_flaky_data } from '@js/graph_data/flaky.js'; +import { settings } from '@js/variables/settings.js'; + + +function makeTestData(entries) { + return entries.map(e => ({ + name: e.name, + full_name: e.full_name || `Suite.${e.name}`, + run_start: e.run_start, + run_alias: e.run_alias || e.run_start, + passed: e.passed ?? 0, + failed: e.failed ?? 0, + skipped: e.skipped ?? 0, + elapsed_s: e.elapsed_s ?? 1.0, + message: e.message || '', + })); +} + +describe('get_most_flaky_data', () => { + beforeEach(() => { + settings.switch.suitePathsTestSection = false; + settings.show.aliases = false; + }); + + describe('bar graph type', () => { + it('counts status flips and returns bar chart data', () => { + // Test A: PASS -> FAIL -> PASS = 2 flips + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + { name: 'Test A', run_start: '2025-01-17 10:00:00', passed: 1 }, + ]); + const [graphData] = get_most_flaky_data('test', 'bar', data, false, false, 10); + expect(graphData.labels).toEqual(['Test A']); + expect(graphData.datasets[0].data).toEqual([2]); + }); + + it('excludes tests with zero flips', () => { + // Test A: PASS -> PASS -> PASS = 0 flips (not flaky) + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-17 10:00:00', passed: 1 }, + ]); + const [graphData] = get_most_flaky_data('test', 'bar', data, false, false, 10); + expect(graphData.labels).toEqual([]); + expect(graphData.datasets[0].data).toEqual([]); + }); + + it('sorts by flips descending', () => { + const data = makeTestData([ + // Test A: PASS -> FAIL = 1 flip + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + // Test B: PASS -> FAIL -> PASS = 2 flips + { name: 'Test B', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test B', run_start: '2025-01-16 10:00:00', failed: 1 }, + { name: 'Test B', run_start: '2025-01-17 10:00:00', passed: 1 }, + ]); + const [graphData] = get_most_flaky_data('test', 'bar', data, false, false, 10); + expect(graphData.labels[0]).toBe('Test B'); + expect(graphData.datasets[0].data[0]).toBe(2); + }); + + it('respects limit parameter', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + { name: 'Test B', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test B', run_start: '2025-01-16 10:00:00', failed: 1 }, + { name: 'Test C', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test C', run_start: '2025-01-16 10:00:00', failed: 1 }, + ]); + const [graphData] = get_most_flaky_data('test', 'bar', data, false, false, 2); + expect(graphData.labels.length).toBe(2); + }); + + it('ignores skipped tests when ignore=true', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', skipped: 1 }, + { name: 'Test A', run_start: '2025-01-17 10:00:00', passed: 1 }, + ]); + // With ignore=true, skipped entries are excluded entirely + const [graphData] = get_most_flaky_data('test', 'bar', data, true, false, 10); + expect(graphData.labels).toEqual([]); + }); + + it('counts skipped as a status flip when ignore=false', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', skipped: 1 }, + ]); + const [graphData] = get_most_flaky_data('test', 'bar', data, false, false, 10); + expect(graphData.labels).toEqual(['Test A']); + expect(graphData.datasets[0].data).toEqual([1]); + }); + + it('uses full_name when suitePathsTestSection is true', () => { + settings.switch.suitePathsTestSection = true; + const data = makeTestData([ + { name: 'Test A', full_name: 'Root.Suite.Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', full_name: 'Root.Suite.Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + ]); + const [graphData] = get_most_flaky_data('test', 'bar', data, false, false, 10); + expect(graphData.labels[0]).toBe('Root.Suite.Test A'); + }); + + it('returns callback data with full test information', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + ]); + const [, callbackData] = get_most_flaky_data('test', 'bar', data, false, false, 10); + expect(callbackData['Test A']).toBeDefined(); + expect(callbackData['Test A'].flips).toBe(1); + expect(callbackData['Test A'].run_starts).toHaveLength(2); + }); + }); + + describe('timeline graph type', () => { + it('returns graphData, runStarts, and pointMeta', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + ]); + const [graphData, runStarts, pointMeta] = get_most_flaky_data('test', 'timeline', data, false, false, 10); + + expect(graphData.labels).toEqual(['Test A']); + expect(graphData.datasets.length).toBeGreaterThan(0); + expect(runStarts).toHaveLength(2); + expect(pointMeta).toBeDefined(); + }); + + it('records correct status in pointMeta', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1, elapsed_s: 2.0 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1, elapsed_s: 3.0, message: 'error' }, + ]); + const [, , pointMeta] = get_most_flaky_data('test', 'timeline', data, false, false, 10); + expect(pointMeta['Test A::0'].status).toBe('PASS'); + expect(pointMeta['Test A::1'].status).toBe('FAIL'); + expect(pointMeta['Test A::1'].message).toBe('error'); + }); + + it('sorts run starts chronologically', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-17 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1 }, + ]); + const [, runStarts] = get_most_flaky_data('test', 'timeline', data, false, false, 10); + expect(runStarts[0]).toBe('2025-01-15 10:00:00'); + expect(runStarts[1]).toBe('2025-01-17 10:00:00'); + }); + + it('uses run_alias when show.aliases is true', () => { + settings.show.aliases = true; + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', run_alias: 'Alias1', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', run_alias: 'Alias2', failed: 1 }, + ]); + const [, runStarts] = get_most_flaky_data('test', 'timeline', data, false, false, 10); + expect(runStarts).toContain('Alias1'); + expect(runStarts).toContain('Alias2'); + }); + + it('color codes by status (PASS, FAIL, SKIP)', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', passed: 1 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1 }, + { name: 'Test A', run_start: '2025-01-17 10:00:00', skipped: 1 }, + ]); + const [graphData] = get_most_flaky_data('test', 'timeline', data, false, false, 10); + const bgColors = graphData.datasets.map(d => d.backgroundColor); + // Should have distinct colors for PASS, FAIL, SKIP + expect(new Set(bgColors).size).toBeGreaterThanOrEqual(2); + }); + }); + + describe('recent sorting', () => { + it('prioritizes tests with most recent failures', () => { + const data = makeTestData([ + { name: 'Old Flaky', run_start: '2025-01-10 10:00:00', passed: 1 }, + { name: 'Old Flaky', run_start: '2025-01-11 10:00:00', failed: 1 }, + { name: 'Old Flaky', run_start: '2025-01-12 10:00:00', passed: 1 }, + { name: 'New Flaky', run_start: '2025-01-20 10:00:00', passed: 1 }, + { name: 'New Flaky', run_start: '2025-01-21 10:00:00', failed: 1 }, + ]); + const [graphData] = get_most_flaky_data('test', 'bar', data, false, true, 10); + expect(graphData.labels[0]).toBe('New Flaky'); + }); + }); +}); diff --git a/tests/javascript/graph_data/graph_config.test.js b/tests/javascript/graph_data/graph_config.test.js new file mode 100644 index 00000000..1b08e71c --- /dev/null +++ b/tests/javascript/graph_data/graph_config.test.js @@ -0,0 +1,216 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock dependencies +vi.mock('@js/variables/settings.js', () => ({ + settings: { + show: { + animation: false, + duration: 1500, + legends: true, + axisTitles: true, + rounding: 6, + }, + }, +})); +vi.mock('@js/variables/globals.js', () => ({ + heatMapHourAll: true, +})); +vi.mock('@js/log.js', () => ({ + open_log_file: vi.fn(), +})); +vi.mock('@js/common.js', () => ({ + format_duration: (val) => `${val}s`, +})); + +import { get_graph_config } from '@js/graph_data/graph_config.js'; +import { settings } from '@js/variables/settings.js'; + + +describe('get_graph_config', () => { + const sampleBarData = { + labels: ['Run 1', 'Run 2'], + datasets: [{ data: [5, 10] }], + }; + + const sampleTimelineData = { + labels: ['Test A'], + datasets: [{ data: [{ x: [0, 1], y: 'Test A' }] }], + }; + + describe('bar type', () => { + it('returns type "bar" with correct structure', () => { + const config = get_graph_config('bar', sampleBarData, 'My Title', 'X Axis', 'Y Axis'); + expect(config.type).toBe('bar'); + expect(config.data).toBe(sampleBarData); + expect(config.options).toBeDefined(); + }); + + it('includes stacked y axis', () => { + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.scales.y.stacked).toBe(true); + }); + + it('sets interaction mode to x', () => { + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.interaction.mode).toBe('x'); + }); + + it('includes datalabels plugin config', () => { + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.plugins.datalabels).toBeDefined(); + expect(config.options.plugins.datalabels.color).toBe('#000'); + }); + }); + + describe('line type', () => { + it('returns type "line" and wraps data in datasets when dataSets=true', () => { + const lineData = [{ label: 'Set 1', data: [1, 2, 3] }]; + const config = get_graph_config('line', lineData, 'Title', 'X', 'Y'); + expect(config.type).toBe('line'); + expect(config.data.datasets).toBe(lineData); + }); + + it('does not wrap data when dataSets=false', () => { + const lineData = { datasets: [{ data: [1, 2] }] }; + const config = get_graph_config('line', lineData, 'Title', 'X', 'Y', false); + expect(config.data).toBe(lineData); + }); + + it('uses time scale on x axis', () => { + const config = get_graph_config('line', [], 'Title', 'X', 'Y'); + expect(config.options.scales.x.type).toBe('time'); + }); + + it('limits x axis ticks to 10', () => { + const config = get_graph_config('line', [], 'Title', 'X', 'Y'); + expect(config.options.scales.x.ticks.maxTicksLimit).toBe(10); + }); + }); + + describe('timeline type', () => { + it('returns type "bar" with indexAxis "y"', () => { + const config = get_graph_config('timeline', sampleTimelineData, '', 'X', 'Y'); + expect(config.type).toBe('bar'); + expect(config.options.indexAxis).toBe('y'); + }); + + it('hides legend', () => { + const config = get_graph_config('timeline', sampleTimelineData, '', 'X', 'Y'); + expect(config.options.plugins.legend.display).toBe(false); + }); + + it('includes stacked y axis', () => { + const config = get_graph_config('timeline', sampleTimelineData, '', 'X', 'Y'); + expect(config.options.scales.y.stacked).toBe(true); + }); + }); + + describe('boxplot type', () => { + it('returns type "boxplot" with correct structure', () => { + const config = get_graph_config('boxplot', sampleBarData, 'Title', 'X', 'Y'); + expect(config.type).toBe('boxplot'); + expect(config.options.plugins.tooltip.enabled).toBe(true); + expect(config.options.plugins.legend.display).toBe(false); + }); + }); + + describe('radar type', () => { + it('returns type "radar" with r scale', () => { + const config = get_graph_config('radar', sampleBarData, '', 'X', 'Y'); + expect(config.type).toBe('radar'); + expect(config.options.scales.r).toBeDefined(); + expect(config.options.scales.r.angleLines.display).toBe(true); + }); + + it('includes legend', () => { + const config = get_graph_config('radar', sampleBarData, '', 'X', 'Y'); + expect(config.options.plugins.legend.display).toBe(true); + }); + }); + + describe('common options', () => { + it('is responsive and does not maintain aspect ratio', () => { + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.responsive).toBe(true); + expect(config.options.maintainAspectRatio).toBe(false); + }); + + it('includes normalized flag', () => { + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.normalized).toBe(true); + }); + + it('shows title when graphTitle is non-empty', () => { + const config = get_graph_config('bar', sampleBarData, 'My Title', 'X', 'Y'); + expect(config.options.plugins.title.display).toBe(true); + expect(config.options.plugins.title.text).toBe('My Title'); + }); + + it('hides title when graphTitle is empty', () => { + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.plugins.title.display).toBe(false); + }); + + it('disables animation when settings.show.animation is false', () => { + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.animation).toBe(false); + }); + + it('enables animation when settings.show.animation is true', () => { + settings.show.animation = true; + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.animation).toBeDefined(); + expect(config.options.animation).not.toBe(false); + settings.show.animation = false; // restore + }); + + it('sets x/y axis titles', () => { + const config = get_graph_config('bar', sampleBarData, '', 'My X', 'My Y'); + expect(config.options.scales.x.title.text).toBe('My X'); + expect(config.options.scales.y.title.text).toBe('My Y'); + }); + }); + + describe('duration axis formatting', () => { + it('uses format_duration on x axis when xTitle is "Duration"', () => { + const config = get_graph_config('bar', sampleBarData, '', 'Duration', 'Y'); + const formatted = config.options.scales.x.ticks.callback(5); + expect(formatted).toBe('5s'); + }); + + it('uses format_duration on y axis when yTitle is "Duration"', () => { + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Duration'); + const formatted = config.options.scales.y.ticks.callback(10); + expect(formatted).toBe('10s'); + }); + + it('adds tooltip label callback for duration axes', () => { + const config = get_graph_config('bar', sampleBarData, '', 'Duration', 'Y'); + expect(config.options.plugins.tooltip.callbacks.label).toBeDefined(); + }); + }); + + describe('legend and axis title settings', () => { + it('hides legend when settings.show.legends is false', () => { + settings.show.legends = false; + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.plugins.legend.display).toBe(false); + settings.show.legends = true; // restore + }); + + it('hides axis titles when settings.show.axisTitles is false', () => { + settings.show.axisTitles = false; + const config = get_graph_config('bar', sampleBarData, '', 'X', 'Y'); + expect(config.options.scales.x.title.display).toBe(false); + expect(config.options.scales.y.title.display).toBe(false); + settings.show.axisTitles = true; // restore + }); + }); + + describe('unknown graph type', () => { + it('returns undefined for unsupported type', () => { + const config = get_graph_config('unknown', sampleBarData, '', 'X', 'Y'); + expect(config).toBeUndefined(); + }); + }); +}); diff --git a/tests/javascript/graph_data/helpers.test.js b/tests/javascript/graph_data/helpers.test.js new file mode 100644 index 00000000..e4dd5da6 --- /dev/null +++ b/tests/javascript/graph_data/helpers.test.js @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock dependencies +vi.mock('@js/variables/settings.js', () => ({ + settings: { + switch: { suitePathsSuiteSection: false }, + show: { rounding: 6 }, + }, +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + barConfig: { + borderSkipped: false, + borderRadius: () => ({ topLeft: 6, topRight: 6, bottomLeft: 6, bottomRight: 6 }), + }, +})); +vi.mock('@js/variables/globals.js', () => ({ + inFullscreen: false, + inFullscreenGraph: '', +})); + +import { convert_timeline_data } from '@js/graph_data/helpers.js'; + + +describe('convert_timeline_data', () => { + it('groups datasets by status label+colors', () => { + const datasets = [ + { + label: 'PASS', + data: [{ x: [0, 1], y: 'Test A' }], + backgroundColor: 'green', + borderColor: 'darkgreen', + }, + { + label: 'PASS', + data: [{ x: [1, 2], y: 'Test B' }], + backgroundColor: 'green', + borderColor: 'darkgreen', + }, + { + label: 'FAIL', + data: [{ x: [0, 1], y: 'Test C' }], + backgroundColor: 'red', + borderColor: 'darkred', + }, + ]; + + const result = convert_timeline_data(datasets); + + // Should group into 2 datasets: PASS and FAIL + expect(result).toHaveLength(2); + const passDataset = result.find(d => d.label === 'PASS'); + const failDataset = result.find(d => d.label === 'FAIL'); + expect(passDataset.data).toHaveLength(2); + expect(failDataset.data).toHaveLength(1); + }); + + it('preserves data coordinates', () => { + const datasets = [ + { + label: 'PASS', + data: [{ x: [2, 3], y: 'Test A' }], + backgroundColor: 'green', + borderColor: 'darkgreen', + }, + ]; + + const result = convert_timeline_data(datasets); + expect(result[0].data[0]).toEqual({ x: [2, 3], y: 'Test A' }); + }); + + it('returns empty array for empty input', () => { + expect(convert_timeline_data([])).toEqual([]); + }); + + it('sets parsing: true on grouped datasets', () => { + const datasets = [ + { + label: 'PASS', + data: [{ x: [0, 1], y: 'Test A' }], + backgroundColor: 'green', + borderColor: 'darkgreen', + }, + ]; + const result = convert_timeline_data(datasets); + expect(result[0].parsing).toBe(true); + }); + + it('preserves colors from original datasets', () => { + const datasets = [ + { + label: 'SKIP', + data: [{ x: [0, 1], y: 'Test A' }], + backgroundColor: 'yellow', + borderColor: 'gold', + }, + ]; + const result = convert_timeline_data(datasets); + expect(result[0].backgroundColor).toBe('yellow'); + expect(result[0].borderColor).toBe('gold'); + }); +}); diff --git a/tests/javascript/graph_data/messages.test.js b/tests/javascript/graph_data/messages.test.js new file mode 100644 index 00000000..73f7a71d --- /dev/null +++ b/tests/javascript/graph_data/messages.test.js @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies +vi.mock('@js/variables/settings.js', () => ({ + settings: { + show: { + aliases: false, + rounding: 6, + }, + }, +})); +vi.mock('@js/variables/globals.js', () => ({ + inFullscreen: false, + inFullscreenGraph: '', +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + failedConfig: { + backgroundColor: 'rgba(206, 62, 1, 0.7)', + borderColor: '#ce3e01', + }, +})); +vi.mock('@js/variables/data.js', () => ({ + message_config: 'placeholder_message_config', +})); +vi.mock('@js/graph_data/helpers.js', () => ({ + convert_timeline_data: (datasets) => { + const grouped = {}; + for (const ds of datasets) { + const key = `${ds.label}::${ds.backgroundColor}::${ds.borderColor}`; + if (!grouped[key]) { + grouped[key] = { label: ds.label, data: [], backgroundColor: ds.backgroundColor, borderColor: ds.borderColor, parsing: true }; + } + grouped[key].data.push(...ds.data); + } + return Object.values(grouped); + }, +})); +vi.mock('@js/common.js', () => ({ + strip_tz_suffix: (s) => s.replace(/[+-]\d{2}:\d{2}$/, ''), +})); + +import { get_messages_data } from '@js/graph_data/messages.js'; +import { settings } from '@js/variables/settings.js'; + + +function makeTestData(entries) { + return entries.map(e => ({ + name: e.name || 'Some Test', + run_start: e.run_start, + run_alias: e.run_alias || e.run_start, + passed: e.passed ?? 0, + failed: e.failed ?? 0, + skipped: e.skipped ?? 0, + elapsed_s: e.elapsed_s ?? 1.0, + message: e.message || '', + })); +} + + +describe('get_messages_data', () => { + beforeEach(() => { + settings.show.aliases = false; + }); + + describe('bar graph type', () => { + it('groups failures by message and returns bar chart data', () => { + const data = makeTestData([ + { run_start: '2025-01-15 10:00:00', failed: 1, message: 'Assertion failed' }, + { run_start: '2025-01-16 10:00:00', failed: 1, message: 'Assertion failed' }, + { run_start: '2025-01-15 10:00:00', failed: 1, message: 'Timeout' }, + ]); + const [graphData, callbackData] = get_messages_data('test', 'bar', data); + + expect(graphData.labels).toContain('Assertion failed'); + expect(graphData.labels).toContain('Timeout'); + // Assertion failed appeared 2 times, Timeout 1 time + const idx = graphData.labels.indexOf('Assertion failed'); + expect(graphData.datasets[0].data[idx]).toBe(2); + }); + + it('ignores items with no message', () => { + const data = makeTestData([ + { run_start: '2025-01-15 10:00:00', failed: 1, message: '' }, + { run_start: '2025-01-15 10:00:00', failed: 1, message: 'Error' }, + ]); + const [graphData] = get_messages_data('test', 'bar', data); + expect(graphData.labels).toEqual(['Error']); + }); + + it('ignores passed items (only includes failed/skipped)', () => { + const data = makeTestData([ + { run_start: '2025-01-15 10:00:00', passed: 1, message: 'All good' }, + { run_start: '2025-01-15 10:00:00', failed: 1, message: 'Error' }, + ]); + const [graphData] = get_messages_data('test', 'bar', data); + expect(graphData.labels).toEqual(['Error']); + }); + + it('includes skipped items with messages', () => { + const data = makeTestData([ + { run_start: '2025-01-15 10:00:00', skipped: 1, message: 'Precondition not met' }, + ]); + const [graphData] = get_messages_data('test', 'bar', data); + expect(graphData.labels).toEqual(['Precondition not met']); + }); + + it('sorts messages by frequency descending', () => { + const data = makeTestData([ + { run_start: '2025-01-15 10:00:00', failed: 1, message: 'Rare Error' }, + { run_start: '2025-01-15 10:00:00', failed: 1, message: 'Common Error' }, + { run_start: '2025-01-16 10:00:00', failed: 1, message: 'Common Error' }, + { run_start: '2025-01-17 10:00:00', failed: 1, message: 'Common Error' }, + ]); + const [graphData] = get_messages_data('test', 'bar', data); + expect(graphData.labels[0]).toBe('Common Error'); + expect(graphData.datasets[0].data[0]).toBe(3); + }); + + it('limits to 10 messages by default', () => { + const data = makeTestData( + Array.from({ length: 15 }, (_, i) => ({ + run_start: '2025-01-15 10:00:00', + failed: 1, + message: `Error ${i}`, + })) + ); + const [graphData] = get_messages_data('test', 'bar', data); + expect(graphData.labels.length).toBe(10); + }); + + it('returns empty data for no failures', () => { + const data = makeTestData([ + { run_start: '2025-01-15 10:00:00', passed: 1, message: '' }, + ]); + const [graphData] = get_messages_data('test', 'bar', data); + expect(graphData.labels).toEqual([]); + expect(graphData.datasets[0].data).toEqual([]); + }); + }); + + describe('timeline graph type', () => { + it('returns graphData, runStartsArray, and pointMeta', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1, message: 'Error 1' }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', failed: 1, message: 'Error 1' }, + ]); + const [graphData, runStartsArray, pointMeta] = get_messages_data('test', 'timeline', data); + + expect(graphData.labels).toContain('Error 1'); + expect(graphData.datasets.length).toBeGreaterThan(0); + expect(runStartsArray.length).toBeGreaterThan(0); + expect(pointMeta).toBeDefined(); + }); + + it('records status in pointMeta for each data point', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', failed: 1, elapsed_s: 2.5, message: 'Error' }, + ]); + const [, , pointMeta] = get_messages_data('test', 'timeline', data); + const key = 'Error::0'; + expect(pointMeta[key]).toBeDefined(); + expect(pointMeta[key].status).toBe('FAIL'); + expect(pointMeta[key].elapsed_s).toBe(2.5); + }); + + it('sorts run starts chronologically', () => { + const data = makeTestData([ + { run_start: '2025-01-17 10:00:00', failed: 1, message: 'Error' }, + { run_start: '2025-01-15 10:00:00', failed: 1, message: 'Error' }, + ]); + const [, runStartsArray] = get_messages_data('test', 'timeline', data); + const firstTime = new Date(runStartsArray[0]).getTime(); + const lastTime = new Date(runStartsArray[runStartsArray.length - 1]).getTime(); + expect(firstTime).toBeLessThanOrEqual(lastTime); + }); + }); +}); diff --git a/tests/javascript/graph_data/time_consuming.test.js b/tests/javascript/graph_data/time_consuming.test.js new file mode 100644 index 00000000..c0074210 --- /dev/null +++ b/tests/javascript/graph_data/time_consuming.test.js @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies +vi.mock('@js/variables/settings.js', () => ({ + settings: { + switch: { + useLibraryNames: false, + suitePathsSuiteSection: false, + suitePathsTestSection: false, + }, + show: { + aliases: false, + rounding: 6, + }, + }, +})); +vi.mock('@js/variables/globals.js', () => ({ + inFullscreen: false, + inFullscreenGraph: '', +})); +vi.mock('@js/variables/chartconfig.js', () => ({ + blueConfig: { + backgroundColor: 'rgba(54, 162, 235, 0.5)', + borderColor: 'rgba(54, 162, 235)', + }, +})); +vi.mock('@js/graph_data/helpers.js', () => ({ + convert_timeline_data: (datasets) => { + const grouped = {}; + for (const ds of datasets) { + const key = `${ds.label}::${ds.backgroundColor}::${ds.borderColor}`; + if (!grouped[key]) { + grouped[key] = { label: ds.label, data: [], backgroundColor: ds.backgroundColor, borderColor: ds.borderColor, parsing: true }; + } + grouped[key].data.push(...ds.data); + } + return Object.values(grouped); + }, +})); + +import { get_most_time_consuming_or_most_used_data } from '@js/graph_data/time_consuming.js'; +import { settings } from '@js/variables/settings.js'; + + +function makeTestData(entries) { + return entries.map(e => ({ + name: e.name, + full_name: e.full_name || `Suite.${e.name}`, + run_start: e.run_start, + run_alias: e.run_alias || e.run_start, + elapsed_s: e.elapsed_s ?? 1.0, + total_time_s: e.total_time_s ?? 1.0, + times_run: e.times_run ?? 1, + passed: e.passed ?? 1, + failed: e.failed ?? 0, + skipped: e.skipped ?? 0, + owner: e.owner || undefined, + })); +} + +describe('get_most_time_consuming_or_most_used_data', () => { + beforeEach(() => { + settings.switch.useLibraryNames = false; + settings.switch.suitePathsSuiteSection = false; + settings.switch.suitePathsTestSection = false; + settings.show.aliases = false; + }); + + describe('bar graph type - most time consuming', () => { + it('returns bar data sorted by occurrence count across runs', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', elapsed_s: 5 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', elapsed_s: 3 }, + { name: 'Test B', run_start: '2025-01-15 10:00:00', elapsed_s: 10 }, + ]); + const [graphData, callbackData] = get_most_time_consuming_or_most_used_data( + 'test', 'bar', data, false, false + ); + + expect(graphData.labels).toBeDefined(); + expect(graphData.datasets).toHaveLength(1); + expect(graphData.datasets[0].data.length).toBeGreaterThan(0); + }); + + it('returns data for only last run when onlyLastRun=true', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', elapsed_s: 5 }, + { name: 'Test B', run_start: '2025-01-15 10:00:00', elapsed_s: 10 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', elapsed_s: 3 }, + { name: 'Test B', run_start: '2025-01-16 10:00:00', elapsed_s: 8 }, + ]); + const [graphData] = get_most_time_consuming_or_most_used_data( + 'test', 'bar', data, true, false + ); + // Should only include last run data (2025-01-16) + expect(graphData.labels.length).toBeLessThanOrEqual(10); + // B has more elapsed time, should be first + expect(graphData.labels[0]).toBe('Test B'); + }); + + it('uses total_time_s for keyword dataType', () => { + const data = makeTestData([ + { name: 'KW A', run_start: '2025-01-15 10:00:00', total_time_s: 20, elapsed_s: 1 }, + { name: 'KW B', run_start: '2025-01-15 10:00:00', total_time_s: 5, elapsed_s: 10 }, + ]); + const [graphData] = get_most_time_consuming_or_most_used_data( + 'keyword', 'bar', data, true, false + ); + // For keywords, total_time_s is used, KW A has higher total_time_s + expect(graphData.labels[0]).toBe('KW A'); + }); + }); + + describe('bar graph type - most used', () => { + it('sorts by times_run when mostUsed=true and onlyLastRun=true', () => { + const data = makeTestData([ + { name: 'KW A', run_start: '2025-01-15 10:00:00', times_run: 100 }, + { name: 'KW B', run_start: '2025-01-15 10:00:00', times_run: 50 }, + ]); + const [graphData] = get_most_time_consuming_or_most_used_data( + 'keyword', 'bar', data, true, true + ); + expect(graphData.labels[0]).toBe('KW A'); + expect(graphData.datasets[0].data[0]).toBe(100); + }); + }); + + describe('timeline graph type', () => { + it('returns graphData with labels and datasets, plus callback data', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', elapsed_s: 5 }, + { name: 'Test A', run_start: '2025-01-16 10:00:00', elapsed_s: 3 }, + ]); + const [graphData, callbackData] = get_most_time_consuming_or_most_used_data( + 'test', 'timeline', data, false, false + ); + + expect(graphData.labels).toContain('Test A'); + expect(graphData.datasets.length).toBeGreaterThan(0); + expect(callbackData.runs).toBeDefined(); + expect(callbackData.details).toBeDefined(); + }); + + it('includes details with duration and pass/fail/skip counts', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', elapsed_s: 5, passed: 1, failed: 0, skipped: 0 }, + ]); + const [, callbackData] = get_most_time_consuming_or_most_used_data( + 'test', 'timeline', data, false, false + ); + const details = callbackData.details; + expect(details['Test A']).toBeDefined(); + const run = details['Test A']['2025-01-15 10:00:00']; + expect(run.duration).toBe(5); + expect(run.passed).toBe(1); + }); + }); + + describe('suite paths settings', () => { + it('uses full_name for suite data type with suitePathsSuiteSection', () => { + settings.switch.suitePathsSuiteSection = true; + const data = makeTestData([ + { name: 'MySuite', full_name: 'Root.MySuite', run_start: '2025-01-15 10:00:00' }, + ]); + const [graphData] = get_most_time_consuming_or_most_used_data( + 'suite', 'bar', data, true, false + ); + expect(graphData.labels[0]).toBe('Root.MySuite'); + }); + + it('uses owner.name for keywords with useLibraryNames', () => { + settings.switch.useLibraryNames = true; + const data = makeTestData([ + { name: 'MyKW', owner: 'BuiltIn', run_start: '2025-01-15 10:00:00' }, + ]); + const [graphData] = get_most_time_consuming_or_most_used_data( + 'keyword', 'bar', data, true, false + ); + expect(graphData.labels[0]).toBe('BuiltIn.MyKW'); + }); + }); + + describe('callback data structure', () => { + it('bar callback includes aliases, run_starts, and details', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', run_alias: 'Alias1', elapsed_s: 5 }, + ]); + const [, callbackData] = get_most_time_consuming_or_most_used_data( + 'test', 'bar', data, false, false + ); + expect(callbackData.aliases).toBeDefined(); + expect(callbackData.run_starts).toBeDefined(); + expect(callbackData.details).toBeDefined(); + }); + + it('timeline callback includes runs, aliases, and details', () => { + const data = makeTestData([ + { name: 'Test A', run_start: '2025-01-15 10:00:00', run_alias: 'Alias1', elapsed_s: 5 }, + ]); + const [, callbackData] = get_most_time_consuming_or_most_used_data( + 'test', 'timeline', data, false, false + ); + expect(callbackData.runs).toBeDefined(); + expect(callbackData.aliases).toBeDefined(); + expect(callbackData.details).toBeDefined(); + }); + }); +}); diff --git a/tests/javascript/graph_data/tooltip_helpers.test.js b/tests/javascript/graph_data/tooltip_helpers.test.js new file mode 100644 index 00000000..60b662c2 --- /dev/null +++ b/tests/javascript/graph_data/tooltip_helpers.test.js @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { build_tooltip_meta, lookup_tooltip_meta, format_status } from '@js/graph_data/tooltip_helpers.js'; + + +describe('build_tooltip_meta', () => { + const sampleData = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'Run A', elapsed_s: '5.5', passed: 1, failed: 0, skipped: 0, message: '' }, + { run_start: '2025-01-16 10:00:00', run_alias: 'Run B', elapsed_s: '3.2', passed: 0, failed: 1, skipped: 0, message: 'Assertion failed' }, + ]; + + it('populates byLabel with run_start and run_alias keys', () => { + const meta = build_tooltip_meta(sampleData); + expect(meta.byLabel['2025-01-15 10:00:00']).toBeDefined(); + expect(meta.byLabel['Run A']).toBeDefined(); + expect(meta.byLabel['2025-01-16 10:00:00']).toBeDefined(); + expect(meta.byLabel['Run B']).toBeDefined(); + }); + + it('populates byTime with timestamp keys', () => { + const meta = build_tooltip_meta(sampleData); + const t1 = new Date('2025-01-15T10:00:00').getTime(); + const t2 = new Date('2025-01-16T10:00:00').getTime(); + expect(meta.byTime[t1]).toBeDefined(); + expect(meta.byTime[t2]).toBeDefined(); + }); + + it('parses elapsed from the specified durationField', () => { + const data = [{ run_start: '2025-01-15 10:00:00', run_alias: 'A', total_time_s: '12.5', passed: 0, failed: 0, skipped: 0, message: '' }]; + const meta = build_tooltip_meta(data, 'total_time_s'); + expect(meta.byLabel['A'].elapsed_s).toBe(12.5); + }); + + it('defaults to elapsed_s field', () => { + const meta = build_tooltip_meta(sampleData); + expect(meta.byLabel['Run A'].elapsed_s).toBe(5.5); + }); + + it('stores pass/fail/skip counts and message', () => { + const meta = build_tooltip_meta(sampleData); + expect(meta.byLabel['Run B']).toEqual({ + elapsed_s: 3.2, + passed: 0, + failed: 1, + skipped: 0, + message: 'Assertion failed', + }); + }); + + it('handles empty data', () => { + const meta = build_tooltip_meta([]); + expect(meta.byLabel).toEqual({}); + expect(meta.byTime).toEqual({}); + }); + + it('handles missing optional fields gracefully', () => { + const data = [{ run_start: '2025-01-15 10:00:00', run_alias: 'A' }]; + const meta = build_tooltip_meta(data); + expect(meta.byLabel['A']).toEqual({ + elapsed_s: 0, + passed: 0, + failed: 0, + skipped: 0, + message: '', + }); + }); + + describe('aggregate mode', () => { + it('sums values for the same run_start when aggregate=true', () => { + const data = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'Run A', elapsed_s: '5', passed: 3, failed: 1, skipped: 0, message: '' }, + { run_start: '2025-01-15 10:00:00', run_alias: 'Run A', elapsed_s: '3', passed: 2, failed: 0, skipped: 1, message: '' }, + ]; + const meta = build_tooltip_meta(data, 'elapsed_s', true); + expect(meta.byLabel['Run A'].elapsed_s).toBe(8); + expect(meta.byLabel['Run A'].passed).toBe(5); + expect(meta.byLabel['Run A'].failed).toBe(1); + expect(meta.byLabel['Run A'].skipped).toBe(1); + }); + + it('sums byTime for same timestamp when aggregate=true', () => { + const data = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'A', elapsed_s: '2', passed: 1, failed: 0, skipped: 0, message: '' }, + { run_start: '2025-01-15 10:00:00', run_alias: 'A', elapsed_s: '3', passed: 0, failed: 1, skipped: 0, message: '' }, + ]; + const meta = build_tooltip_meta(data, 'elapsed_s', true); + const t = new Date('2025-01-15T10:00:00').getTime(); + expect(meta.byTime[t].elapsed_s).toBe(5); + }); + + it('does not aggregate different run_starts', () => { + const data = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'A', elapsed_s: '5', passed: 1, failed: 0, skipped: 0, message: '' }, + { run_start: '2025-01-16 10:00:00', run_alias: 'B', elapsed_s: '3', passed: 0, failed: 1, skipped: 0, message: '' }, + ]; + const meta = build_tooltip_meta(data, 'elapsed_s', true); + expect(meta.byLabel['A'].elapsed_s).toBe(5); + expect(meta.byLabel['B'].elapsed_s).toBe(3); + }); + + it('keeps first entry when aggregate=false and same key appears again', () => { + const data = [ + { run_start: '2025-01-15 10:00:00', run_alias: 'A', elapsed_s: '5', passed: 1, failed: 0, skipped: 0, message: '' }, + { run_start: '2025-01-15 10:00:00', run_alias: 'A', elapsed_s: '3', passed: 0, failed: 1, skipped: 0, message: '' }, + ]; + const meta = build_tooltip_meta(data, 'elapsed_s', false); + // First entry wins, no aggregation + expect(meta.byLabel['A'].elapsed_s).toBe(5); + expect(meta.byLabel['A'].passed).toBe(1); + }); + }); +}); + + +describe('lookup_tooltip_meta', () => { + const meta = { + byLabel: { + 'Test A': { elapsed_s: 5, passed: 1, failed: 0, skipped: 0, message: '' }, + '2025-01-15 10:00:00': { elapsed_s: 3, passed: 0, failed: 1, skipped: 0, message: 'err' }, + }, + byTime: { + [new Date('2025-01-15T10:00:00').getTime()]: { elapsed_s: 3, passed: 0, failed: 1, skipped: 0, message: 'err' }, + }, + }; + + it('returns null for empty tooltipItems', () => { + expect(lookup_tooltip_meta(meta, [])).toBeNull(); + expect(lookup_tooltip_meta(meta, null)).toBeNull(); + expect(lookup_tooltip_meta(meta, undefined)).toBeNull(); + }); + + it('looks up by chart data labels (bar charts)', () => { + const tooltipItems = [{ + dataIndex: 0, + chart: { data: { labels: ['Test A', 'Test B'] } }, + }]; + const result = lookup_tooltip_meta(meta, tooltipItems); + expect(result).toEqual(meta.byLabel['Test A']); + }); + + it('looks up by raw x value (time axis)', () => { + const t = new Date('2025-01-15T10:00:00'); + const tooltipItems = [{ + dataIndex: 0, + chart: { data: { labels: [] } }, + raw: { x: t }, + }]; + const result = lookup_tooltip_meta(meta, tooltipItems); + expect(result).toEqual(meta.byTime[t.getTime()]); + }); + + it('falls back to tooltip label text', () => { + const tooltipItems = [{ + dataIndex: 99, + chart: { data: { labels: [] } }, + label: 'Test A', + }]; + const result = lookup_tooltip_meta(meta, tooltipItems); + expect(result).toEqual(meta.byLabel['Test A']); + }); + + it('returns null when no match found', () => { + const tooltipItems = [{ + dataIndex: 0, + chart: { data: { labels: [] } }, + label: 'Unknown', + }]; + expect(lookup_tooltip_meta(meta, tooltipItems)).toBeNull(); + }); +}); + + +describe('format_status', () => { + it('returns PASS for single pass', () => { + expect(format_status({ passed: 1, failed: 0, skipped: 0 })).toBe('PASS'); + }); + + it('returns FAIL for single fail', () => { + expect(format_status({ passed: 0, failed: 1, skipped: 0 })).toBe('FAIL'); + }); + + it('returns SKIP for single skip', () => { + expect(format_status({ passed: 0, failed: 0, skipped: 1 })).toBe('SKIP'); + }); + + it('returns aggregate string for mixed counts', () => { + expect(format_status({ passed: 5, failed: 2, skipped: 1 })).toBe('Passed: 5, Failed: 2, Skipped: 1'); + }); + + it('returns aggregate string when all zeros', () => { + expect(format_status({ passed: 0, failed: 0, skipped: 0 })).toBe('Passed: 0, Failed: 0, Skipped: 0'); + }); + + it('returns aggregate string for multiple passes', () => { + expect(format_status({ passed: 3, failed: 0, skipped: 0 })).toBe('Passed: 3, Failed: 0, Skipped: 0'); + }); +}); diff --git a/tests/javascript/localstorage.test.js b/tests/javascript/localstorage.test.js new file mode 100644 index 00000000..a679be02 --- /dev/null +++ b/tests/javascript/localstorage.test.js @@ -0,0 +1,527 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock dependencies that localstorage.js imports +vi.mock('@js/common.js', () => ({ + add_alert: vi.fn(), + close_alert: vi.fn(), + camelcase_to_underscore: (str) => + str.replace(/([A-Z]+)/g, '_$1').replace(/^_/, '').toLowerCase(), +})); +vi.mock('@js/variables/data.js', () => import('./mocks/data.js')); +vi.mock('@js/variables/globals.js', () => import('./mocks/globals.js')); +vi.mock('@js/variables/graphs.js', () => import('./mocks/graphs.js')); + +// We need to test merge_deep and related functions. +// Since they use structuredClone (available in Node 17+), we can import them. + +describe('localstorage.js pure logic', () => { + describe('merge_deep logic', () => { + // Re-implement the pure logic from localstorage.js to test it + function isObject(v) { + return v && typeof v === 'object' && !Array.isArray(v); + } + + function merge_objects_base(local, defaults) { + const merged = {}; + for (const key of new Set([...Object.keys(defaults), ...Object.keys(local)])) { + if (!(key in defaults)) continue; + if (!(key in local)) { + merged[key] = structuredClone(defaults[key]); + continue; + } + if (isObject(local[key]) && isObject(defaults[key])) { + merged[key] = merge_objects_base(local[key], defaults[key]); + } else { + merged[key] = structuredClone(local[key]); + } + } + return merged; + } + + it('merges two flat objects keeping local values', () => { + const local = { a: 1, b: 2 }; + const defaults = { a: 10, b: 20, c: 30 }; + const result = merge_objects_base(local, defaults); + expect(result).toEqual({ a: 1, b: 2, c: 30 }); + }); + + it('removes keys not in defaults', () => { + const local = { a: 1, b: 2, extra: 99 }; + const defaults = { a: 10, b: 20 }; + const result = merge_objects_base(local, defaults); + expect(result).toEqual({ a: 1, b: 2 }); + expect(result).not.toHaveProperty('extra'); + }); + + it('adds missing defaults', () => { + const local = { a: 1 }; + const defaults = { a: 10, b: 20 }; + const result = merge_objects_base(local, defaults); + expect(result).toEqual({ a: 1, b: 20 }); + }); + + it('merges nested objects recursively', () => { + const local = { nested: { a: 1 } }; + const defaults = { nested: { a: 10, b: 20 } }; + const result = merge_objects_base(local, defaults); + expect(result).toEqual({ nested: { a: 1, b: 20 } }); + }); + + it('deep clones default values', () => { + const defaults = { nested: { a: [1, 2, 3] } }; + const local = {}; + const result = merge_objects_base(local, defaults); + result.nested.a.push(4); + expect(defaults.nested.a).toEqual([1, 2, 3]); + }); + + it('deep clones local values', () => { + const local = { arr: [1, 2, 3] }; + const defaults = { arr: [] }; + const result = merge_objects_base(local, defaults); + result.arr.push(4); + expect(local.arr).toEqual([1, 2, 3]); + }); + + it('handles empty objects', () => { + expect(merge_objects_base({}, {})).toEqual({}); + expect(merge_objects_base({}, { a: 1 })).toEqual({ a: 1 }); + expect(merge_objects_base({ a: 1 }, {})).toEqual({}); + }); + }); + + describe('set_nested_setting logic', () => { + function set_nested_setting(obj, path, value) { + const keys = path.split('.'); + const lastKey = keys.pop(); + let deep = obj; + keys.forEach(k => { + if (!deep.hasOwnProperty(k) || typeof deep[k] !== 'object') { + deep[k] = {}; + } + deep = deep[k]; + }); + deep[lastKey] = value; + } + + it('sets a top-level value', () => { + const obj = { a: 1 }; + set_nested_setting(obj, 'a', 2); + expect(obj.a).toBe(2); + }); + + it('sets a nested value', () => { + const obj = { nested: { a: 1 } }; + set_nested_setting(obj, 'nested.a', 2); + expect(obj.nested.a).toBe(2); + }); + + it('creates intermediate objects if missing', () => { + const obj = {}; + set_nested_setting(obj, 'a.b.c', 42); + expect(obj.a.b.c).toBe(42); + }); + + it('overwrites non-object intermediate', () => { + const obj = { a: 'string' }; + set_nested_setting(obj, 'a.b', 42); + expect(obj.a.b).toBe(42); + }); + }); + + describe('merge_deep logic', () => { + // Reimplements the full merge_deep from localstorage.js + function isObject(v) { + return v && typeof v === 'object' && !Array.isArray(v); + } + + function merge_objects_base(local, defaults) { + const merged = {}; + for (const key of new Set([...Object.keys(defaults), ...Object.keys(local)])) { + if (!(key in defaults)) continue; + if (!(key in local)) { + merged[key] = structuredClone(defaults[key]); + continue; + } + if (isObject(local[key]) && isObject(defaults[key])) { + merged[key] = merge_objects_base(local[key], defaults[key]); + } else { + merged[key] = structuredClone(local[key]); + } + } + return merged; + } + + function merge_view_section_or_graph(local, defaults, page = null) { + const result = { show: [], hide: [] }; + const isOverview = page === 'overview'; + const allowed = new Set([...defaults.show, ...defaults.hide]); + const localShow = new Set(local.show || []); + const localHide = new Set(local.hide || []); + for (const val of [...localShow]) { + if (!allowed.has(val) && !isOverview) localShow.delete(val); + } + for (const val of [...localHide]) { + if (!allowed.has(val)) localHide.delete(val); + } + for (const val of allowed) { + if (!localShow.has(val) && !localHide.has(val)) localShow.add(val); + } + result.show = [...localShow]; + result.hide = [...localHide]; + return result; + } + + function merge_view(localView, defaultView) { + const result = {}; + for (const page of Object.keys(defaultView)) { + const defaultPage = defaultView[page]; + const localPage = localView[page] || {}; + result[page] = { + sections: merge_view_section_or_graph( + localPage.sections || {}, + defaultPage.sections, + page + ), + graphs: merge_view_section_or_graph( + localPage.graphs || {}, + defaultPage.graphs + ), + }; + } + return result; + } + + function merge_theme_colors(local, defaults) { + const result = merge_objects_base(local, defaults); + if (local.custom) { + result.custom = structuredClone(local.custom); + } + return result; + } + + function merge_layout(localLayout, mergedDefaults) { + if (!localLayout) return localLayout; + const result = structuredClone(localLayout); + const allowedGraphs = collect_allowed_graphs(mergedDefaults); + for (const key of Object.keys(result)) { + try { + const arr = JSON.parse(result[key]); + const filtered = arr.filter(item => allowedGraphs.has(item.id)); + result[key] = JSON.stringify(filtered); + } catch (e) { + delete result[key]; + } + } + return result; + } + + function collect_allowed_graphs(settings) { + const allowed = new Set(); + const extract = (obj) => { + if (!obj) return; + for (const key of ['show', 'hide']) { + if (Array.isArray(obj[key])) { + for (const g of obj[key]) allowed.add(g); + } + } + }; + if (settings.view.dashboard) extract(settings.view.dashboard.graphs); + if (settings.view.compare) extract(settings.view.compare.graphs); + return allowed; + } + + function merge_deep(local, defaults) { + const result = {}; + for (const key of new Set([...Object.keys(defaults), ...Object.keys(local)])) { + const defaultVal = defaults[key]; + const localVal = local[key]; + if (key !== 'layouts' && key !== 'libraries' && key !== 'theme' && key !== 'filterProfiles' && defaultVal === undefined && localVal !== undefined) { + continue; + } + if (defaultVal !== undefined && localVal === undefined) { + result[key] = structuredClone(defaultVal); + continue; + } + if (key === 'view') { + result[key] = merge_view(localVal, defaultVal); + } else if (key === 'layouts') { + result[key] = merge_layout(localVal, defaults); + } else if (key === 'theme_colors') { + result[key] = merge_theme_colors(localVal, defaultVal); + } else if (isObject(localVal) && isObject(defaultVal)) { + result[key] = merge_objects_base(localVal, defaultVal); + } else { + result[key] = structuredClone(localVal); + } + } + return result; + } + + describe('merge_view_section_or_graph', () => { + it('adds missing defaults to show', () => { + const local = { show: ['A'], hide: [] }; + const defaults = { show: ['A', 'B', 'C'], hide: [] }; + const result = merge_view_section_or_graph(local, defaults); + expect(result.show).toContain('A'); + expect(result.show).toContain('B'); + expect(result.show).toContain('C'); + }); + + it('preserves items in hide', () => { + const local = { show: ['A'], hide: ['B'] }; + const defaults = { show: ['A', 'B'], hide: [] }; + const result = merge_view_section_or_graph(local, defaults); + expect(result.show).toContain('A'); + expect(result.hide).toContain('B'); + expect(result.show).not.toContain('B'); + }); + + it('removes unknown items from local show (non-overview)', () => { + const local = { show: ['A', 'UNKNOWN'], hide: [] }; + const defaults = { show: ['A'], hide: [] }; + const result = merge_view_section_or_graph(local, defaults); + expect(result.show).toContain('A'); + expect(result.show).not.toContain('UNKNOWN'); + }); + + it('preserves unknown items in show for overview page', () => { + const local = { show: ['A', 'DynamicProject'], hide: [] }; + const defaults = { show: ['A'], hide: [] }; + const result = merge_view_section_or_graph(local, defaults, 'overview'); + expect(result.show).toContain('DynamicProject'); + }); + + it('removes unknown items from hide', () => { + const local = { show: [], hide: ['UNKNOWN'] }; + const defaults = { show: ['A'], hide: [] }; + const result = merge_view_section_or_graph(local, defaults); + expect(result.hide).not.toContain('UNKNOWN'); + expect(result.show).toContain('A'); + }); + + it('handles empty local', () => { + const local = {}; + const defaults = { show: ['A', 'B'], hide: ['C'] }; + const result = merge_view_section_or_graph(local, defaults); + expect(result.show).toContain('A'); + expect(result.show).toContain('B'); + expect(result.show).toContain('C'); + }); + }); + + describe('merge_view', () => { + it('merges view pages with sections and graphs', () => { + const localView = { + dashboard: { + sections: { show: ['Run Statistics'], hide: ['Suite Statistics'] }, + graphs: { show: ['graphA'], hide: [] }, + }, + }; + const defaultView = { + dashboard: { + sections: { show: ['Run Statistics', 'Suite Statistics', 'Test Statistics'], hide: [] }, + graphs: { show: ['graphA', 'graphB'], hide: [] }, + }, + }; + const result = merge_view(localView, defaultView); + expect(result.dashboard.sections.show).toContain('Run Statistics'); + expect(result.dashboard.sections.show).toContain('Test Statistics'); + expect(result.dashboard.sections.hide).toContain('Suite Statistics'); + expect(result.dashboard.graphs.show).toContain('graphB'); + }); + + it('creates page from defaults when missing in local', () => { + const localView = {}; + const defaultView = { + dashboard: { + sections: { show: ['A'], hide: [] }, + graphs: { show: ['G1'], hide: [] }, + }, + }; + const result = merge_view(localView, defaultView); + expect(result.dashboard.sections.show).toContain('A'); + expect(result.dashboard.graphs.show).toContain('G1'); + }); + }); + + describe('merge_theme_colors', () => { + it('merges standard color keys using merge_objects_base', () => { + const local = { light: { background: '#fff' }, dark: { background: '#000' }, custom: {} }; + const defaults = { light: { background: '#eee', card: '#ffffff' }, dark: { background: '#0f172a', card: 'rgba(30,41,59,0.9)' }, custom: {} }; + const result = merge_theme_colors(local, defaults); + expect(result.light.background).toBe('#fff'); + expect(result.light.card).toBe('#ffffff'); + expect(result.dark.background).toBe('#000'); + }); + + it('preserves custom colors from local even if not in defaults', () => { + const local = { light: {}, dark: {}, custom: { light: { myColor: '#abc' }, dark: {} } }; + const defaults = { light: {}, dark: {}, custom: {} }; + const result = merge_theme_colors(local, defaults); + expect(result.custom.light.myColor).toBe('#abc'); + }); + + it('deep clones custom to avoid mutation', () => { + const local = { light: {}, dark: {}, custom: { light: { myColor: '#abc' }, dark: {} } }; + const defaults = { light: {}, dark: {}, custom: {} }; + const result = merge_theme_colors(local, defaults); + result.custom.light.myColor = '#changed'; + expect(local.custom.light.myColor).toBe('#abc'); + }); + }); + + describe('merge_layout', () => { + it('filters layout entries to only allowed graph IDs', () => { + const localLayout = { + section1: JSON.stringify([{ id: 'graphA' }, { id: 'graphB' }, { id: 'removed' }]), + }; + const mergedDefaults = { + view: { + dashboard: { graphs: { show: ['graphA', 'graphB'], hide: [] } }, + compare: { graphs: { show: [], hide: [] } }, + }, + }; + const result = merge_layout(localLayout, mergedDefaults); + const parsed = JSON.parse(result.section1); + expect(parsed).toHaveLength(2); + expect(parsed.map(p => p.id)).toEqual(['graphA', 'graphB']); + }); + + it('returns null when localLayout is null', () => { + expect(merge_layout(null, {})).toBeNull(); + }); + + it('returns undefined when localLayout is undefined', () => { + expect(merge_layout(undefined, {})).toBeUndefined(); + }); + + it('removes sections with invalid JSON', () => { + const localLayout = { + valid: JSON.stringify([{ id: 'graphA' }]), + invalid: 'not-json', + }; + const mergedDefaults = { + view: { + dashboard: { graphs: { show: ['graphA'], hide: [] } }, + compare: { graphs: { show: [], hide: [] } }, + }, + }; + const result = merge_layout(localLayout, mergedDefaults); + expect(result.valid).toBeDefined(); + expect(result.invalid).toBeUndefined(); + }); + }); + + describe('collect_allowed_graphs', () => { + it('collects graph IDs from dashboard and compare views', () => { + const settings = { + view: { + dashboard: { graphs: { show: ['g1', 'g2'], hide: ['g3'] } }, + compare: { graphs: { show: ['g4'], hide: [] } }, + }, + }; + const allowed = collect_allowed_graphs(settings); + expect(allowed.has('g1')).toBe(true); + expect(allowed.has('g2')).toBe(true); + expect(allowed.has('g3')).toBe(true); + expect(allowed.has('g4')).toBe(true); + }); + + it('handles missing dashboard or compare views', () => { + const settings = { view: {} }; + const allowed = collect_allowed_graphs(settings); + expect(allowed.size).toBe(0); + }); + }); + + describe('merge_deep (full integration)', () => { + it('preserves layouts key even when not in defaults', () => { + const local = { layouts: { sec: '[]' }, show: { aliases: true } }; + const defaults = { + show: { aliases: false, legends: true }, + view: { + dashboard: { graphs: { show: [], hide: [] } }, + compare: { graphs: { show: [], hide: [] } }, + }, + }; + const result = merge_deep(local, defaults); + expect(result.layouts).toBeDefined(); + expect(result.show.aliases).toBe(true); + expect(result.show.legends).toBe(true); + }); + + it('preserves libraries key even when not in defaults', () => { + const local = { libraries: { BuiltIn: true }, show: { aliases: false } }; + const defaults = { show: { aliases: false } }; + const result = merge_deep(local, defaults); + expect(result.libraries).toBeDefined(); + }); + + it('preserves filterProfiles key even when not in defaults', () => { + const local = { filterProfiles: { myProfile: {} }, show: { aliases: false } }; + const defaults = { show: { aliases: false } }; + const result = merge_deep(local, defaults); + expect(result.filterProfiles).toBeDefined(); + }); + + it('removes unknown keys not in exceptions list', () => { + const local = { unknownKey: 'value', show: { aliases: true } }; + const defaults = { show: { aliases: false } }; + const result = merge_deep(local, defaults); + expect(result.unknownKey).toBeUndefined(); + }); + + it('adds missing defaults', () => { + const local = {}; + const defaults = { show: { aliases: false } }; + const result = merge_deep(local, defaults); + expect(result.show.aliases).toBe(false); + }); + + it('delegates view key to merge_view', () => { + const local = { + view: { + dashboard: { + sections: { show: ['A'], hide: ['B'] }, + graphs: { show: [], hide: [] }, + }, + }, + }; + const defaults = { + view: { + dashboard: { + sections: { show: ['A', 'B', 'C'], hide: [] }, + graphs: { show: ['G1'], hide: [] }, + }, + }, + }; + const result = merge_deep(local, defaults); + expect(result.view.dashboard.sections.hide).toContain('B'); + expect(result.view.dashboard.sections.show).toContain('C'); + expect(result.view.dashboard.graphs.show).toContain('G1'); + }); + + it('delegates theme_colors key to merge_theme_colors', () => { + const local = { + theme_colors: { + light: { background: '#fff' }, + dark: {}, + custom: { light: { extra: '#123' }, dark: {} }, + }, + }; + const defaults = { + theme_colors: { + light: { background: '#eee', card: '#ffffff' }, + dark: { background: '#000' }, + custom: {}, + }, + }; + const result = merge_deep(local, defaults); + expect(result.theme_colors.light.background).toBe('#fff'); + expect(result.theme_colors.light.card).toBe('#ffffff'); + expect(result.theme_colors.custom.light.extra).toBe('#123'); + }); + }); + }); +}); diff --git a/tests/javascript/mocks/chartconfig.js b/tests/javascript/mocks/chartconfig.js new file mode 100644 index 00000000..82dc1f45 --- /dev/null +++ b/tests/javascript/mocks/chartconfig.js @@ -0,0 +1,79 @@ +// Mock for variables/chartconfig.js — provides chart color/style constants for testing + +const passedBackgroundBorderColor = "#97bd61"; +const passedBackgroundColor = "rgba(151, 189, 97, 0.7)"; +const skippedBackgroundBorderColor = "#fed84f"; +const skippedBackgroundColor = "rgba(254, 216, 79, 0.7)"; +const failedBackgroundBorderColor = "#ce3e01"; +const failedBackgroundColor = "rgba(206, 62, 1, 0.7)"; +const greyBackgroundBorderColor = "#0f172a"; +const greyBackgroundColor = "rgba(33, 37, 41, 0.7)"; +const blueBackgroundBorderColor = "rgba(54, 162, 235)"; +const blueBackgroundColor = "rgba(54, 162, 235, 0.5)"; +const graphFontSize = 12; + +const barConfig = { + borderSkipped: false, + borderRadius: () => ({ + topLeft: 6, + topRight: 6, + bottomLeft: 6, + bottomRight: 6, + }), +}; + +const passedConfig = { + backgroundColor: passedBackgroundColor, + borderColor: passedBackgroundBorderColor, + ...barConfig, +}; +const failedConfig = { + backgroundColor: failedBackgroundColor, + borderColor: failedBackgroundBorderColor, + ...barConfig, +}; +const skippedConfig = { + backgroundColor: skippedBackgroundColor, + borderColor: skippedBackgroundBorderColor, + ...barConfig, +}; +const blueConfig = { + backgroundColor: blueBackgroundColor, + borderColor: blueBackgroundBorderColor, + ...barConfig, +}; +const lineConfig = { + tension: 0.1, + pointRadius: 4, + pointHoverRadius: 6, +}; +const dataLabelConfig = { + color: "#eee", + backgroundColor: () => "rgba(0, 0, 0, 0.6)", + borderRadius: 4, + padding: 3, + align: "center", + anchor: "center", + font: { size: graphFontSize }, +}; + +export { + passedBackgroundBorderColor, + passedBackgroundColor, + skippedBackgroundBorderColor, + skippedBackgroundColor, + failedBackgroundBorderColor, + failedBackgroundColor, + greyBackgroundBorderColor, + greyBackgroundColor, + blueBackgroundBorderColor, + blueBackgroundColor, + graphFontSize, + barConfig, + passedConfig, + failedConfig, + skippedConfig, + blueConfig, + lineConfig, + dataLabelConfig, +}; diff --git a/tests/javascript/mocks/data.js b/tests/javascript/mocks/data.js new file mode 100644 index 00000000..015a09e3 --- /dev/null +++ b/tests/javascript/mocks/data.js @@ -0,0 +1,32 @@ +// Mock for variables/data.js — provides lightweight test data +// instead of compressed data from the HTML template + +const runs = []; +const suites = []; +const tests = []; +const keywords = []; +const message_config = "placeholder_message_config"; +const force_json_config = false; +const json_config = "placeholder_json_config"; +let filteredAmount = 0; +const filteredAmountDefault = 0; +const use_logs = false; +const server = false; +const unified_dashboard_title = "Robot Framework Dashboard"; +const no_auto_update = false; + +export { + runs, + suites, + tests, + keywords, + message_config, + force_json_config, + json_config, + filteredAmount, + filteredAmountDefault, + use_logs, + server, + unified_dashboard_title, + no_auto_update, +}; diff --git a/tests/javascript/mocks/globals.js b/tests/javascript/mocks/globals.js new file mode 100644 index 00000000..b53d099e --- /dev/null +++ b/tests/javascript/mocks/globals.js @@ -0,0 +1,69 @@ +// Mock for variables/globals.js — provides mutable global state for testing + +const CARDS_PER_ROW = 3; +const DEFAULT_DURATION_PERCENTAGE = 20; +const projects_by_tag = {}; +const projects_by_name = {}; +const latestRunByProjectTag = {}; +const latestRunByProjectName = {}; +const versionsByProject = {}; +let areGroupedProjectsPrepared = false; +let filteredRuns; +let filteredSuites; +let filteredTests; +let filteredKeywords; +let gridUnified = null; +let gridRun = null; +let gridSuite = null; +let gridTest = null; +let gridKeyword = null; +let gridCompare = null; +let gridEditMode = false; +let selectedRunSetting = ''; +let selectedTagSetting = ''; +let showingRunTags = false; +let showingProjectVersionDialogue = false; +let inFullscreen = false; +let inFullscreenGraph = ""; +let heatMapHourAll = true; +let previousFolder = ""; +let lastScrollY = 0; +let ignoreSkips = false; +let ignoreSkipsRecent = false; +let onlyFailedFolders = false; +let overviewNavStore = { scrollHandler: null, resizeHandler: null }; + +export { + CARDS_PER_ROW, + DEFAULT_DURATION_PERCENTAGE, + projects_by_tag, + projects_by_name, + latestRunByProjectTag, + latestRunByProjectName, + versionsByProject, + areGroupedProjectsPrepared, + filteredRuns, + filteredSuites, + filteredTests, + filteredKeywords, + gridUnified, + gridRun, + gridSuite, + gridTest, + gridKeyword, + gridCompare, + gridEditMode, + selectedRunSetting, + selectedTagSetting, + showingRunTags, + showingProjectVersionDialogue, + inFullscreen, + inFullscreenGraph, + heatMapHourAll, + previousFolder, + lastScrollY, + ignoreSkips, + ignoreSkipsRecent, + onlyFailedFolders, + overviewNavStore, +}; diff --git a/tests/javascript/mocks/graphs.js b/tests/javascript/mocks/graphs.js new file mode 100644 index 00000000..31826e8d --- /dev/null +++ b/tests/javascript/mocks/graphs.js @@ -0,0 +1,36 @@ +// Mock for variables/graphs.js — provides minimal graph config for tests +import { camelcase_to_underscore } from '@js/common.js'; + +const tables = []; +const hideGraphs = []; +const fullscreenButtons = []; +const defaultGraphTypes = {}; +const graphChangeButtons = {}; +const graphVars = []; +const compareRunIds = ['compareRun1', 'compareRun2', 'compareRun3', 'compareRun4']; +const overviewSections = ["overviewLatestRuns", "overviewTotalStats"]; +const dashboardSections = ["Run Statistics", "Suite Statistics", "Test Statistics", "Keyword Statistics"]; +const unifiedSections = ["Dashboard Statistics"]; +const compareSections = ["Compare Statistics"]; +const tableSections = ["Table Statistics"]; +const dashboardGraphs = []; +const compareGraphs = []; +const tableGraphs = []; + +export { + tables, + hideGraphs, + fullscreenButtons, + defaultGraphTypes, + graphChangeButtons, + graphVars, + compareRunIds, + overviewSections, + unifiedSections, + dashboardSections, + compareSections, + tableSections, + dashboardGraphs, + compareGraphs, + tableGraphs, +}; diff --git a/tests/conftest.py b/tests/python/conftest.py similarity index 95% rename from tests/conftest.py rename to tests/python/conftest.py index c9a6293c..14af940a 100644 --- a/tests/conftest.py +++ b/tests/python/conftest.py @@ -8,7 +8,7 @@ # GC-strictness artefact that only appears with pytest-cov active. warnings.filterwarnings("ignore", category=ResourceWarning) -OUTPUTS_DIR = Path(__file__).parent.parent / "testdata" / "outputs" +OUTPUTS_DIR = Path(__file__).parent.parent / "robot" / "resources" / "outputs" SAMPLE_XML = OUTPUTS_DIR / "output-20250313-002134.xml" diff --git a/tests/test_arguments.py b/tests/python/test_arguments.py similarity index 100% rename from tests/test_arguments.py rename to tests/python/test_arguments.py diff --git a/tests/test_dashboard.py b/tests/python/test_dashboard.py similarity index 100% rename from tests/test_dashboard.py rename to tests/python/test_dashboard.py diff --git a/tests/test_database.py b/tests/python/test_database.py similarity index 99% rename from tests/test_database.py rename to tests/python/test_database.py index aae60f3e..502aa842 100644 --- a/tests/test_database.py +++ b/tests/python/test_database.py @@ -5,7 +5,7 @@ from robotframework_dashboard.database import DatabaseProcessor from robotframework_dashboard.processors import OutputProcessor -OUTPUTS_DIR = Path(__file__).parent.parent / "testdata" / "outputs" +OUTPUTS_DIR = Path(__file__).parent.parent / "robot" / "resources" / "outputs" SAMPLE_XML = OUTPUTS_DIR / "output-20250313-002134.xml" SAMPLE_XML_2 = OUTPUTS_DIR / "output-20250313-002151.xml" diff --git a/tests/test_dependencies.py b/tests/python/test_dependencies.py similarity index 100% rename from tests/test_dependencies.py rename to tests/python/test_dependencies.py diff --git a/tests/test_main.py b/tests/python/test_main.py similarity index 100% rename from tests/test_main.py rename to tests/python/test_main.py diff --git a/tests/test_processors.py b/tests/python/test_processors.py similarity index 99% rename from tests/test_processors.py rename to tests/python/test_processors.py index f3d67e7f..6105ba19 100644 --- a/tests/test_processors.py +++ b/tests/python/test_processors.py @@ -3,7 +3,7 @@ import pytest from robotframework_dashboard.processors import OutputProcessor -OUTPUTS_DIR = Path(__file__).parent.parent / "testdata" / "outputs" +OUTPUTS_DIR = Path(__file__).parent.parent / "robot" / "resources" / "outputs" SAMPLE_XML = OUTPUTS_DIR / "output-20250313-002134.xml" diff --git a/tests/test_robotdashboard.py b/tests/python/test_robotdashboard.py similarity index 99% rename from tests/test_robotdashboard.py rename to tests/python/test_robotdashboard.py index fdbab22f..6a1923ac 100644 --- a/tests/test_robotdashboard.py +++ b/tests/python/test_robotdashboard.py @@ -4,7 +4,7 @@ from robotframework_dashboard.robotdashboard import RobotDashboard -OUTPUTS_DIR = Path(__file__).parent.parent / "testdata" / "outputs" +OUTPUTS_DIR = Path(__file__).parent.parent / "robot" / "resources" / "outputs" SAMPLE_XML = OUTPUTS_DIR / "output-20250313-002134.xml" SAMPLE_XML_2 = OUTPUTS_DIR / "output-20250313-002151.xml" diff --git a/tests/test_server.py b/tests/python/test_server.py similarity index 99% rename from tests/test_server.py rename to tests/python/test_server.py index 627d3a0f..65338ddd 100644 --- a/tests/test_server.py +++ b/tests/python/test_server.py @@ -13,7 +13,7 @@ from robotframework_dashboard.server import ApiServer, ResponseMessage -OUTPUTS_DIR = Path(__file__).parent.parent / "testdata" / "outputs" +OUTPUTS_DIR = Path(__file__).parent.parent / "robot" / "resources" / "outputs" SAMPLE_XML = OUTPUTS_DIR / "output-20250313-002134.xml" diff --git a/atest/resources/cli_output/aliases.txt b/tests/robot/resources/cli_output/aliases.txt similarity index 100% rename from atest/resources/cli_output/aliases.txt rename to tests/robot/resources/cli_output/aliases.txt diff --git a/atest/resources/cli_output/dashboardtitle.txt b/tests/robot/resources/cli_output/dashboardtitle.txt similarity index 100% rename from atest/resources/cli_output/dashboardtitle.txt rename to tests/robot/resources/cli_output/dashboardtitle.txt diff --git a/atest/resources/cli_output/databaseclass.txt b/tests/robot/resources/cli_output/databaseclass.txt similarity index 100% rename from atest/resources/cli_output/databaseclass.txt rename to tests/robot/resources/cli_output/databaseclass.txt diff --git a/atest/resources/cli_output/databasepath.txt b/tests/robot/resources/cli_output/databasepath.txt similarity index 100% rename from atest/resources/cli_output/databasepath.txt rename to tests/robot/resources/cli_output/databasepath.txt diff --git a/atest/resources/cli_output/excludemilliseconds.txt b/tests/robot/resources/cli_output/excludemilliseconds.txt similarity index 100% rename from atest/resources/cli_output/excludemilliseconds.txt rename to tests/robot/resources/cli_output/excludemilliseconds.txt diff --git a/atest/resources/cli_output/generatedashboard.txt b/tests/robot/resources/cli_output/generatedashboard.txt similarity index 100% rename from atest/resources/cli_output/generatedashboard.txt rename to tests/robot/resources/cli_output/generatedashboard.txt diff --git a/atest/resources/cli_output/help.txt b/tests/robot/resources/cli_output/help.txt similarity index 100% rename from atest/resources/cli_output/help.txt rename to tests/robot/resources/cli_output/help.txt diff --git a/atest/resources/cli_output/listruns.txt b/tests/robot/resources/cli_output/listruns.txt similarity index 100% rename from atest/resources/cli_output/listruns.txt rename to tests/robot/resources/cli_output/listruns.txt diff --git a/atest/resources/cli_output/messageconfig.txt b/tests/robot/resources/cli_output/messageconfig.txt similarity index 100% rename from atest/resources/cli_output/messageconfig.txt rename to tests/robot/resources/cli_output/messageconfig.txt diff --git a/atest/resources/cli_output/namedashboard.txt b/tests/robot/resources/cli_output/namedashboard.txt similarity index 100% rename from atest/resources/cli_output/namedashboard.txt rename to tests/robot/resources/cli_output/namedashboard.txt diff --git a/atest/resources/cli_output/offlinedependencies.txt b/tests/robot/resources/cli_output/offlinedependencies.txt similarity index 100% rename from atest/resources/cli_output/offlinedependencies.txt rename to tests/robot/resources/cli_output/offlinedependencies.txt diff --git a/atest/resources/cli_output/outputfolderpath.txt b/tests/robot/resources/cli_output/outputfolderpath.txt similarity index 100% rename from atest/resources/cli_output/outputfolderpath.txt rename to tests/robot/resources/cli_output/outputfolderpath.txt diff --git a/atest/resources/cli_output/outputpath.txt b/tests/robot/resources/cli_output/outputpath.txt similarity index 100% rename from atest/resources/cli_output/outputpath.txt rename to tests/robot/resources/cli_output/outputpath.txt diff --git a/atest/resources/cli_output/quantity.txt b/tests/robot/resources/cli_output/quantity.txt similarity index 100% rename from atest/resources/cli_output/quantity.txt rename to tests/robot/resources/cli_output/quantity.txt diff --git a/atest/resources/cli_output/removerun.txt b/tests/robot/resources/cli_output/removerun.txt similarity index 100% rename from atest/resources/cli_output/removerun.txt rename to tests/robot/resources/cli_output/removerun.txt diff --git a/atest/resources/cli_output/uselogs.txt b/tests/robot/resources/cli_output/uselogs.txt similarity index 100% rename from atest/resources/cli_output/uselogs.txt rename to tests/robot/resources/cli_output/uselogs.txt diff --git a/atest/resources/cli_output/version.txt b/tests/robot/resources/cli_output/version.txt similarity index 100% rename from atest/resources/cli_output/version.txt rename to tests/robot/resources/cli_output/version.txt diff --git a/atest/resources/dashboard_output/compare/baseCompareSection.png b/tests/robot/resources/dashboard_output/compare/baseCompareSection.png similarity index 100% rename from atest/resources/dashboard_output/compare/baseCompareSection.png rename to tests/robot/resources/dashboard_output/compare/baseCompareSection.png diff --git a/atest/resources/dashboard_output/keyword/baseKeywordSection.png b/tests/robot/resources/dashboard_output/keyword/baseKeywordSection.png similarity index 100% rename from atest/resources/dashboard_output/keyword/baseKeywordSection.png rename to tests/robot/resources/dashboard_output/keyword/baseKeywordSection.png diff --git a/atest/resources/dashboard_output/overview/baseView.png b/tests/robot/resources/dashboard_output/overview/baseView.png similarity index 100% rename from atest/resources/dashboard_output/overview/baseView.png rename to tests/robot/resources/dashboard_output/overview/baseView.png diff --git a/atest/resources/dashboard_output/overview/runTags.png b/tests/robot/resources/dashboard_output/overview/runTags.png similarity index 100% rename from atest/resources/dashboard_output/overview/runTags.png rename to tests/robot/resources/dashboard_output/overview/runTags.png diff --git a/atest/resources/dashboard_output/run/baseRunSection.png b/tests/robot/resources/dashboard_output/run/baseRunSection.png similarity index 100% rename from atest/resources/dashboard_output/run/baseRunSection.png rename to tests/robot/resources/dashboard_output/run/baseRunSection.png diff --git a/atest/resources/dashboard_output/run/changedSettings.png b/tests/robot/resources/dashboard_output/run/changedSettings.png similarity index 100% rename from atest/resources/dashboard_output/run/changedSettings.png rename to tests/robot/resources/dashboard_output/run/changedSettings.png diff --git a/atest/resources/dashboard_output/run/runAmountFilter.png b/tests/robot/resources/dashboard_output/run/runAmountFilter.png similarity index 100% rename from atest/resources/dashboard_output/run/runAmountFilter.png rename to tests/robot/resources/dashboard_output/run/runAmountFilter.png diff --git a/atest/resources/dashboard_output/run/runDateFilter.png b/tests/robot/resources/dashboard_output/run/runDateFilter.png old mode 100755 new mode 100644 similarity index 100% rename from atest/resources/dashboard_output/run/runDateFilter.png rename to tests/robot/resources/dashboard_output/run/runDateFilter.png diff --git a/atest/resources/dashboard_output/run/runNameFilter.png b/tests/robot/resources/dashboard_output/run/runNameFilter.png similarity index 100% rename from atest/resources/dashboard_output/run/runNameFilter.png rename to tests/robot/resources/dashboard_output/run/runNameFilter.png diff --git a/atest/resources/dashboard_output/run/runTags.png b/tests/robot/resources/dashboard_output/run/runTags.png similarity index 100% rename from atest/resources/dashboard_output/run/runTags.png rename to tests/robot/resources/dashboard_output/run/runTags.png diff --git a/atest/resources/dashboard_output/run/runTagsFilterAmount.png b/tests/robot/resources/dashboard_output/run/runTagsFilterAmount.png similarity index 100% rename from atest/resources/dashboard_output/run/runTagsFilterAmount.png rename to tests/robot/resources/dashboard_output/run/runTagsFilterAmount.png diff --git a/atest/resources/dashboard_output/run/runTagsFilterDev.png b/tests/robot/resources/dashboard_output/run/runTagsFilterDev.png similarity index 100% rename from atest/resources/dashboard_output/run/runTagsFilterDev.png rename to tests/robot/resources/dashboard_output/run/runTagsFilterDev.png diff --git a/atest/resources/dashboard_output/run/runTagsFilterDevProd.png b/tests/robot/resources/dashboard_output/run/runTagsFilterDevProd.png similarity index 100% rename from atest/resources/dashboard_output/run/runTagsFilterDevProd.png rename to tests/robot/resources/dashboard_output/run/runTagsFilterDevProd.png diff --git a/atest/resources/dashboard_output/run/runTagsFilterProd.png b/tests/robot/resources/dashboard_output/run/runTagsFilterProd.png similarity index 100% rename from atest/resources/dashboard_output/run/runTagsFilterProd.png rename to tests/robot/resources/dashboard_output/run/runTagsFilterProd.png diff --git a/atest/resources/dashboard_output/suite/baseSuiteSection.png b/tests/robot/resources/dashboard_output/suite/baseSuiteSection.png similarity index 100% rename from atest/resources/dashboard_output/suite/baseSuiteSection.png rename to tests/robot/resources/dashboard_output/suite/baseSuiteSection.png diff --git a/atest/resources/dashboard_output/tables/baseKeywordTable.png b/tests/robot/resources/dashboard_output/tables/baseKeywordTable.png similarity index 100% rename from atest/resources/dashboard_output/tables/baseKeywordTable.png rename to tests/robot/resources/dashboard_output/tables/baseKeywordTable.png diff --git a/atest/resources/dashboard_output/tables/baseRunTable.png b/tests/robot/resources/dashboard_output/tables/baseRunTable.png similarity index 100% rename from atest/resources/dashboard_output/tables/baseRunTable.png rename to tests/robot/resources/dashboard_output/tables/baseRunTable.png diff --git a/atest/resources/dashboard_output/tables/baseSuiteTable.png b/tests/robot/resources/dashboard_output/tables/baseSuiteTable.png similarity index 100% rename from atest/resources/dashboard_output/tables/baseSuiteTable.png rename to tests/robot/resources/dashboard_output/tables/baseSuiteTable.png diff --git a/atest/resources/dashboard_output/tables/baseTestTable.png b/tests/robot/resources/dashboard_output/tables/baseTestTable.png similarity index 100% rename from atest/resources/dashboard_output/tables/baseTestTable.png rename to tests/robot/resources/dashboard_output/tables/baseTestTable.png diff --git a/atest/resources/dashboard_output/test/baseTestSection.png b/tests/robot/resources/dashboard_output/test/baseTestSection.png similarity index 100% rename from atest/resources/dashboard_output/test/baseTestSection.png rename to tests/robot/resources/dashboard_output/test/baseTestSection.png diff --git a/atest/resources/database_output/keywords.txt b/tests/robot/resources/database_output/keywords.txt similarity index 100% rename from atest/resources/database_output/keywords.txt rename to tests/robot/resources/database_output/keywords.txt diff --git a/atest/resources/database_output/runs.txt b/tests/robot/resources/database_output/runs.txt similarity index 100% rename from atest/resources/database_output/runs.txt rename to tests/robot/resources/database_output/runs.txt diff --git a/atest/resources/database_output/suites.txt b/tests/robot/resources/database_output/suites.txt similarity index 100% rename from atest/resources/database_output/suites.txt rename to tests/robot/resources/database_output/suites.txt diff --git a/atest/resources/database_output/tests.txt b/tests/robot/resources/database_output/tests.txt similarity index 100% rename from atest/resources/database_output/tests.txt rename to tests/robot/resources/database_output/tests.txt diff --git a/atest/resources/keywords/cli-keywords.resource b/tests/robot/resources/keywords/cli-keywords.resource similarity index 100% rename from atest/resources/keywords/cli-keywords.resource rename to tests/robot/resources/keywords/cli-keywords.resource diff --git a/atest/resources/keywords/dashboard-keywords.resource b/tests/robot/resources/keywords/dashboard-keywords.resource similarity index 99% rename from atest/resources/keywords/dashboard-keywords.resource rename to tests/robot/resources/keywords/dashboard-keywords.resource index 3b3ebbcb..f8b7c0b5 100644 --- a/atest/resources/keywords/dashboard-keywords.resource +++ b/tests/robot/resources/keywords/dashboard-keywords.resource @@ -8,7 +8,7 @@ Resource ../variables/variables.resource *** Variables *** -${ROOT_FOLDER} ${CURDIR}/../../.. +${ROOT_FOLDER} ${CURDIR}/../../../.. ${SCREENSHOT_FOLDER} ${ROOT_FOLDER}/results/browser/screenshot ${REFERENCE_FOLDER} ${CURDIR}/../dashboard_output diff --git a/atest/resources/keywords/database-keywords.resource b/tests/robot/resources/keywords/database-keywords.resource similarity index 100% rename from atest/resources/keywords/database-keywords.resource rename to tests/robot/resources/keywords/database-keywords.resource diff --git a/tests/robot/resources/keywords/general-keywords.resource b/tests/robot/resources/keywords/general-keywords.resource new file mode 100644 index 00000000..a67ce5d4 --- /dev/null +++ b/tests/robot/resources/keywords/general-keywords.resource @@ -0,0 +1,56 @@ +*** Settings *** +Library OperatingSystem +Library String +Library pabot.pabotlib +Resource ../variables/variables.resource + + +*** Keywords *** +Get Dashboard Index + Acquire Lock name=dashboard_index + ${exists} Run Keyword And Return Status Should Exist path=index.txt + IF not ${exists} + Create File path=index.txt content=1:${TEST_NAME} + RETURN ${1} + END + ${index} Get File path=index.txt + ${index} Split String string=${index} separator=: + ${index} Convert To Integer ${index}[0] + ${index} Evaluate ${index} + 1 + Create File path=index.txt content=${index}:${TEST_NAME} + RETURN ${index} + +Generate Dashboard + ${index} Get Dashboard Index + Release Lock name=dashboard_index + VAR ${DASHBOARD_INDEX} ${index} scope=test + ${files} Catenate SEPARATOR=${SPACE} + ... -o ${CURDIR}/../outputs/output-20250313-002134.xml:prod:project_1:version_1.0 + ... -o ${CURDIR}/../outputs/output-20250313-002151.xml:dev:project_1:version_1.0 + ... -o ${CURDIR}/../outputs/output-20250313-002222.xml:prod:project_2:version_1.0 + ... -o ${CURDIR}/../outputs/output-20250313-002257.xml:dev:project_2:version_1.1 + ... -o ${CURDIR}/../outputs/output-20250313-002338.xml:prod:project_1:version_1.1 + ... -o ${CURDIR}/../outputs/output-20250313-002400.xml:dev:project_1:version_1.1 + ... -o ${CURDIR}/../outputs/output-20250313-002431.xml:prod:project_2:version_1.1 + ... -o ${CURDIR}/../outputs/output-20250313-002457.xml:dev:project_2:version_1.1 + ... -o ${CURDIR}/../outputs/output-20250313-002528.xml:prod:project_1:version_1.1 + ... -o ${CURDIR}/../outputs/output-20250313-002549.xml:prod:project_1:version_1.2 + ... -o ${CURDIR}/../outputs/output-20250313-002636.xml:prod:project_2:version_1.2 + ... -o ${CURDIR}/../outputs/output-20250313-002703.xml:prod:project_2:version_1.2 + ... -o ${CURDIR}/../outputs/output-20250313-002739.xml:dev:project_1:version_1.2 + ... -o ${CURDIR}/../outputs/output-20250313-002915.xml:dev:project_1:version_1.2 + ... -o ${CURDIR}/../outputs/output-20250313-003006.xml:dev:project_2:amount:version_1.2 + ${output} Run command=robotdashboard -d robotresults_${DASHBOARD_INDEX}.db ${files} -n robotdashboard_${DASHBOARD_INDEX}.html + Log ${output} + +Remove Database And Dashboard + ${files} List Files In Directory path=${CURDIR}/../../.. + FOR ${file} IN @{files} + IF ('.db' in $file or '.html' in $file) and not ('robotresults_' in $file or 'robotdashboard_' in $file) + Remove File path=${file} + END + END + +Remove Database And Dashboard With Index + Remove File path=robotresults_${DASHBOARD_INDEX}.db + Remove File path=robotdashboard_${DASHBOARD_INDEX}.html diff --git a/testdata/outputs/log-20250313-002134.html b/tests/robot/resources/outputs/log-20250313-002134.html similarity index 100% rename from testdata/outputs/log-20250313-002134.html rename to tests/robot/resources/outputs/log-20250313-002134.html diff --git a/testdata/outputs/log-20250313-002151.html b/tests/robot/resources/outputs/log-20250313-002151.html similarity index 100% rename from testdata/outputs/log-20250313-002151.html rename to tests/robot/resources/outputs/log-20250313-002151.html diff --git a/testdata/outputs/log-20250313-002222.html b/tests/robot/resources/outputs/log-20250313-002222.html similarity index 100% rename from testdata/outputs/log-20250313-002222.html rename to tests/robot/resources/outputs/log-20250313-002222.html diff --git a/testdata/outputs/log-20250313-002257.html b/tests/robot/resources/outputs/log-20250313-002257.html similarity index 100% rename from testdata/outputs/log-20250313-002257.html rename to tests/robot/resources/outputs/log-20250313-002257.html diff --git a/testdata/outputs/log-20250313-002338.html b/tests/robot/resources/outputs/log-20250313-002338.html similarity index 100% rename from testdata/outputs/log-20250313-002338.html rename to tests/robot/resources/outputs/log-20250313-002338.html diff --git a/testdata/outputs/log-20250313-002400.html b/tests/robot/resources/outputs/log-20250313-002400.html similarity index 100% rename from testdata/outputs/log-20250313-002400.html rename to tests/robot/resources/outputs/log-20250313-002400.html diff --git a/testdata/outputs/log-20250313-002431.html b/tests/robot/resources/outputs/log-20250313-002431.html similarity index 100% rename from testdata/outputs/log-20250313-002431.html rename to tests/robot/resources/outputs/log-20250313-002431.html diff --git a/testdata/outputs/log-20250313-002457.html b/tests/robot/resources/outputs/log-20250313-002457.html similarity index 100% rename from testdata/outputs/log-20250313-002457.html rename to tests/robot/resources/outputs/log-20250313-002457.html diff --git a/testdata/outputs/log-20250313-002528.html b/tests/robot/resources/outputs/log-20250313-002528.html similarity index 100% rename from testdata/outputs/log-20250313-002528.html rename to tests/robot/resources/outputs/log-20250313-002528.html diff --git a/testdata/outputs/log-20250313-002549.html b/tests/robot/resources/outputs/log-20250313-002549.html similarity index 100% rename from testdata/outputs/log-20250313-002549.html rename to tests/robot/resources/outputs/log-20250313-002549.html diff --git a/testdata/outputs/log-20250313-002636.html b/tests/robot/resources/outputs/log-20250313-002636.html similarity index 100% rename from testdata/outputs/log-20250313-002636.html rename to tests/robot/resources/outputs/log-20250313-002636.html diff --git a/testdata/outputs/log-20250313-002703.html b/tests/robot/resources/outputs/log-20250313-002703.html similarity index 100% rename from testdata/outputs/log-20250313-002703.html rename to tests/robot/resources/outputs/log-20250313-002703.html diff --git a/testdata/outputs/log-20250313-002739.html b/tests/robot/resources/outputs/log-20250313-002739.html similarity index 100% rename from testdata/outputs/log-20250313-002739.html rename to tests/robot/resources/outputs/log-20250313-002739.html diff --git a/testdata/outputs/log-20250313-002915.html b/tests/robot/resources/outputs/log-20250313-002915.html similarity index 100% rename from testdata/outputs/log-20250313-002915.html rename to tests/robot/resources/outputs/log-20250313-002915.html diff --git a/testdata/outputs/log-20250313-003006.html b/tests/robot/resources/outputs/log-20250313-003006.html similarity index 100% rename from testdata/outputs/log-20250313-003006.html rename to tests/robot/resources/outputs/log-20250313-003006.html diff --git a/testdata/outputs/output-20250313-002134.xml b/tests/robot/resources/outputs/output-20250313-002134.xml similarity index 100% rename from testdata/outputs/output-20250313-002134.xml rename to tests/robot/resources/outputs/output-20250313-002134.xml diff --git a/testdata/outputs/output-20250313-002151.xml b/tests/robot/resources/outputs/output-20250313-002151.xml similarity index 100% rename from testdata/outputs/output-20250313-002151.xml rename to tests/robot/resources/outputs/output-20250313-002151.xml diff --git a/testdata/outputs/output-20250313-002222.xml b/tests/robot/resources/outputs/output-20250313-002222.xml similarity index 100% rename from testdata/outputs/output-20250313-002222.xml rename to tests/robot/resources/outputs/output-20250313-002222.xml diff --git a/testdata/outputs/output-20250313-002257.xml b/tests/robot/resources/outputs/output-20250313-002257.xml similarity index 100% rename from testdata/outputs/output-20250313-002257.xml rename to tests/robot/resources/outputs/output-20250313-002257.xml diff --git a/testdata/outputs/output-20250313-002338.xml b/tests/robot/resources/outputs/output-20250313-002338.xml similarity index 100% rename from testdata/outputs/output-20250313-002338.xml rename to tests/robot/resources/outputs/output-20250313-002338.xml diff --git a/testdata/outputs/output-20250313-002400.xml b/tests/robot/resources/outputs/output-20250313-002400.xml similarity index 100% rename from testdata/outputs/output-20250313-002400.xml rename to tests/robot/resources/outputs/output-20250313-002400.xml diff --git a/testdata/outputs/output-20250313-002431.xml b/tests/robot/resources/outputs/output-20250313-002431.xml similarity index 100% rename from testdata/outputs/output-20250313-002431.xml rename to tests/robot/resources/outputs/output-20250313-002431.xml diff --git a/testdata/outputs/output-20250313-002457.xml b/tests/robot/resources/outputs/output-20250313-002457.xml similarity index 100% rename from testdata/outputs/output-20250313-002457.xml rename to tests/robot/resources/outputs/output-20250313-002457.xml diff --git a/testdata/outputs/output-20250313-002528.xml b/tests/robot/resources/outputs/output-20250313-002528.xml similarity index 100% rename from testdata/outputs/output-20250313-002528.xml rename to tests/robot/resources/outputs/output-20250313-002528.xml diff --git a/testdata/outputs/output-20250313-002549.xml b/tests/robot/resources/outputs/output-20250313-002549.xml similarity index 100% rename from testdata/outputs/output-20250313-002549.xml rename to tests/robot/resources/outputs/output-20250313-002549.xml diff --git a/testdata/outputs/output-20250313-002636.xml b/tests/robot/resources/outputs/output-20250313-002636.xml similarity index 100% rename from testdata/outputs/output-20250313-002636.xml rename to tests/robot/resources/outputs/output-20250313-002636.xml diff --git a/testdata/outputs/output-20250313-002703.xml b/tests/robot/resources/outputs/output-20250313-002703.xml similarity index 100% rename from testdata/outputs/output-20250313-002703.xml rename to tests/robot/resources/outputs/output-20250313-002703.xml diff --git a/testdata/outputs/output-20250313-002739.xml b/tests/robot/resources/outputs/output-20250313-002739.xml similarity index 100% rename from testdata/outputs/output-20250313-002739.xml rename to tests/robot/resources/outputs/output-20250313-002739.xml diff --git a/testdata/outputs/output-20250313-002915.xml b/tests/robot/resources/outputs/output-20250313-002915.xml similarity index 100% rename from testdata/outputs/output-20250313-002915.xml rename to tests/robot/resources/outputs/output-20250313-002915.xml diff --git a/testdata/outputs/output-20250313-003006.xml b/tests/robot/resources/outputs/output-20250313-003006.xml similarity index 100% rename from testdata/outputs/output-20250313-003006.xml rename to tests/robot/resources/outputs/output-20250313-003006.xml diff --git a/atest/resources/variables/variables.resource b/tests/robot/resources/variables/variables.resource similarity index 100% rename from atest/resources/variables/variables.resource rename to tests/robot/resources/variables/variables.resource diff --git a/atest/testsuites/00_cli.robot b/tests/robot/testsuites/00_cli.robot similarity index 99% rename from atest/testsuites/00_cli.robot rename to tests/robot/testsuites/00_cli.robot index 79f6ce97..354e410d 100644 --- a/atest/testsuites/00_cli.robot +++ b/tests/robot/testsuites/00_cli.robot @@ -7,7 +7,7 @@ Suite Teardown Run Teardown Only Once keyword=Remove Database And Dashboar *** Variables *** -${OUTPUTS_FOLDER} ${CURDIR}/../../testdata/outputs +${OUTPUTS_FOLDER} ${CURDIR}/../resources/outputs ${OS} ${None} # set on runtime diff --git a/atest/testsuites/01_database.robot b/tests/robot/testsuites/01_database.robot similarity index 100% rename from atest/testsuites/01_database.robot rename to tests/robot/testsuites/01_database.robot diff --git a/atest/testsuites/02_overview.robot b/tests/robot/testsuites/02_overview.robot similarity index 100% rename from atest/testsuites/02_overview.robot rename to tests/robot/testsuites/02_overview.robot diff --git a/atest/testsuites/03_dashboard.robot b/tests/robot/testsuites/03_dashboard.robot similarity index 100% rename from atest/testsuites/03_dashboard.robot rename to tests/robot/testsuites/03_dashboard.robot diff --git a/atest/testsuites/04_compare.robot b/tests/robot/testsuites/04_compare.robot similarity index 100% rename from atest/testsuites/04_compare.robot rename to tests/robot/testsuites/04_compare.robot diff --git a/atest/testsuites/05_tables.robot b/tests/robot/testsuites/05_tables.robot similarity index 100% rename from atest/testsuites/05_tables.robot rename to tests/robot/testsuites/05_tables.robot diff --git a/atest/testsuites/06_filters.robot b/tests/robot/testsuites/06_filters.robot similarity index 100% rename from atest/testsuites/06_filters.robot rename to tests/robot/testsuites/06_filters.robot diff --git a/atest/testsuites/07_settings.robot b/tests/robot/testsuites/07_settings.robot similarity index 100% rename from atest/testsuites/07_settings.robot rename to tests/robot/testsuites/07_settings.robot diff --git a/atest/testsuites/__init__.robot b/tests/robot/testsuites/__init__.robot similarity index 97% rename from atest/testsuites/__init__.robot rename to tests/robot/testsuites/__init__.robot index 5f569fa0..ec093cae 100644 --- a/atest/testsuites/__init__.robot +++ b/tests/robot/testsuites/__init__.robot @@ -20,7 +20,7 @@ Remove Index Move All Screenshots # All screenshots in pabot dirs have to go to a central screenshot dir to be able to see them in the log.html file - @{directories} List Directories In Directory path=${CURDIR}/../../results/pabot_results + @{directories} List Directories In Directory path=${CURDIR}/../../../results/pabot_results FOR ${directory} IN @{directories} VAR ${pabot_dir} ${CURDIR}/../../results/pabot_results/${directory}/screenshots ${exists} Run Keyword And Return Status Directory Should Exist path=${pabot_dir} diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 00000000..40d79531 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import { resolve } from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@js': resolve(__dirname, 'robotframework_dashboard/js'), + }, + }, + test: { + include: ['tests/javascript/**/*.test.js'], + environment: 'node', + }, +});