Repository: falconry/falcon

Issue:
Test failure on windows
Tests on windows have some failures and errors.

The summary is `47 failed, 2094 passed, 79 skipped, 1 warning, 16 errors in 1885.17s (0:31:25)`

The errors output is here:
https://gist.github.com/CaselIT/16792866e7dd2c545b186207976e060e

Some failues are related to asgi, while other are simple stuff like `/` vs `\` in the path.
Probably a skip on the incompatible tests and the use of `os.path.normpath` would solve most of these

This is mainly an issue to let you know this, I don't think it's really necessary fixing these, since the rests run so slow on windows (31 minutes) that it would not be practical to develop on it.


Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/falconry/falcon/pull/1658?src=pr&el=h1) Report
> Merging [#1658](https://codecov.io/gh/falconry/falcon/pull/1658?src=pr&el=desc) into [master](https://codecov.io/gh/falconry/falcon/commit/00e5c9c2499c3afb5f8d48fca80591531563d461?src=pr&el=desc) will **not change** coverage.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/falconry/falcon/pull/1658/graphs/tree.svg?width=650&token=zOtkfNQ3h6&height=150&src=pr)](https://codecov.io/gh/falconry/falcon/pull/1658?src=pr&el=tree)

```diff
@@          Coverage Diff           @@
##           master   #1658   +/-   ##
======================================
  Coverage     100%    100%           
======================================
  Files          45      45           
  Lines        3123    3123           
  Branches      482     482           
======================================
  Hits         3123    3123
```


| [Impacted Files](https://codecov.io/gh/falconry/falcon/pull/1658?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [falcon/routing/static.py](https://codecov.io/gh/falconry/falcon/pull/1658/diff?src=pr&el=tree#diff-ZmFsY29uL3JvdXRpbmcvc3RhdGljLnB5) | `100% <100%> (ø)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/falconry/falcon/pull/1658?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/falconry/falcon/pull/1658?src=pr&el=footer). Last update [00e5c9c...cf097ec](https://codecov.io/gh/falconry/falcon/pull/1658?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

CaselIT: Currently on windows is
`2146 passed, 87 skipped, 1 warning in 414.10s (0:06:54)`
CaselIT: Upon further investigation, most (about 80%) of the time on windows  is lost in the `mimetypes.init()` call.

checking to see if mimetypes has been inited before rerunning the function brings the time down to less than 2 minutes
CaselIT: I think it's ready for review.
After the change to the mimetypes init the status on windows is `2158 passed, 75 skipped, 1 warning in 92.66s (0:01:32)`
CaselIT: I'm not familiar with travis so I cannot offer any insight on how it works for windows.

Seems that codecov has thrown a fit in the last commit

Patch:
diff --git a/falcon/response.py b/falcon/response.py
index 6652971..12a9ba9 100644
--- a/falcon/response.py
+++ b/falcon/response.py
@@ -1069,5 +1069,6 @@ class ResponseOptions:
         self.default_media_type = DEFAULT_MEDIA_TYPE
         self.media_handlers = Handlers()
 
-        mimetypes.init()
+        if not mimetypes.inited:
+            mimetypes.init()
         self.static_media_types = mimetypes.types_map
diff --git a/falcon/routing/static.py b/falcon/routing/static.py
index c44e29f..b5fa12e 100644
--- a/falcon/routing/static.py
+++ b/falcon/routing/static.py
@@ -49,13 +49,16 @@ class StaticRoute:
         if not prefix.startswith('/'):
             raise ValueError("prefix must start with '/'")
 
-        if not os.path.isabs(directory):
+        self._directory = os.path.normpath(directory)
+        if not os.path.isabs(self._directory):
             raise ValueError('directory must be an absolute path')
 
         if fallback_filename is None:
             self._fallback_filename = None
         else:
-            self._fallback_filename = os.path.join(directory, fallback_filename)
+            self._fallback_filename = os.path.normpath(
+                os.path.join(self._directory, fallback_filename)
+            )
             if not os.path.isfile(self._fallback_filename):
                 raise ValueError('fallback_filename is not a file')
 
@@ -66,7 +69,6 @@ class StaticRoute:
             prefix += '/'
 
         self._prefix = prefix
-        self._directory = directory
         self._downloadable = downloadable
 
     def match(self, path):


Test patch:
diff --git a/docs/_newsfragments/1656-tests-on-windows.misc.rst b/docs/_newsfragments/1656-tests-on-windows.misc.rst
new file mode 100644
index 0000000..fd6cc3a
--- /dev/null
+++ b/docs/_newsfragments/1656-tests-on-windows.misc.rst
@@ -0,0 +1,1 @@
+Fix test errors on Windows
diff --git a/tests/asgi/_asgi_test_app.py b/tests/asgi/_asgi_test_app.py
index 7a76a6b..c6dc672 100644
--- a/tests/asgi/_asgi_test_app.py
+++ b/tests/asgi/_asgi_test_app.py
@@ -1,11 +1,14 @@
 import asyncio
 from collections import Counter
+import sys
 import time
 
 import falcon
 import falcon.asgi
 import falcon.util
 
+_WIN32 = sys.platform.startswith('win')
+
 
 class Things:
     def __init__(self):
@@ -67,7 +70,8 @@ class Things:
         cms = falcon.util.wrap_sync_to_async(callmesafely, threadsafe=False)
         loop = falcon.util.get_loop()
 
-        num_cms_tasks = 1000
+        # NOTE(caselit): on windows it takes more time so create less tasks
+        num_cms_tasks = 100 if _WIN32 else 1000
 
         for i in range(num_cms_tasks):
             # NOTE(kgriffs): create_task() is used here, so that the coroutines
diff --git a/tests/asgi/test_asgi_servers.py b/tests/asgi/test_asgi_servers.py
index b811ddd..289544f 100644
--- a/tests/asgi/test_asgi_servers.py
+++ b/tests/asgi/test_asgi_servers.py
@@ -2,6 +2,7 @@ from contextlib import contextmanager
 import os
 import platform
 import subprocess
+import sys
 import time
 
 import pytest
@@ -14,6 +15,7 @@ from falcon import testing
 _MODULE_DIR = os.path.abspath(os.path.dirname(__file__))
 
 _PYPY = platform.python_implementation() == 'PyPy'
+_WIN32 = sys.platform.startswith('win')
 
 _SERVER_HOST = '127.0.0.1'
 _SIZE_1_KB = 1024
@@ -101,6 +103,15 @@ def _run_server_isolated(process_factory, host, port):
 
     yield server
 
+    if _WIN32:
+        import signal
+        print('\n[Sending CTRL_C_EVENT to server process...]')
+        server.send_signal(signal.CTRL_C_EVENT)
+        try:
+            server.wait(timeout=10)
+        except (KeyboardInterrupt, subprocess.TimeoutExpired):
+            pass
+
     print('\n[Sending SIGTERM to server process...]')
     server.terminate()
 
@@ -148,6 +159,8 @@ def _daphne_factory(host, port):
 @pytest.fixture(params=[_uvicorn_factory, _daphne_factory])
 def server_base_url(request):
     process_factory = request.param
+    if _WIN32 and process_factory == _daphne_factory:
+        pytest.skip('daphne does not support windows')
 
     for i in range(3):
         server_port = testing.get_unused_port()
diff --git a/tests/asgi/test_boundedstream_asgi.py b/tests/asgi/test_boundedstream_asgi.py
index 7209f68..dbc83c1 100644
--- a/tests/asgi/test_boundedstream_asgi.py
+++ b/tests/asgi/test_boundedstream_asgi.py
@@ -13,7 +13,7 @@ from falcon import asgi, testing
     b'catsup',
     b'\xDE\xAD\xBE\xEF' * 512,
     testing.rand_string(1, 2048),
-])
+], ids=['empty', 'null', 'null-ff', 'normal', 'long', 'random'])
 @pytest.mark.parametrize('extra_body', [True, False])
 @pytest.mark.parametrize('set_content_length', [True, False])
 def test_read_all(body, extra_body, set_content_length):
@@ -155,7 +155,7 @@ def test_filelike():
     b'catsup',
     b'\xDE\xAD\xBE\xEF' * 512,
     testing.rand_string(1, 2048).encode(),
-])
+], ids=['empty', 'null', 'null-ff', 'normal', 'long', 'random'])
 @pytest.mark.parametrize('chunk_size', [1, 2, 10, 64, 100, 1000, 10000])
 def test_read_chunks(body, chunk_size):
     def stream():
diff --git a/tests/conftest.py b/tests/conftest.py
index 2681553..5c0f968 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,7 +3,7 @@ import pytest
 import falcon
 
 
-@pytest.fixture(params=[True, False])
+@pytest.fixture(params=[True, False], ids=['asgi', 'wsgi'])
 def asgi(request):
     is_asgi = request.param
 
diff --git a/tests/test_cmd_print_api.py b/tests/test_cmd_print_api.py
index d4a11a3..beeb004 100644
--- a/tests/test_cmd_print_api.py
+++ b/tests/test_cmd_print_api.py
@@ -1,4 +1,5 @@
 import io
+from os.path import normpath
 
 import pytest
 
@@ -46,7 +47,7 @@ def test_traverse_with_verbose(app):
         get_info, options_info = options_info, get_info
 
     assert options_info.startswith('-->OPTIONS')
-    assert 'falcon/responders.py:' in options_info
+    assert '{}:'.format(normpath('falcon/responders.py')) in options_info
 
     assert get_info.startswith('-->GET')
 
@@ -54,10 +55,11 @@ def test_traverse_with_verbose(app):
     # 18 or 25 (in the case of DummyResourceAsync) in the present file.
     # Adjust the test if the said responder is relocated, or just check for
     # any number if this becomes too painful to maintain.
+    path = normpath('tests/test_cmd_print_api.py')
 
     assert (
-        get_info.endswith('tests/test_cmd_print_api.py:13') or
-        get_info.endswith('tests/test_cmd_print_api.py:20')
+        get_info.endswith('{}:14'.format(path)) or
+        get_info.endswith('{}:21'.format(path))
     )
 
 
diff --git a/tests/test_response.py b/tests/test_response.py
index c3ea3d0..6971975 100644
--- a/tests/test_response.py
+++ b/tests/test_response.py
@@ -1,6 +1,8 @@
+from unittest.mock import MagicMock
+
 import pytest
 
-from falcon import MEDIA_TEXT
+from falcon import MEDIA_TEXT, ResponseOptions
 
 from _util import create_resp  # NOQA
 
@@ -58,3 +60,18 @@ def test_response_removed_stream_len(resp):
 
     with pytest.raises(AttributeError):
         resp.stream_len
+
+
+def test_response_option_mimetype_init(monkeypatch):
+    mock = MagicMock()
+    mock.inited = False
+    monkeypatch.setattr('falcon.response.mimetypes', mock)
+
+    ro = ResponseOptions()
+
+    assert ro.static_media_types is mock.types_map
+    mock.reset_mock()
+    mock.inited = True
+    ro = ResponseOptions()
+    assert ro.static_media_types is mock.types_map
+    mock.init.assert_not_called()
diff --git a/tests/test_static.py b/tests/test_static.py
index d8e60b6..c51e9c8 100644
--- a/tests/test_static.py
+++ b/tests/test_static.py
@@ -12,11 +12,11 @@ import falcon.testing as testing
 import _util  # NOQA
 
 
-@pytest.fixture(params=[True, False])
-def client(request):
-    app = _util.create_app(asgi=request.param)
+@pytest.fixture()
+def client(asgi):
+    app = _util.create_app(asgi=asgi)
     client = testing.TestClient(app)
-    client.asgi = request.param
+    client.asgi = asgi
     return client
 
 
@@ -136,6 +136,12 @@ def test_invalid_args_fallback_filename(client, default):
         client.app.add_static_route(prefix, directory, fallback_filename=default)
 
 
+# NOTE(caselit) depending on the system configuration mime types can have alternative names
+_MIME_ALTERNATIVE = {
+    'application/zip': ('application/zip', 'application/x-zip-compressed')
+}
+
+
 @pytest.mark.parametrize('uri_prefix, uri_path, expected_path, mtype', [
     ('/static/', '/css/test.css', '/css/test.css', 'text/css'),
     ('/static', '/css/test.css', '/css/test.css', 'text/css'),
@@ -180,8 +186,8 @@ def test_good_path(asgi, uri_prefix, uri_path, expected_path, mtype, monkeypatch
         sr(req, resp)
         body = resp.stream.read()
 
-    assert resp.content_type == mtype
-    assert body.decode() == '/var/www/statics' + expected_path
+    assert resp.content_type in _MIME_ALTERNATIVE.get(mtype, (mtype, ))
+    assert body.decode() == os.path.normpath('/var/www/statics' + expected_path)
 
 
 def test_lifo(client, monkeypatch):
@@ -192,11 +198,11 @@ def test_lifo(client, monkeypatch):
 
     response = client.simulate_request(path='/downloads/thing.zip')
     assert response.status == falcon.HTTP_200
-    assert response.text == '/opt/somesite/downloads/thing.zip'
+    assert response.text == os.path.normpath('/opt/somesite/downloads/thing.zip')
 
     response = client.simulate_request(path='/downloads/archive/thingtoo.zip')
     assert response.status == falcon.HTTP_200
-    assert response.text == '/opt/somesite/x/thingtoo.zip'
+    assert response.text == os.path.normpath('/opt/somesite/x/thingtoo.zip')
 
 
 def test_lifo_negative(client, monkeypatch):
@@ -207,11 +213,11 @@ def test_lifo_negative(client, monkeypatch):
 
     response = client.simulate_request(path='/downloads/thing.zip')
     assert response.status == falcon.HTTP_200
-    assert response.text == '/opt/somesite/downloads/thing.zip'
+    assert response.text == os.path.normpath('/opt/somesite/downloads/thing.zip')
 
     response = client.simulate_request(path='/downloads/archive/thingtoo.zip')
     assert response.status == falcon.HTTP_200
-    assert response.text == '/opt/somesite/downloads/archive/thingtoo.zip'
+    assert response.text == os.path.normpath('/opt/somesite/downloads/archive/thingtoo.zip')
 
 
 def test_downloadable(client, monkeypatch):
@@ -243,6 +249,7 @@ def test_downloadable_not_found(client):
 @pytest.mark.parametrize('uri, default, expected, content_type', [
     ('', 'default', 'default', 'application/octet-stream'),
     ('other', 'default.html', 'default.html', 'text/html'),
+    ('zip', 'default.zip', 'default.zip', 'application/zip'),
     ('index2', 'index', 'index2', 'application/octet-stream'),
     ('absolute', '/foo/bar/index', '/foo/bar/index', 'application/octet-stream'),
     ('docs/notes/test.txt', 'index.html', 'index.html', 'text/html'),
@@ -253,13 +260,13 @@ def test_fallback_filename(asgi, uri, default, expected, content_type, downloada
                            monkeypatch):
 
     def mock_open(path, mode):
-        if default in path:
+        if os.path.normpath(default) in path:
             return io.BytesIO(path.encode())
 
         raise IOError()
 
     monkeypatch.setattr(io, 'open', mock_open)
-    monkeypatch.setattr('os.path.isfile', lambda file: default in file)
+    monkeypatch.setattr('os.path.isfile', lambda file: os.path.normpath(default) in file)
 
     sr = create_sr(
         asgi,
@@ -290,8 +297,8 @@ def test_fallback_filename(asgi, uri, default, expected, content_type, downloada
         body = resp.stream.read()
 
     assert sr.match(req.path)
-    assert body.decode() == os.path.join('/var/www/statics', expected)
-    assert resp.content_type == content_type
+    assert body.decode() == os.path.normpath(os.path.join('/var/www/statics', expected))
+    assert resp.content_type in _MIME_ALTERNATIVE.get(content_type, (content_type, ))
 
     if downloadable:
         assert os.path.basename(expected) in resp.downloadable_as
@@ -329,7 +336,7 @@ def test_e2e_fallback_filename(client, monkeypatch, strip_slash, path, fallback,
             assert response.status == falcon.HTTP_404
         else:
             assert response.status == falcon.HTTP_200
-            assert response.text == directory + expected
+            assert response.text == os.path.normpath(directory + expected)
 
     test('/static', '/opt/somesite/static/', static_exp)
     test('/assets', '/opt/somesite/assets/', assert_axp)
@@ -357,7 +364,7 @@ def test_filesystem_traversal_fuse(client, monkeypatch):
     def suspicious_normpath(path):
         return 'assets/../../../../' + path
 
-    monkeypatch.setattr('os.path.normpath', suspicious_normpath)
     client.app.add_static_route('/static', '/etc/nginx/includes/static-data')
+    monkeypatch.setattr('os.path.normpath', suspicious_normpath)
     response = client.simulate_request(path='/static/shadow')
     assert response.status == falcon.HTTP_404