Skip to content

Commit 665b70b

Browse files
committed
initial implementation
1 parent adb75a4 commit 665b70b

3 files changed

Lines changed: 192 additions & 1 deletion

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ jobs:
105105
run: |
106106
python -m pip install --upgrade pip
107107
python -m pip install -r requirements-lock.txt
108-
python -m pip install 'pytest>=8,<9'
108+
python -m pip install 'pytest>=8,<9' 'hypothesis>=6.100,<7'
109109
110110
- name: Run unittest suite
111111
run: python -m unittest discover tests -v

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ desktop = ["pywebview>=5.0,<6"]
3232
dev = [
3333
"pytest>=8,<9",
3434
"mypy>=1.10,<2",
35+
"hypothesis>=6.100,<7",
3536
]
3637

3738
[project.scripts]

tests/test_blob_parsing_fuzz.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Property-based fuzz tests for blob / bubble parsing (issue #71).
2+
3+
Run:
4+
python -m unittest tests.test_blob_parsing_fuzz -v
5+
python -m pytest tests/test_blob_parsing_fuzz.py -v
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import os
12+
import sys
13+
import unittest
14+
15+
from hypothesis import given, settings
16+
from hypothesis import strategies as st
17+
18+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
19+
if REPO_ROOT not in sys.path:
20+
sys.path.insert(0, REPO_ROOT)
21+
22+
from models import Bubble, SchemaError
23+
from utils.cli_chat_reader import _extract_blob_refs, messages_to_bubbles
24+
from utils.text_extract import extract_text_from_bubble
25+
26+
# Bounded strategies: fast enough for CI (<30s total with default example counts).
27+
_JSON_VALUES = st.one_of(
28+
st.none(),
29+
st.booleans(),
30+
st.integers(),
31+
st.floats(allow_nan=False, allow_infinity=False),
32+
st.text(max_size=200),
33+
st.lists(st.text(max_size=80), max_size=8),
34+
)
35+
36+
_BUBBLE_RAW = st.dictionaries(
37+
st.text(min_size=0, max_size=40),
38+
_JSON_VALUES,
39+
max_size=12,
40+
)
41+
42+
_BUBBLE_ID = st.text(
43+
alphabet=st.characters(blacklist_categories=("Cs",), blacklist_characters="\x00"),
44+
min_size=1,
45+
max_size=80,
46+
)
47+
48+
@st.composite
49+
def _cli_message(draw) -> dict:
50+
role = draw(st.sampled_from(["user", "assistant", "system", "tool", ""]))
51+
content = draw(
52+
st.one_of(
53+
st.text(max_size=500),
54+
st.lists(
55+
st.dictionaries(
56+
st.sampled_from(
57+
["type", "text", "toolName", "args", "toolCallId", "result"]
58+
),
59+
st.one_of(st.text(max_size=120), st.integers(), st.none()),
60+
max_size=6,
61+
),
62+
max_size=8,
63+
),
64+
st.none(),
65+
)
66+
)
67+
return {"role": role, "content": content}
68+
69+
_BUBBLE_LIKE = st.dictionaries(
70+
st.sampled_from(["text", "richText", "codeBlocks", "type", "metadata"]),
71+
st.one_of(
72+
st.text(max_size=300),
73+
st.none(),
74+
st.lists(
75+
st.dictionaries(
76+
st.text(max_size=20),
77+
st.one_of(st.text(max_size=100), st.integers()),
78+
max_size=5,
79+
),
80+
max_size=4,
81+
),
82+
st.dictionaries(st.text(max_size=20), _JSON_VALUES, max_size=5),
83+
),
84+
max_size=6,
85+
)
86+
87+
88+
def _classify_blob_bytes(data: bytes) -> None:
89+
"""Mirror traverse_blobs blob classification without SQLite."""
90+
try:
91+
msg = json.loads(data.decode("utf-8"))
92+
if isinstance(msg, dict) and "role" in msg:
93+
return
94+
except (UnicodeDecodeError, json.JSONDecodeError, TypeError):
95+
pass
96+
_extract_blob_refs(data)
97+
98+
99+
class TestBubbleFromDictFuzz(unittest.TestCase):
100+
@given(raw=_BUBBLE_RAW, bubble_id=_BUBBLE_ID)
101+
@settings(max_examples=80, deadline=None)
102+
def test_never_raises_unhandled(self, raw: dict, bubble_id: str) -> None:
103+
try:
104+
bubble = Bubble.from_dict(raw, bubble_id=bubble_id)
105+
except SchemaError:
106+
return
107+
except Exception as exc:
108+
self.fail(f"unexpected {type(exc).__name__}: {exc}")
109+
self.assertEqual(bubble.bubble_id, bubble_id)
110+
self.assertIs(bubble.raw, raw)
111+
112+
@given(raw=_BUBBLE_RAW, bubble_id=_BUBBLE_ID)
113+
@settings(max_examples=80, deadline=None)
114+
def test_parsing_is_idempotent(self, raw: dict, bubble_id: str) -> None:
115+
try:
116+
first = Bubble.from_dict(raw, bubble_id=bubble_id)
117+
second = Bubble.from_dict(raw, bubble_id=bubble_id)
118+
except SchemaError:
119+
return
120+
except Exception as exc:
121+
self.fail(f"unexpected {type(exc).__name__}: {exc}")
122+
self.assertEqual(first, second)
123+
124+
125+
class TestBlobChainParsingFuzz(unittest.TestCase):
126+
@given(data=st.binary(max_size=4096))
127+
@settings(max_examples=120, deadline=None)
128+
def test_extract_blob_refs_never_raises(self, data: bytes) -> None:
129+
try:
130+
refs = _extract_blob_refs(data)
131+
except Exception as exc:
132+
self.fail(f"unexpected {type(exc).__name__}: {exc}")
133+
self.assertIsInstance(refs, list)
134+
for ref in refs:
135+
self.assertIsInstance(ref, str)
136+
self.assertEqual(len(ref), 64)
137+
138+
@given(data=st.binary(max_size=4096))
139+
@settings(max_examples=80, deadline=None)
140+
def test_extract_blob_refs_is_idempotent(self, data: bytes) -> None:
141+
self.assertEqual(_extract_blob_refs(data), _extract_blob_refs(data))
142+
143+
@given(data=st.binary(max_size=4096))
144+
@settings(max_examples=80, deadline=None)
145+
def test_blob_classification_never_raises(self, data: bytes) -> None:
146+
try:
147+
_classify_blob_bytes(data)
148+
except Exception as exc:
149+
self.fail(f"unexpected {type(exc).__name__}: {exc}")
150+
151+
152+
class TestTextExtractionFuzz(unittest.TestCase):
153+
@given(bubble=_BUBBLE_LIKE)
154+
@settings(max_examples=100, deadline=None)
155+
def test_extract_text_from_bubble_never_raises(self, bubble: dict) -> None:
156+
try:
157+
extract_text_from_bubble(bubble)
158+
except Exception as exc:
159+
self.fail(f"unexpected {type(exc).__name__}: {exc}")
160+
161+
@given(bubble=_BUBBLE_LIKE)
162+
@settings(max_examples=80, deadline=None)
163+
def test_extract_text_is_idempotent(self, bubble: dict) -> None:
164+
self.assertEqual(
165+
extract_text_from_bubble(bubble),
166+
extract_text_from_bubble(bubble),
167+
)
168+
169+
@given(
170+
messages=st.lists(_cli_message(), max_size=12),
171+
created_at=st.integers(min_value=0, max_value=2_000_000_000_000),
172+
)
173+
@settings(max_examples=80, deadline=None)
174+
def test_messages_to_bubbles_then_extract_never_raises(
175+
self, messages: list[dict], created_at: int
176+
) -> None:
177+
try:
178+
bubbles = messages_to_bubbles(messages, created_at)
179+
except Exception as exc:
180+
self.fail(f"messages_to_bubbles raised {type(exc).__name__}: {exc}")
181+
self.assertIsInstance(bubbles, list)
182+
for bubble in bubbles:
183+
try:
184+
extract_text_from_bubble(bubble)
185+
except Exception as exc:
186+
self.fail(f"extract_text_from_bubble raised {type(exc).__name__}: {exc}")
187+
188+
189+
if __name__ == "__main__":
190+
unittest.main()

0 commit comments

Comments
 (0)