Commit 254cfed177
Verified · cmc
Layout: unified · split
.github/workflows/test.yml added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | name: Test | |
| 2 | ||
| 3 | # crumb had a test suite from one commit that had never passed: four tests, all | |
| 4 | # failing. They are fixed and this runs them, so the next break is visible. | |
| 5 | on: | |
| 6 | pull_request: | |
| 7 | push: | |
| 8 | branches: [main] | |
| 9 | ||
| 10 | permissions: | |
| 11 | contents: read | |
| 12 | ||
| 13 | jobs: | |
| 14 | test: | |
| 15 | runs-on: ubuntu-latest | |
| 16 | steps: | |
| 17 | - uses: actions/checkout@v7 | |
| 18 | ||
| 19 | - uses: actions/setup-python@v7 | |
| 20 | with: | |
| 21 | python-version: '3.11' | |
| 22 | ||
| 23 | - name: Install | |
| 24 | run: pip install -r requirements-dev.txt | |
| 25 | ||
| 26 | - name: Tests | |
| 27 | run: python -m pytest -q | |
| 28 | ||
| 29 | # The webextension half of crumb is not covered by the Python tests, and a | |
| 30 | # malformed manifest is silent until a browser refuses to load it. | |
| 31 | - name: Extension manifest is valid JSON | |
| 32 | run: python -c "import json; json.load(open('crumb_extension/manifest.json')); print('manifest.json parses')" | |
__pycache__/search.cpython-314.pyc added
Binary file not shown.
__pycache__/server.cpython-314.pyc added
Binary file not shown.
requirements-dev.in added +2
| @@ -0,0 +1,2 @@ | ||
| 1 | flask | |
| 2 | pytest | |
requirements-dev.txt added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | # This file was autogenerated by uv via the following command: | |
| 2 | # uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.11 | |
| 3 | blinker==1.9.0 | |
| 4 | # via flask | |
| 5 | click==8.4.2 | |
| 6 | # via flask | |
| 7 | flask==3.1.3 | |
| 8 | # via -r requirements-dev.in | |
| 9 | iniconfig==2.3.0 | |
| 10 | # via pytest | |
| 11 | itsdangerous==2.2.0 | |
| 12 | # via flask | |
| 13 | jinja2==3.1.6 | |
| 14 | # via flask | |
| 15 | markupsafe==3.0.3 | |
| 16 | # via | |
| 17 | # flask | |
| 18 | # jinja2 | |
| 19 | # werkzeug | |
| 20 | packaging==26.3 | |
| 21 | # via pytest | |
| 22 | pluggy==1.6.0 | |
| 23 | # via pytest | |
| 24 | pygments==2.21.0 | |
| 25 | # via pytest | |
| 26 | pytest==9.1.1 | |
| 27 | # via -r requirements-dev.in | |
| 28 | werkzeug==3.1.8 | |
| 29 | # via flask | |
tests/__pycache__/test_search.cpython-314-pytest-9.1.1.pyc added
Binary file not shown.
tests/__pycache__/test_server.cpython-314-pytest-9.1.1.pyc added
Binary file not shown.
tests/test_search.py +50 −28
| @@ -1,34 +1,56 @@ | ||
| 1 | import io | |
| 2 | import os | |
| 3 | import tempfile | |
| 1 | 4 | import unittest |
| 5 | from contextlib import redirect_stdout | |
| 2 | 6 | from unittest.mock import patch |
| 7 | ||
| 8 | import search | |
| 3 | 9 | from search import search_log |
| 4 | 10 | |
| 11 | ||
| 5 | 12 | class TestSearchLog(unittest.TestCase): |
| 13 | """search_log prints matches; it does not modify the log. The previous tests | |
| 14 | read the file back and asserted on its contents, which could only ever pass | |
| 15 | for the match case and never for the no-match one.""" | |
| 16 | ||
| 17 | ENTRY = ( | |
| 18 | "* Example Entry\n" | |
| 19 | ":PROPERTIES:\n" | |
| 20 | ":URL: http://example.com\n" | |
| 21 | ":TIMESTAMP: 2023-10-27 10:00:00\n" | |
| 22 | ":END:\n\n" | |
| 23 | ) | |
| 24 | ||
| 25 | def setUp(self): | |
| 26 | handle, self.log_path = tempfile.mkstemp(suffix=".org") | |
| 27 | os.close(handle) | |
| 28 | with open(self.log_path, "w") as f: | |
| 29 | f.write(self.ENTRY) | |
| 30 | ||
| 31 | def tearDown(self): | |
| 32 | os.unlink(self.log_path) | |
| 33 | ||
| 34 | def run_search(self, query): | |
| 35 | out = io.StringIO() | |
| 36 | with patch.object(search, "LOG_PATH", self.log_path), redirect_stdout(out): | |
| 37 | search_log(query) | |
| 38 | return out.getvalue() | |
| 39 | ||
| 40 | def test_a_match_is_printed(self): | |
| 41 | self.assertIn("Example Entry", self.run_search("example")) | |
| 42 | ||
| 43 | def test_the_search_is_case_insensitive(self): | |
| 44 | self.assertIn("Example Entry", self.run_search("EXAMPLE")) | |
| 45 | ||
| 46 | def test_a_property_value_matches(self): | |
| 47 | self.assertIn("Example Entry", self.run_search("example.com")) | |
| 48 | ||
| 49 | def test_no_match_prints_no_entry(self): | |
| 50 | self.assertNotIn("Example Entry", self.run_search("nonexistent")) | |
| 6 | 51 | |
| 7 | @patch('search.LOG_PATH', 'test_log_path') | |
| 8 | def test_search_log_with_match(self, mock_log_path): | |
| 9 | query = 'crumb' | |
| 10 | # Create a dummy log file (for testing) | |
| 11 | with open('test_log_path', 'w') as f: | |
| 12 | f.write("* Example Entry\n") | |
| 13 | f.write(":PROPERTIES:\n") | |
| 14 | f.write(":URL: http://example.com\n") | |
| 15 | f.write(":TIMESTAMP: 2023-10-27 10:00:00\n") | |
| 16 | ||
| 17 | search_log(query) | |
| 18 | # Assert that the function prints the expected output | |
| 19 | with open('test_log_path', 'r') as f: | |
| 20 | output = f.read() | |
| 21 | self.assertIn("* Example Entry\n", output) | |
| 22 | ||
| 23 | @patch('search.LOG_PATH', 'test_log_path') | |
| 24 | def test_search_log_no_match(self, mock_log_path): | |
| 25 | query = 'nonexistent' | |
| 26 | with open('test_log_path', 'w') as f: | |
| 27 | f.write("* Example Entry\n") | |
| 28 | f.write(":PROPERTIES:\n") | |
| 29 | f.write(":URL: http://example.com\n") | |
| 30 | f.write(":TIMESTAMP: 2023-10-27 10:00:00\n") | |
| 31 | search_log(query) | |
| 32 | with open('test_log_path', 'r') as f: | |
| 33 | output = f.read() | |
| 34 | self.assertNotIn("* Example Entry\n", output) | |
| 52 | def test_a_missing_log_is_reported_not_raised(self): | |
| 53 | out = io.StringIO() | |
| 54 | with patch.object(search, "LOG_PATH", self.log_path + ".absent"), redirect_stdout(out): | |
| 55 | search_log("anything") | |
| 56 | self.assertIn("No history file found.", out.getvalue()) | |
tests/test_server.py +53 −22
| @@ -1,35 +1,66 @@ | ||
| 1 | import json | |
| 2 | import os | |
| 3 | import tempfile | |
| 1 | 4 | import unittest |
| 2 | from flask import Flask | |
| 5 | from unittest.mock import patch | |
| 6 | ||
| 3 | 7 | from server import app |
| 4 | 8 | |
| 9 | ||
| 5 | 10 | class TestServer(unittest.TestCase): |
| 11 | """The POST handler appends to LOG_PATH, which defaults to the developer's | |
| 12 | own ~/.crumb/history.org. Every test here patches it at a temporary file: | |
| 13 | without that, running the suite writes into real browsing history.""" | |
| 14 | ||
| 15 | def setUp(self): | |
| 16 | self.client = app.test_client() | |
| 17 | handle, self.log_path = tempfile.mkstemp(suffix=".org") | |
| 18 | os.close(handle) | |
| 6 | 19 | |
| 7 | def test_log_visit_post(self): | |
| 8 | # Create a test client | |
| 9 | client = app.test_client() | |
| 20 | def tearDown(self): | |
| 21 | os.unlink(self.log_path) | |
| 10 | 22 | |
| 11 | # Sample JSON data | |
| 23 | def test_log_visit_post_writes_an_entry(self): | |
| 12 | 24 | data = { |
| 13 | 'title': 'Test Visit', | |
| 14 | 'url': 'https://test.com', | |
| 15 | 'hostname': 'test.com', | |
| 16 | 'path': '/', | |
| 17 | 'query': 'test', | |
| 18 | 'tabId': 123, | |
| 19 | 'windowId': 456, | |
| 20 | 'favIconUrl': 'https://example.com/favicon.ico' | |
| 25 | "title": "Test Visit", | |
| 26 | "url": "https://test.com", | |
| 27 | "hostname": "test.com", | |
| 28 | "path": "/", | |
| 29 | "query": "test", | |
| 30 | "tabId": 123, | |
| 31 | "windowId": 456, | |
| 32 | "favIconUrl": "https://example.com/favicon.ico", | |
| 21 | 33 | } |
| 22 | 34 | |
| 23 | # Send a POST request | |
| 24 | response = client.post('/', data=data, content_type='application/json') | |
| 35 | # json=, not data=: with data= Flask sends it form-encoded regardless of | |
| 36 | # content_type, request.json comes back empty and the handler 400s. | |
| 37 | with patch("server.LOG_PATH", self.log_path): | |
| 38 | response = self.client.post("/", json=data) | |
| 25 | 39 | |
| 26 | # Assertions | |
| 27 | 40 | self.assertEqual(response.status_code, 204) |
| 28 | # Optionally check the log file contents (for verification) | |
| 29 | # This part would require additional logging setup. | |
| 30 | # self.assertTrue('Test Visit' in self.get_log_content()) | |
| 41 | ||
| 42 | with open(self.log_path) as f: | |
| 43 | written = f.read() | |
| 44 | self.assertIn("* Test Visit", written) | |
| 45 | self.assertIn(":URL: https://test.com", written) | |
| 46 | self.assertIn(":QUERY: test", written) | |
| 47 | self.assertIn(":END:", written) | |
| 48 | ||
| 49 | def test_optional_fields_are_omitted_when_empty(self): | |
| 50 | with patch("server.LOG_PATH", self.log_path): | |
| 51 | response = self.client.post("/", json={"title": "Bare", "url": "https://x"}) | |
| 52 | ||
| 53 | self.assertEqual(response.status_code, 204) | |
| 54 | with open(self.log_path) as f: | |
| 55 | written = f.read() | |
| 56 | self.assertNotIn(":QUERY:", written) | |
| 57 | self.assertNotIn(":FAVICON:", written) | |
| 31 | 58 | |
| 32 | 59 | def test_log_visit_options(self): |
| 33 | # Send an OPTIONS request | |
| 34 | response = client.options( '/', content_type='application/json') | |
| 35 | self.assertEqual(response.status_code, 204) #Verify 204 No Content for OPTIONS | |
| 60 | response = self.client.options("/") | |
| 61 | self.assertEqual(response.status_code, 204) | |
| 62 | ||
| 63 | def test_cors_headers_are_present(self): | |
| 64 | response = self.client.options("/") | |
| 65 | self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*") | |
| 66 | self.assertEqual(response.headers["Access-Control-Allow-Headers"], "Content-Type") | |