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 | import unittest | 4 | import unittest |
| 5 | from contextlib import redirect_stdout | ||
| 2 | from unittest.mock import patch | 6 | from unittest.mock import patch |
| 7 | |||
| 8 | import search | ||
| 3 | from search import search_log | 9 | from search import search_log |
| 4 | 10 | ||
| 11 | |||
| 5 | class TestSearchLog(unittest.TestCase): | 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') | 52 | def test_a_missing_log_is_reported_not_raised(self): |
| 8 | def test_search_log_with_match(self, mock_log_path): | 53 | out = io.StringIO() |
| 9 | query = 'crumb' | 54 | with patch.object(search, "LOG_PATH", self.log_path + ".absent"), redirect_stdout(out): |
| 10 | # Create a dummy log file (for testing) | 55 | search_log("anything") |
| 11 | with open('test_log_path', 'w') as f: | 56 | self.assertIn("No history file found.", out.getvalue()) |
| 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) | ||
tests/test_server.py +53 −22
| @@ -1,35 +1,66 @@ | |||
| 1 | import json | ||
| 2 | import os | ||
| 3 | import tempfile | ||
| 1 | import unittest | 4 | import unittest |
| 2 | from flask import Flask | 5 | from unittest.mock import patch |
| 6 | |||
| 3 | from server import app | 7 | from server import app |
| 4 | 8 | ||
| 9 | |||
| 5 | class TestServer(unittest.TestCase): | 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): | 20 | def tearDown(self): |
| 8 | # Create a test client | 21 | os.unlink(self.log_path) |
| 9 | client = app.test_client() | ||
| 10 | 22 | ||
| 11 | # Sample JSON data | 23 | def test_log_visit_post_writes_an_entry(self): |
| 12 | data = { | 24 | data = { |
| 13 | 'title': 'Test Visit', | 25 | "title": "Test Visit", |
| 14 | 'url': 'https://test.com', | 26 | "url": "https://test.com", |
| 15 | 'hostname': 'test.com', | 27 | "hostname": "test.com", |
| 16 | 'path': '/', | 28 | "path": "/", |
| 17 | 'query': 'test', | 29 | "query": "test", |
| 18 | 'tabId': 123, | 30 | "tabId": 123, |
| 19 | 'windowId': 456, | 31 | "windowId": 456, |
| 20 | 'favIconUrl': 'https://example.com/favicon.ico' | 32 | "favIconUrl": "https://example.com/favicon.ico", |
| 21 | } | 33 | } |
| 22 | 34 | ||
| 23 | # Send a POST request | 35 | # json=, not data=: with data= Flask sends it form-encoded regardless of |
| 24 | response = client.post('/', data=data, content_type='application/json') | 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 | self.assertEqual(response.status_code, 204) | 40 | self.assertEqual(response.status_code, 204) |
| 28 | # Optionally check the log file contents (for verification) | 41 | |
| 29 | # This part would require additional logging setup. | 42 | with open(self.log_path) as f: |
| 30 | # self.assertTrue('Test Visit' in self.get_log_content()) | 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 | def test_log_visit_options(self): | 59 | def test_log_visit_options(self): |
| 33 | # Send an OPTIONS request | 60 | response = self.client.options("/") |
| 34 | response = client.options( '/', content_type='application/json') | 61 | self.assertEqual(response.status_code, 204) |
| 35 | self.assertEqual(response.status_code, 204) #Verify 204 No Content for OPTIONS | 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") | ||