diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0ddf837 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest + + - name: Run Python tests + run: python -m pytest tests/ -q + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install Playwright package + run: npm install + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }} + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run Playwright smoke tests + run: npm run test:e2e diff --git a/.gitignore b/.gitignore index 00cbf8d..9b89f40 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +node_modules/ +test-results/ .ipynb_checkpoints legacy_code/ .DS_Store diff --git a/README.md b/README.md index 2c55e36..cae7dc3 100644 --- a/README.md +++ b/README.md @@ -14,4 +14,44 @@ A method for drawing ancestral recombination graphs from tskit tree sequences in Users can click and drag the nodes (including the sample) along the x-axis to further clean up the layout of the graph. The simulation does not take into account line crosses, which can often be improved with some fiddling. Once a node has been moved by a user, its position is fixed with regards to the force simulation. -See [tutorial.md](https://github.com/kitchensjn/tskit_arg_visualizer/blob/main/docs/tutorial.md) for a walkthrough of the package. \ No newline at end of file +See [tutorial.md](https://github.com/kitchensjn/tskit_arg_visualizer/blob/main/docs/tutorial.md) for a walkthrough of the package. + +## Testing (Local and CI) + +This repository now includes: + +- Python tests (pytest), under `tests/` +- Minimal browser smoke tests (Playwright), under `tests/playwright/` + +### Local setup + +1. Install Python dependencies and test runner: + +```bash +python -m pip install --upgrade pip +pip install -e . +pip install pytest +``` + +2. Install Playwright tooling: + +```bash +npm install +npx playwright install --with-deps chromium +``` + +3. Run tests: + +```bash +python -m pytest tests/ -q +npm run test:e2e +``` + +### GitHub Actions + +CI is defined in `.github/workflows/ci.yml` and runs both: + +- `python -m pytest tests/ -q` +- `npm run test:e2e` + +If commands change locally, update them in both this README and the workflow file so local and CI behavior stay aligned. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ef81547 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,74 @@ +{ + "name": "tskit_arg_visualizer-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tskit_arg_visualizer-e2e", + "version": "0.0.0", + "devDependencies": { + "@playwright/test": "^1.54.1" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..247c9c5 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "tskit_arg_visualizer-e2e", + "private": true, + "version": "0.0.0", + "description": "Minimal Playwright setup for browser smoke tests", + "scripts": { + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:install": "playwright install --with-deps chromium" + }, + "devDependencies": { + "@playwright/test": "^1.54.1" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..5663db9 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,18 @@ +const { defineConfig } = require("@playwright/test"); + +module.exports = defineConfig({ + testDir: "tests/playwright", + timeout: 30 * 1000, + expect: { + timeout: 5 * 1000, + }, + use: { + headless: true, + }, + projects: [ + { + name: "chromium", + use: { browserName: "chromium" }, + }, + ], +}); diff --git a/tests/playwright/smoke.spec.js b/tests/playwright/smoke.spec.js new file mode 100644 index 0000000..c615244 --- /dev/null +++ b/tests/playwright/smoke.spec.js @@ -0,0 +1,28 @@ +const { test, expect } = require("@playwright/test"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +// This smoke test intentionally avoids network or project-specific runtime +// dependencies. It verifies that Playwright is wired up and can execute +// JavaScript in a real browser context in CI. +test("opens local HTML and evaluates client-side JS", async ({ page }) => { + const html = [ + "", + "", + "argviz smoke", + "", + "

ready

", + " ", + "", + "", + ].join("\n"); + + const htmlPath = path.join(os.tmpdir(), `argviz-playwright-${Date.now()}.html`); + fs.writeFileSync(htmlPath, html, "utf8"); + + await page.goto(`file://${htmlPath}`); + await expect(page.locator("#status")).toHaveText("playwright-ok"); +}); diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..2c83575 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,182 @@ +import msprime +import pytest + +import tskit_arg_visualizer as argviz + + +def _example_d3arg(): + ts = msprime.sim_ancestry( + samples=4, + sequence_length=100, + recombination_rate=1e-2, + record_full_arg=True, + ploidy=1, + random_seed=123, + ) + return ts, argviz.D3ARG.from_ts(ts) + + +class TestRenderingSmoke: + def test_draw_returns_drawinfo_without_opening_browser(self, monkeypatch): + _, d3arg = _example_d3arg() + + # Keep test non-interactive while still exercising HTML generation path. + monkeypatch.setattr(argviz, "display", lambda *_args, **_kwargs: None) + + info = d3arg.draw( + width=320, + height=240, + tree_highlighting=False, + show_mutations=False, + is_notebook=True, + ) + + assert isinstance(info, argviz.DrawInfo) + # Width/height may be adjusted internally (e.g., axis/title spacing), + # so only assert broad sanity constraints here. + assert float(info.width) >= 320 + assert float(info.height) >= 240 + assert info.uid.startswith("arg_") + + included_nodes = info.included.nodes + assert isinstance(included_nodes, list) + assert len(included_nodes) > 0 + assert set(included_nodes).issubset(set(d3arg.nodes["id"])) + + def test_draw_nodes_returns_drawinfo_without_opening_browser(self, monkeypatch): + _, d3arg = _example_d3arg() + + monkeypatch.setattr(argviz, "display", lambda *_args, **_kwargs: None) + + info = d3arg.draw_nodes( + seed_nodes=[d3arg.sample_order[0]], + depth=1, + width=320, + height=240, + tree_highlighting=False, + show_mutations=False, + is_notebook=True, + ) + + assert isinstance(info, argviz.DrawInfo) + assert float(info.width) >= 320 + assert float(info.height) >= 240 + assert info.uid.startswith("arg_") + + included_nodes = info.included.nodes + assert isinstance(included_nodes, list) + assert len(included_nodes) > 0 + assert set(included_nodes).issubset(set(d3arg.nodes["id"])) + + def test_draw_genome_bar_smoke_notebook_mode(self, monkeypatch): + _, d3arg = _example_d3arg() + + monkeypatch.setattr(argviz, "display", lambda *_args, **_kwargs: None) + + result = d3arg.draw_genome_bar( + width=320, + show_mutations=True, + is_notebook=True, + ) + assert result is None + + +class TestGraphSubsetSmoke: + def test_from_ts_and_subset_graph_smoke(self): + ts, d3arg = _example_d3arg() + + assert d3arg.num_samples == ts.num_samples + assert not d3arg.nodes.empty + assert not d3arg.edges.empty + + subset = d3arg.subset_graph(seed_nodes=[d3arg.sample_order[0]], depth=1) + assert not subset.nodes.empty + assert not subset.edges.empty + + node_ids = set(subset.nodes["id"].tolist()) + assert set(subset.edges["source"]).issubset(node_ids) + assert set(subset.edges["target"]).issubset(node_ids) + assert set(subset.mutations["edge"]).issubset(set(subset.edges["id"])) + + +class TestSecondaryPositionUtilities: + def test_extract_x_positions_from_json_smoke(self): + arg_no_labels = { + "width": 500, + "y_axis": {"include_labels": False}, + "data": { + "nodes": [ + {"id": 1, "x": 50}, + {"id": 2, "x": 450}, + ] + }, + } + pos_no_labels = argviz.extract_x_positions_from_json(arg_no_labels) + assert pos_no_labels[1] == pytest.approx(0.0) + assert pos_no_labels[2] == pytest.approx(1.0) + + arg_with_labels = { + "width": 500, + "y_axis": {"include_labels": True}, + "data": { + "nodes": [ + {"id": 1, "x": 150}, + {"id": 2, "x": 450}, + ] + }, + } + pos_with_labels = argviz.extract_x_positions_from_json(arg_with_labels) + assert pos_with_labels[1] == pytest.approx(0.0) + assert pos_with_labels[2] == pytest.approx(1.0) + + def test_calculate_evenly_distributed_positions_smoke(self): + positions = argviz.calculate_evenly_distributed_positions( + num_elements=5, start=0, end=1, round_to=3 + ) + assert len(positions) == 5 + assert positions[0] == 0 + assert positions[-1] == 1 + assert positions == sorted(positions) + + midpoint = argviz.calculate_evenly_distributed_positions( + num_elements=1, start=10, end=20, round_to=3 + ) + assert midpoint == [15.0] + + def test_convert_time_to_position_monotonic_in_time_scale(self): + y_shift = 7 + height = 200 + y_for_recent = argviz.convert_time_to_position( + t=0, + min_time=0, + max_time=10, + scale="time", + unique_times=[0, 5, 10], + h_spacing=0.5, + height=height, + y_shift=y_shift, + ) + y_for_ancient = argviz.convert_time_to_position( + t=10, + min_time=0, + max_time=10, + scale="time", + unique_times=[0, 5, 10], + h_spacing=0.5, + height=height, + y_shift=y_shift, + ) + assert y_for_ancient < y_for_recent + + def test_convert_time_to_position_rank_requires_known_time(self): + with pytest.raises(RuntimeError): + argviz.convert_time_to_position( + t=3, + min_time=0, + max_time=10, + scale="rank", + unique_times=[0, 1, 2], + h_spacing=0.5, + height=100, + y_shift=0, + )