krz/crumb
A local alternative to your browser's history.
clone: git clone https://gitbay.org/krz/crumb.git
254cfed17728ab9b6011a298c6dd58a3730910ef
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T02:16:10Z
.github/workflows/test.yml | 32 +++++++++ __pycache__/search.cpython-314.pyc | Bin 0 -> 1701 bytes __pycache__/server.cpython-314.pyc | Bin 0 -> 3686 bytes requirements-dev.in | 2 + requirements-dev.txt | 29 ++++++++ .../test_search.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 4560 bytes .../test_server.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 4751 bytes tests/test_search.py | 78 +++++++++++++-------- tests/test_server.py | 75 ++++++++++++++------ 9 files changed, 166 insertions(+), 50 deletions(-) new file mode 100644 @@ -0,0 +1,32 @@ +name: Test + +# crumb had a test suite from one commit that had never passed: four tests, all +# failing. They are fixed and this runs them, so the next break is visible. +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: '3.11' + + - name: Install + run: pip install -r requirements-dev.txt + + - name: Tests + run: python -m pytest -q + + # The webextension half of crumb is not covered by the Python tests, and a + # malformed manifest is silent until a browser refuses to load it. + - name: Extension manifest is valid JSON + run: python -c "import json; json.load(open('crumb_extension/manifest.json')); print('manifest.json parses')" new file mode 100644 Binary files /dev/null and b/__pycache__/search.cpython-314.pyc differ new file mode 100644 Binary files /dev/null and b/__pycache__/server.cpython-314.pyc differ new file mode 100644 @@ -0,0 +1,2 @@ +flask +pytest new file mode 100644 @@ -0,0 +1,29 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements-dev.in -o requirements-dev.txt --python-version 3.11 +blinker==1.9.0 + # via flask +click==8.4.2 + # via flask +flask==3.1.3 + # via -r requirements-dev.in +iniconfig==2.3.0 + # via pytest +itsdangerous==2.2.0 + # via flask +jinja2==3.1.6 + # via flask +markupsafe==3.0.3 + # via + # flask + # jinja2 + # werkzeug +packaging==26.3 + # via pytest +pluggy==1.6.0 + # via pytest +pygments==2.21.0 + # via pytest +pytest==9.1.1 + # via -r requirements-dev.in +werkzeug==3.1.8 + # via flask new file mode 100644 Binary files /dev/null and b/tests/__pycache__/test_search.cpython-314-pytest-9.1.1.pyc differ new file mode 100644 Binary files /dev/null and b/tests/__pycache__/test_server.cpython-314-pytest-9.1.1.pyc differ @@ -1,34 +1,56 @@ +import io +import os +import tempfile import unittest +from contextlib import redirect_stdout from unittest.mock import patch + +import search from search import search_log + class TestSearchLog(unittest.TestCase): + """search_log prints matches; it does not modify the log. The previous tests + read the file back and asserted on its contents, which could only ever pass + for the match case and never for the no-match one.""" + + ENTRY = ( + "* Example Entry\n" + ":PROPERTIES:\n" + ":URL: http://example.com\n" + ":TIMESTAMP: 2023-10-27 10:00:00\n" + ":END:\n\n" + ) + + def setUp(self): + handle, self.log_path = tempfile.mkstemp(suffix=".org") + os.close(handle) + with open(self.log_path, "w") as f: + f.write(self.ENTRY) + + def tearDown(self): + os.unlink(self.log_path) + + def run_search(self, query): + out = io.StringIO() + with patch.object(search, "LOG_PATH", self.log_path), redirect_stdout(out): + search_log(query) + return out.getvalue() + + def test_a_match_is_printed(self): + self.assertIn("Example Entry", self.run_search("example")) + + def test_the_search_is_case_insensitive(self): + self.assertIn("Example Entry", self.run_search("EXAMPLE")) + + def test_a_property_value_matches(self): + self.assertIn("Example Entry", self.run_search("example.com")) + + def test_no_match_prints_no_entry(self): + self.assertNotIn("Example Entry", self.run_search("nonexistent")) - @patch('search.LOG_PATH', 'test_log_path') - def test_search_log_with_match(self, mock_log_path): - query = 'crumb' - # Create a dummy log file (for testing) - with open('test_log_path', 'w') as f: - f.write("* Example Entry\n") - f.write(":PROPERTIES:\n") - f.write(":URL: http://example.com\n") - f.write(":TIMESTAMP: 2023-10-27 10:00:00\n") - - search_log(query) - # Assert that the function prints the expected output - with open('test_log_path', 'r') as f: - output = f.read() - self.assertIn("* Example Entry\n", output) - - @patch('search.LOG_PATH', 'test_log_path') - def test_search_log_no_match(self, mock_log_path): - query = 'nonexistent' - with open('test_log_path', 'w') as f: - f.write("* Example Entry\n") - f.write(":PROPERTIES:\n") - f.write(":URL: http://example.com\n") - f.write(":TIMESTAMP: 2023-10-27 10:00:00\n") - search_log(query) - with open('test_log_path', 'r') as f: - output = f.read() - self.assertNotIn("* Example Entry\n", output) + def test_a_missing_log_is_reported_not_raised(self): + out = io.StringIO() + with patch.object(search, "LOG_PATH", self.log_path + ".absent"), redirect_stdout(out): + search_log("anything") + self.assertIn("No history file found.", out.getvalue()) @@ -1,35 +1,66 @@ +import json +import os +import tempfile import unittest -from flask import Flask +from unittest.mock import patch + from server import app + class TestServer(unittest.TestCase): + """The POST handler appends to LOG_PATH, which defaults to the developer's + own ~/.crumb/history.org. Every test here patches it at a temporary file: + without that, running the suite writes into real browsing history.""" + + def setUp(self): + self.client = app.test_client() + handle, self.log_path = tempfile.mkstemp(suffix=".org") + os.close(handle) - def test_log_visit_post(self): - # Create a test client - client = app.test_client() + def tearDown(self): + os.unlink(self.log_path) - # Sample JSON data + def test_log_visit_post_writes_an_entry(self): data = { - 'title': 'Test Visit', - 'url': 'https://test.com', - 'hostname': 'test.com', - 'path': '/', - 'query': 'test', - 'tabId': 123, - 'windowId': 456, - 'favIconUrl': 'https://example.com/favicon.ico' + "title": "Test Visit", + "url": "https://test.com", + "hostname": "test.com", + "path": "/", + "query": "test", + "tabId": 123, + "windowId": 456, + "favIconUrl": "https://example.com/favicon.ico", } - # Send a POST request - response = client.post('/', data=data, content_type='application/json') + # json=, not data=: with data= Flask sends it form-encoded regardless of + # content_type, request.json comes back empty and the handler 400s. + with patch("server.LOG_PATH", self.log_path): + response = self.client.post("/", json=data) - # Assertions self.assertEqual(response.status_code, 204) - # Optionally check the log file contents (for verification) - # This part would require additional logging setup. - # self.assertTrue('Test Visit' in self.get_log_content()) + + with open(self.log_path) as f: + written = f.read() + self.assertIn("* Test Visit", written) + self.assertIn(":URL: https://test.com", written) + self.assertIn(":QUERY: test", written) + self.assertIn(":END:", written) + + def test_optional_fields_are_omitted_when_empty(self): + with patch("server.LOG_PATH", self.log_path): + response = self.client.post("/", json={"title": "Bare", "url": "https://x"}) + + self.assertEqual(response.status_code, 204) + with open(self.log_path) as f: + written = f.read() + self.assertNotIn(":QUERY:", written) + self.assertNotIn(":FAVICON:", written) def test_log_visit_options(self): - # Send an OPTIONS request - response = client.options( '/', content_type='application/json') - self.assertEqual(response.status_code, 204) #Verify 204 No Content for OPTIONS + response = self.client.options("/") + self.assertEqual(response.status_code, 204) + + def test_cors_headers_are_present(self): + response = self.client.options("/") + self.assertEqual(response.headers["Access-Control-Allow-Origin"], "*") + self.assertEqual(response.headers["Access-Control-Allow-Headers"], "Content-Type")