diff --git a/.agents/context/testing.md b/.agents/context/testing.md index cbead5272..185965334 100644 --- a/.agents/context/testing.md +++ b/.agents/context/testing.md @@ -11,8 +11,10 @@ pytest -m "not server" # skip tests that need a live server (CI ``` Config lives in `pyproject.toml` (`[tool.pytest.ini_options]`): discovery is scoped to -`py/tests/` and `pythonpath = ["py"]` makes `import visdom` work without an editable install. -Experimental `test_*.py` scripts in the repo root (and `test/`) are intentionally out of scope. +`py/tests/`, and `pythonpath = ["py", "py/tests"]` makes both `import visdom` and +`import testutils` work without an editable install. Because discovery is scoped by `testpaths`, +experimental `test_*.py` scripts in the repo root (and `test/`) stay out of scope; `testutils/` is +excluded via `norecursedirs` so helpers are importable but never collected. ## Run E2E / Visual Tests (Cypress) @@ -38,12 +40,90 @@ Always use port `8098` and `-env_path /tmp` for isolation. ## Writing Python Tests -- Place in `py/tests/`, name files `test_*.py`, classes `Test*` (unittest `TestCase` or plain - pytest functions both work — pytest auto-discovers unittest). -- Keep them hermetic (no running server). A test that genuinely needs a live server must be - marked `@pytest.mark.server` so CI can deselect it. -- Start with simple hermetic tests (e.g., `test_smoke.py`) and add focused unit tests for - server/window/env lifecycle code as coverage grows. +### Where a test goes + +``` +py/tests/ + conftest.py shared fixtures, auto-loaded by pytest + testutils/ importable helpers (fakes, payload builders, HTTP base class) + unit/ pure logic: no Application, no I/O beyond tmp_path + integration/ in-process Application, real HTTP, or handler dispatch +``` + +`py/tests/` has **no** `__init__.py` on purpose — `setup.py` runs `find_packages(where="py")`, +so a package there would ship a top-level `tests` distribution to users. `testutils/` is a +package and is reachable because `py/tests` is on `pythonpath`. + +Everything under `py/tests/` must be **hermetic and collectable**: no externally launched +server, no browser, no assertion a human has to make. A script that needs a live server and is +judged by looking at the UI goes in `example/manual/` instead — see +`example/manual/visual_check.py`. Pixel correctness is Playwright's and Cypress's job, not +pytest's. + +- Name a file after what it covers — `integration/window_types.py`, not + `integration/test_window_types.py`. The `unit/` and `integration/` directories already say these + are tests, so the filename does not repeat it; `python_files = ["*.py"]` in `pyproject.toml` + collects them, and `norecursedirs` keeps `testutils/` importable but uncollected. Test + *functions* and `Test*` classes still need their usual prefixes. +- **Which style you use depends on whether the test needs HTTP.** + + | Test needs | Write | Why | + |---|---|---| + | no `Application`, or a handler object | plain `def test_*()` functions | fixtures and `parametrize` both work | + | a real HTTP round trip | a `VisdomHTTPTestCase` subclass | `tornado.testing.AsyncHTTPTestCase` is a `unittest.TestCase`, and that is what starts the app | + + **pytest cannot inject fixtures into `TestCase` methods** — `def test_x(self, app)` fails, and + only autouse fixtures reach them. `@pytest.mark.parametrize` does not work on them either; use + a small `_assert_*` helper called from several one-line test methods instead. A module-level + `pytestmark = pytest.mark.integration` **does** apply to `TestCase` classes, so always set one. +- Keep them hermetic. A test that needs an **externally launched** server must be marked + `@pytest.mark.server` so CI can deselect it; nothing in the tracked suite needs one today. + +### Shared fixtures (`py/tests/conftest.py`) + +| Fixture | Gives you | +|---|---| +| `env_path` | disposable environment directory | +| `store` / `spy_store` | `JSONStore` / one that records backend calls | +| `app` / `app_factory` | `Application` on a temp `env_path`; factory for reload assertions | +| `handler` / `app_handler` | duck-typed handler, standalone or sharing an `Application`'s state | +| `fake_socket` | records `write_message`; `.commands()` and `.last(cmd)` for assertions | +| `offline_client` | `Visdom(send=False)` — never opens a connection | +| `capture_send` | runs a client call and returns the payload it would have sent | + +`reset_warn_once` is autouse: `shared_utils.warn_once` dedupes against a module-level set, so +without it a warning raised by one test silently suppresses the same warning in another. + +### HTTP tests + +Subclass `testutils.VisdomHTTPTestCase`. It starts the app in-process on an ephemeral port, gives +every test a fresh `env_path` that is cleaned up in `tearDown`, and provides `post_json`, +`create_window`, `create_text_window`, `update`, `close_window`, `win_exists`, `get_win_data`, +`get_envs`, `save` and `panes`, on top of `AsyncHTTPTestCase`'s own `fetch`. Override the +`app_kwargs` class attribute to vary server configuration: + +```python +class TestReadonlyRoutes(VisdomHTTPTestCase): + app_kwargs = {"readonly": True} +``` + +`AsyncHTTPTestCase` already runs the `Application` in-process on its own `IOLoop`, driving each +request through `io_loop.run_sync`. **Do not replace it with a background thread, a hand-rolled +`asyncio` loop, or an out-of-process server** — none of that buys anything, and it was tried and +reverted. The `TestCase` style is the accepted cost of using it. + +Because fixtures cannot reach these tests, anything shared goes on the class: `self.env_path` for +the temp directory, and a small base class between `VisdomHTTPTestCase` and your test classes for +helpers several of them need (see `WindowTypeTestCase` in `integration/window_types.py`). +Need a second `Application` over the same directory, for a reload assertion? Construct it directly +with `Application(port=8097, env_path=self.env_path)` — `app_factory` is not available here. + +### Markers + +`unit`, `integration`, `slow`, and `server` are registered in `pyproject.toml`. Set +`pytestmark = pytest.mark.unit` or `pytest.mark.integration` at the top of every new file; it +works for both plain functions and `TestCase` classes. Many older files predate this and carry no +marker, so `-m integration` currently under-selects. ## CI diff --git a/example/manual/README.md b/example/manual/README.md new file mode 100644 index 000000000..13ac1d619 --- /dev/null +++ b/example/manual/README.md @@ -0,0 +1,31 @@ + + +# Manual checks + +Scripts here drive a **running** visdom server and are judged by looking at the +browser. They are not tests: pytest does not collect them (`testpaths` is +`py/tests`), and nothing in CI runs them. + +Automated checks belong elsewhere: + +| Question | Where it is answered | +|---|---| +| Does the server behave correctly? | `py/tests/` (pytest) | +| Does the UI still render the same pixels? | `playwright/`, `cypress/` | +| Does this *look* right to a person? | here | + +## `visual_check.py` + +Creates one window of each visualization type in the `visual_check` environment, +then prints a checklist to walk through in the browser. Useful after a +dependency bump or a change to the plotting payloads. + +```bash +visdom -port 8097 -env_path /tmp # in one shell +python example/manual/visual_check.py +# then open http://localhost:8097/env/visual_check +``` diff --git a/example/manual/visual_check.py b/example/manual/visual_check.py new file mode 100644 index 000000000..693639b7f --- /dev/null +++ b/example/manual/visual_check.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Manual visual check: plot one window of every kind, then look at them. + +This is a script for a human, not a test. It needs a running server and its +result is a judgement about how the browser rendered the panes, which is why it +lives in ``example/manual/`` rather than under ``py/tests/`` — pytest collects +nothing here. Automated rendering checks belong to the Playwright and Cypress +visual-regression suites. + +Usage: + 1. Start a server: visdom -port 8097 + 2. Run this: python example/manual/visual_check.py + 3. Open a browser: http://localhost:8097/env/visual_check +""" + +import math +import sys +import time +import numpy as np + +from visdom import Visdom + +ENV = "visual_check" + + +def main(): + viz = Visdom(server="http://localhost", port=8097, env=ENV) + if not viz.check_connection(timeout_seconds=5): + print("ERROR: Cannot connect to Visdom server.") + print("Start it with: visdom -port 8097") + sys.exit(1) + + print(f"Connected to Visdom. Creating visualizations in env '{ENV}'...") + print(f"Open http://localhost:8097/env/{ENV} to view.\n") + + # =========================================================== + # 1. LINE PLOT — Single line + # =========================================================== + x = np.linspace(0, 4 * math.pi, 200) + viz.line( + Y=np.sin(x), + X=x, + opts=dict( + title="1. Sine Wave (Line Plot)", + xlabel="x", + ylabel="sin(x)", + ), + win="line_single", + ) + print("[OK] 1. Single line plot (sine wave)") + + # =========================================================== + # 2. LINE PLOT — Multiple lines + # =========================================================== + viz.line( + Y=np.column_stack((np.sin(x), np.cos(x), np.sin(x) * np.cos(x))), + X=np.column_stack((x, x, x)), + opts=dict( + title="2. Multiple Lines (sin, cos, sin*cos)", + legend=["sin(x)", "cos(x)", "sin(x)*cos(x)"], + xlabel="x", + ylabel="y", + ), + win="line_multi", + ) + print("[OK] 2. Multiple line plot") + + # =========================================================== + # 3. LINE PLOT — Append update (streaming data) + # =========================================================== + win = viz.line( + Y=np.array([0.0]), + X=np.array([0]), + opts=dict(title="3. Streaming Line (append update)"), + win="line_streaming", + ) + for i in range(1, 50): + viz.line( + Y=np.array([np.sin(i * 0.2) + np.random.randn() * 0.1]), + X=np.array([i]), + win=win, + update="append", + ) + print("[OK] 3. Streaming line plot (50 appended points)") + + # =========================================================== + # 4. SCATTER — 2D with classes + # =========================================================== + n = 200 + x_scatter = np.random.randn(n, 2) + labels = (x_scatter[:, 0] > 0).astype(int) + 1 # 1 or 2 + viz.scatter( + X=x_scatter, + Y=labels, + opts=dict( + title="4. 2D Scatter (two classes)", + legend=["Class A", "Class B"], + markersize=8, + xlabel="Feature 1", + ylabel="Feature 2", + ), + win="scatter_2d", + ) + print("[OK] 4. 2D scatter plot with classes") + + # =========================================================== + # 5. SCATTER — 3D + # =========================================================== + viz.scatter( + X=np.random.rand(100, 3), + opts=dict( + title="5. 3D Scatter Plot", + markersize=5, + ), + win="scatter_3d", + ) + print("[OK] 5. 3D scatter plot") + + # =========================================================== + # 6. BAR CHART — Simple + # =========================================================== + viz.bar( + X=np.array([28, 55, 43, 91, 72, 35]), + opts=dict( + title="6. Bar Chart", + rownames=["Jan", "Feb", "Mar", "Apr", "May", "Jun"], + ), + win="bar_simple", + ) + print("[OK] 6. Simple bar chart") + + # =========================================================== + # 7. BAR CHART — Stacked + # =========================================================== + viz.bar( + X=np.random.rand(5, 3) * 100, + opts=dict( + title="7. Stacked Bar Chart", + stacked=True, + legend=["Product A", "Product B", "Product C"], + rownames=["Q1", "Q2", "Q3", "Q4", "Q5"], + ), + win="bar_stacked", + ) + print("[OK] 7. Stacked bar chart") + + # =========================================================== + # 8. HEATMAP + # =========================================================== + hm_data = np.outer(np.arange(1, 11), np.arange(1, 11)) + viz.heatmap( + X=hm_data, + opts=dict( + title="8. Heatmap (Multiplication Table)", + columnnames=[str(i) for i in range(1, 11)], + rownames=[str(i) for i in range(1, 11)], + colormap="Viridis", + ), + win="heatmap", + ) + print("[OK] 8. Heatmap") + + # =========================================================== + # 9. HISTOGRAM + # =========================================================== + viz.histogram( + X=np.random.randn(1000), + opts=dict( + title="9. Histogram (Normal Distribution)", + numbins=40, + ), + win="histogram", + ) + print("[OK] 9. Histogram") + + # =========================================================== + # 10. BOX PLOT + # =========================================================== + viz.boxplot( + X=np.column_stack( + ( + np.random.randn(100) * 1 + 5, + np.random.randn(100) * 2 + 3, + np.random.randn(100) * 0.5 + 7, + ) + ), + opts=dict( + title="10. Box Plot (3 groups)", + legend=["Group A", "Group B", "Group C"], + ), + win="boxplot", + ) + print("[OK] 10. Box plot") + + # =========================================================== + # 11. SURFACE PLOT + # =========================================================== + xx = np.linspace(-3, 3, 50) + yy = np.linspace(-3, 3, 50) + X_grid, Y_grid = np.meshgrid(xx, yy) + Z = np.sin(np.sqrt(X_grid**2 + Y_grid**2)) + viz.surf( + X=Z, + opts=dict( + title="11. 3D Surface (sin(sqrt(x²+y²)))", + colormap="Hot", + ), + win="surface", + ) + print("[OK] 11. 3D surface plot") + + # =========================================================== + # 12. CONTOUR PLOT + # =========================================================== + viz.contour( + X=Z, + opts=dict(title="12. Contour Plot"), + win="contour", + ) + print("[OK] 12. Contour plot") + + # =========================================================== + # 13. PIE CHART + # =========================================================== + viz.pie( + X=np.array([30, 25, 20, 15, 10]), + opts=dict( + title="13. Pie Chart (Market Share)", + legend=["Chrome", "Firefox", "Safari", "Edge", "Other"], + ), + win="pie", + ) + print("[OK] 13. Pie chart") + + # =========================================================== + # 14. IMAGE — Random RGB + # =========================================================== + # Create a gradient image + img = np.zeros((3, 128, 256)) + img[0] = np.linspace(0, 1, 256).reshape(1, -1) # Red gradient → + img[1] = np.linspace(0, 1, 128).reshape(-1, 1) # Green gradient ↓ + img[2] = 0.5 # Constant blue + viz.image( + img, + opts=dict( + title="14. RGB Gradient Image", + caption="Red increases left→right, Green increases top→bottom", + ), + win="image_rgb", + ) + print("[OK] 14. RGB gradient image") + + # =========================================================== + # 15. IMAGE GRID — Multiple images + # =========================================================== + viz.images( + np.random.rand(16, 3, 64, 64), + nrow=4, + opts=dict(title="15. Image Grid (4x4)"), + win="image_grid", + ) + print("[OK] 15. Image grid") + + # =========================================================== + # 16. TEXT + # =========================================================== + viz.text( + "
All visualizations created successfully.
" + "All dependency tests PASSED
", + opts=dict(title="16. Text/HTML Window"), + win="text_report", + ) + print("[OK] 16. HTML text window") + + # =========================================================== + # 17. LINE PLOT — With markers and fill + # =========================================================== + x2 = np.linspace(0, 10, 50) + viz.line( + Y=np.column_stack((np.exp(-x2 / 3), np.exp(-x2 / 5))), + X=np.column_stack((x2, x2)), + opts=dict( + title="17. Line with Markers & Fill", + markers=True, + fillarea=True, + legend=["Fast decay", "Slow decay"], + markersize=5, + ), + win="line_markers_fill", + ) + print("[OK] 17. Line with markers and fill area") + + # =========================================================== + # DONE + # =========================================================== + print(f"\n{'='*60}") + print(f"ALL 17 VISUALIZATIONS CREATED SUCCESSFULLY") + print(f"{'='*60}") + print(f"\nView at: http://localhost:8097/env/{ENV}") + print("Manually verify each plot renders correctly in the browser.") + print("\nChecklist:") + print(" [ ] Line plots show smooth curves with correct legends") + print(" [ ] Scatter plots show colored points in 2D and 3D") + print(" [ ] Bar/histogram/box/pie charts render with labels") + print(" [ ] Heatmap shows color gradient with axis labels") + print(" [ ] Surface/contour show 3D data") + print(" [ ] Images render (gradient + grid)") + print(" [ ] Text window shows formatted HTML") + print(" [ ] Windows are draggable and resizable") + print(" [ ] Plots are zoomable and interactive (hover tooltips)") + + +if __name__ == "__main__": + main() diff --git a/py/tests/conftest.py b/py/tests/conftest.py new file mode 100644 index 000000000..7be7c11cc --- /dev/null +++ b/py/tests/conftest.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Fixtures shared by the whole suite. + +Everything here is hermetic: temporary directories only, no listening sockets, +no network. Tests that genuinely need an externally launched visdom must carry +``@pytest.mark.server`` so CI can deselect them. +""" + +from unittest.mock import patch + +import pytest + +from visdom.data_model.json_store import JSONStore +from visdom.server.app import Application +from visdom.utils import shared_utils + +from testutils.fakes import FakeHandler, FakeSocket, SpyStore + + +@pytest.fixture +def env_path(tmp_path): + """Disposable environment directory.""" + return str(tmp_path) + + +@pytest.fixture +def store(env_path): + """JSONStore backed by a temporary directory.""" + return JSONStore(env_path) + + +@pytest.fixture +def spy_store(env_path): + """JSONStore that records which backend methods the server calls.""" + return SpyStore(env_path) + + +@pytest.fixture +def app(env_path): + """Application instance with temporary persistence and no listener. + + The port is only recorded on the instance; nothing binds it, so this is + safe to construct in parallel with other tests. + """ + return Application(port=8097, env_path=env_path) + + +@pytest.fixture +def app_factory(env_path): + """Build Applications sharing one ``env_path``, for reload assertions.""" + + def build(**kwargs): + kwargs.setdefault("port", 8097) + kwargs.setdefault("env_path", env_path) + return Application(**kwargs) + + return build + + +@pytest.fixture +def fake_socket(): + return FakeSocket() + + +@pytest.fixture +def handler(env_path): + """Duck-typed handler with its own empty state and store.""" + return FakeHandler(env_path=env_path) + + +@pytest.fixture +def app_handler(app): + """Handler sharing a real Application's state, storage and subscribers.""" + return FakeHandler( + state=app.state, + storage=app.storage, + subs=app.subs, + sources=app.sources, + env_path=app.env_path, + ) + + +@pytest.fixture +def offline_client(): + """Visdom client that never opens a connection. + + With ``send=False`` the client's ``_send`` returns the payload instead of + performing I/O, so plot methods can be asserted as pure functions. + """ + import visdom + + return visdom.Visdom(send=False, use_incoming_socket=False) + + +@pytest.fixture +def capture_send(offline_client): + """Run a client call and return the payload it would have transmitted. + + Usage:: + + sent = capture_send(lambda v: v.line(Y=[1, 2, 3])) + assert sent["payload"]["data"][0]["type"] == "scatter" + """ + + def run(call, win_exists=None): + sent = {} + + def capture(msg, endpoint="events", **_): + sent["payload"] = msg + sent["endpoint"] = endpoint + return "win_capture" + + patches = [patch.object(offline_client, "_send", side_effect=capture)] + if win_exists is not None: + patches.append( + patch.object(offline_client, "win_exists", return_value=win_exists) + ) + for p in patches: + p.start() + try: + call(offline_client) + finally: + for p in reversed(patches): + p.stop() + return sent + + return run + + +@pytest.fixture(autouse=True) +def reset_warn_once(): + """Isolate ``shared_utils.warn_once`` between tests. + + It dedupes against a module-level set, so without this a warning raised by + an earlier test silently suppresses the same warning in a later one, and + assertions on it pass or fail depending on execution order. + """ + previous = set(shared_utils._seen_warnings) + shared_utils._seen_warnings.clear() + yield + shared_utils._seen_warnings.clear() + shared_utils._seen_warnings.update(previous) diff --git a/py/tests/integration/auth.py b/py/tests/integration/auth.py new file mode 100644 index 000000000..71c0d2686 --- /dev/null +++ b/py/tests/integration/auth.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Who may talk to the server, and what they may ask it to do. + +Two independent gates, both off by default: + +* ``-enable_login`` makes the server issue a signed ``user_password`` cookie + from ``IndexHandler.post`` and refuse every ``@check_auth`` route without + one. The browser sends ``sha256(password)``, never the password itself, and + the server stores ``hash_password`` of that. +* ``-readonly`` leaves reads alone and refuses writes, over sockets and now + over HTTP too. + +A login-enabled ``Application`` reads its cookie secret from +``DEFAULT_ENV_PATH``, which is the developer's real ``~/.visdom``. Every +fixture here points that at a temporary directory first, so nothing touches a +real installation. +""" + +import hashlib +import hmac +import json +import os +import unittest +from unittest.mock import patch + +import pytest + +from visdom.server import app as app_module +from visdom.server.app import Application +from visdom.server.handlers import web_handlers +from visdom.server.handlers.socket_handlers import SocketWrapper, VisSocketWrapper +from visdom.utils.server_utils import hash_password + +from testutils import socket_double +from testutils.http import VisdomHTTPTestCase + +pytestmark = pytest.mark.integration + +USERNAME = "visdom_user" +PASSWORD = "correct horse battery staple" +# What the login page actually posts: sjcl.hash.sha256 of the typed password. +CLIENT_HASH = hashlib.sha256(PASSWORD.encode("utf-8")).hexdigest() +COOKIE_SECRET = "test_cookie_secret" + +CREDENTIAL = {"username": USERNAME, "password": hash_password(CLIENT_HASH)} + + +class LoginTestCase(VisdomHTTPTestCase): + """Server started the way ``visdom -enable_login`` starts it.""" + + app_kwargs = {"user_credential": CREDENTIAL} + + def get_app(self): + with open(os.path.join(self.env_path, "COOKIE_SECRET"), "w") as secret_file: + secret_file.write(COOKIE_SECRET) + # Application reads DEFAULT_ENV_PATH + "COOKIE_SECRET" at construction + # and never again, so the patch only has to span the build. + with patch.object(app_module, "DEFAULT_ENV_PATH", self.env_path + os.sep): + return super().get_app() + + def login(self, username=USERNAME, password=CLIENT_HASH): + return self.fetch( + "/", + method="POST", + body=json.dumps({"username": username, "password": password}), + headers={"Content-Type": "application/json"}, + ) + + def session_headers(self): + """Headers carrying the cookie a successful login just issued.""" + resp = self.login() + self.assertEqual(resp.code, 200) + cookies = resp.headers.get_list("Set-Cookie") + self.assertTrue(cookies, "login issued no cookie") + return { + "Cookie": "; ".join(c.split(";")[0] for c in cookies), + "Content-Type": "application/json", + } + + def post_as_user(self, path, body): + return self.fetch( + path, method="POST", body=json.dumps(body), headers=self.session_headers() + ) + + +# -- The login page and its form --------------------------------------------- + + +class TestLoginPage(LoginTestCase): + def test_the_root_serves_the_login_page_to_a_stranger(self): + resp = self.fetch("/") + + self.assertEqual(resp.code, 200) + self.assertIn("Visdom Login", resp.body.decode()) + + def test_the_dashboard_is_not_served_to_a_stranger(self): + self.assertNotIn("visdom-container", self.fetch("/").body.decode()) + + def test_the_dashboard_is_served_once_logged_in(self): + resp = self.fetch("/", headers=self.session_headers()) + + self.assertEqual(resp.code, 200) + self.assertNotIn("Visdom Login", resp.body.decode()) + + +class TestLoginCredentials(LoginTestCase): + def test_the_right_credentials_are_accepted(self): + self.assertEqual(self.login().code, 200) + + def test_the_right_credentials_issue_a_cookie(self): + cookies = self.login().headers.get_list("Set-Cookie") + + self.assertTrue(any("user_password" in c for c in cookies)) + + def _assert_rejected(self, resp): + self.assertEqual(resp.code, 400) + self.assertEqual(resp.headers.get_list("Set-Cookie"), []) + + def test_a_wrong_password_is_rejected(self): + self._assert_rejected(self.login(password=hashlib.sha256(b"nope").hexdigest())) + + def test_a_wrong_username_is_rejected(self): + self._assert_rejected(self.login(username="somebody_else")) + + def test_the_raw_password_is_not_the_password(self): + """The server only ever sees the client-side sha256.""" + self._assert_rejected(self.login(password=PASSWORD)) + + def test_an_empty_password_is_rejected(self): + self._assert_rejected(self.login(password="")) + + def test_both_halves_are_always_compared(self): + """A wrong username must still cost a password comparison. + + The regression: ``==`` short-circuited, so a bad username skipped the + second check entirely and the response came back measurably sooner. + """ + with patch.object( + web_handlers.hmac, "compare_digest", wraps=hmac.compare_digest + ) as compare: + self.login(username="somebody_else") + + self.assertEqual(compare.call_count, 2) + + def test_the_stored_credential_is_not_the_client_hash(self): + """What is kept in memory is salted and derived, not the wire value.""" + stored = CREDENTIAL["password"] + + self.assertNotIn(CLIENT_HASH, stored) + self.assertEqual(len(stored.split("$")), 2) + + +# -- Authorization on the routes --------------------------------------------- + + +class TestUnauthenticatedRequests(LoginTestCase): + """``@check_auth`` answers 401 and does nothing else.""" + + def _assert_401(self, path, body): + resp = self.post_json(path, body) + self.assertEqual(resp.code, 401, path) + return resp + + def test_creating_a_window_is_unauthorized(self): + self._assert_401( + "/events", {"eid": "main", "data": [{"type": "text", "content": "hi"}]} + ) + self.assertEqual(self.panes(), {}) + + def test_updating_a_window_is_unauthorized(self): + self._assert_401( + "/update", {"eid": "main", "win": "w", "data": [{"content": "hi"}]} + ) + + def test_closing_a_window_is_unauthorized(self): + self._assert_401("/close", {"eid": "main", "win": "w"}) + + def test_deleting_an_environment_is_unauthorized(self): + self._app.state["expt"] = {"jsons": {}, "reload": {}} + + self._assert_401("/delete_env", {"eid": "expt"}) + + self.assertIn("expt", self._app.state) + + def test_saving_is_unauthorized(self): + self._assert_401("/save", {"data": ["main"]}) + + def test_forking_is_unauthorized(self): + self._assert_401("/fork_env", {"prev_eid": "main", "eid": "copy"}) + self.assertNotIn("copy", self._app.state) + + def test_reading_window_data_is_unauthorized(self): + self._assert_401("/win_data", {"eid": "main", "win": None}) + + def test_asking_whether_a_window_exists_is_unauthorized(self): + self._assert_401("/win_exists", {"eid": "main", "win": "w"}) + + def test_reading_the_environment_list_is_unauthorized(self): + self._assert_401("/env_state", {}) + + def test_logging_an_experiment_is_unauthorized(self): + self._assert_401("/experiments/log", {"eid": "main", "params": {"lr": 0.1}}) + + def test_an_unauthorized_response_carries_no_body(self): + self.assertEqual(self._assert_401("/env_state", {}).body, b"") + + +class TestAuthenticatedRequests(LoginTestCase): + """The same routes, with the cookie the login just issued.""" + + def test_a_window_can_be_created(self): + resp = self.post_as_user( + "/events", {"eid": "main", "data": [{"type": "text", "content": "hi"}]} + ) + + self.assertEqual(resp.code, 200) + self.assertIn(resp.body.decode(), self.panes()) + + def test_the_environment_list_can_be_read(self): + resp = self.post_as_user("/env_state", {}) + + self.assertEqual(resp.code, 200) + self.assertEqual(json.loads(resp.body), ["main"]) + + def test_a_forged_cookie_is_not_a_session(self): + resp = self.fetch( + "/env_state", + method="POST", + body="{}", + headers={"Cookie": "user_password=made_up"}, + ) + + self.assertEqual(resp.code, 401) + + +class TestHealthIsPublic(LoginTestCase): + def test_health_answers_without_a_session(self): + """Deliberately unauthenticated: it is what a load balancer polls.""" + resp = self.fetch("/health") + + self.assertEqual(resp.code, 200) + self.assertEqual(json.loads(resp.body), {"status": "ok"}) + + +class TestAuthDisabledByDefault(VisdomHTTPTestCase): + def test_no_login_means_no_cookie_is_needed(self): + self.assertEqual(self.post_json("/env_state", {}).code, 200) + + def test_the_root_serves_the_dashboard(self): + self.assertNotIn("Visdom Login", self.fetch("/").body.decode()) + + +# -- Sockets under login ----------------------------------------------------- + + +@pytest.fixture +def login_app(env_path, monkeypatch): + """Login-enabled Application whose cookie secret lives in ``env_path``.""" + with open(os.path.join(env_path, "COOKIE_SECRET"), "w") as secret_file: + secret_file.write(COOKIE_SECRET) + monkeypatch.setattr(app_module, "DEFAULT_ENV_PATH", env_path + os.sep) + return Application(port=8097, env_path=env_path, user_credential=CREDENTIAL) + + +def test_login_is_enabled_by_supplying_a_credential(login_app): + assert login_app.login_enabled is True + + +def test_an_unauthenticated_subscriber_socket_is_closed(login_app): + """``open`` closes before registering, so the socket never joins subs.""" + sub = socket_double(SocketWrapper, login_app) + + sub.open() + + assert login_app.subs == {} + assert list(sub.messages) == [] + + +def test_an_unauthenticated_source_socket_is_closed(login_app): + source = socket_double(VisSocketWrapper, login_app) + + source.open() + + assert login_app.sources == {} + assert list(source.messages) == [] + + +def test_a_socket_opens_when_login_is_disabled(app): + """The same double registers fine against a server without login.""" + sub = socket_double(SocketWrapper, app) + + sub.open() + + assert list(app.subs) == [sub.sid] + + +# -- Readonly enforcement ---------------------------------------------------- + + +class TestReadonlyRejectsWrites(VisdomHTTPTestCase): + """Every mutating route answers 403 and changes nothing.""" + + app_kwargs = {"readonly": True} + + def _assert_403(self, path, body): + resp = self.post_json(path, body) + self.assertEqual(resp.code, 403, path) + self.assertFalse(json.loads(resp.body)["success"]) + self.assertIn("readonly", json.loads(resp.body)["error"]) + return resp + + def _seed(self, eid="main"): + """Put a pane in state directly, since /events is refused here.""" + self._app.state.setdefault(eid, {"jsons": {}, "reload": {}}) + self._app.state[eid]["jsons"]["win_seed"] = { + "id": "win_seed", + "type": "text", + "content": "seeded", + "i": 0, + "version": 1, + } + return "win_seed" + + def test_creating_a_window_is_refused(self): + self._assert_403( + "/events", {"eid": "main", "data": [{"type": "text", "content": "hi"}]} + ) + + self.assertEqual(self.panes(), {}) + + def test_updating_a_window_is_refused(self): + win = self._seed() + + self._assert_403( + "/update", + {"eid": "main", "win": win, "data": [{"type": "text", "content": "new"}]}, + ) + + self.assertEqual(self.panes()[win]["content"], "seeded") + + def test_closing_a_window_is_refused(self): + win = self._seed() + + self._assert_403("/close", {"eid": "main", "win": win}) + + self.assertIn(win, self.panes()) + + def test_deleting_an_environment_is_refused(self): + self._app.state["expt"] = {"jsons": {}, "reload": {}} + + self._assert_403("/delete_env", {"eid": "expt"}) + + self.assertIn("expt", self._app.state) + + def test_saving_is_refused(self): + """The pane held in memory never reaches the env file on disk. + + ``main.json`` itself exists either way — ``load_state`` writes it at + startup, before any request has been served — so the assertion is on + its contents. + """ + self._seed() + + self._assert_403("/save", {"data": ["main"]}) + + with open(os.path.join(self.env_path, "main.json")) as env_file: + self.assertEqual(json.load(env_file)["jsons"], {}) + + def test_forking_is_refused(self): + self._assert_403("/fork_env", {"prev_eid": "main", "eid": "copy"}) + + self.assertNotIn("copy", self._app.state) + + def test_pushing_window_data_is_refused(self): + """The write behind /win_data, which also serves reads.""" + self._assert_403( + "/win_data", {"eid": "main", "win": None, "data": json.dumps({})} + ) + + def test_uploading_an_environment_is_refused(self): + resp = self.post_json("/upload_env", {}) + + self.assertEqual(resp.code, 403) + self.assertFalse(json.loads(resp.body)["success"]) + + def test_logging_an_experiment_is_refused(self): + resp = self.post_json("/experiments/log", {"eid": "main", "params": {"a": 1}}) + + self.assertEqual(resp.code, 403) + + +class TestReadonlyAllowsReads(VisdomHTTPTestCase): + app_kwargs = {"readonly": True} + + def test_the_environment_list_is_served(self): + resp = self.post_json("/env_state", {}) + + self.assertEqual(resp.code, 200) + self.assertEqual(json.loads(resp.body), ["main"]) + + def test_window_data_is_served(self): + resp = self.post_json("/win_data", {"eid": "main", "win": None}) + + self.assertEqual(resp.code, 200) + self.assertEqual(json.loads(resp.body), {}) + + def test_window_existence_is_answered(self): + resp = self.post_json("/win_exists", {"eid": "main", "win": "nope"}) + + self.assertEqual(resp.code, 200) + self.assertEqual(resp.body, b"false") + + def test_the_environment_page_renders(self): + self.assertEqual(self.fetch("/env/main").code, 200) + + def test_health_is_served(self): + self.assertEqual(self.fetch("/health").code, 200) + + +class TestWritesSurviveWithoutReadonly(VisdomHTTPTestCase): + """The decorator must not be refusing writes on an ordinary server.""" + + def test_every_guarded_route_still_answers_200(self): + win = self.create_text_window(content="writable") + + self.assertEqual( + [ + self.update(win, [{"type": "text", "content": "more"}]).code, + self.post_json("/save", {"data": ["main"]}).code, + self.post_json("/fork_env", {"prev_eid": "main", "eid": "copy"}).code, + self.post_json( + "/win_data", {"eid": "main", "win": None, "data": json.dumps({})} + ).code, + self.close_window(win).code, + self.post_json("/delete_env", {"eid": "copy"}).code, + ], + [200] * 6, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/py/tests/integration/build_verification.py b/py/tests/integration/build_verification.py new file mode 100644 index 000000000..392513e33 --- /dev/null +++ b/py/tests/integration/build_verification.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Checks that the frontend build output exists and is served. + +Two kinds of asset live under ``py/visdom/static``: + +* **tracked** — ``js/main.js`` and its source map, the three HTML pages, and the + stylesheets under ``css/``. They are committed, so they are asserted outright. +* **downloaded** — jQuery, bootstrap and d3, fetched by ``build.py`` and ignored + by git (``.gitignore:17,19``). CI installs the package and runs pytest without + ever building the frontend, so these are absent there. Asserting them would + make the job red for a reason that has nothing to do with the code under test, + so each is skipped when it is not on disk and checked when it is. +""" + +import os + +import pytest + +from testutils import VisdomHTTPTestCase +from visdom.utils.shared_utils import get_visdom_path + +pytestmark = pytest.mark.integration + +# Committed by the repo; a missing one means the build output was clobbered. +TRACKED_ASSETS = [ + "index.html", + "login.html", + "error.html", + os.path.join("js", "main.js"), + os.path.join("js", "main.js.map"), + os.path.join("css", "style.css"), + os.path.join("css", "login.css"), + os.path.join("css", "error.css"), + os.path.join("css", "network.css"), + os.path.join("css", "rc-tree-select-overrides.css"), +] + +# Written by build.py at install time; absent in a plain checkout. +DOWNLOADED_ASSETS = [ + "js/jquery.min.js", + "js/d3.v3.min.js", + "css/bootstrap.min.css", +] + +# webpack emits well over a megabyte; anything near zero means a broken build +# that still produced a file. +MIN_BUNDLE_BYTES = 100_000 + + +def _static(relpath): + return os.path.join(get_visdom_path("static"), relpath) + + +@pytest.mark.parametrize("relpath", TRACKED_ASSETS) +def test_tracked_asset_exists(relpath): + """Every committed static asset is present in the installed package.""" + assert os.path.exists(_static(relpath)), f"missing build artifact: {relpath}" + + +def test_main_bundle_is_not_a_stub(): + """main.js is a real bundle, not an empty or truncated file.""" + size = os.path.getsize(_static(os.path.join("js", "main.js"))) + assert size > MIN_BUNDLE_BYTES, f"bundle suspiciously small: {size} bytes" + + +class TestStaticServing(VisdomHTTPTestCase): + """The Application serves the build output over HTTP.""" + + def _assert_served(self, url): + resp = self.fetch(url) + self.assertEqual(resp.code, 200, f"{url} returned {resp.code}") + return resp + + def test_index_page_returns_html(self): + body = self._assert_served("/").body.decode() + self.assertIn("alert(1)') + + +class TestDeleteMissingEnv(VisdomHTTPTestCase): + def test_deleting_an_unknown_env_is_a_no_op(self): + self.assertEqual( + self.post_json("/delete_env", {"eid": "never_existed"}).code, 200 + ) + + def test_deleting_a_none_env_is_a_no_op(self): + self.assertEqual(self.post_json("/delete_env", {"eid": None}).code, 200) + + +class TestErrorPageDetails(VisdomHTTPTestCase): + """What a 500 tells the client on an ordinary server. + + The error page renders the exception, its traceback and the request the + handler was serving whenever the app is built with error details on. That + used to be unconditional, so ``error.html``'s own "what happened" branch + for a production server was unreachable. + """ + + def test_the_status_is_still_reported(self): + body = self.fetch("/error/500").body.decode() + + self.assertIn("500", body) + self.assertIn("Internal Server Error", body) + + def test_the_production_message_is_shown_instead(self): + self.assertIn("What happened", self.fetch("/error/500").body.decode()) + + def test_the_traceback_is_not_rendered(self): + self.assertNotIn("Traceback", self.fetch("/error/500").body.decode()) + + def test_the_source_paths_are_not_rendered(self): + self.assertNotIn("web_handlers.py", self.fetch("/error/500").body.decode()) + + def test_the_request_is_not_rendered(self): + body = self.fetch("/error/500").body.decode() + + self.assertNotIn("Remote IP", body) + self.assertNotIn("127.0.0.1", body) + + +class TestErrorPageUnderDebugLogging(VisdomHTTPTestCase): + """``-logging_level DEBUG`` is the operator asking for the detail back.""" + + def get_app(self): + root = logging.getLogger() + previous = root.level + root.setLevel(logging.DEBUG) + try: + return super().get_app() + finally: + root.setLevel(previous) + + def test_the_traceback_is_rendered(self): + self.assertIn("Traceback", self.fetch("/error/500").body.decode()) + + def test_the_request_is_rendered(self): + body = self.fetch("/error/500").body.decode() + + self.assertIn("Remote IP", body) + self.assertIn("127.0.0.1", body) + + +class TestRenderedPages(VisdomHTTPTestCase): + def test_env_page_renders(self): + self.assertEqual(self.fetch("/env/main").code, 200) + + def test_compare_page_renders(self): + self.assertEqual(self.fetch("/compare/main+main").code, 200) + + +if __name__ == "__main__": + unittest.main() diff --git a/py/tests/integration/environment_lifecycle.py b/py/tests/integration/environment_lifecycle.py new file mode 100644 index 000000000..1572649e5 --- /dev/null +++ b/py/tests/integration/environment_lifecycle.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Environment lifecycle over real HTTP: create, fork, save, delete, reload. + +An environment is never created explicitly -- it appears the moment a pane is +addressed to it. From there ``/fork_env`` deep-copies it, ``/save`` writes it +through the storage backend, ``/delete_env`` removes both the state and the +file, and a fresh ``Application`` over the same directory picks it back up. +That the writes go through the backend rather than around it is asserted +separately in ``storage_wiring``. +""" + +import json +import os +import unittest + +import pytest + +from visdom.server.app import Application + +from testutils.http import VisdomHTTPTestCase + +pytestmark = pytest.mark.integration + + +class TestStartupState(VisdomHTTPTestCase): + def test_main_env_exists_and_is_empty_on_startup(self): + self.assertIn("main", self.get_envs()) + self.assertEqual(self.get_win_data(), {}) + + def test_env_state_returns_a_list(self): + self.assertIsInstance(self.get_envs(), list) + + +class TestImplicitCreation(VisdomHTTPTestCase): + def test_addressing_an_unknown_env_creates_it(self): + self.create_text_window(eid="new_env", content="hello") + self.assertIn("new_env", self.get_envs()) + + def test_env_state_lists_every_env_created(self): + self.create_text_window(eid="x_env") + self.create_text_window(eid="y_env") + self.assertTrue({"main", "x_env", "y_env"} <= set(self.get_envs())) + + +class TestForkEnv(VisdomHTTPTestCase): + def test_fork_copies_the_panes_across(self): + self.create_text_window(eid="main", content="original", win="w1") + resp = self.post_json("/fork_env", {"prev_eid": "main", "eid": "fork1"}) + self.assertEqual(resp.code, 200) + self.assertEqual(resp.body.decode(), "fork1") + self.assertEqual(self.get_win_data("w1", eid="fork1")["content"], "original") + + def test_fork_is_independent_of_its_source(self): + self.create_text_window(eid="main", content="original", win="w1") + self.post_json("/fork_env", {"prev_eid": "main", "eid": "fork2"}) + + self.update("w1", [{"content": "modified"}], eid="fork2") + + self.assertEqual(self.get_win_data("w1", eid="main")["content"], "original") + + def test_forking_an_unknown_env_is_a_bad_request(self): + resp = self.post_json("/fork_env", {"prev_eid": "no_exist", "eid": "new"}) + self.assertEqual(resp.code, 400) + self.assertIn("env to be forked doesn't exist", resp.reason) + self.assertNotIn("new", self.get_envs()) + + +class TestSaveEnv(VisdomHTTPTestCase): + def test_save_writes_a_file_named_for_the_env(self): + self.create_text_window(eid="main", content="save me") + self.save(["main"]) + self.assertTrue(os.path.exists(os.path.join(self.env_path, "main.json"))) + + def test_saved_file_carries_jsons_and_reload(self): + self.create_text_window(eid="main", content="save me") + self.save(["main"]) + with open(os.path.join(self.env_path, "main.json")) as handle: + saved = json.load(handle) + self.assertIn("jsons", saved) + self.assertIn("reload", saved) + + def test_save_handles_several_envs_at_once(self): + self.create_text_window(eid="env_a", content="a") + self.create_text_window(eid="env_b", content="b") + self.save(["env_a", "env_b"]) + for eid in ("env_a", "env_b"): + self.assertTrue(os.path.exists(os.path.join(self.env_path, eid + ".json"))) + + def test_save_reports_only_the_envs_that_existed(self): + self.create_text_window(eid="main", content="x") + saved = json.loads(self.save(["main", "nonexistent"]).body) + self.assertIn("main", saved) + self.assertNotIn("nonexistent", saved) + + +class TestDeleteEnv(VisdomHTTPTestCase): + def test_delete_removes_the_env_from_state(self): + self.create_text_window(eid="del_me", content="bye") + self.post_json("/delete_env", {"eid": "del_me"}) + self.assertNotIn("del_me", self.get_envs()) + + def test_delete_removes_the_saved_file_too(self): + self.create_text_window(eid="del_file", content="bye") + self.save(["del_file"]) + path = os.path.join(self.env_path, "del_file.json") + self.assertTrue(os.path.exists(path)) + + self.post_json("/delete_env", {"eid": "del_file"}) + + self.assertFalse(os.path.exists(path)) + + def test_main_env_cannot_be_deleted(self): + self.post_json("/delete_env", {"eid": "main"}) + self.assertIn("main", self.get_envs()) + + +class TestReload(VisdomHTTPTestCase): + def test_a_saved_env_is_reloaded_by_a_fresh_application(self): + self.create_text_window(eid="persist", content="I survive", win="w1") + self.save(["persist"]) + + restarted = Application(port=8097, env_path=self.env_path) + + self.assertIn("persist", restarted.state) + self.assertEqual( + restarted.state["persist"]["jsons"]["w1"]["content"], "I survive" + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/py/tests/test_experiment_log_handler.py b/py/tests/integration/experiment_log_handler.py similarity index 100% rename from py/tests/test_experiment_log_handler.py rename to py/tests/integration/experiment_log_handler.py diff --git a/py/tests/integration/polling_parity.py b/py/tests/integration/polling_parity.py new file mode 100644 index 000000000..8c34cb27e --- /dev/null +++ b/py/tests/integration/polling_parity.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""The polling transport, and its parity with the WebSocket one. + +``AGENTS.md`` requires every socket feature to work over both transports. +Polling replaces the socket with two routes: ``/socket_wrap`` for subscribers +and ``/vis_socket_wrap`` for sources. A client asks for a sid, then POSTs +``message_type: "send"`` to push a command and ``message_type: "query"`` to +drain whatever the server has queued for it. Server-side each sid owns a +``SocketWrapper`` whose ``write_message`` appends to a deque instead of writing +to a connection, so the same ``on_message`` dispatch runs either way. + +The HTTP round trip is what is under test here, so these are +``VisdomHTTPTestCase`` subclasses. The reaper and the queue are reachable +without a request and stay plain functions. +""" + +import json +import time +import types +import unittest + +import pytest + +from visdom.server.defaults import MAX_SOCKET_WAIT +from visdom.server.handlers.socket_handlers import SocketWrapper, VisSocketWrapper + +from testutils import socket_double +from testutils.http import VisdomHTTPTestCase + +pytestmark = pytest.mark.integration + + +class PollingTestCase(VisdomHTTPTestCase): + """Server configured the way ``visdom -use_frontend_client_polling`` runs. + + ``sub_sid`` / ``source_sid`` mint an id the way the two client kinds do: + the browser GETs ``/socket_wrap``, the python client POSTs to + ``/vis_socket_wrap`` without one. + """ + + app_kwargs = {"use_frontend_client_polling": True} + + def sub_sid(self): + return json.loads(self.fetch("/socket_wrap").body)["sid"] + + def source_sid(self): + return json.loads(self.poll("/vis_socket_wrap", None, "query").body)["sid"] + + def poll(self, route, sid, message_type, message=None): + body = {"sid": sid, "message_type": message_type} + if message is not None: + body["message"] = message + return self.post_json(route, body) + + def query(self, sid, route="/socket_wrap"): + """Drain the queued messages for ``sid``, decoded.""" + resp = json.loads(self.poll(route, sid, "query").body) + self.assertTrue(resp["success"], resp) + return [json.loads(m) for m in resp["messages"]] + + def send(self, sid, command, route="/socket_wrap"): + return self.poll(route, sid, "send", json.dumps(command)) + + +# -- Minting a connection ---------------------------------------------------- + + +class TestSubscriberHandshake(PollingTestCase): + def test_get_mints_a_sid(self): + resp = json.loads(self.fetch("/socket_wrap").body) + + self.assertTrue(resp["success"]) + self.assertTrue(resp["sid"]) + + def test_each_client_gets_its_own_sid(self): + self.assertNotEqual(self.sub_sid(), self.sub_sid()) + + def test_the_sid_is_registered_as_a_subscriber(self): + sid = self.sub_sid() + + self.assertIn(sid, self._app.subs) + self.assertEqual(self._app.sources, {}) + + def test_the_first_query_returns_the_register_handshake(self): + """register, layouts, envs — then a fourth, redundant layout push. + + ``SocketHandlerOrWrapper.initialize`` broadcasts layouts to every + subscriber, and on this path the socket has already registered itself + by then, because ``AnySocketWrapper.initialize`` opens it first. So it + receives its own broadcast. A WebSocket subscriber opens *after* + ``initialize`` and sees three messages. Idempotent either way, but it + is the one place the transports differ, so it is pinned here rather + than left to surprise the next reader. + """ + sid = self.sub_sid() + + messages = self.query(sid) + + self.assertEqual( + [m["command"] for m in messages], + ["register", "layout_update", "env_update", "layout_update"], + ) + self.assertEqual(messages[0]["data"], sid) + + def test_a_new_client_re_pushes_layouts_to_the_existing_ones(self): + """Same cause as above, seen from the other side of the registry.""" + first = self.sub_sid() + self.query(first) + + self.sub_sid() + + self.assertEqual([m["command"] for m in self.query(first)], ["layout_update"]) + + def test_the_handshake_is_delivered_only_once(self): + sid = self.sub_sid() + self.query(sid) + + self.assertEqual(self.query(sid), []) + + +class TestSourceHandshake(PollingTestCase): + def test_a_post_without_a_sid_mints_one(self): + resp = json.loads(self.poll("/vis_socket_wrap", None, "query").body) + + self.assertTrue(resp["success"]) + self.assertTrue(resp["sid"]) + + def test_the_sid_is_registered_as_a_source(self): + sid = self.source_sid() + + self.assertIn(sid, self._app.sources) + self.assertEqual(self._app.subs, {}) + + def test_the_source_handshake_is_an_alive_ping(self): + sid = self.source_sid() + + self.assertEqual( + self.query(sid, route="/vis_socket_wrap"), + [{"command": "alive", "data": "vis_alive"}], + ) + + def test_minting_twice_yields_two_sources(self): + self.source_sid() + self.source_sid() + + self.assertEqual(len(self._app.sources), 2) + + +# -- Protocol errors --------------------------------------------------------- + + +class TestPollingProtocolErrors(PollingTestCase): + def _assert_failure(self, resp, reason): + body = json.loads(resp.body) + self.assertEqual(resp.code, 200) + self.assertFalse(body["success"]) + self.assertEqual(body["reason"], reason) + self.assertTrue(body["detail"]) + return body + + def test_an_unknown_sid_is_reported_as_closed(self): + body = self._assert_failure( + self.poll("/socket_wrap", "never_minted", "query"), "closed" + ) + + self.assertIn("never_minted", body["message"]) + + def test_a_subscriber_sid_is_not_a_source_sid(self): + """The two registries are separate; a sid is only valid on its route.""" + sid = self.sub_sid() + + self._assert_failure(self.poll("/vis_socket_wrap", sid, "query"), "closed") + + def test_send_without_a_message_is_rejected(self): + sid = self.sub_sid() + + self._assert_failure(self.poll("/socket_wrap", sid, "send"), "no msg") + + def test_an_unknown_message_type_is_rejected(self): + sid = self.sub_sid() + + body = self._assert_failure( + self.poll("/socket_wrap", sid, "subscribe"), "invalid" + ) + + self.assertIn("subscribe", body["message"]) + + def test_a_missing_message_type_is_rejected(self): + sid = self.sub_sid() + + self._assert_failure(self.post_json("/socket_wrap", {"sid": sid}), "invalid") + + def test_a_closed_socket_stops_answering(self): + sid = self.sub_sid() + self._app.subs[sid].close() + + self._assert_failure(self.poll("/socket_wrap", sid, "query"), "closed") + + +# -- Commands over the polling transport ------------------------------------- + + +class TestPollingCommandParity(PollingTestCase): + """Each command has the same effect as it does over a WebSocket.""" + + def test_close_removes_the_pane(self): + win = self.create_text_window(content="polled") + sid = self.sub_sid() + + resp = self.send(sid, {"cmd": "close", "eid": "main", "data": win}) + + self.assertTrue(json.loads(resp.body)["success"]) + self.assertNotIn(win, self.panes()) + + def test_close_reaches_the_sources(self): + win = self.create_text_window(content="polled") + source_sid = self.source_sid() + self.query(source_sid, route="/vis_socket_wrap") + sid = self.sub_sid() + + self.send(sid, {"cmd": "close", "eid": "main", "data": win}) + + forwarded = self.query(source_sid, route="/vis_socket_wrap") + self.assertEqual(forwarded[0]["event_type"], "close") + self.assertEqual(forwarded[0]["target"], win) + self.assertIsNotNone(forwarded[0]["pane_data"]) + + def test_undo_restores_the_pane(self): + win = self.create_text_window(content="polled") + sid = self.sub_sid() + self.send(sid, {"cmd": "close", "eid": "main", "data": win}) + + self.send(sid, {"cmd": "undo", "eid": "main"}) + + self.assertIn(win, self.panes()) + + def test_delete_env_removes_the_environment(self): + self.create_text_window(eid="expt", content="polled") + sid = self.sub_sid() + + self.send(sid, {"cmd": "delete_env", "eid": "expt"}) + + self.assertNotIn("expt", self._app.state) + + def test_save_layouts_updates_the_application(self): + sid = self.sub_sid() + layouts = '[["view A", {"win_0": [0, 0, 3, 3]}]]' + + self.send(sid, {"cmd": "save_layouts", "data": layouts}) + + self.assertEqual(self._app.layouts, layouts) + + def test_save_layouts_is_broadcast_back_to_the_poller(self): + sid = self.sub_sid() + self.query(sid) + layouts = '[["view B", {}]]' + + self.send(sid, {"cmd": "save_layouts", "data": layouts}) + + self.assertEqual( + self.query(sid), [{"command": "layout_update", "data": layouts}] + ) + + def test_a_source_can_save_layouts_too(self): + """The command is handled on the shared base, not the subscriber.""" + sid = self.source_sid() + layouts = '[["view C", {}]]' + + resp = self.send( + sid, {"cmd": "save_layouts", "data": layouts}, "/vis_socket_wrap" + ) + + self.assertTrue(json.loads(resp.body)["success"]) + self.assertEqual(self._app.layouts, layouts) + + def test_update_comment_lands_on_the_pane(self): + win = self.create_text_window(content="polled") + sid = self.sub_sid() + + self.send( + sid, {"cmd": "update_comment", "eid": "main", "win": win, "data": "note"} + ) + + self.assertEqual(self.panes()[win]["comment"], "note") + + def test_layout_item_update_is_recorded(self): + win = self.create_text_window(content="polled") + sid = self.sub_sid() + + self.send( + sid, + {"cmd": "layout_item_update", "eid": "main", "win": win, "data": [0, 0]}, + ) + + self.assertEqual(self._app.state["main"]["reload"][win], [0, 0]) + + def test_echo_comes_back_on_the_source_route(self): + sid = self.source_sid() + self.query(sid, route="/vis_socket_wrap") + + self.send(sid, {"cmd": "echo", "data": "ping"}, "/vis_socket_wrap") + + self.assertEqual( + self.query(sid, route="/vis_socket_wrap"), + [{"cmd": "echo", "data": "ping"}], + ) + + def test_a_broadcast_reaches_every_polling_subscriber(self): + first, second = self.sub_sid(), self.sub_sid() + self.query(first) + self.query(second) + + self.create_text_window(content="broadcast") + + self.assertEqual(len(self.query(first)), 1) + self.assertEqual(len(self.query(second)), 1) + + +class TestPollingUnderReadonly(PollingTestCase): + app_kwargs = {"use_frontend_client_polling": True, "readonly": True} + + def test_the_handshake_reports_the_mode(self): + sid = self.sub_sid() + + self.assertTrue(self.query(sid)[0]["readonly"]) + + def test_a_command_is_accepted_but_dropped(self): + self._app.state["expt"] = {"jsons": {}, "reload": {}} + sid = self.sub_sid() + + resp = self.send(sid, {"cmd": "delete_env", "eid": "expt"}) + + self.assertTrue(json.loads(resp.body)["success"]) + self.assertIn("expt", self._app.state) + + +# -- The idle reaper --------------------------------------------------------- +# +# ``socket_wrap_monitor_thread`` runs on a 15s PeriodicCallback and closes +# polling clients that have stopped reading. Calling it directly needs no +# loop, only a monitor object to stop. + + +def _reaper(app, sockets): + """Register ``sockets`` and return the wrapper the monitor runs on.""" + stopped = [] + app.socket_wrap_monitor = types.SimpleNamespace(stop=lambda: stopped.append(True)) + for sock in sockets: + sock.open() + return sockets[0] if sockets else socket_double(SocketWrapper, app), stopped + + +def test_the_reaper_closes_an_idle_subscriber(app): + sub, _ = _reaper(app, [socket_double(SocketWrapper, app)]) + sub.last_read_time = time.time() - MAX_SOCKET_WAIT - 1 + + sub.socket_wrap_monitor_thread() + + assert app.subs == {} + + +def test_the_reaper_keeps_a_subscriber_that_still_polls(app): + sub, _ = _reaper(app, [socket_double(SocketWrapper, app)]) + sub.last_read_time = time.time() + + sub.socket_wrap_monitor_thread() + + assert list(app.subs) == [sub.sid] + + +def test_the_reaper_closes_an_idle_source(app): + source, _ = _reaper(app, [socket_double(VisSocketWrapper, app)]) + source.last_read_time = time.time() - MAX_SOCKET_WAIT - 1 + + source.socket_wrap_monitor_thread() + + assert app.sources == {} + + +def test_the_reaper_leaves_the_other_clients_alone(app): + idle = socket_double(SocketWrapper, app) + active = socket_double(SocketWrapper, app) + _reaper(app, [idle, active]) + idle.last_read_time = time.time() - MAX_SOCKET_WAIT - 1 + active.last_read_time = time.time() + + idle.socket_wrap_monitor_thread() + + assert list(app.subs) == [active.sid] + + +def test_the_reaper_stops_itself_once_everyone_has_gone(app): + sub, stopped = _reaper(app, [socket_double(SocketWrapper, app)]) + sub.on_close() + + sub.socket_wrap_monitor_thread() + + assert stopped == [True] + + +def test_a_query_postpones_the_reaper(app): + sub, _ = _reaper(app, [socket_double(SocketWrapper, app)]) + sub.last_read_time = time.time() - MAX_SOCKET_WAIT - 1 + + sub.get_messages() + sub.socket_wrap_monitor_thread() + + assert list(app.subs) == [sub.sid] + + +# -- The pending-message queue ----------------------------------------------- + + +def test_the_queue_holds_everything_written_between_polls(app): + """Nothing bounds ``messages``; a client that stops polling accumulates. + + Pinned down as current behaviour. The reaper above is what keeps it from + growing forever, so the two belong together: drop the reaper and this deque + is an unbounded leak per abandoned client. + """ + sub = socket_double(SocketWrapper, app) + for n in range(200): + sub.write_message(json.dumps({"n": n})) + + drained = sub.get_messages() + + assert len(drained) == 200 + assert [json.loads(m)["n"] for m in drained] == list(range(200)) + + +def test_the_queue_is_drained_in_arrival_order(app): + sub = socket_double(SocketWrapper, app) + sub.write_message("first") + sub.write_message("second") + + assert sub.get_messages() == ["first", "second"] + assert sub.get_messages() == [] + + +if __name__ == "__main__": + unittest.main() diff --git a/py/tests/integration/socket_commands.py b/py/tests/integration/socket_commands.py new file mode 100644 index 000000000..532c892a7 --- /dev/null +++ b/py/tests/integration/socket_commands.py @@ -0,0 +1,845 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Every command ``on_message`` dispatches, driven through a real socket. + +``AnySocketHandlerOrWrapper.on_message`` is one long ``elif`` chain and is the +only entry point for pane closing, undo, environment deletion, layout edits, +comments and the embeddings drill-down. None of it needed a WebSocket to test: +``testutils.socket_double`` builds the real handler classes over a real +``Application`` with a recording ``write_message``. + +Two commands hand work to ``IOLoop.current().run_in_executor``. The +``inline_executor`` fixture replaces that with a synchronous stand-in, so the +side effect has happened by the time the call returns and the assertions do not +race a thread pool. +""" + +import json +import types + +import pytest +import tornado.ioloop + +from visdom.server.defaults import DEFAULT_MAX_UNDO_HISTORY +from visdom.utils.server_utils import count_deleted, push_deleted + +from testutils import commands, last, open_source, open_sub, sent + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def inline_executor(monkeypatch): + """Run ``IOLoop.current().run_in_executor`` calls immediately, in-thread. + + Records ``(func, args)`` so a test can assert what was scheduled as well as + what it did. + """ + scheduled = [] + + def run_in_executor(_self_executor, func, *args): + scheduled.append((func, args)) + return func(*args) + + loop = types.SimpleNamespace( + run_in_executor=lambda executor, func, *args: run_in_executor( + executor, func, *args + ) + ) + monkeypatch.setattr(tornado.ioloop.IOLoop, "current", staticmethod(lambda: loop)) + return scheduled + + +def send(sock, **msg): + """Dispatch one command through the real ``on_message``.""" + sock.on_message(json.dumps(msg)) + + +def pane(win="win_0", ptype="plot", content=None, **extra): + """A pane as it sits in ``state[eid]["jsons"]``.""" + p = { + "id": win, + "i": 0, + "type": ptype, + "content": {"data": [], "layout": {}} if content is None else content, + } + p.update(extra) + return p + + +@pytest.fixture +def env(app): + """``app`` with an ``expt`` environment holding one pane.""" + app.state["expt"] = {"jsons": {"win_0": pane()}, "reload": {}} + return app + + +# -- close ------------------------------------------------------------------- + + +def test_close_removes_the_pane(env): + sub = open_sub(env) + send(sub, cmd="close", eid="expt", data="win_0") + + assert env.state["expt"]["jsons"] == {} + + +def test_close_pushes_the_pane_onto_the_undo_stack(env): + sub = open_sub(env) + send(sub, cmd="close", eid="expt", data="win_0") + + assert count_deleted(env.storage, "expt") == 1 + + +def test_close_forwards_the_pane_data_to_sources(env): + """The close event carries the pane that was removed, not ``None``. + + The pane used to be popped a second time under the unescaped eid, so by the + time the event was built there was nothing left to attach and every source + received ``pane_data: None``. + """ + source = open_source(env) + sub = open_sub(env) + + send(sub, cmd="close", eid="expt", data="win_0") + + event = sent(source)[-1] + assert event["event_type"] == "close" + assert event["target"] == "win_0" + assert event["eid"] == "expt" + assert event["pane_data"]["id"] == "win_0" + + +def test_close_of_an_unknown_pane_reports_no_pane_data(env): + """Closing something that is not there still tells the sources.""" + source = open_source(env) + sub = open_sub(env) + + send(sub, cmd="close", eid="expt", data="ghost") + + assert sent(source)[-1]["pane_data"] is None + assert count_deleted(env.storage, "expt") == 0 + + +def test_close_broadcasts_the_undo_count(env): + sub = open_sub(env) + sub.eid = "expt" + + send(sub, cmd="close", eid="expt", data="win_0") + + undo_state = last(sub, "undo_state") + assert undo_state["eid"] == "expt" + assert undo_state["count"] == 1 + + +def test_close_escapes_the_environment_id(app): + """A slash in the eid is escaped before the state lookup.""" + app.state["a_b"] = {"jsons": {"win_0": pane()}, "reload": {}} + source = open_source(app) + sub = open_sub(app) + + send(sub, cmd="close", eid="a/b", data="win_0") + + assert app.state["a_b"]["jsons"] == {} + assert sent(source)[-1]["eid"] == "a_b" + + +def test_close_of_an_unknown_environment_is_a_noop(env): + source = open_source(env) + sub = open_sub(env) + + send(sub, cmd="close", eid="ghost", data="win_0") + + assert sent(source) == [{"command": "alive", "data": "vis_alive"}] + assert env.state["expt"]["jsons"] != {} + + +def test_close_without_a_target_is_ignored(env): + sub = open_sub(env) + + send(sub, cmd="close", eid="expt") + + assert "win_0" in env.state["expt"]["jsons"] + + +# -- undo -------------------------------------------------------------------- + + +def test_undo_restores_the_last_closed_pane(env): + sub = open_sub(env) + send(sub, cmd="close", eid="expt", data="win_0") + + send(sub, cmd="undo", eid="expt") + + assert "win_0" in env.state["expt"]["jsons"] + + +def test_undo_puts_the_pane_at_the_end_of_the_order(env): + """A restored pane gets a fresh index so it cannot collide.""" + env.state["expt"]["jsons"]["win_1"] = pane("win_1") + env.state["expt"]["jsons"]["win_1"]["i"] = 5 + sub = open_sub(env) + send(sub, cmd="close", eid="expt", data="win_0") + + send(sub, cmd="undo", eid="expt") + + assert env.state["expt"]["jsons"]["win_0"]["i"] == 6 + + +def test_undo_broadcasts_the_restored_pane(env): + sub = open_sub(env) + sub.eid = "expt" + send(sub, cmd="close", eid="expt", data="win_0") + + send(sub, cmd="undo", eid="expt") + + restored = [m for m in sent(sub) if m.get("id") == "win_0"] + assert restored + assert restored[-1]["eid"] == "expt" + + +def test_undo_on_an_empty_stack_only_reports_the_count(env): + sub = open_sub(env) + sub.eid = "expt" + + send(sub, cmd="undo", eid="expt") + + assert last(sub, "undo_state")["count"] == 0 + assert env.state["expt"]["jsons"].keys() == {"win_0"} + + +def test_undo_stack_is_capped(env): + """Closing more panes than the cap keeps only the most recent ones.""" + for i in range(DEFAULT_MAX_UNDO_HISTORY + 3): + push_deleted(env.storage, "expt", f"win_{i}", pane(f"win_{i}")) + + assert count_deleted(env.storage, "expt") == DEFAULT_MAX_UNDO_HISTORY + + +def test_undo_of_an_unknown_environment_is_a_noop(env): + sub = open_sub(env) + + send(sub, cmd="undo", eid="ghost") + + assert commands(sub) == ["register", "layout_update", "env_update"] + + +# -- delete_env -------------------------------------------------------------- + + +def test_delete_env_drops_the_environment(env): + sub = open_sub(env) + + send(sub, cmd="delete_env", eid="expt") + + assert "expt" not in env.state + assert not env.storage.env_exists("expt") + + +def test_delete_env_clears_the_undo_history(env): + sub = open_sub(env) + send(sub, cmd="close", eid="expt", data="win_0") + + send(sub, cmd="delete_env", eid="expt") + + assert count_deleted(env.storage, "expt") == 0 + + +def test_delete_env_announces_the_new_environment_list(env): + sub = open_sub(env) + + send(sub, cmd="delete_env", eid="expt") + + assert last(sub, "env_update")["data"] == ["main"] + + +def test_delete_env_refuses_to_remove_main(env): + sub = open_sub(env) + + send(sub, cmd="delete_env", eid="main") + + assert "main" in env.state + + +def test_delete_env_escapes_the_environment_id(app): + app.state["a_b"] = {"jsons": {}, "reload": {}} + sub = open_sub(app) + + send(sub, cmd="delete_env", eid="a/b") + + assert "a_b" not in app.state + + +# -- save -------------------------------------------------------------------- + + +def test_save_forks_the_previous_environment(env): + sub = open_sub(env) + + send(sub, cmd="save", eid="copy", prev_eid="expt", data={"win_0": [0, 0, 3, 3]}) + + assert "win_0" in env.state["copy"]["jsons"] + assert env.state["copy"]["reload"] == {"win_0": [0, 0, 3, 3]} + + +def test_save_leaves_the_source_environment_alone(env): + sub = open_sub(env) + send(sub, cmd="save", eid="copy", prev_eid="expt", data={}) + + env.state["copy"]["jsons"]["win_1"] = pane("win_1") + + assert "win_1" not in env.state["expt"]["jsons"] + + +def test_save_persists_the_new_environment(env): + sub = open_sub(env) + + send(sub, cmd="save", eid="copy", prev_eid="expt", data={}) + + assert env.storage.env_exists("copy") + + +def test_save_repoints_the_socket_at_the_new_environment(env): + sub = open_sub(env) + + send(sub, cmd="save", eid="copy", prev_eid="expt", data={}) + + assert sub.eid == "copy" + + +def test_save_without_a_known_source_is_a_noop(env): + sub = open_sub(env) + + send(sub, cmd="save", eid="copy", prev_eid="ghost", data={}) + + assert "copy" not in env.state + + +def test_save_without_a_previous_id_is_a_noop(env): + sub = open_sub(env) + + send(sub, cmd="save", eid="copy", data={}) + + assert "copy" not in env.state + + +# -- save_all ---------------------------------------------------------------- + + +def test_save_all_persists_every_environment(env, inline_executor): + sub = open_sub(env) + + send(sub, cmd="save_all") + + assert env.storage.env_exists("expt") + assert env.storage.env_exists("main") + + +def test_save_all_is_handed_to_the_executor(env, inline_executor): + """The write is offloaded rather than blocking the socket loop.""" + sub = open_sub(env) + + send(sub, cmd="save_all") + + func, args = inline_executor[0] + assert func == env.storage.save_all + assert args == (env.state,) + + +# -- save_layouts ------------------------------------------------------------ + + +def test_save_layouts_stores_the_payload(env): + sub = open_sub(env) + + send(sub, cmd="save_layouts", data='[["view A", {}]]') + + assert env.layouts == '[["view A", {}]]' + assert env.storage.load_layouts() == '[["view A", {}]]' + + +def test_save_layouts_tells_the_subscribers(env): + sub = open_sub(env) + + send(sub, cmd="save_layouts", data="[]") + + assert last(sub, "layout_update")["data"] == "[]" + + +def test_a_source_socket_can_save_layouts(env): + """A source connection sending ``save_layouts`` used to raise ValueError. + + Only subscriber sockets carried an implementation of ``broadcast_layouts``; + the base class raised, and the exception escaped the message loop. + """ + sub = open_sub(env) + source = open_source(env) + + send(source, cmd="save_layouts", data='[["from a source", {}]]') + + assert env.layouts == '[["from a source", {}]]' + assert last(sub, "layout_update")["data"] == '[["from a source", {}]]' + + +def test_save_layouts_without_data_is_a_noop(env): + sub = open_sub(env) + before = env.layouts + + send(sub, cmd="save_layouts") + + assert env.layouts == before + + +# -- layout_item_update ------------------------------------------------------ + + +def test_layout_item_update_records_the_position(env): + sub = open_sub(env) + + send(sub, cmd="layout_item_update", eid="expt", win="win_0", data=[0, 0, 4, 4]) + + assert env.state["expt"]["reload"]["win_0"] == [0, 0, 4, 4] + + +@pytest.mark.parametrize( + "msg", + [ + {"cmd": "layout_item_update", "win": "win_0", "data": []}, + {"cmd": "layout_item_update", "eid": "expt", "data": []}, + {"cmd": "layout_item_update", "eid": "ghost", "win": "win_0", "data": []}, + ], +) +def test_layout_item_update_drops_malformed_messages(env, msg): + sub = open_sub(env) + + sub.on_message(json.dumps(msg)) + + assert env.state["expt"]["reload"] == {} + + +# -- update_plot_layout ------------------------------------------------------ + + +def test_update_plot_layout_patches_the_layout(env): + sub = open_sub(env) + + send(sub, cmd="update_plot_layout", eid="expt", win="win_0", data={"title": "new"}) + + assert env.state["expt"]["jsons"]["win_0"]["content"]["layout"]["title"] == "new" + + +def test_update_plot_layout_merges_into_an_existing_layout(env): + env.state["expt"]["jsons"]["win_0"]["content"]["layout"] = {"title": "old", "a": 1} + sub = open_sub(env) + + send(sub, cmd="update_plot_layout", eid="expt", win="win_0", data={"title": "new"}) + + layout = env.state["expt"]["jsons"]["win_0"]["content"]["layout"] + assert layout == {"title": "new", "a": 1} + + +def test_update_plot_layout_targets_one_history_frame(app): + """A ``plot_history`` pane holds a list of frames; only ``frame`` changes.""" + frames = [{"data": [], "layout": {}}, {"data": [], "layout": {}}] + app.state["expt"] = { + "jsons": {"win_0": pane(ptype="plot_history", content=frames)}, + "reload": {}, + } + sub = open_sub(app) + + send( + sub, + cmd="update_plot_layout", + eid="expt", + win="win_0", + frame=1, + data={"title": "second"}, + ) + + stored = app.state["expt"]["jsons"]["win_0"]["content"] + assert stored[1]["layout"] == {"title": "second"} + assert stored[0]["layout"] == {} + + +@pytest.mark.parametrize("frame", [None, -1, 2, "1", 1.0, True, [1]]) +def test_update_plot_layout_rejects_a_bad_history_frame(app, frame): + """The frame index must be an in-range int, and is checked as one. + + Out-of-range and missing frames were already rejected, but the comparison + ran before any type check, so a string or list frame from a client raised + ``TypeError`` out of the socket loop. ``True`` is excluded too: it would + otherwise sail through as index 1. + """ + frames = [{"data": [], "layout": {}}, {"data": [], "layout": {}}] + app.state["expt"] = { + "jsons": {"win_0": pane(ptype="plot_history", content=frames)}, + "reload": {}, + } + sub = open_sub(app) + + send( + sub, + cmd="update_plot_layout", + eid="expt", + win="win_0", + frame=frame, + data={"title": "nope"}, + ) + + assert all( + f["layout"] == {} for f in app.state["expt"]["jsons"]["win_0"]["content"] + ) + + +def test_update_plot_layout_rejects_a_non_dict_patch(env): + sub = open_sub(env) + + send(sub, cmd="update_plot_layout", eid="expt", win="win_0", data=["title"]) + + assert env.state["expt"]["jsons"]["win_0"]["content"]["layout"] == {} + + +def test_update_plot_layout_rejects_a_pane_without_plot_content(env): + env.state["expt"]["jsons"]["win_0"]["content"] = "just text" + sub = open_sub(env) + + send(sub, cmd="update_plot_layout", eid="expt", win="win_0", data={"title": "x"}) + + assert env.state["expt"]["jsons"]["win_0"]["content"] == "just text" + + +def test_update_plot_layout_drops_an_unknown_pane(env): + sub = open_sub(env) + + send(sub, cmd="update_plot_layout", eid="expt", win="ghost", data={"title": "x"}) + + assert env.state["expt"]["jsons"]["win_0"]["content"]["layout"] == {} + + +# -- update_comment ---------------------------------------------------------- + + +def test_update_comment_stores_the_text(env, inline_executor): + sub = open_sub(env) + + send(sub, cmd="update_comment", eid="expt", win="win_0", data="looks good") + + assert env.state["expt"]["jsons"]["win_0"]["comment"] == "looks good" + + +def test_update_comment_bumps_the_version(env, inline_executor): + sub = open_sub(env) + + send(sub, cmd="update_comment", eid="expt", win="win_0", data="first") + send(sub, cmd="update_comment", eid="expt", win="win_0", data="second") + + assert env.state["expt"]["jsons"]["win_0"]["version"] == 3 + + +def test_update_comment_broadcasts_a_json_patch(env, inline_executor): + sub = open_sub(env) + sub.eid = "expt" + + send(sub, cmd="update_comment", eid="expt", win="win_0", data="looks good") + + packet = last(sub, "window_update") + assert packet["win"] == "win_0" + assert packet["eid"] == "expt" + assert packet["content"] == [ + {"op": "add", "path": "/comment", "value": "looks good"}, + {"op": "replace", "path": "/version", "value": packet["version"]}, + ] + + +def test_update_comment_persists_the_environment(env, inline_executor): + sub = open_sub(env) + + send(sub, cmd="update_comment", eid="expt", win="win_0", data="looks good") + + saved = env.storage.load_env("expt") + assert saved["jsons"]["win_0"]["comment"] == "looks good" + + +@pytest.mark.parametrize("comment", [42, None, ["a"], {"text": "a"}]) +def test_update_comment_rejects_a_non_string(env, comment): + sub = open_sub(env) + + send(sub, cmd="update_comment", eid="expt", win="win_0", data=comment) + + assert "comment" not in env.state["expt"]["jsons"]["win_0"] + + +def test_update_comment_drops_an_unknown_pane(env): + sub = open_sub(env) + + send(sub, cmd="update_comment", eid="expt", win="ghost", data="hi") + + assert "comment" not in env.state["expt"]["jsons"]["win_0"] + + +def test_update_comment_drops_an_unknown_environment(env): + sub = open_sub(env) + + send(sub, cmd="update_comment", eid="ghost", win="win_0", data="hi") + + assert "comment" not in env.state["expt"]["jsons"]["win_0"] + + +# -- forward_to_vis ---------------------------------------------------------- + + +def test_forward_to_vis_reaches_the_sources(env): + source = open_source(env) + sub = open_sub(env) + + send( + sub, + cmd="forward_to_vis", + data={"eid": "expt", "target": "win_0", "event_type": "Click"}, + ) + + assert sent(source)[-1]["event_type"] == "Click" + + +def test_forward_to_vis_attaches_the_pane(env): + source = open_source(env) + sub = open_sub(env) + + send(sub, cmd="forward_to_vis", data={"eid": "expt", "target": "win_0"}) + + assert sent(source)[-1]["pane_data"]["id"] == "win_0" + + +def test_forward_to_vis_honours_an_opt_out(env): + """``pane_data: False`` means the source does not want the pane inlined.""" + source = open_source(env) + sub = open_sub(env) + + send( + sub, + cmd="forward_to_vis", + data={"eid": "expt", "target": "win_0", "pane_data": False}, + ) + + assert sent(source)[-1]["pane_data"] is False + + +@pytest.mark.parametrize("packet", ["a string", 42, ["a"], None]) +def test_forward_to_vis_rejects_a_non_dict_payload(env, packet): + source = open_source(env) + sub = open_sub(env) + + send(sub, cmd="forward_to_vis", data=packet) + + assert commands(source) == ["alive"] + + +@pytest.mark.parametrize("packet", [{"target": "win_0"}, {"eid": "expt"}, {}]) +def test_forward_to_vis_rejects_an_incomplete_packet(env, packet): + source = open_source(env) + sub = open_sub(env) + + send(sub, cmd="forward_to_vis", data=packet) + + assert commands(source) == ["alive"] + + +def test_forward_to_vis_warns_the_sender_about_a_missing_env(env): + source = open_source(env) + sub = open_sub(env) + + send(sub, cmd="forward_to_vis", data={"eid": "ghost", "target": "win_0"}) + + notification = last(sub, "notification") + assert notification["data"]["type"] == "warning" + assert "ghost" in notification["data"]["message"] + assert commands(source) == ["alive"] + + +def test_forward_to_vis_warns_the_sender_about_a_missing_pane(env): + source = open_source(env) + sub = open_sub(env) + + send(sub, cmd="forward_to_vis", data={"eid": "expt", "target": "ghost"}) + + notification = last(sub, "notification") + assert notification["data"]["type"] == "warning" + assert "ghost" in notification["data"]["message"] + assert commands(source) == ["alive"] + + +def test_forward_to_vis_notifies_only_the_sender(env): + other = open_sub(env) + sub = open_sub(env) + + send(sub, cmd="forward_to_vis", data={"eid": "ghost", "target": "win_0"}) + + assert last(sub, "notification") is not None + assert last(other, "notification") is None + + +# -- pop_embeddings_pane ----------------------------------------------------- + + +def embeddings_pane(history=None): + """An embeddings pane mid-drilldown, with previous states to pop back to.""" + return pane( + ptype="embeddings", + content={"data": [[9, 9]], "selected": 3, "has_previous": True}, + old_content=[[[1, 1]], [[5, 5]]] if history is None else history, + ) + + +def test_pop_embeddings_restores_the_previous_points(app): + app.state["expt"] = {"jsons": {"win_0": embeddings_pane()}, "reload": {}} + sub = open_sub(app) + + send(sub, cmd="pop_embeddings_pane", data={"eid": "expt", "target": "win_0"}) + + content = app.state["expt"]["jsons"]["win_0"]["content"] + assert content["data"] == [[5, 5]] + assert content["selected"] is None + assert content["has_previous"] is True + + +def test_pop_embeddings_clears_has_previous_on_the_last_step(app): + app.state["expt"] = { + "jsons": {"win_0": embeddings_pane([[[1, 1]]])}, + "reload": {}, + } + sub = open_sub(app) + + send(sub, cmd="pop_embeddings_pane", data={"eid": "expt", "target": "win_0"}) + + assert app.state["expt"]["jsons"]["win_0"]["content"]["has_previous"] is False + + +def test_pop_embeddings_issues_a_new_content_id(app): + app.state["expt"] = {"jsons": {"win_0": embeddings_pane()}, "reload": {}} + app.state["expt"]["jsons"]["win_0"]["contentID"] = "old" + sub = open_sub(app) + + send(sub, cmd="pop_embeddings_pane", data={"eid": "expt", "target": "win_0"}) + + assert app.state["expt"]["jsons"]["win_0"]["contentID"] != "old" + + +def test_pop_embeddings_broadcasts_the_pane(app): + app.state["expt"] = {"jsons": {"win_0": embeddings_pane()}, "reload": {}} + sub = open_sub(app) + sub.eid = "expt" + + send(sub, cmd="pop_embeddings_pane", data={"eid": "expt", "target": "win_0"}) + + broadcast = [m for m in sent(sub) if m.get("id") == "win_0"][-1] + assert broadcast["eid"] == "expt" + assert broadcast["content"]["data"] == [[5, 5]] + + +@pytest.mark.parametrize("drop_key", [False, True], ids=["empty", "absent"]) +def test_pop_embeddings_survives_an_exhausted_history(app, drop_key): + """Popping with nothing left used to raise out of the message loop. + + The pane is left untouched and the socket stays usable, rather than an + ``IndexError`` (empty list) or ``KeyError`` (absent key) escaping into + Tornado's WebSocket callback. + """ + p = embeddings_pane([]) + if drop_key: + del p["old_content"] + app.state["expt"] = {"jsons": {"win_0": p}, "reload": {}} + sub = open_sub(app) + + send(sub, cmd="pop_embeddings_pane", data={"eid": "expt", "target": "win_0"}) + + assert app.state["expt"]["jsons"]["win_0"]["content"]["data"] == [[9, 9]] + + send(sub, cmd="layout_item_update", eid="expt", win="win_0", data=[0, 0, 1, 1]) + assert app.state["expt"]["reload"]["win_0"] == [0, 0, 1, 1] + + +@pytest.mark.parametrize( + "packet", + [ + "a string", + None, + {"target": "win_0"}, + {"eid": "expt"}, + {"eid": "ghost", "target": "win_0"}, + {"eid": "expt", "target": "ghost"}, + ], +) +def test_pop_embeddings_drops_malformed_messages(app, packet): + app.state["expt"] = {"jsons": {"win_0": embeddings_pane()}, "reload": {}} + sub = open_sub(app) + + send(sub, cmd="pop_embeddings_pane", data=packet) + + assert app.state["expt"]["jsons"]["win_0"]["content"]["data"] == [[9, 9]] + + +# -- echo, and the source/subscriber split ----------------------------------- + + +def test_echo_returns_the_message_to_the_sources(env): + source = open_source(env) + + send(source, cmd="echo", data="ping") + + assert sent(source)[-1] == {"cmd": "echo", "data": "ping"} + + +def test_echo_reaches_every_source(env): + first = open_source(env) + second = open_source(env) + + send(first, cmd="echo", data="ping") + + assert sent(second)[-1]["data"] == "ping" + + +def test_echo_does_not_fall_through_to_the_base_commands(env): + """``echo`` returns early; it is not also treated as an unknown command.""" + source = open_source(env) + sub = open_sub(env) + + send(source, cmd="echo", data="ping") + + assert commands(sub) == ["register", "layout_update", "env_update"] + + +def test_a_source_socket_handles_the_shared_commands_too(env): + """Anything that is not ``echo`` falls through to the shared dispatch.""" + source = open_source(env) + + send(source, cmd="delete_env", eid="expt") + + assert "expt" not in env.state + + +def test_echo_from_a_subscriber_is_not_a_command(env): + """``echo`` only exists on the source socket class.""" + sub = open_sub(env) + + send(sub, cmd="echo", data="ping") + + assert commands(sub) == ["register", "layout_update", "env_update"] + + +# -- unknown commands -------------------------------------------------------- + + +@pytest.mark.parametrize("cmd", ["not_a_command", "", None]) +def test_an_unrecognised_command_is_ignored(env, cmd): + sub = open_sub(env) + + send(sub, cmd=cmd) + + assert commands(sub) == ["register", "layout_update", "env_update"] + assert env.state["expt"]["jsons"].keys() == {"win_0"} diff --git a/py/tests/integration/socket_lifecycle.py b/py/tests/integration/socket_lifecycle.py new file mode 100644 index 000000000..6e2a70ff7 --- /dev/null +++ b/py/tests/integration/socket_lifecycle.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""Socket connection lifecycle: open, register, close. + +Subscriber sockets (``SocketHandlerOrWrapper``) and source sockets +(``VisSocketHandlerOrWrapper``) share a base ``open`` that mints a sid and adds +the socket to one of the app's two registries, then each layers its own +handshake on top. These tests drive the real classes through +``testutils.socket_double``; see that module for why a duck type is not enough. + +There is no HTTP here, but there is a real ``Application`` and real handler +dispatch, which is what ``integration`` means in this suite. +""" + +import json + +import pytest + +from visdom.server.app import Application +from visdom.server.handlers.socket_handlers import ( + SocketFailureReason, + SocketWrapper, + VisSocketWrapper, +) + +from testutils import commands, last, open_source, open_sub, sent, socket_double + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def readonly_app(env_path): + """Application started the way ``visdom -readonly`` starts it.""" + return Application(port=8097, env_path=env_path, readonly=True) + + +# -- Opening a subscriber ---------------------------------------------------- + + +def test_open_assigns_a_sid(app): + """Every socket gets its own id at open time.""" + first = open_sub(app) + second = open_sub(app) + + assert first.sid + assert second.sid + assert first.sid != second.sid + + +def test_open_registers_the_subscriber(app): + """The socket lands in ``app.subs`` under its own sid.""" + sub = open_sub(app) + + assert app.subs[sub.sid] is sub + assert app.sources == {} + + +def test_open_defaults_the_environment_to_main(app): + """A fresh socket is subscribed to ``main`` until it says otherwise.""" + assert open_sub(app).eid == "main" + + +def test_register_payload_carries_the_sid(app): + """The first message back is the register handshake.""" + sub = open_sub(app) + register = sent(sub)[0] + + assert register["command"] == "register" + assert register["data"] == sub.sid + + +def test_register_payload_reports_writable_server(app): + """``readonly`` is false on a normal server.""" + assert sent(open_sub(app))[0]["readonly"] is False + + +def test_register_payload_reports_readonly_server(readonly_app): + """A ``-readonly`` server says so in the handshake.""" + assert sent(open_sub(readonly_app))[0]["readonly"] is True + + +def test_register_payload_lists_environments_sorted(app): + """``envList`` is the sorted env ids, so the client need not sort.""" + app.state["zebra"] = {"jsons": {}, "reload": {}} + app.state["alpha"] = {"jsons": {}, "reload": {}} + + assert sent(open_sub(app))[0]["envList"] == ["alpha", "main", "zebra"] + + +def test_open_follows_up_with_layouts_and_environments(app): + """Register, then the saved layouts, then the env list — in that order.""" + assert commands(open_sub(app)) == ["register", "layout_update", "env_update"] + + +def test_layout_update_carries_the_saved_layouts(app): + """The layout message ships whatever the app has loaded.""" + app.layouts = '[["view A", {"win_0": [0, 0, 3, 3]}]]' + sub = open_sub(app) + + assert last(sub, "layout_update")["data"] == app.layouts + + +def test_env_update_carries_the_environment_ids(app): + """The env message ships the live state keys.""" + app.state["expt"] = {"jsons": {}, "reload": {}} + sub = open_sub(app) + + assert sorted(last(sub, "env_update")["data"]) == ["expt", "main"] + + +def test_opening_only_notifies_the_new_socket(app): + """An existing subscriber is not re-sent the handshake of a new one.""" + first = open_sub(app) + before = len(first.messages) + + open_sub(app) + + assert len(first.messages) == before + + +# -- Opening a source -------------------------------------------------------- + + +def test_source_registers_separately_from_subscribers(app): + """Sources and subscribers live in different registries.""" + source = open_source(app) + + assert app.sources[source.sid] is source + assert app.subs == {} + + +def test_source_handshake_is_an_alive_ping(app): + """A source is told the server is up; it gets no layouts or env list.""" + source = open_source(app) + + assert sent(source) == [{"command": "alive", "data": "vis_alive"}] + + +def test_both_socket_kinds_can_be_open_at_once(app): + """The two registries do not collide.""" + sub = open_sub(app) + source = open_source(app) + + assert list(app.subs) == [sub.sid] + assert list(app.sources) == [source.sid] + + +# -- Closing ----------------------------------------------------------------- + + +def test_close_deregisters_a_subscriber(app): + """``on_close`` removes the socket from ``app.subs``.""" + sub = open_sub(app) + sub.on_close() + + assert app.subs == {} + + +def test_close_deregisters_a_source(app): + """``on_close`` removes the socket from ``app.sources``.""" + source = open_source(app) + source.on_close() + + assert app.sources == {} + + +def test_close_leaves_other_sockets_registered(app): + """Closing one subscriber does not disturb the others.""" + first = open_sub(app) + second = open_sub(app) + first.on_close() + + assert list(app.subs) == [second.sid] + + +def test_close_on_an_unopened_socket_is_a_noop(app): + """A socket that never registered can still be closed.""" + socket_double(SocketWrapper, app).on_close() + socket_double(VisSocketWrapper, app).on_close() + + assert app.subs == {} + assert app.sources == {} + + +def test_close_twice_is_a_noop(app): + """A second close does not raise on the already-removed sid.""" + sub = open_sub(app) + sub.on_close() + sub.on_close() + + assert app.subs == {} + + +def test_polling_close_routes_through_on_close(app): + """``close()`` on a polling wrapper is the deregistration path.""" + sub = open_sub(app) + sub.close() + + assert app.subs == {} + + +def test_reopening_keeps_the_socket_under_its_first_sid(app): + """A second ``open`` mints a new sid but does not re-register. + + ``open`` assigns ``self.sid`` before checking membership, so an already + registered socket keeps its registry entry under the *old* sid while + carrying the new one. ``on_close`` pops ``self.sid``, so the entry then + outlives the socket. Pinned down here as current behaviour; nothing in the + server reopens a live socket today. + """ + sub = open_sub(app) + first_sid = sub.sid + + sub.open() + + assert sub.sid != first_sid + assert list(app.subs) == [first_sid] + + sub.on_close() + assert list(app.subs) == [first_sid] + + +# -- Message queue ----------------------------------------------------------- + + +def test_get_messages_drains_the_queue(app): + """A polling client reads each message once.""" + sub = open_sub(app) + + drained = sub.get_messages() + + assert len(drained) == 3 + assert sub.get_messages() == [] + + +def test_get_messages_stamps_the_read_time(app): + """Reading refreshes the idle timer the reaper watches.""" + sub = open_sub(app) + sub.last_read_time = 0 + + sub.get_messages() + + assert sub.last_read_time > 0 + + +# -- Readonly short-circuit -------------------------------------------------- + + +def test_readonly_socket_ignores_commands(readonly_app): + """A readonly server drops every socket command before dispatch.""" + readonly_app.state["expt"] = {"jsons": {}, "reload": {}} + sub = open_sub(readonly_app) + + sub.on_message(json.dumps({"cmd": "delete_env", "eid": "expt"})) + + assert "expt" in readonly_app.state + + +def test_readonly_socket_ignores_an_unknown_command(readonly_app): + """The short-circuit happens before the command is even recognised.""" + sub = open_sub(readonly_app) + before = len(sub.messages) + + sub.on_message(json.dumps({"cmd": "not_a_command"})) + + assert len(sub.messages) == before + + +# -- SocketFailureReason ----------------------------------------------------- + + +@pytest.mark.parametrize( + "reason,value", + [ + (SocketFailureReason.CONNECTION_CLOSED, "closed"), + (SocketFailureReason.MISSING_MESSAGE, "no msg"), + (SocketFailureReason.INVALID_MESSAGE_TYPE, "invalid"), + ], +) +def test_failure_reason_wire_value(reason, value): + """The short code is what goes on the wire; the detail explains it.""" + assert reason.value == value + assert reason.detail + + +def test_failure_response_shape(): + """A failure response always reports success false with a reason.""" + resp = SocketFailureReason.CONNECTION_CLOSED.to_failure_response() + + assert resp["success"] is False + assert resp["reason"] == "closed" + assert resp["detail"] == SocketFailureReason.CONNECTION_CLOSED.detail + assert "message" not in resp + + +def test_failure_response_carries_an_optional_message(): + """Callers can attach the offending value for debugging.""" + resp = SocketFailureReason.MISSING_MESSAGE.to_failure_response("sid=None") + + assert resp["message"] == "sid=None" + + +def test_failure_reasons_have_distinct_codes(): + """No two reasons share a wire value.""" + values = [reason.value for reason in SocketFailureReason] + + assert len(values) == len(set(values)) diff --git a/py/tests/test_storage_wiring.py b/py/tests/integration/storage_wiring.py similarity index 79% rename from py/tests/test_storage_wiring.py rename to py/tests/integration/storage_wiring.py index 0daed4bf7..0c00fb8e5 100644 --- a/py/tests/test_storage_wiring.py +++ b/py/tests/integration/storage_wiring.py @@ -11,14 +11,13 @@ (the DataStore backend) rather than writing environment files directly. As end-to-end save/fork/reload behavior is already -covered in ``test_environment_lifecycle``; here we assert the abstraction itself +covered in ``environment_lifecycle``; here we assert the abstraction itself is in place so a future refactor cannot silently bypass the backend. """ import json import os import tempfile -import types import unittest from unittest import mock @@ -39,10 +38,8 @@ push_deleted, ) - -def _env(win_id="win_0"): - """Minimal environment payload (one window).""" - return {"jsons": {win_id: {"id": win_id}}, "reload": {}} +from testutils.fakes import FakeHandler, FakeSocket, SpyStore +from testutils.payloads import env_payload class TestStorageWiring(unittest.TestCase): @@ -71,31 +68,12 @@ def spy(state, eids): self.app.storage.save_envs = spy - written = [] - handler = types.SimpleNamespace( - storage=self.app.storage, state=self.app.state, write=written.append - ) + handler = FakeHandler(state=self.app.state, storage=self.app.storage) SaveHandler.wrap_func(handler, {"data": ["main"]}) self.assertEqual(len(calls), 1) self.assertIn("main", calls[0]) - self.assertIn("main", json.loads(written[0])) - - -class _SpyStore(JSONStore): - """JSONStore that records load-path calls while delegating to the real impl.""" - - def __init__(self, env_path): - super().__init__(env_path) - self.calls = {"list_envs": 0, "load_env": []} - - def list_envs(self): - self.calls["list_envs"] += 1 - return super().list_envs() - - def load_env(self, eid): - self.calls["load_env"].append(eid) - return super().load_env(eid) + self.assertIn("main", handler.json_body()) class TestLoadStateWiring(unittest.TestCase): @@ -109,26 +87,28 @@ def tearDown(self): self._tmp.cleanup() def _seed(self, eid, env=None): - JSONStore(self.env_path).save_env(eid, env if env is not None else _env()) + JSONStore(self.env_path).save_env( + eid, env if env is not None else env_payload() + ) def test_saved_envs_load_into_state(self): self._seed("main") - self._seed("expt", _env("w1")) + self._seed("expt", env_payload("w1")) app = Application(port=8097, env_path=self.env_path) self.assertIn("expt", app.state) - self.assertEqual(dict(app.state["expt"]), _env("w1")) + self.assertEqual(dict(app.state["expt"]), env_payload("w1")) def test_lazy_by_default(self): self._seed("expt") app = Application(port=8097, env_path=self.env_path) self.assertIsInstance(app.state["expt"], LazyEnvData) - self.assertEqual(app.state["expt"]["jsons"], _env()["jsons"]) + self.assertEqual(app.state["expt"]["jsons"], env_payload()["jsons"]) def test_eager_loads_plain_dicts(self): self._seed("expt") app = Application(port=8097, env_path=self.env_path, eager_data_loading=True) self.assertIsInstance(app.state["expt"], dict) - self.assertEqual(app.state["expt"], _env()) + self.assertEqual(app.state["expt"], env_payload()) def test_eager_preserves_extra_env_keys(self): """Eager loading keeps env keys the server itself does not read. @@ -137,13 +117,13 @@ def test_eager_preserves_extra_env_keys(self): dropping unknown keys here would lose it on the next full-env save. """ experiment = {"env_id": "expt", "name": "run-1", "status": "running"} - self._seed("expt", dict(_env("w1"), experiment=experiment)) + self._seed("expt", dict(env_payload("w1"), experiment=experiment)) app = Application(port=8097, env_path=self.env_path, eager_data_loading=True) self.assertEqual(app.state["expt"]["experiment"], experiment) def test_load_state_routes_through_storage(self): self._seed("expt") - with mock.patch("visdom.server.app.JSONStore", _SpyStore): + with mock.patch("visdom.server.app.JSONStore", SpyStore): app = Application(port=8097, env_path=self.env_path) self.assertEqual(app.storage.calls["list_envs"], 1) self.assertEqual(app.storage.calls["load_env"], []) @@ -184,19 +164,15 @@ def spy(eid): def _handler(self, state, **extra): """A stand-in handler exposing only what the delete paths read.""" - return types.SimpleNamespace( - storage=self.store, - state=state, - env_path=self.env_path, - subs={}, - **extra, + return FakeHandler( + state=state, storage=self.store, env_path=self.env_path, **extra ) def test_web_delete_routes_through_storage(self): - self.store.save_env("expt", _env()) + self.store.save_env("expt", env_payload()) self.assertTrue(self.store.env_exists("expt")) calls = self._spy_delete() - handler = self._handler({"expt": _env()}) + handler = self._handler({"expt": env_payload()}) DeleteEnvHandler.wrap_func(handler, {"eid": "expt"}) self.assertEqual(calls, ["expt"]) self.assertNotIn("expt", handler.state) @@ -204,17 +180,17 @@ def test_web_delete_routes_through_storage(self): self.assertFalse(os.path.exists(os.path.join(self.env_path, "expt.json"))) def test_web_delete_protects_main(self): - self.store.save_env("main", _env()) + self.store.save_env("main", env_payload()) calls = self._spy_delete() - handler = self._handler({"main": _env()}) + handler = self._handler({"main": env_payload()}) DeleteEnvHandler.wrap_func(handler, {"eid": "main"}) self.assertEqual(calls, []) self.assertTrue(self.store.env_exists("main")) def test_socket_delete_routes_through_storage(self): - self.store.save_env("expt", _env()) + self.store.save_env("expt", env_payload()) calls = self._spy_delete() - fake = self._handler({"expt": _env()}, readonly=False) + fake = self._handler({"expt": env_payload()}, readonly=False) AnySocketHandlerOrWrapper.on_message( fake, json.dumps({"cmd": "delete_env", "eid": "expt"}) ) @@ -310,7 +286,7 @@ def tearDown(self): self._tmp.cleanup() def test_defers_until_access_then_caches(self): - self._store.save_env("main", _env()) + self._store.save_env("main", env_payload()) reads = [] real_load = self._store.load_env @@ -321,7 +297,7 @@ def spy(eid): self._store.load_env = spy lazy = LazyEnvData(self._store, "main") self.assertEqual(reads, []) - self.assertEqual(lazy["jsons"], _env()["jsons"]) + self.assertEqual(lazy["jsons"], env_payload()["jsons"]) self.assertEqual(reads, ["main"]) _ = lazy["reload"] self.assertEqual(reads, ["main"]) @@ -332,55 +308,44 @@ def test_missing_env_raises_value_error(self): _ = lazy["jsons"] -class _FakeSocket: - """Minimal stand-in for a client socket used by the read helpers.""" - - def __init__(self): - self.messages = [] - self.eid = None - - def write_message(self, msg): - self.messages.append(msg) - - class TestReadHelperWiring(unittest.TestCase): """``load_env`` reads a cold env through the store, not raw env_path.""" def setUp(self): self._tmp = tempfile.TemporaryDirectory() - self.store = _SpyStore(self._tmp.name) + self.store = SpyStore(self._tmp.name) def tearDown(self): self._tmp.cleanup() def test_load_env_reads_cold_env_through_store(self): - JSONStore(self.store.env_path).save_env("expt", _env()) + JSONStore(self.store.env_path).save_env("expt", env_payload()) state = {} - socket = _FakeSocket() + socket = FakeSocket() load_env(state, "expt", socket, self.store) self.assertEqual(self.store.calls["load_env"], ["expt"]) - self.assertEqual(dict(state["expt"]), _env()) + self.assertEqual(dict(state["expt"]), env_payload()) def test_load_env_skips_store_when_already_in_state(self): - state = {"expt": _env()} - socket = _FakeSocket() + state = {"expt": env_payload()} + socket = FakeSocket() load_env(state, "expt", socket, self.store) self.assertEqual(self.store.calls["load_env"], []) def test_gather_envs_lists_through_store(self): - JSONStore(self.store.env_path).save_env("on_disk", _env()) - items = gather_envs({"in_memory": _env()}, self.store) + JSONStore(self.store.env_path).save_env("on_disk", env_payload()) + items = gather_envs({"in_memory": env_payload()}, self.store) self.assertEqual(self.store.calls["list_envs"], 1) self.assertEqual(items, ["in_memory", "on_disk"]) def test_gather_envs_in_memory_only(self): - store = _SpyStore(None) - self.assertEqual(gather_envs({"main": _env()}, store), ["main"]) + store = SpyStore(None) + self.assertEqual(gather_envs({"main": env_payload()}, store), ["main"]) def test_compare_envs_reads_cold_env_through_store(self): - JSONStore(self.store.env_path).save_env("cold", _env()) - state = {"warm": _env("w1")} - socket = _FakeSocket() + JSONStore(self.store.env_path).save_env("cold", env_payload()) + state = {"warm": env_payload("w1")} + socket = FakeSocket() compare_envs(state, ["warm", "cold"], socket, self.store) self.assertEqual(self.store.calls["load_env"], ["cold"]) self.assertIn("cold", state) diff --git a/py/tests/integration/update_plots.py b/py/tests/integration/update_plots.py new file mode 100644 index 000000000..766fe048b --- /dev/null +++ b/py/tests/integration/update_plots.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""``POST /update`` for plot panes -- heatmaps, traces, layout and opts. + +Once ``UpdateHandler.update`` is past the content panes it works on +``p["content"]["data"]``, and which branch runs depends on how many traces the +``name`` argument selects: none injects a trace, exactly one heatmap takes the +``updateDir`` path, and anything else falls through to the positional trace +update. Every one of those branches is driven here over HTTP. +""" + +import unittest + +import pytest + +from testutils.http import VisdomHTTPTestCase + +pytestmark = pytest.mark.integration + + +class PlotUpdateTestCase(VisdomHTTPTestCase): + """Creates the two plot shapes the update paths distinguish between.""" + + def create_heatmap(self, z=None, x=None, y=None): + return self.create_window( + [ + { + "type": "heatmap", + "z": [[1, 2], [3, 4]] if z is None else z, + "x": ["a", "b"] if x is None else x, + "y": ["c", "d"] if y is None else y, + } + ], + layout={"title": "hm"}, + ) + + def create_scatter(self, name="t1", x=None, y=None): + return self.create_window( + [ + { + "type": "scatter", + "x": [1, 2] if x is None else x, + "y": [3, 4] if y is None else y, + "name": name, + } + ], + layout={"title": "scatter"}, + ) + + def update_heatmap(self, win, z, update_dir, x=None, y=None): + """Mirror the client: ``append`` is set for every directional update. + + ``Visdom.heatmap`` derives it from ``updateDir`` (``__init__.py:3006``) + and it is load-bearing -- without it the opts loop at the end of the + heatmap branch overwrites the labels it just extended. + """ + return self.update( + win, + [{"type": "heatmap", "z": z, "x": x, "y": y}], + name=None, + updateDir=update_dir, + append=update_dir != "replace", + ) + + def traces(self, win): + return self.get_win_data(win)["content"]["data"] + + def heatmap_z(self, win): + return self.traces(win)[0]["z"] + + +class TestHeatmapRowUpdates(PlotUpdateTestCase): + """``appendRow`` and ``prependRow`` grow ``z`` along the y axis.""" + + def test_append_row_adds_to_the_bottom(self): + win = self.create_heatmap() + self.update_heatmap(win, [[5, 6]], "appendRow", y=["e"]) + z = self.heatmap_z(win) + self.assertEqual(len(z), 3) + self.assertEqual(z[2], [5, 6]) + + def test_append_row_extends_the_row_labels(self): + win = self.create_heatmap() + self.update_heatmap(win, [[5, 6]], "appendRow", y=["e"]) + self.assertEqual(self.traces(win)[0]["y"], ["c", "d", "e"]) + + def test_prepend_row_adds_to_the_top(self): + win = self.create_heatmap() + self.update_heatmap(win, [[5, 6]], "prependRow", y=["e"]) + z = self.heatmap_z(win) + self.assertEqual(len(z), 3) + self.assertEqual(z[0], [5, 6]) + + def test_prepend_row_extends_the_row_labels(self): + win = self.create_heatmap() + self.update_heatmap(win, [[5, 6]], "prependRow", y=["e"]) + self.assertEqual(self.traces(win)[0]["y"], ["e", "c", "d"]) + + +class TestHeatmapColumnUpdates(PlotUpdateTestCase): + """``appendColumn`` and ``prependColumn`` grow every row of ``z``.""" + + def test_append_column_adds_to_the_right(self): + win = self.create_heatmap() + self.update_heatmap(win, [[7], [8]], "appendColumn", x=["e"]) + z = self.heatmap_z(win) + self.assertEqual(len(z[0]), 3) + self.assertEqual([row[2] for row in z], [7, 8]) + + def test_prepend_column_adds_to_the_left(self): + win = self.create_heatmap() + self.update_heatmap(win, [[7], [8]], "prependColumn", x=["e"]) + z = self.heatmap_z(win) + self.assertEqual(len(z[0]), 3) + self.assertEqual([row[0] for row in z], [7, 8]) + + def test_column_update_extends_the_column_labels(self): + win = self.create_heatmap() + self.update_heatmap(win, [[7], [8]], "appendColumn", x=["e"]) + self.assertEqual(self.traces(win)[0]["x"], ["a", "b", "e"]) + + +class TestHeatmapReplace(PlotUpdateTestCase): + """``replace`` swaps the matrix outright, dimensions and all.""" + + def test_replace_swaps_the_matrix(self): + win = self.create_heatmap() + self.update_heatmap(win, [[9]], "replace", x=["only"], y=["one"]) + self.assertEqual(self.heatmap_z(win), [[9]]) + + def test_replace_swaps_the_labels(self): + win = self.create_heatmap() + self.update_heatmap(win, [[9]], "replace", x=["only"], y=["one"]) + trace = self.traces(win)[0] + self.assertEqual(trace["x"], ["only"]) + self.assertEqual(trace["y"], ["one"]) + + +class TestHeatmapMismatch(PlotUpdateTestCase): + """A shape or label conflict logs and leaves the plot untouched.""" + + def test_wrong_column_count_is_a_no_op(self): + win = self.create_heatmap() + resp = self.update_heatmap(win, [[5, 6, 7]], "appendRow", y=["e"]) + self.assertEqual(resp.code, 200) + self.assertEqual(self.heatmap_z(win), [[1, 2], [3, 4]]) + + def test_wrong_row_count_is_a_no_op(self): + win = self.create_heatmap() + resp = self.update_heatmap(win, [[7]], "appendColumn", x=["e"]) + self.assertEqual(resp.code, 200) + self.assertEqual(self.heatmap_z(win), [[1, 2], [3, 4]]) + + def test_duplicate_labels_are_a_no_op(self): + win = self.create_heatmap() + self.update_heatmap(win, [[5, 6]], "appendRow", y=["c"]) + self.assertEqual(self.heatmap_z(win), [[1, 2], [3, 4]]) + + def test_missing_labels_are_a_no_op(self): + win = self.create_heatmap() + self.update_heatmap(win, [[5, 6]], "appendRow", y=None) + self.assertEqual(self.heatmap_z(win), [[1, 2], [3, 4]]) + + +class TestTraceUpdates(PlotUpdateTestCase): + """A ``name`` selects which traces the update applies to.""" + + def create_two_traces(self): + return self.create_window( + [ + {"type": "scatter", "x": [1], "y": [2], "name": "t1"}, + {"type": "scatter", "x": [3], "y": [4], "name": "t2"}, + ], + layout={"title": "multi"}, + ) + + def test_named_update_replaces_the_trace(self): + win = self.create_scatter() + self.update( + win, + [{"type": "scatter", "x": [10, 20], "y": [30, 40], "name": "t1"}], + name="t1", + append=False, + ) + self.assertEqual(self.traces(win)[0]["x"], [10, 20]) + + def test_named_update_appends_to_the_trace(self): + win = self.create_scatter() + self.update( + win, + [{"type": "scatter", "x": [5], "y": [6], "name": "t1"}], + name="t1", + append=True, + ) + trace = self.traces(win)[0] + self.assertEqual(trace["x"], [1, 2, 5]) + self.assertEqual(trace["y"], [3, 4, 6]) + + def test_delete_removes_only_the_named_trace(self): + win = self.create_two_traces() + self.update(win, [{}], name="t1", delete=True) + remaining = self.traces(win) + self.assertEqual(len(remaining), 1) + self.assertEqual(remaining[0]["name"], "t2") + + def test_an_unknown_name_injects_a_new_trace(self): + win = self.create_scatter() + self.update( + win, + [{"type": "scatter", "x": [10], "y": [20], "name": "new_trace"}], + name="new_trace", + ) + traces = self.traces(win) + self.assertEqual(len(traces), 2) + self.assertEqual(traces[1]["name"], "new_trace") + self.assertEqual(traces[1]["x"], [10]) + + def test_a_named_update_carrying_several_entries_is_rejected(self): + win = self.create_scatter() + resp = self.update( + win, + [ + {"type": "scatter", "x": [5], "y": [6], "name": "t1"}, + {"type": "scatter", "x": [7], "y": [8], "name": "t1"}, + ], + name="t1", + ) + self.assertEqual(resp.code, 400) + + +class TestMarkerUpdates(PlotUpdateTestCase): + """Marker colours concatenate alongside the points they belong to.""" + + def create_coloured_scatter(self, colors): + return self.create_window( + [ + { + "type": "scatter", + "x": [1], + "y": [2], + "name": "t1", + "marker": {"color": colors}, + } + ], + layout={"title": "markers"}, + ) + + def append_colour(self, win, color, append=True): + return self.update( + win, + [ + { + "type": "scatter", + "x": [3], + "y": [4], + "name": "t1", + "marker": {"color": [color]}, + } + ], + name="t1", + append=append, + ) + + def test_append_concatenates_the_colours(self): + win = self.create_coloured_scatter(["red"]) + self.append_colour(win, "blue") + self.assertEqual(self.traces(win)[0]["marker"]["color"], ["red", "blue"]) + + def test_replace_swaps_the_colours(self): + win = self.create_coloured_scatter(["red"]) + self.append_colour(win, "blue", append=False) + self.assertEqual(self.traces(win)[0]["marker"]["color"], ["blue"]) + + +class TestLayoutAndOptsUpdates(PlotUpdateTestCase): + """An update with no ``data`` still applies the layout and opts.""" + + def test_layout_only_update_changes_the_title(self): + win = self.create_scatter() + self.update(win, None, layout={"title": "new title"}) + pane = self.get_win_data(win) + self.assertEqual(pane["content"]["layout"]["title"], "new title") + + def test_layout_only_update_bumps_the_version(self): + win = self.create_scatter() + self.assertEqual(self.get_win_data(win)["version"], 1) + self.update(win, None, layout={"title": "new title"}) + self.assertEqual(self.get_win_data(win)["version"], 2) + + def test_opts_legend_renames_the_trace(self): + win = self.create_scatter() + self.update(win, None, opts={"legend": ["renamed_trace"]}) + self.assertEqual(self.traces(win)[0]["name"], "renamed_trace") + + +class TestCategoricalXUpdate(PlotUpdateTestCase): + """A categorical x axis must survive the all-missing-points check. + + That check exists to skip a trace whose points are all None/NaN/Inf. Only + numbers can be either, so handing a label to ``math.isnan`` used to raise + ``TypeError`` and answer 500. + """ + + def create_categorical(self): + return self.create_scatter(x=["a", "b"], y=[1, 2]) + + def test_appending_to_a_categorical_axis_succeeds(self): + win = self.create_categorical() + resp = self.update( + win, + [{"type": "scatter", "x": ["c"], "y": [3], "name": "t1"}], + name="t1", + append=True, + ) + self.assertEqual(resp.code, 200, resp.body) + + def test_appending_to_a_categorical_axis_keeps_the_labels(self): + win = self.create_categorical() + self.update( + win, + [{"type": "scatter", "x": ["c"], "y": [3], "name": "t1"}], + name="t1", + append=True, + ) + trace = self.traces(win)[0] + self.assertEqual(trace["x"], ["a", "b", "c"]) + self.assertEqual(trace["y"], [1, 2, 3]) + + def test_a_wholly_missing_numeric_update_is_still_skipped(self): + win = self.create_scatter() + self.update( + win, + [{"type": "scatter", "x": [None, None], "y": [9, 9], "name": "t1"}], + name="t1", + append=False, + ) + self.assertEqual(self.traces(win)[0]["x"], [1, 2]) + + +class TestEmptyDataUpdate(PlotUpdateTestCase): + """Updates carrying fewer data entries than the plot has traces. + + Each of these indexed into ``data`` without checking its length first and + answered 500 with an ``IndexError``. + """ + + def test_an_empty_data_list_applies_the_layout(self): + win = self.create_scatter() + resp = self.update(win, [], layout={"title": "new title"}) + self.assertEqual(resp.code, 200, resp.body) + pane = self.get_win_data(win) + self.assertEqual(pane["content"]["layout"]["title"], "new title") + + def test_an_empty_data_list_leaves_the_traces_alone(self): + win = self.create_scatter() + self.update(win, [], layout={"title": "new title"}) + self.assertEqual(self.traces(win)[0]["x"], [1, 2]) + + def test_a_trace_can_be_injected_into_an_emptied_plot(self): + win = self.create_scatter() + self.update(win, [{}], name="t1", delete=True) + self.assertEqual(self.traces(win), []) + + resp = self.update( + win, + [{"type": "scatter", "x": [7], "y": [8], "name": "t2"}], + name="t2", + ) + self.assertEqual(resp.code, 200, resp.body) + traces = self.traces(win) + self.assertEqual(len(traces), 1) + self.assertEqual(traces[0]["name"], "t2") + self.assertEqual(traces[0]["x"], [7]) + + def test_an_unnamed_delete_still_empties_the_plot(self): + """``Visdom.heatmap(update="remove")`` names no trace. + + It posts ``data: []`` with ``delete`` set (``__init__.py:3008``), which + the empty-data shortcut above must not mistake for an opts-only update. + """ + win = self.create_heatmap() + resp = self.update(win, [], name=None, delete=True) + self.assertEqual(resp.code, 200, resp.body) + self.assertEqual(self.traces(win), []) + + def test_an_unnamed_update_may_cover_only_the_first_traces(self): + win = self.create_window( + [ + {"type": "scatter", "x": [1], "y": [2], "name": "t1"}, + {"type": "scatter", "x": [3], "y": [4], "name": "t2"}, + ], + layout={"title": "multi"}, + ) + resp = self.update( + win, + [{"type": "scatter", "x": [9], "y": [9]}], + append=True, + ) + self.assertEqual(resp.code, 200, resp.body) + traces = self.traces(win) + self.assertEqual(traces[0]["x"], [1, 9]) + self.assertEqual(traces[1]["x"], [3]) + + +class TestUnsupportedUpdate(PlotUpdateTestCase): + """Only a handful of pane types accept ``/update`` at all.""" + + def test_a_bar_pane_reports_the_type_it_is(self): + win = self.create_window( + [{"type": "bar", "x": ["a"], "y": [1], "name": "b1"}], + layout={"title": "bar"}, + ) + resp = self.update( + win, + [{"type": "bar", "x": ["b"], "y": [2], "name": "b1"}], + name="b1", + ) + self.assertIn(b"win is not scatter", resp.body) + self.assertIn(b"was bar", resp.body) + + def test_an_update_to_a_missing_window_is_reported(self): + resp = self.update("no_such_win", [{"type": "scatter", "x": [1], "y": [2]}]) + self.assertEqual(resp.body, b"win does not exist") + + +if __name__ == "__main__": + unittest.main() diff --git a/py/tests/integration/update_text_media.py b/py/tests/integration/update_text_media.py new file mode 100644 index 000000000..d63f70c99 --- /dev/null +++ b/py/tests/integration/update_text_media.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 + +# Copyright 2017-present, The Visdom Authors +# All rights reserved. +# +# This source code is licensed under the license found in the +# LICENSE file in the root directory of this source tree. + +"""``POST /update`` for the panes that carry content rather than traces. + +``UpdateHandler.update`` dispatches on ``p["type"]`` before it ever looks at +plot data, and the first three branches -- ``text``, ``image_history`` and the +separate ``update_embeddings_packet`` route -- each mutate the pane in a +different shape. Text concatenates, image history appends and moves a cursor, +and embeddings swap ``content["data"]`` while stacking the old value onto +``old_content``. All three are asserted here through a real HTTP round trip. +""" + +import unittest + +import pytest + +from testutils.http import VisdomHTTPTestCase +from testutils.payloads import content_args + +pytestmark = pytest.mark.integration + + +class TestTextUpdate(VisdomHTTPTestCase): + """Text updates concatenate onto the existing content with ``