krz/crumb

A local alternative to your browser's history. browser-history cli local-first privacy

Commit 254cfed177

254cfed17728ab9b6011a298c6dd58a3730910ef

parent: 06bd0864a5

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-23 02:16 UTC

Fix the tests, and run them

The suite arrived in one commit and had never passed. All four tests failed:

- test_server posted with data= and content_type json, which Flask sends
  form-encoded, so request.json was empty and the handler returned 400 where the
  test wanted 204. It also referenced an undefined client in the OPTIONS test.
- test_search patched LOG_PATH with a value, then declared a mock parameter that
  @patch never passes, so both tests raised TypeError before running. Underneath
  that they asserted the wrong thing entirely: search_log prints matches and does
  not touch the file, but the tests read the file back — so the no-match case
  could not pass under any implementation.

Both are rewritten against what the code does. search_log's output is captured
and asserted; the server tests patch LOG_PATH at a temp file, which the old ones
did not — running them appended to the developer's own ~/.crumb/history.org.

Nine tests now, covering CORS headers, optional-field omission, case-insensitive
search and the missing-log path. requirements-dev.txt pins flask and pytest. CI
also checks the extension manifest parses, since nothing else looks at it.

Layout: unified · split

.github/workflows/test.yml added +32
@@ -0,0 +1,32 @@
1name: 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.
5on:
6 pull_request:
7 push:
8 branches: [main]
9
10permissions:
11 contents: read
12
13jobs:
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 @@
1flask
2pytest
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
3blinker==1.9.0
4 # via flask
5click==8.4.2
6 # via flask
7flask==3.1.3
8 # via -r requirements-dev.in
9iniconfig==2.3.0
10 # via pytest
11itsdangerous==2.2.0
12 # via flask
13jinja2==3.1.6
14 # via flask
15markupsafe==3.0.3
16 # via
17 # flask
18 # jinja2
19 # werkzeug
20packaging==26.3
21 # via pytest
22pluggy==1.6.0
23 # via pytest
24pygments==2.21.0
25 # via pytest
26pytest==9.1.1
27 # via -r requirements-dev.in
28werkzeug==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 @@
1import io
2import os
3import tempfile
1import unittest 4import unittest
5from contextlib import redirect_stdout
2from unittest.mock import patch 6from unittest.mock import patch
7
8import search
3from search import search_log 9from search import search_log
4 10
11
5class TestSearchLog(unittest.TestCase): 12class 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 @@
1import json
2import os
3import tempfile
1import unittest 4import unittest
2from flask import Flask 5from unittest.mock import patch
6
3from server import app 7from server import app
4 8
9
5class TestServer(unittest.TestCase): 10class 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")