Skip to content

Commit f8bb68c

Browse files
[fix] Fixed real time updates in non-admin visualizer + selenium tests #250
Closes #250 Closes #251 --------- Co-authored-by: Federico Capoano <f.capoano@openwisp.io>
1 parent 8d75354 commit f8bb68c

4 files changed

Lines changed: 284 additions & 5 deletions

File tree

.github/workflows/ci.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,14 @@ jobs:
9696
- name: Tests
9797
if: ${{ !cancelled() && steps.deps.conclusion == 'success' }}
9898
run: |
99-
coverage run runtests.py --parallel
100-
WIFI_MESH=1 coverage run runtests.py --parallel --exclude-tag=selenium_tests
101-
SAMPLE_APP=1 coverage run ./runtests.py --parallel --exclude-tag=selenium_tests
99+
coverage run runtests.py --tag=no_parallel
100+
coverage run runtests.py --parallel --exclude-tag=no_parallel
101+
WIFI_MESH=1 coverage run runtests.py --parallel \
102+
--exclude-tag=no_parallel \
103+
--exclude-tag=selenium_tests
104+
SAMPLE_APP=1 coverage run ./runtests.py --parallel \
105+
--exclude-tag=no_parallel \
106+
--exclude-tag=selenium_tests
102107
coverage combine
103108
coverage xml
104109
env:

openwisp_network_topology/templates/netjsongraph/netjsongraph-script.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@
148148
const data=JSON.parse(e.data);
149149
if(data.type==="broadcast_topology"){
150150
const topology=JSON.parse(data.topology);
151-
window.graph.utils.JSONDataUpdate.call(window.graph, topology);
151+
graph.utils.JSONDataUpdate.call(graph, topology);
152152
}
153153
}
154154

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import json
2+
from time import sleep
3+
4+
import pytest
5+
from asgiref.sync import sync_to_async
6+
from channels.db import database_sync_to_async
7+
from channels.testing import ChannelsLiveServerTestCase, WebsocketCommunicator
8+
from django.conf import settings
9+
from django.test import tag
10+
from django.urls import reverse
11+
from django.utils.module_loading import import_string
12+
from selenium.webdriver.common.by import By
13+
from swapper import load_model
14+
15+
from openwisp_users.tests.utils import TestOrganizationMixin
16+
from openwisp_utils.tests import SeleniumTestMixin
17+
18+
from .utils import CreateGraphObjectsMixin, LoadMixin
19+
20+
Link = load_model("topology", "Link")
21+
Node = load_model("topology", "Node")
22+
Topology = load_model("topology", "Topology")
23+
24+
25+
@pytest.mark.asyncio
26+
@pytest.mark.django_db(transaction=True)
27+
@tag("selenium_tests")
28+
@tag("no_parallel")
29+
class TestRealTime(
30+
TestOrganizationMixin,
31+
CreateGraphObjectsMixin,
32+
LoadMixin,
33+
SeleniumTestMixin,
34+
ChannelsLiveServerTestCase,
35+
):
36+
app_label = "topology"
37+
prefix = f"admin:{app_label}"
38+
node_model = Node
39+
link_model = Link
40+
topology_model = Topology
41+
application = import_string(getattr(settings, "ASGI_APPLICATION"))
42+
browser = "chrome"
43+
maxDiff = None
44+
retry_max = 6
45+
46+
def setUp(self):
47+
org = self._create_org()
48+
self.admin = self._create_admin(
49+
username=self.admin_username, password=self.admin_password
50+
)
51+
self.admin_client = self.client
52+
self.admin_client.force_login(self.admin)
53+
54+
self.topology = self._create_topology(organization=org)
55+
self.node1 = self._create_node(
56+
label="node1",
57+
addresses=["192.168.0.1"],
58+
topology=self.topology,
59+
organization=org,
60+
)
61+
self.node2 = self._create_node(
62+
label="node2",
63+
addresses=["192.168.0.2"],
64+
topology=self.topology,
65+
organization=org,
66+
)
67+
self.link = self._create_link(
68+
source=self.node1,
69+
target=self.node2,
70+
topology=self.topology,
71+
status="up",
72+
)
73+
self.org = org
74+
75+
async def _prepare(self, admin=True):
76+
communicator = await self._get_communicator(self.admin_client, self.topology.pk)
77+
connected, _ = await communicator.connect()
78+
assert connected is True
79+
if admin:
80+
path = reverse(f"{self.prefix}_topology_change", args=[self.topology.pk])
81+
else:
82+
path = reverse("topology_detail", kwargs={"pk": self.topology.pk})
83+
self.login()
84+
if admin:
85+
self.open(path)
86+
self.find_element(By.CSS_SELECTOR, "input.visualizelink").click()
87+
else:
88+
# non admin visualizer doesn't need clicking to show the graph
89+
self.open(path, html_container=".djnjg-overlay")
90+
return communicator
91+
92+
def _snooze(self):
93+
"""Allows a bit of time for the UI to update, reduces flakyness"""
94+
sleep(0.25)
95+
96+
def _create_node3(self):
97+
self.node3 = self._create_node(
98+
label="node3",
99+
addresses=["192.168.0.3"],
100+
topology=self.topology,
101+
organization=self.org,
102+
)
103+
104+
def _assert_no_js_errors(self):
105+
browser_logs = []
106+
for log in self.get_browser_logs():
107+
# ignore if not console-api
108+
if log["source"] != "console-api":
109+
continue
110+
# ignore errors coming from the library
111+
# to reduce flakyness, if there's really
112+
# a sever error the UI will not work as expected
113+
if "/static/netjsongraph/js/src/netjsongraph.min.js" in log["message"]:
114+
print(f"ignoring library error: {log}")
115+
continue
116+
else:
117+
print(log)
118+
browser_logs.append(log)
119+
self.assertEqual(browser_logs, [])
120+
121+
async def _get_communicator(self, admin_client, topology_id):
122+
session_id = admin_client.cookies["sessionid"].value
123+
communicator = WebsocketCommunicator(
124+
self.application,
125+
path=f"ws/network-topology/topology/{topology_id}/",
126+
headers=[
127+
(
128+
b"cookie",
129+
f"sessionid={session_id}".encode("ascii"),
130+
)
131+
],
132+
)
133+
return communicator
134+
135+
async def test_real_time_link_status_update(self):
136+
# preparation
137+
self.link.status = "down"
138+
await database_sync_to_async(self.link.save)()
139+
communicator = await self._prepare()
140+
# changing the status of a link will change it in the browser graph too
141+
self.link.status = "up"
142+
await database_sync_to_async(self.link.save)()
143+
message = await communicator.receive_json_from()
144+
assert (
145+
json.loads(message["topology"])["links"][0]["properties"]["status"] == "up"
146+
)
147+
self._snooze()
148+
self.assertEqual(
149+
self.web_driver.execute_script("return graph.data;")["links"][0][
150+
"properties"
151+
]["status"],
152+
"up",
153+
)
154+
# test status changing to down
155+
self.link.status = "down"
156+
await sync_to_async(self.link.save)()
157+
message = await communicator.receive_json_from()
158+
assert (
159+
json.loads(message["topology"])["links"][0]["properties"]["status"]
160+
== "down"
161+
)
162+
self._snooze()
163+
self.assertEqual(
164+
self.web_driver.execute_script("return graph.data;")["links"][0][
165+
"properties"
166+
]["status"],
167+
"down",
168+
)
169+
self._assert_no_js_errors()
170+
await communicator.disconnect()
171+
172+
async def test_node_status_update(self):
173+
# preparation
174+
communicator = await self._prepare()
175+
# saving a new node will add it to the UI
176+
await database_sync_to_async(self._create_node3)()
177+
message = await communicator.receive_json_from()
178+
self.assertEqual(len(json.loads(message["topology"])["nodes"]), 3)
179+
self._snooze()
180+
self._assert_no_js_errors()
181+
self.assertEqual(
182+
len(self.web_driver.execute_script("return graph.data;")["nodes"]),
183+
3,
184+
)
185+
# deleting the node from the DB will remove it from the UI
186+
await database_sync_to_async(self.node3.delete)()
187+
message = await communicator.receive_json_from()
188+
self.assertEqual(len(json.loads(message["topology"])["nodes"]), 2)
189+
self._snooze()
190+
self._assert_no_js_errors()
191+
self.assertEqual(
192+
len(self.web_driver.execute_script("return graph.data;")["nodes"]),
193+
2,
194+
)
195+
await communicator.disconnect()
196+
197+
async def test_node_link_update(self):
198+
# preparation
199+
communicator = await self._prepare()
200+
# deleting the link from the DB will remove it from the UI
201+
await database_sync_to_async(self.link.delete)()
202+
message = await communicator.receive_json_from()
203+
self.assertEqual(len(json.loads(message["topology"])["links"]), 0)
204+
self._snooze()
205+
self._assert_no_js_errors()
206+
self.assertEqual(
207+
len(self.web_driver.execute_script("return graph.data;")["links"]),
208+
0,
209+
)
210+
# adding a link will add it to the UI
211+
await database_sync_to_async(self.link.save)()
212+
message = await communicator.receive_json_from()
213+
self.assertEqual(len(json.loads(message["topology"])["links"]), 1)
214+
self._snooze()
215+
self._assert_no_js_errors()
216+
self.assertEqual(
217+
len(self.web_driver.execute_script("return graph.data;")["links"]),
218+
1,
219+
)
220+
await communicator.disconnect()
221+
222+
async def test_non_admin_visualizer(self):
223+
# preparation
224+
communicator = await self._prepare(admin=False)
225+
# changing the status of a link will change it in the browser graph too
226+
with self.subTest("change link status"):
227+
self.link.status = "down"
228+
await database_sync_to_async(self.link.save)()
229+
message = await communicator.receive_json_from()
230+
assert (
231+
json.loads(message["topology"])["links"][0]["properties"]["status"]
232+
== "down"
233+
)
234+
self._snooze()
235+
self.assertEqual(
236+
self.web_driver.execute_script("return graph.data;")["links"][0][
237+
"properties"
238+
]["status"],
239+
"down",
240+
)
241+
self._assert_no_js_errors()
242+
# removing a link from the DB will remove it from the UI
243+
with self.subTest("remove link"):
244+
await database_sync_to_async(self.link.delete)()
245+
message = await communicator.receive_json_from()
246+
self.assertEqual(len(json.loads(message["topology"])["links"]), 0)
247+
self._snooze()
248+
self.assertEqual(
249+
len(self.web_driver.execute_script("return graph.data;")["links"]),
250+
0,
251+
)
252+
self._assert_no_js_errors()
253+
# creating a new node will add it to the UI
254+
with self.subTest("add node"):
255+
await database_sync_to_async(self._create_node3)()
256+
message = await communicator.receive_json_from()
257+
self.assertEqual(len(json.loads(message["topology"])["nodes"]), 3)
258+
self._snooze()
259+
self.assertEqual(
260+
len(self.web_driver.execute_script("return graph.data;")["nodes"]),
261+
3,
262+
)
263+
self._assert_no_js_errors()
264+
await communicator.disconnect()

tests/openwisp2/settings.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
}
1717
}
1818

19+
if TESTING and "--exclude-tag=no_parallel" not in sys.argv:
20+
DATABASES["default"]["TEST"] = {
21+
"NAME": os.path.join(BASE_DIR, "openwisp_network_topology_tests.db"),
22+
}
1923

2024
SECRET_KEY = "@q4z-^s=mv59#o=uutv4*m=h@)ik4%zp1)-k^_(!_7*x_&+ze$"
2125

@@ -93,7 +97,13 @@
9397

9498
ASGI_APPLICATION = "openwisp2.asgi.application"
9599

96-
CHANNEL_LAYERS = {"default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}}
100+
# Needed to test UI updates via websockets
101+
CHANNEL_LAYERS = {
102+
"default": {
103+
"BACKEND": "channels_redis.core.RedisChannelLayer",
104+
"CONFIG": {"hosts": ["redis://localhost/9"]},
105+
}
106+
}
97107
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
98108

99109
LANGUAGE_CODE = "en-gb"

0 commit comments

Comments
 (0)