Skip to content

Commit e7e93e5

Browse files
authored
Merge pull request #20 from bertheto/feat/export-thread-markdown
UI-03: Export thread as downloadable Markdown transcript
2 parents dff4817 + cb82f99 commit e7e93e5

5 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2+
import '../../../src/static/js/shared-threads.js';
3+
import '../../../src/static/js/components/acb-thread-header.js';
4+
import '../../../src/static/js/components/acb-thread-context-menu.js';
5+
6+
const { exportThread } = window.AcbThreads;
7+
8+
// ─────────────────────────────────────────────────────────────────────────────
9+
// DOM / visual-structure tests
10+
// ─────────────────────────────────────────────────────────────────────────────
11+
12+
describe('Web Component: acb-thread-header — export button', () => {
13+
let element;
14+
15+
beforeEach(() => {
16+
element = document.createElement('acb-thread-header');
17+
document.body.appendChild(element);
18+
});
19+
20+
afterEach(() => {
21+
document.body.innerHTML = '';
22+
});
23+
24+
it('renders export button in thread header', () => {
25+
const btn = element.querySelector('#export-thread-btn');
26+
expect(btn).toBeTruthy();
27+
expect(btn.tagName).toBe('BUTTON');
28+
});
29+
30+
it('export button has correct aria-label', () => {
31+
const btn = element.querySelector('#export-thread-btn');
32+
expect(btn?.getAttribute('aria-label')).toBe('Export thread as Markdown');
33+
});
34+
35+
it('export button has download SVG icon', () => {
36+
const btn = element.querySelector('#export-thread-btn');
37+
const svg = btn?.querySelector('svg');
38+
expect(svg).toBeTruthy();
39+
});
40+
});
41+
42+
describe('Web Component: acb-thread-context-menu — Export .md item', () => {
43+
let element;
44+
45+
beforeEach(() => {
46+
element = document.createElement('acb-thread-context-menu');
47+
document.body.appendChild(element);
48+
});
49+
50+
afterEach(() => {
51+
document.body.innerHTML = '';
52+
});
53+
54+
it('renders export item in context menu', () => {
55+
const btn = element.querySelector('#ctx-export');
56+
expect(btn).toBeTruthy();
57+
expect(btn.tagName).toBe('BUTTON');
58+
});
59+
60+
it('context menu export item has correct text', () => {
61+
const btn = element.querySelector('#ctx-export');
62+
expect(btn?.textContent?.trim()).toBe('Export .md');
63+
});
64+
});
65+
66+
// ─────────────────────────────────────────────────────────────────────────────
67+
// Logic tests — exportThread()
68+
// ─────────────────────────────────────────────────────────────────────────────
69+
70+
describe('exportThread()', () => {
71+
let fetchMock;
72+
let createObjectURLMock;
73+
let revokeObjectURLMock;
74+
let appendChildSpy;
75+
let removeChildSpy;
76+
let clickSpy;
77+
78+
beforeEach(() => {
79+
fetchMock = vi.fn();
80+
global.fetch = fetchMock;
81+
82+
createObjectURLMock = vi.fn(() => 'blob:mock-url');
83+
revokeObjectURLMock = vi.fn();
84+
global.URL.createObjectURL = createObjectURLMock;
85+
global.URL.revokeObjectURL = revokeObjectURLMock;
86+
87+
clickSpy = vi.fn();
88+
appendChildSpy = vi.spyOn(document.body, 'appendChild').mockImplementation((el) => {
89+
if (el.tagName === 'A') {
90+
el.click = clickSpy;
91+
}
92+
});
93+
removeChildSpy = vi.spyOn(document.body, 'removeChild').mockImplementation(() => {});
94+
});
95+
96+
afterEach(() => {
97+
vi.restoreAllMocks();
98+
delete global.fetch;
99+
});
100+
101+
it('exportThread creates Blob and triggers download', async () => {
102+
const markdownContent = '# My Thread\n\n> **Status:** discuss\n';
103+
fetchMock.mockResolvedValue({
104+
ok: true,
105+
text: async () => markdownContent,
106+
});
107+
108+
await exportThread({ threadId: 'thread-123', topic: 'My Thread' });
109+
110+
expect(fetchMock).toHaveBeenCalledWith('/api/threads/thread-123/export');
111+
expect(createObjectURLMock).toHaveBeenCalledOnce();
112+
const blobArg = createObjectURLMock.mock.calls[0][0];
113+
expect(blobArg).toBeInstanceOf(Blob);
114+
expect(clickSpy).toHaveBeenCalledOnce();
115+
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:mock-url');
116+
});
117+
118+
it('exportThread uses slugified filename from topic', async () => {
119+
fetchMock.mockResolvedValue({
120+
ok: true,
121+
text: async () => '# My Cool Thread\n',
122+
});
123+
124+
let capturedHref = null;
125+
let capturedDownload = null;
126+
appendChildSpy.mockImplementation((el) => {
127+
if (el.tagName === 'A') {
128+
capturedHref = el.href;
129+
capturedDownload = el.download;
130+
el.click = clickSpy;
131+
}
132+
});
133+
134+
await exportThread({ threadId: 'thread-abc', topic: 'My Cool Thread' });
135+
136+
expect(capturedDownload).toBe('my-cool-thread.md');
137+
});
138+
139+
it('exportThread handles fetch error gracefully', async () => {
140+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
141+
fetchMock.mockRejectedValue(new Error('Network error'));
142+
143+
await expect(exportThread({ threadId: 'thread-err', topic: 'Error Thread' })).resolves.toBeUndefined();
144+
expect(warnSpy).toHaveBeenCalled();
145+
expect(createObjectURLMock).not.toHaveBeenCalled();
146+
147+
warnSpy.mockRestore();
148+
});
149+
});

src/static/js/components/acb-thread-context-menu.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<button id="ctx-close" class="ctx-item" type="button" role="menuitem" onclick="closeThreadFromMenu()">Close</button>
2020
<button id="ctx-archive" class="ctx-item" type="button" role="menuitem" onclick="archiveThreadFromMenu()">Archive</button>
2121
<button id="ctx-unarchive" class="ctx-item" type="button" role="menuitem" onclick="unarchiveThreadFromMenu()" style="display: none;">Unarchive</button>
22+
<button id="ctx-export" class="ctx-item" type="button" role="menuitem" onclick="exportThreadFromMenu()">Export .md</button>
2223
<hr class="ctx-divider" aria-hidden="true">
2324
<button id="ctx-delete" class="ctx-item ctx-item--destructive" type="button" role="menuitem" onclick="deleteThreadFromMenu()">Delete</button>
2425
</div>`;

src/static/js/components/acb-thread-header.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66
this.innerHTML = `
77
<div id="thread-header" style="display:none">
88
<h2 id="thread-title"></h2>
9+
<button id="export-thread-btn" type="button" title="Export as Markdown" aria-label="Export thread as Markdown" onclick="exportFromHeader()">
10+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
11+
<path d="M8 1v9M4.5 6.5 8 10l3.5-3.5M2 11v2.5A1.5 1.5 0 0 0 3.5 15h9A1.5 1.5 0 0 0 14 13.5V11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
12+
</svg>
13+
</button>
914
<div id="online-presence" title="">
1015
<span id="online-count">1</span>
1116
</div>

src/static/js/shared-threads.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,36 @@
231231
await closeThread(id);
232232
}
233233

234+
async function exportThread({ threadId, topic }) {
235+
if (!threadId) return;
236+
try {
237+
const response = await fetch(`/api/threads/${threadId}/export`);
238+
if (!response.ok) {
239+
console.warn(`[ACB] Export failed: HTTP ${response.status}`);
240+
return;
241+
}
242+
const text = await response.text();
243+
const slug = (topic || threadId)
244+
.toLowerCase()
245+
.replace(/[^\w-]/g, "-")
246+
.replace(/-+/g, "-")
247+
.replace(/^-|-$/g, "") || "thread";
248+
const filename = `${slug}.md`;
249+
const blob = new Blob([text], { type: "text/markdown; charset=utf-8" });
250+
const url = URL.createObjectURL(blob);
251+
const a = document.createElement("a");
252+
a.href = url;
253+
a.download = filename;
254+
a.style.display = "none";
255+
document.body.appendChild(a);
256+
a.click();
257+
document.body.removeChild(a);
258+
URL.revokeObjectURL(url);
259+
} catch (err) {
260+
console.warn("[ACB] Export error:", err);
261+
}
262+
}
263+
234264
async function copyThreadNameFromMenu({
235265
getContextMenuThread,
236266
hideThreadContextMenu,
@@ -281,6 +311,7 @@ Task: After entering, stand by. Human programmers may need to publish requiremen
281311
archiveThreadFromMenu,
282312
unarchiveThreadFromMenu,
283313
closeThreadFromMenu,
314+
exportThread,
284315
copyThreadNameFromMenu,
285316
copyJoinPromptFromMenu,
286317
};

tests/test_export_markdown.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""
2+
Tests for the thread Markdown export endpoint (UI-03).
3+
4+
GET /api/threads/{thread_id}/export
5+
"""
6+
import os
7+
import httpx
8+
import pytest
9+
10+
BASE_URL = os.getenv("AGENTCHATBUS_BASE_URL", "http://127.0.0.1:39766")
11+
12+
13+
def _build_client() -> httpx.Client:
14+
return httpx.Client(base_url=BASE_URL, timeout=10)
15+
16+
17+
def _require_server_or_skip(client: httpx.Client) -> None:
18+
try:
19+
resp = client.get("/api/threads")
20+
if resp.status_code < 500:
21+
return
22+
except Exception:
23+
pass
24+
pytest.skip(f"AgentChatBus server is not reachable at {BASE_URL}")
25+
26+
27+
@pytest.fixture(scope="module")
28+
def export_thread_id() -> str:
29+
"""Thread with 3 messages for export tests."""
30+
with _build_client() as client:
31+
_require_server_or_skip(client)
32+
r = client.post("/api/threads", json={"topic": "Export-Test-UI03"})
33+
assert r.status_code == 201, r.text
34+
tid = r.json()["id"]
35+
36+
for i in range(1, 4):
37+
r2 = client.post(
38+
f"/api/threads/{tid}/messages",
39+
json={"author": f"agent-{i}", "role": "user", "content": f"Message {i} content"},
40+
)
41+
assert r2.status_code == 201, r2.text
42+
43+
return tid
44+
45+
46+
def test_export_with_messages(export_thread_id: str):
47+
"""Thread with 3 messages produces valid Markdown structure."""
48+
with _build_client() as client:
49+
_require_server_or_skip(client)
50+
r = client.get(f"/api/threads/{export_thread_id}/export")
51+
assert r.status_code == 200
52+
md = r.text
53+
assert md.startswith("# Export-Test-UI03"), f"Expected h1 title, got: {md[:80]!r}"
54+
assert "---" in md
55+
assert "Message 1 content" in md
56+
assert "Message 2 content" in md
57+
assert "Message 3 content" in md
58+
assert "### " in md, "Expected ### headers for messages"
59+
60+
61+
def test_export_content_type(export_thread_id: str):
62+
"""Response Content-Type must be text/markdown."""
63+
with _build_client() as client:
64+
_require_server_or_skip(client)
65+
r = client.get(f"/api/threads/{export_thread_id}/export")
66+
assert r.status_code == 200
67+
assert "text/markdown" in r.headers.get("content-type", "")
68+
69+
70+
def test_export_content_disposition(export_thread_id: str):
71+
"""Content-Disposition must contain a .md filename slug."""
72+
with _build_client() as client:
73+
_require_server_or_skip(client)
74+
r = client.get(f"/api/threads/{export_thread_id}/export")
75+
assert r.status_code == 200
76+
cd = r.headers.get("content-disposition", "")
77+
assert "attachment" in cd
78+
assert ".md" in cd
79+
80+
81+
def test_export_404():
82+
"""Non-existent thread must return 404."""
83+
with _build_client() as client:
84+
_require_server_or_skip(client)
85+
r = client.get("/api/threads/does-not-exist-xxxxxx/export")
86+
assert r.status_code == 404
87+
88+
89+
def test_export_empty_thread():
90+
"""Thread with no messages returns a markdown header without message sections."""
91+
with _build_client() as client:
92+
_require_server_or_skip(client)
93+
r = client.post("/api/threads", json={"topic": "Export-Empty-UI03"})
94+
assert r.status_code == 201
95+
tid = r.json()["id"]
96+
97+
r2 = client.get(f"/api/threads/{tid}/export")
98+
assert r2.status_code == 200
99+
md = r2.text
100+
assert "# Export-Empty-UI03" in md
101+
assert "**Messages:** 0" in md
102+
assert "### " not in md, "No message headers expected for empty thread"
103+
104+
105+
def test_export_special_chars():
106+
"""Topic and content with special Markdown chars must not corrupt output."""
107+
with _build_client() as client:
108+
_require_server_or_skip(client)
109+
r = client.post(
110+
"/api/threads", json={"topic": "Export Special & Chars | Test # 42"}
111+
)
112+
assert r.status_code == 201
113+
tid = r.json()["id"]
114+
115+
client.post(
116+
f"/api/threads/{tid}/messages",
117+
json={
118+
"author": "agent-x",
119+
"role": "user",
120+
"content": 'Content with | pipes | and "quotes" and `backticks`',
121+
},
122+
)
123+
124+
r2 = client.get(f"/api/threads/{tid}/export")
125+
assert r2.status_code == 200
126+
md = r2.text
127+
assert "Export Special" in md
128+
assert "pipes" in md
129+
assert "backticks" in md

0 commit comments

Comments
 (0)