Skip to content

Commit a51109d

Browse files
committed
test: end-to-end coverage of scripts/export.py + manual QA checklist (closes #27)
Adds tests/test_cli_export_e2e.py — five unittest cases that drive scripts/export.py main() against a self-contained SQLite fixture (workspaceStorage + globalStorage cursorDiskKV) under tempfile, with WORKSPACE_PATH / CLI_CHATS_PATH / XDG_STATE_HOME overrides. Coverage: - no-zip mode writes .md files under <out>/<date>/... - YAML frontmatter contains log_id, title, workspace, created_at, updated_at - default zip mode writes cursor-export-<date>.zip containing .md entries - manifest.jsonl entries carry log_id / path / updated_at - --since last is idempotent — second run does not rewrite unchanged files Adds tests/cli-export-qa-checklist.md as the manual companion: --help, zip + no-zip + --since last exports, app.py launch + curl smoke, markdown/PDF download from the web UI. Adds exports/ to .gitignore alongside the existing export/ entry so either pluralisation of the example output directory stays untracked. The fixture is self-contained (does not depend on the conftest.py from PR #32) so the suite runs on master CI as-is under `unittest discover`.
1 parent 95d3140 commit a51109d

3 files changed

Lines changed: 334 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ instance/
2929
# Logs & local data
3030
*.log
3131
export/
32+
exports/
3233

3334
# IDE / OS
3435
.idea/

tests/cli-export-qa-checklist.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# CLI export + browse — manual QA checklist
2+
3+
Companion to the automated `tests/test_cli_export_e2e.py` suite. Run this once
4+
per release branch (or whenever `scripts/export.py`, `app.py`, or the
5+
workspace-scan service modules are touched) on each supported platform.
6+
7+
Mark each row pass/fail and attach short notes for any unexpected output.
8+
9+
## Environment
10+
11+
| Item | Value |
12+
|--------------------|------------------------------------------------|
13+
| OS / version | |
14+
| Python version | `python3 --version` |
15+
| Repo SHA | `git rev-parse HEAD` |
16+
| `WORKSPACE_PATH` | (env var, if overridden) |
17+
| Cursor data layout | default `~/.config/Cursor/User/workspaceStorage` or override |
18+
19+
## 1. CLI: `python scripts/export.py --help`
20+
21+
- [ ] Exits 0
22+
- [ ] Usage block lists `--since {all,last}`, `--out`, `--no-zip`, `--no-composer`, `--base-dir`, `--exclude-rules`
23+
- [ ] No stack trace, no deprecation warning
24+
25+
## 2. CLI: default zip export
26+
27+
```
28+
python scripts/export.py --out ./export
29+
```
30+
31+
- [ ] Exits 0
32+
- [ ] Final stdout line: `Exported N chat(s) to ./export/cursor-export-YYYY-MM-DD.zip`
33+
- [ ] Archive opens with `unzip -l` and contains at least one `.md` entry
34+
- [ ] Each `.md` inside the archive starts with a `---`-fenced YAML
35+
frontmatter containing `log_id`, `title`, `workspace`, `created_at`,
36+
`updated_at`
37+
38+
## 3. CLI: no-zip export
39+
40+
```
41+
python scripts/export.py --out ./export --no-zip
42+
```
43+
44+
- [ ] Exits 0
45+
- [ ] `./export/manifest.jsonl` exists and is non-empty
46+
- [ ] Each manifest line is valid JSON with `log_id`, `path`, `updated_at`
47+
- [ ] At least one `.md` file is written under `./export/<date>/...`
48+
- [ ] Frontmatter fields as in §2
49+
50+
## 4. CLI: incremental (`--since last`)
51+
52+
```
53+
python scripts/export.py --out ./export --no-zip --since last
54+
```
55+
56+
(run after §3)
57+
58+
- [ ] Exits 0
59+
- [ ] If no new chats: stdout prints `No conversations found since last export.`
60+
- [ ] Existing `.md` files in `./export` retain their previous mtimes (not rewritten)
61+
- [ ] After producing a new chat in Cursor, re-running picks it up and writes
62+
only the new file
63+
64+
## 5. App server launch
65+
66+
```
67+
python app.py --port 3001
68+
```
69+
70+
- [ ] Process stays running for at least 30 s without crash
71+
- [ ] `curl -sI http://127.0.0.1:3001/` returns `HTTP/1.0 200 OK` (or 200 over 1.1)
72+
- [ ] Home page lists at least one workspace card (assuming real Cursor data)
73+
- [ ] No `Exception` / `Traceback` in server log
74+
75+
## 6. Browse flow (web UI)
76+
77+
Open `http://127.0.0.1:3001/` in a browser.
78+
79+
- [ ] Workspace list renders without console errors
80+
- [ ] Clicking a workspace card opens the workspace view
81+
- [ ] Within a workspace, opening a chat renders its bubbles
82+
- [ ] Markdown export button on a chat downloads a `.md` whose frontmatter
83+
matches §2
84+
- [ ] PDF export button on a chat downloads a `.pdf` that opens cleanly
85+
86+
## Sign-off
87+
88+
| Reviewer | Platform | Date | Result |
89+
|----------|----------|------|--------|
90+
| | | | |

tests/test_cli_export_e2e.py

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
from __future__ import annotations
2+
3+
import io
4+
import json
5+
import os
6+
import re
7+
import sqlite3
8+
import sys
9+
import tempfile
10+
import unittest
11+
import zipfile
12+
from contextlib import contextmanager
13+
from pathlib import Path
14+
15+
REPO_ROOT = Path(__file__).resolve().parent.parent
16+
if str(REPO_ROOT) not in sys.path:
17+
sys.path.insert(0, str(REPO_ROOT))
18+
19+
from scripts import export as export_script # noqa: E402
20+
21+
22+
HAPPY_COMPOSER_ID = "cmp-export-happy"
23+
HAPPY_BUBBLE_ID = "bub-export-happy"
24+
HAPPY_WORKSPACE_ID = "ws-export-happy"
25+
26+
27+
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()
58+
59+
60+
def _make_workspace_storage(parent: str, *, last_updated_ms: int = 1_715_000_500_000) -> str:
61+
ws_root = os.path.join(parent, "workspaceStorage")
62+
global_root = os.path.join(parent, "globalStorage")
63+
os.makedirs(ws_root, exist_ok=True)
64+
os.makedirs(global_root, exist_ok=True)
65+
66+
ws_dir = os.path.join(ws_root, HAPPY_WORKSPACE_ID)
67+
os.makedirs(ws_dir, exist_ok=True)
68+
project_folder = os.path.join(parent, "happy-project")
69+
os.makedirs(project_folder, exist_ok=True)
70+
with open(os.path.join(ws_dir, "workspace.json"), "w", encoding="utf-8") as f:
71+
json.dump({"folder": project_folder}, f)
72+
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()
81+
82+
_make_global_state_db(os.path.join(global_root, "state.vscdb"), last_updated_ms=last_updated_ms)
83+
return ws_root
84+
85+
86+
@contextmanager
87+
def _run_export(argv: list[str], *, workspace_path: str, state_dir: str):
88+
"""Invoke ``scripts.export.main()`` under controlled env + argv."""
89+
prior_argv = sys.argv
90+
prior_ws = os.environ.get("WORKSPACE_PATH")
91+
prior_cli = os.environ.get("CLI_CHATS_PATH")
92+
prior_state = os.environ.get("XDG_STATE_HOME")
93+
sys.argv = ["scripts/export.py", *argv]
94+
os.environ["WORKSPACE_PATH"] = workspace_path
95+
os.environ["CLI_CHATS_PATH"] = os.path.join(os.path.dirname(workspace_path), "cli_chats_empty")
96+
os.makedirs(os.environ["CLI_CHATS_PATH"], exist_ok=True)
97+
os.environ["XDG_STATE_HOME"] = state_dir
98+
captured = io.StringIO()
99+
prior_stdout = sys.stdout
100+
sys.stdout = captured
101+
try:
102+
try:
103+
export_script.main()
104+
except SystemExit as exc:
105+
if exc.code not in (None, 0):
106+
raise
107+
yield captured.getvalue()
108+
finally:
109+
sys.stdout = prior_stdout
110+
sys.argv = prior_argv
111+
for key, prior in (("WORKSPACE_PATH", prior_ws), ("CLI_CHATS_PATH", prior_cli), ("XDG_STATE_HOME", prior_state)):
112+
if prior is None:
113+
os.environ.pop(key, None)
114+
else:
115+
os.environ[key] = prior
116+
117+
118+
class TestCliExportEndToEnd(unittest.TestCase):
119+
def setUp(self):
120+
self._tmp = tempfile.TemporaryDirectory()
121+
self.tmpdir = self._tmp.name
122+
self.workspace_path = _make_workspace_storage(self.tmpdir)
123+
self.out_dir = os.path.join(self.tmpdir, "out")
124+
os.makedirs(self.out_dir, exist_ok=True)
125+
self.state_dir = os.path.join(self.tmpdir, "state")
126+
os.makedirs(self.state_dir, exist_ok=True)
127+
128+
def tearDown(self):
129+
self._tmp.cleanup()
130+
131+
# ─── no-zip mode ────────────────────────────────────────────────────────
132+
133+
def test_export_no_zip_writes_markdown_files(self):
134+
with _run_export(["--out", self.out_dir, "--no-zip"],
135+
workspace_path=self.workspace_path,
136+
state_dir=self.state_dir):
137+
pass
138+
139+
md_files = list(Path(self.out_dir).rglob("*.md"))
140+
self.assertTrue(md_files, msg=f"expected markdown files under {self.out_dir}, found nothing")
141+
142+
# The seeded composer surfaces as one of the .md files
143+
matched = [p for p in md_files if HAPPY_COMPOSER_ID[:8] in p.name]
144+
self.assertTrue(matched, msg=f"expected composer id in filename, got {[p.name for p in md_files]}")
145+
146+
def test_markdown_frontmatter_has_required_fields(self):
147+
with _run_export(["--out", self.out_dir, "--no-zip"],
148+
workspace_path=self.workspace_path,
149+
state_dir=self.state_dir):
150+
pass
151+
152+
md_files = list(Path(self.out_dir).rglob("*.md"))
153+
matched = [p for p in md_files if HAPPY_COMPOSER_ID[:8] in p.name]
154+
self.assertTrue(matched)
155+
content = matched[0].read_text(encoding="utf-8")
156+
157+
# Frontmatter must contain the five spec-required fields
158+
fm_match = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL)
159+
self.assertIsNotNone(fm_match, msg="expected YAML frontmatter block at top of file")
160+
fm_text = fm_match.group(1)
161+
for required in ("log_id", "title", "workspace", "created_at", "updated_at"):
162+
self.assertIn(f"{required}:", fm_text,
163+
msg=f"frontmatter missing required field '{required}'\n---\n{fm_text}")
164+
165+
# ─── zip mode ──────────────────────────────────────────────────────────
166+
167+
def test_export_zip_mode_writes_archive(self):
168+
with _run_export(["--out", self.out_dir],
169+
workspace_path=self.workspace_path,
170+
state_dir=self.state_dir):
171+
pass
172+
173+
zips = list(Path(self.out_dir).rglob("*.zip"))
174+
self.assertTrue(zips, msg=f"expected a zip archive under {self.out_dir}, found {list(Path(self.out_dir).iterdir())}")
175+
with zipfile.ZipFile(zips[0], "r") as zf:
176+
names = zf.namelist()
177+
self.assertTrue(any(name.endswith(".md") for name in names),
178+
msg=f"expected .md entries inside {zips[0].name}, got {names}")
179+
180+
# ─── manifest.jsonl ────────────────────────────────────────────────────
181+
182+
def test_manifest_jsonl_has_expected_shape(self):
183+
with _run_export(["--out", self.out_dir, "--no-zip"],
184+
workspace_path=self.workspace_path,
185+
state_dir=self.state_dir):
186+
pass
187+
188+
manifest_path = os.path.join(self.out_dir, "manifest.jsonl")
189+
self.assertTrue(os.path.isfile(manifest_path),
190+
msg=f"expected manifest.jsonl at {manifest_path}")
191+
entries = []
192+
with open(manifest_path, "r", encoding="utf-8") as f:
193+
for line in f:
194+
line = line.strip()
195+
if line:
196+
entries.append(json.loads(line))
197+
self.assertTrue(entries, msg="manifest.jsonl is empty")
198+
entry = next((e for e in entries if e.get("log_id") == HAPPY_COMPOSER_ID), None)
199+
self.assertIsNotNone(entry, msg=f"manifest missing seeded composer; got {entries}")
200+
for required in ("log_id", "path", "updated_at"):
201+
self.assertIn(required, entry, msg=f"manifest entry missing '{required}': {entry}")
202+
203+
# ─── --since last incremental ─────────────────────────────────────────
204+
205+
def test_since_last_skips_already_exported_records(self):
206+
# First export: writes everything.
207+
with _run_export(["--out", self.out_dir, "--no-zip"],
208+
workspace_path=self.workspace_path,
209+
state_dir=self.state_dir):
210+
pass
211+
md_after_first = list(Path(self.out_dir).rglob("*.md"))
212+
self.assertTrue(md_after_first)
213+
214+
# Capture timestamps so we can detect re-writes.
215+
before_mtimes = {p.name: p.stat().st_mtime_ns for p in md_after_first}
216+
217+
# Second export with --since last and no new data should not regenerate
218+
# the markdown for the unchanged composer.
219+
with _run_export(["--out", self.out_dir, "--no-zip", "--since", "last"],
220+
workspace_path=self.workspace_path,
221+
state_dir=self.state_dir):
222+
pass
223+
224+
md_after_second = list(Path(self.out_dir).rglob("*.md"))
225+
# Same set of files (no new ones added because the composer wasn't touched)
226+
self.assertEqual(
227+
sorted(p.name for p in md_after_first),
228+
sorted(p.name for p in md_after_second),
229+
)
230+
# mtimes for the existing composer's markdown should NOT have advanced
231+
seeded_after_second = next(
232+
(p for p in md_after_second if HAPPY_COMPOSER_ID[:8] in p.name), None
233+
)
234+
self.assertIsNotNone(seeded_after_second)
235+
self.assertEqual(
236+
before_mtimes[seeded_after_second.name],
237+
seeded_after_second.stat().st_mtime_ns,
238+
msg="--since last should not rewrite the already-exported markdown",
239+
)
240+
241+
242+
if __name__ == "__main__":
243+
unittest.main()

0 commit comments

Comments
 (0)