Skip to content

Commit c1ae9d1

Browse files
committed
review: Brad's PR #33 pass — XDG isolation, --no-composer fix, flag coverage
Six follow-ups across two threads of Brad's review. Production fixes: * scripts/export.py get_global_state_dir() now honors XDG_STATE_HOME (returns $XDG_STATE_HOME/cursor-chat-browser), falling back to the historical ~/.cursor-chat-browser only when the env var is unset. Without this, the --since last test leaked state into the developer's real home directory; the test still passed but only by timestamp coincidence and corrupted ~/.cursor-chat-browser/export_state.json on every CI run. * scripts/export.py --no-composer was a parsed-but-unused flag — opts["include_composer"] was set but never consulted. Added the missing guard at the IDE-composer iteration site so the flag now actually skips composer rows. Test additions to tests/test_cli_export_e2e.py: * Both DB-seeding helpers (_make_global_state_db, _make_workspace_storage) now use contextlib.closing(sqlite3.connect(...)). A mid-setup exception used to leak the handle and on Windows that blocks TemporaryDirectory.cleanup(). * New TestGetGlobalStateDir regression class — two tests pinning both branches of the XDG behavior so this can't silently regress. * test_no_composer_skips_ide_composer_data — fixture seeds composer data exclusively, so --no-composer must produce zero markdown. * test_exclude_rules_filters_matching_composer — writes a rules file containing "E2E" (substring of the seeded composer's title) and asserts the seeded composer is filtered out before any markdown is written. Doc fix in tests/cli-export-qa-checklist.md: * `curl -sI` sends HTTP/1.1 by default and the dev server replies in kind. Listing HTTP/1.0 first would have triggered false-fail reports from testers seeing HTTP/1.1. Verified locally: - python3 -m unittest tests.test_cli_export_e2e: 9 passed - python3 -m unittest discover tests: 187 passed, OK - ~/.cursor-chat-browser never created during the run - python3 app.py boots clean; curl -sI returns HTTP/1.1 200 OK
1 parent fea609d commit c1ae9d1

3 files changed

Lines changed: 126 additions & 42 deletions

File tree

scripts/export.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ def resolve_workspace_path() -> str:
129129

130130

131131
def get_global_state_dir() -> str:
132+
# Honor XDG_STATE_HOME when set so the export state file (and manifest)
133+
# can be redirected — required for hermetic test runs and useful for
134+
# users following the XDG Base Directory spec. Falls back to the
135+
# historical ~/.cursor-chat-browser location when the env var is unset.
136+
xdg = os.environ.get("XDG_STATE_HOME")
137+
if xdg:
138+
return os.path.join(xdg, "cursor-chat-browser")
132139
return os.path.join(str(Path.home()), ".cursor-chat-browser")
133140

134141

@@ -486,8 +493,9 @@ def assign_workspace(cd, cid):
486493
exported = []
487494
count = 0
488495

489-
# Process IDE composers
490-
for row in ide_composer_rows:
496+
# Process IDE composers (skipped entirely when --no-composer was passed)
497+
include_composer = opts.get("include_composer", True)
498+
for row in ide_composer_rows if include_composer else []:
491499
composer_id = row["key"].split(":")[1]
492500
try:
493501
cd = json.loads(row["value"])

tests/cli-export-qa-checklist.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ python app.py
6868
```
6969

7070
- [ ] Process stays running for at least 30 s without crash
71-
- [ ] `curl -sI http://127.0.0.1:3000/` returns `HTTP/1.0 200 OK` (or 200 over 1.1)
71+
- [ ] `curl -sI http://127.0.0.1:3000/` returns `HTTP/1.1 200 OK` (or `HTTP/1.0 200 OK` under some dev-server configurations)
7272
- [ ] Home page lists at least one workspace card (assuming real Cursor data)
7373
- [ ] No `Exception` / `Traceback` in server log
7474

tests/test_cli_export_e2e.py

Lines changed: 115 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import tempfile
1010
import unittest
1111
import zipfile
12-
from contextlib import contextmanager
12+
from contextlib import closing, contextmanager
1313
from pathlib import Path
1414

1515
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -25,36 +25,38 @@
2525

2626

2727
def _make_global_state_db(path: str, *, last_updated_ms: int = 1_715_000_500_000) -> None:
28-
conn = sqlite3.connect(path)
29-
conn.execute("CREATE TABLE cursorDiskKV ([key] TEXT PRIMARY KEY, value TEXT)")
30-
conn.execute(
31-
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
32-
(
33-
f"composerData:{HAPPY_COMPOSER_ID}",
34-
json.dumps({
35-
"name": "Export E2E conversation",
36-
"createdAt": 1_715_000_000_000,
37-
"lastUpdatedAt": last_updated_ms,
38-
"fullConversationHeadersOnly": [
39-
{"bubbleId": HAPPY_BUBBLE_ID, "type": 1},
40-
],
41-
"modelConfig": {"modelName": "gpt-4o"},
42-
}),
43-
),
44-
)
45-
conn.execute(
46-
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
47-
(
48-
f"bubbleId:{HAPPY_COMPOSER_ID}:{HAPPY_BUBBLE_ID}",
49-
json.dumps({
50-
"text": "hello from the e2e test fixture",
51-
"type": "user",
52-
"createdAt": 1_715_000_400_000,
53-
}),
54-
),
55-
)
56-
conn.commit()
57-
conn.close()
28+
# closing() guarantees conn.close() even if an exec/commit raises
29+
# mid-setup. On Windows TemporaryDirectory.cleanup() refuses to delete
30+
# an open SQLite file, so a leaked handle would fail the whole test.
31+
with closing(sqlite3.connect(path)) as conn:
32+
conn.execute("CREATE TABLE cursorDiskKV ([key] TEXT PRIMARY KEY, value TEXT)")
33+
conn.execute(
34+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
35+
(
36+
f"composerData:{HAPPY_COMPOSER_ID}",
37+
json.dumps({
38+
"name": "Export E2E conversation",
39+
"createdAt": 1_715_000_000_000,
40+
"lastUpdatedAt": last_updated_ms,
41+
"fullConversationHeadersOnly": [
42+
{"bubbleId": HAPPY_BUBBLE_ID, "type": 1},
43+
],
44+
"modelConfig": {"modelName": "gpt-4o"},
45+
}),
46+
),
47+
)
48+
conn.execute(
49+
"INSERT INTO cursorDiskKV ([key], value) VALUES (?, ?)",
50+
(
51+
f"bubbleId:{HAPPY_COMPOSER_ID}:{HAPPY_BUBBLE_ID}",
52+
json.dumps({
53+
"text": "hello from the e2e test fixture",
54+
"type": "user",
55+
"createdAt": 1_715_000_400_000,
56+
}),
57+
),
58+
)
59+
conn.commit()
5860

5961

6062
def _make_workspace_storage(parent: str, *, last_updated_ms: int = 1_715_000_500_000) -> str:
@@ -70,14 +72,13 @@ def _make_workspace_storage(parent: str, *, last_updated_ms: int = 1_715_000_500
7072
with open(os.path.join(ws_dir, "workspace.json"), "w", encoding="utf-8") as f:
7173
json.dump({"folder": project_folder}, f)
7274
local_db = os.path.join(ws_dir, "state.vscdb")
73-
conn = sqlite3.connect(local_db)
74-
conn.execute("CREATE TABLE ItemTable ([key] TEXT PRIMARY KEY, value TEXT)")
75-
conn.execute(
76-
"INSERT INTO ItemTable ([key], value) VALUES (?, ?)",
77-
("composer.composerData", json.dumps({"allComposers": [{"composerId": HAPPY_COMPOSER_ID}]})),
78-
)
79-
conn.commit()
80-
conn.close()
75+
with closing(sqlite3.connect(local_db)) as conn:
76+
conn.execute("CREATE TABLE ItemTable ([key] TEXT PRIMARY KEY, value TEXT)")
77+
conn.execute(
78+
"INSERT INTO ItemTable ([key], value) VALUES (?, ?)",
79+
("composer.composerData", json.dumps({"allComposers": [{"composerId": HAPPY_COMPOSER_ID}]})),
80+
)
81+
conn.commit()
8182

8283
_make_global_state_db(os.path.join(global_root, "state.vscdb"), last_updated_ms=last_updated_ms)
8384
return ws_root
@@ -115,6 +116,38 @@ def _run_export(argv: list[str], *, workspace_path: str, state_dir: str):
115116
os.environ[key] = prior
116117

117118

119+
class TestGetGlobalStateDir(unittest.TestCase):
120+
"""Regression: get_global_state_dir() must honor XDG_STATE_HOME.
121+
122+
Before the fix it hardcoded ~/.cursor-chat-browser, which leaked test
123+
state into the developer's real home directory and made the
124+
--since last test pass only by timestamp coincidence.
125+
"""
126+
127+
def setUp(self):
128+
self._prior = os.environ.get("XDG_STATE_HOME")
129+
130+
def tearDown(self):
131+
if self._prior is None:
132+
os.environ.pop("XDG_STATE_HOME", None)
133+
else:
134+
os.environ["XDG_STATE_HOME"] = self._prior
135+
136+
def test_uses_xdg_state_home_when_set(self):
137+
os.environ["XDG_STATE_HOME"] = "/tmp/some-xdg-root"
138+
self.assertEqual(
139+
export_script.get_global_state_dir(),
140+
"/tmp/some-xdg-root/cursor-chat-browser",
141+
)
142+
143+
def test_falls_back_to_home_when_xdg_unset(self):
144+
os.environ.pop("XDG_STATE_HOME", None)
145+
self.assertEqual(
146+
export_script.get_global_state_dir(),
147+
os.path.join(str(Path.home()), ".cursor-chat-browser"),
148+
)
149+
150+
118151
class TestCliExportEndToEnd(unittest.TestCase):
119152
def setUp(self):
120153
self._tmp = tempfile.TemporaryDirectory()
@@ -238,6 +271,49 @@ def test_since_last_skips_already_exported_records(self):
238271
msg="--since last should not rewrite the already-exported markdown",
239272
)
240273

274+
# ─── --no-composer ─────────────────────────────────────────────────────
275+
276+
def test_no_composer_skips_ide_composer_data(self):
277+
# The fixture seeds IDE composer data exclusively (no CLI sessions
278+
# under CLI_CHATS_PATH). --no-composer must therefore drop the
279+
# seeded composer and exit with "No conversations found.", leaving
280+
# the output dir empty of .md files.
281+
with _run_export(["--out", self.out_dir, "--no-zip", "--no-composer"],
282+
workspace_path=self.workspace_path,
283+
state_dir=self.state_dir):
284+
pass
285+
286+
md_files = list(Path(self.out_dir).rglob("*.md"))
287+
self.assertEqual(
288+
md_files, [],
289+
msg=f"--no-composer must produce zero markdown; got {[p.name for p in md_files]}",
290+
)
291+
292+
# ─── --exclude-rules ───────────────────────────────────────────────────
293+
294+
def test_exclude_rules_filters_matching_composer(self):
295+
# A rule whose word matches the composer's title must drop it
296+
# before any markdown is written. The fixture's composer is named
297+
# "Export E2E conversation" so a single-token rule of "E2E" is
298+
# enough to match via the case-insensitive substring check.
299+
rules_path = os.path.join(self.tmpdir, "exclusion-rules.txt")
300+
with open(rules_path, "w", encoding="utf-8") as f:
301+
f.write("E2E\n")
302+
303+
with _run_export(
304+
["--out", self.out_dir, "--no-zip", "--exclude-rules", rules_path],
305+
workspace_path=self.workspace_path,
306+
state_dir=self.state_dir,
307+
):
308+
pass
309+
310+
md_files = list(Path(self.out_dir).rglob("*.md"))
311+
matched = [p for p in md_files if HAPPY_COMPOSER_ID[:8] in p.name]
312+
self.assertEqual(
313+
matched, [],
314+
msg=f"--exclude-rules failed to filter seeded composer; got {[p.name for p in md_files]}",
315+
)
316+
241317

242318
if __name__ == "__main__":
243319
unittest.main()

0 commit comments

Comments
 (0)