Skip to content

Commit ef263c2

Browse files
committed
allow no reconnect in client
the auto reconnect was causing flakiness in tests also includes minor refactors of testing utils
1 parent 57325e7 commit ef263c2

File tree

6 files changed

+91
-56
lines changed

6 files changed

+91
-56
lines changed

noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def test_python(session: Session) -> None:
107107
session.install(".[all]")
108108
else:
109109
install_idom_dev(session, extras="all")
110-
pytest_args += ["--reruns", "5"]
110+
pytest_args += ["--reruns", "1"]
111111

112112
session.run("pytest", "tests", *pytest_args)
113113

src/idom/client/app/src/index.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function defaultWebSocketEndpoint() {
2525
protocol = "ws:";
2626
}
2727

28-
return protocol + "//" + url.join("/") + window.location.search;
28+
return protocol + "//" + url.join("/") + "?" + queryParams.user.toString();
2929
}
3030

3131
export function mountLayoutWithWebSocket(
@@ -48,7 +48,7 @@ export function mountLayoutWithWebSocket(
4848
});
4949

5050
socket.onopen = (event) => {
51-
console.log(`Connected to ${endpoint}`);
51+
console.log(`Connected.`);
5252
if (mountState.everMounted) {
5353
unmountComponentAtNode(element);
5454
}
@@ -69,6 +69,10 @@ export function mountLayoutWithWebSocket(
6969
};
7070

7171
socket.onclose = (event) => {
72+
if (!shouldReconnect()) {
73+
console.log(`Connection lost.`);
74+
return;
75+
}
7276
const reconnectTimeout = _nextReconnectTimeout(mountState);
7377
console.log(`Connection lost, reconnecting in ${reconnectTimeout} seconds`);
7478
setTimeout(function () {
@@ -95,3 +99,26 @@ function _nextReconnectTimeout(mountState) {
9599
}
96100
return timeout;
97101
}
102+
103+
function shouldReconnect() {
104+
return queryParams.reserved.get("noReconnect") === null;
105+
}
106+
107+
const queryParams = (() => {
108+
const reservedParams = new URLSearchParams();
109+
const userParams = new URLSearchParams(window.location.search);
110+
111+
const reservedParamNames = ["noReconnect"];
112+
reservedParamNames.forEach((name) => {
113+
const value = userParams.get(name);
114+
if (value !== null) {
115+
reservedParams.append(name, userParams.get(name));
116+
userParams.delete(name);
117+
}
118+
});
119+
120+
return {
121+
reserved: reservedParams,
122+
user: userParams,
123+
};
124+
})();

src/idom/client/manage.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515

1616
def web_module_path(package_name: str, must_exist: bool = False) -> Path:
17+
"""Get the :class:`Path` to a web module's source"""
1718
path = _private.web_modules_dir().joinpath(*(package_name + ".js").split("/"))
1819
if must_exist and not path.exists():
1920
raise ValueError(
@@ -23,13 +24,18 @@ def web_module_path(package_name: str, must_exist: bool = False) -> Path:
2324

2425

2526
def web_module_exports(package_name: str) -> List[str]:
27+
"""Get a list of names this module exports"""
2628
web_module_path(package_name, must_exist=True)
2729
return _private.find_js_module_exports_in_source(
2830
web_module_path(package_name).read_text(encoding="utf-8")
2931
)
3032

3133

3234
def web_module_url(package_name: str) -> str:
35+
"""Get the URL the where the web module should reside
36+
37+
If this URL is relative, then the base URL is determined by the client
38+
"""
3339
web_module_path(package_name, must_exist=True)
3440
return (
3541
IDOM_CLIENT_IMPORT_SOURCE_URL.get()
@@ -38,6 +44,7 @@ def web_module_url(package_name: str) -> str:
3844

3945

4046
def web_module_exists(package_name: str) -> bool:
47+
"""Whether a web module with a given name exists"""
4148
return web_module_path(package_name).exists()
4249

4350

src/idom/testing.py

Lines changed: 36 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -92,50 +92,13 @@ def log_records(self) -> List[logging.LogRecord]:
9292
"""A list of captured log records"""
9393
return self._log_handler.records
9494

95-
def assert_logged_exception(
96-
self,
97-
error_type: Type[Exception],
98-
error_pattern: str,
99-
clear_after: bool = True,
100-
) -> None:
101-
"""Assert that a given error type and message were logged"""
102-
try:
103-
re_pattern = re.compile(error_pattern)
104-
for record in self.log_records:
105-
if record.exc_info is not None:
106-
error = record.exc_info[1]
107-
if isinstance(error, error_type) and re_pattern.search(str(error)):
108-
break
109-
else: # pragma: no cover
110-
assert False, f"did not raise {error_type} matching {error_pattern!r}"
111-
finally:
112-
if clear_after:
113-
self.log_records.clear()
114-
115-
def raise_if_logged_exception(
116-
self,
117-
log_level: int = logging.ERROR,
118-
exclude_exc_types: Union[Type[Exception], Tuple[Type[Exception], ...]] = (),
119-
clear_after: bool = True,
120-
) -> None:
121-
"""Raise the first logged exception (if any)
95+
def url(self, path: str = "", query: Optional[Any] = None) -> str:
96+
"""Return a URL string pointing to the host and point of the server
12297
12398
Args:
124-
log_level: The level of log to check
125-
exclude_exc_types: Any exception types to ignore
126-
clear_after: Whether to clear logs after check
99+
path: the path to a resource on the server
100+
query: a dictionary or list of query parameters
127101
"""
128-
try:
129-
for record in self._log_handler.records:
130-
if record.levelno >= log_level and record.exc_info is not None:
131-
error = record.exc_info[1]
132-
if error is not None and not isinstance(error, exclude_exc_types):
133-
raise error
134-
finally:
135-
if clear_after:
136-
self.log_records.clear()
137-
138-
def url(self, path: str = "", query: Optional[Any] = None) -> str:
139102
return urlunparse(
140103
[
141104
"http",
@@ -147,6 +110,35 @@ def url(self, path: str = "", query: Optional[Any] = None) -> str:
147110
]
148111
)
149112

113+
def list_logged_exceptions(
114+
self,
115+
pattern: Optional[str] = "",
116+
types: Union[Type[Exception], Tuple[Type[Exception], ...]] = Exception,
117+
log_level: int = logging.ERROR,
118+
del_log_records: bool = True,
119+
) -> List[Exception]:
120+
"""Return a list of logged exception matching the given criteria
121+
122+
Args:
123+
log_level: The level of log to check
124+
exclude_exc_types: Any exception types to ignore
125+
del_log_records: Whether to delete the log records for yielded exceptions
126+
"""
127+
found: List[Exception] = []
128+
compiled_pattern = re.compile(pattern)
129+
for index, record in enumerate(self.log_records):
130+
if record.levelno >= log_level and record.exc_info is not None:
131+
error = record.exc_info[1]
132+
if (
133+
error is not None
134+
and isinstance(error, types)
135+
and compiled_pattern.search(str(error))
136+
):
137+
if del_log_records:
138+
del self.log_records[index - len(found)]
139+
found.append(error)
140+
return found
141+
150142
def __enter__(self: _Self) -> _Self:
151143
self._log_handler = _LogRecordCaptor()
152144
logging.getLogger().addHandler(self._log_handler)
@@ -161,8 +153,10 @@ def __exit__(
161153
) -> None:
162154
self.server.stop()
163155
logging.getLogger().removeHandler(self._log_handler)
164-
self.raise_if_logged_exception()
165156
del self.mount, self.server
157+
logged_errors = self.list_logged_exceptions(del_log_records=False)
158+
if logged_errors:
159+
raise logged_errors[0]
166160
return None
167161

168162

tests/conftest.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,6 @@ def driver_is_headless(pytestconfig: Config):
109109

110110
@pytest.fixture(autouse=True)
111111
def caplog(_caplog: LogCaptureFixture) -> Iterator[LogCaptureFixture]:
112-
_caplog.set_level(logging.DEBUG)
113112
yield _caplog
114113
# check that there are no ERROR level log messages
115114
for record in _caplog.records:
@@ -118,11 +117,6 @@ def caplog(_caplog: LogCaptureFixture) -> Iterator[LogCaptureFixture]:
118117
assert record.levelno < logging.ERROR
119118

120119

121-
class _PropogateHandler(logging.Handler):
122-
def emit(self, record):
123-
logging.getLogger(record.name).handle(record)
124-
125-
126120
@pytest.fixture(scope="session", autouse=True)
127121
def _restore_client(pytestconfig: Config) -> Iterator[None]:
128122
"""Restore the client's state before and after testing

tests/test_server/test_common/test_shared_state_client.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,25 @@ def Counter(count):
7474

7575
def test_shared_client_state_server_does_not_support_per_client_parameters(
7676
driver_get,
77+
driver_wait,
7778
server_mount_point,
7879
):
79-
driver_get({"per_client_param": 1})
80+
driver_get(
81+
{
82+
"per_client_param": 1,
83+
# we need to stop reconnect attempts to prevent the error from happening
84+
# more than once
85+
"noReconnect": True,
86+
}
87+
)
8088

81-
server_mount_point.assert_logged_exception(
82-
ValueError,
83-
"does not support per-client view parameters",
84-
clear_after=True,
89+
driver_wait.until(
90+
lambda driver: (
91+
len(
92+
server_mount_point.list_logged_exceptions(
93+
"does not support per-client view parameters", ValueError
94+
)
95+
)
96+
== 1
97+
)
8598
)

0 commit comments

Comments
 (0)