@andypai/agent-kanban 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -22
- package/package.json +4 -2
- package/src/__tests__/activity.test.ts +15 -9
- package/src/__tests__/api.test.ts +96 -0
- package/src/__tests__/board-utils.test.ts +100 -0
- package/src/__tests__/commands/board.test.ts +6 -13
- package/src/__tests__/conflict.test.ts +64 -0
- package/src/__tests__/index.test.ts +233 -56
- package/src/__tests__/jira-adf.test.ts +168 -0
- package/src/__tests__/jira-cache.test.ts +304 -0
- package/src/__tests__/jira-client.test.ts +169 -0
- package/src/__tests__/jira-provider-comment.test.ts +281 -0
- package/src/__tests__/jira-provider-mutations.test.ts +771 -0
- package/src/__tests__/jira-provider-read.test.ts +594 -0
- package/src/__tests__/jira-wiring.test.ts +187 -0
- package/src/__tests__/linear-cache-description-activity.test.ts +142 -0
- package/src/__tests__/linear-provider-comment.test.ts +243 -0
- package/src/__tests__/linear-provider-sync.test.ts +493 -0
- package/src/__tests__/local-provider-comment.test.ts +60 -0
- package/src/__tests__/mcp-core.test.ts +164 -0
- package/src/__tests__/mcp-server.test.ts +252 -0
- package/src/__tests__/server.test.ts +298 -0
- package/src/__tests__/webhooks.test.ts +604 -0
- package/src/activity.ts +1 -11
- package/src/api.ts +154 -19
- package/src/commands/board.ts +1 -11
- package/src/commands/mcp.ts +87 -0
- package/src/db.ts +115 -3
- package/src/errors.ts +2 -0
- package/src/id.ts +1 -1
- package/src/index.ts +72 -18
- package/src/mcp/core.ts +193 -0
- package/src/mcp/errors.ts +109 -0
- package/src/mcp/index.ts +13 -0
- package/src/mcp/server.ts +512 -0
- package/src/mcp/types.ts +72 -0
- package/src/providers/capabilities.ts +15 -0
- package/src/providers/index.ts +31 -1
- package/src/providers/jira-adf.ts +275 -0
- package/src/providers/jira-cache.ts +625 -0
- package/src/providers/jira-client.ts +390 -0
- package/src/providers/jira.ts +778 -0
- package/src/providers/linear-cache.ts +249 -70
- package/src/providers/linear-client.ts +256 -13
- package/src/providers/linear.ts +337 -14
- package/src/providers/local.ts +68 -17
- package/src/providers/types.ts +18 -2
- package/src/server.ts +139 -11
- package/src/tunnel.ts +79 -0
- package/src/types.ts +18 -2
- package/src/webhooks.ts +36 -0
- package/ui/dist/assets/index-DBnoKL_k.css +1 -0
- package/ui/dist/assets/index-qNVJ6clH.js +40 -0
- package/ui/dist/index.html +2 -2
- package/src/__tests__/commands/task.test.ts +0 -144
- package/src/commands/task.ts +0 -117
- package/src/fixtures.ts +0 -128
- package/ui/dist/assets/index-B8f9NB4z.css +0 -1
- package/ui/dist/assets/index-zWp-rB7b.js +0 -40
|
@@ -3,63 +3,118 @@ import { Database } from 'bun:sqlite'
|
|
|
3
3
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
|
-
import {
|
|
6
|
+
import { ErrorCode, KanbanError, type ErrorCodeValue } from '../errors.ts'
|
|
7
|
+
import type { Task } from '../types.ts'
|
|
8
|
+
import { parseServeArgs, run } from '../index.ts'
|
|
9
|
+
|
|
10
|
+
async function withTempDb(runTest: (dbPath: string) => Promise<void>): Promise<void> {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), 'kanban-run-'))
|
|
12
|
+
const dbPath = join(dir, 'board.db')
|
|
13
|
+
const prevProvider = process.env['KANBAN_PROVIDER']
|
|
14
|
+
process.env['KANBAN_PROVIDER'] = 'local'
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
await runTest(dbPath)
|
|
18
|
+
} finally {
|
|
19
|
+
if (prevProvider === undefined) delete process.env['KANBAN_PROVIDER']
|
|
20
|
+
else process.env['KANBAN_PROVIDER'] = prevProvider
|
|
21
|
+
rmSync(dir, { recursive: true, force: true })
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function expectOk<T>(result: Awaited<ReturnType<typeof run>>): T {
|
|
26
|
+
expect(result.exitCode).toBe(0)
|
|
27
|
+
expect(result.output.ok).toBe(true)
|
|
28
|
+
if (!result.output.ok) {
|
|
29
|
+
throw new Error('expected successful CLI output')
|
|
30
|
+
}
|
|
31
|
+
return result.output.data as T
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function expectKanbanError(
|
|
35
|
+
runPromise: Promise<Awaited<ReturnType<typeof run>>>,
|
|
36
|
+
code: ErrorCodeValue,
|
|
37
|
+
): Promise<KanbanError> {
|
|
38
|
+
const err = await runPromise.then(
|
|
39
|
+
() => null,
|
|
40
|
+
(e: unknown) => e,
|
|
41
|
+
)
|
|
42
|
+
expect(err).toBeInstanceOf(KanbanError)
|
|
43
|
+
expect((err as KanbanError).code).toBe(code)
|
|
44
|
+
return err as KanbanError
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('parseServeArgs', () => {
|
|
48
|
+
test('defaults: no tunnel, port from PORT env or 3000', () => {
|
|
49
|
+
const prev = process.env['PORT']
|
|
50
|
+
delete process.env['PORT']
|
|
51
|
+
try {
|
|
52
|
+
expect(parseServeArgs(['serve'])).toEqual({ db: undefined, port: 3000, tunnel: false })
|
|
53
|
+
process.env['PORT'] = '4001'
|
|
54
|
+
expect(parseServeArgs(['serve'])).toEqual({ db: undefined, port: 4001, tunnel: false })
|
|
55
|
+
} finally {
|
|
56
|
+
if (prev === undefined) delete process.env['PORT']
|
|
57
|
+
else process.env['PORT'] = prev
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('--tunnel opts in; --port and --db override', () => {
|
|
62
|
+
const opts = parseServeArgs(['serve', '--tunnel', '--port', '5050', '--db', '/tmp/b.db'])
|
|
63
|
+
expect(opts).toEqual({ db: '/tmp/b.db', port: 5050, tunnel: true })
|
|
64
|
+
})
|
|
65
|
+
})
|
|
7
66
|
|
|
8
67
|
describe('run', () => {
|
|
9
68
|
test('applies schema migration before task commands', async () => {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
58
|
-
)
|
|
59
|
-
legacy.run("INSERT INTO columns (id, name, position) VALUES ('c_backlog', 'backlog', 0)")
|
|
60
|
-
legacy.close()
|
|
69
|
+
await withTempDb(async (dbPath) => {
|
|
70
|
+
const legacy = new Database(dbPath)
|
|
71
|
+
legacy.run(
|
|
72
|
+
`CREATE TABLE columns (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
name TEXT UNIQUE NOT NULL,
|
|
75
|
+
position INTEGER NOT NULL,
|
|
76
|
+
color TEXT,
|
|
77
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
78
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
79
|
+
)`,
|
|
80
|
+
)
|
|
81
|
+
legacy.run(
|
|
82
|
+
`CREATE TABLE tasks (
|
|
83
|
+
id TEXT PRIMARY KEY,
|
|
84
|
+
title TEXT NOT NULL,
|
|
85
|
+
description TEXT NOT NULL DEFAULT '',
|
|
86
|
+
column_id TEXT NOT NULL,
|
|
87
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
88
|
+
priority TEXT NOT NULL DEFAULT 'medium',
|
|
89
|
+
assignee TEXT NOT NULL DEFAULT '',
|
|
90
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
91
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
92
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
93
|
+
)`,
|
|
94
|
+
)
|
|
95
|
+
legacy.run(
|
|
96
|
+
`CREATE TABLE activity_log (
|
|
97
|
+
id TEXT PRIMARY KEY,
|
|
98
|
+
task_id TEXT NOT NULL,
|
|
99
|
+
action TEXT NOT NULL,
|
|
100
|
+
field_changed TEXT,
|
|
101
|
+
old_value TEXT,
|
|
102
|
+
new_value TEXT,
|
|
103
|
+
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
104
|
+
)`,
|
|
105
|
+
)
|
|
106
|
+
legacy.run(
|
|
107
|
+
`CREATE TABLE column_time_tracking (
|
|
108
|
+
id TEXT PRIMARY KEY,
|
|
109
|
+
task_id TEXT NOT NULL,
|
|
110
|
+
column_id TEXT NOT NULL,
|
|
111
|
+
entered_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
112
|
+
exited_at TEXT
|
|
113
|
+
)`,
|
|
114
|
+
)
|
|
115
|
+
legacy.run("INSERT INTO columns (id, name, position) VALUES ('c_backlog', 'backlog', 0)")
|
|
116
|
+
legacy.close()
|
|
61
117
|
|
|
62
|
-
try {
|
|
63
118
|
const result = await run(['--db', dbPath, 'task', 'add', 'Migrated task'])
|
|
64
119
|
expect(result.exitCode).toBe(0)
|
|
65
120
|
expect(result.output.ok).toBe(true)
|
|
@@ -68,8 +123,130 @@ describe('run', () => {
|
|
|
68
123
|
const columns = verify.query('PRAGMA table_info(tasks)').all() as { name: string }[]
|
|
69
124
|
expect(columns.some((column) => column.name === 'project')).toBe(true)
|
|
70
125
|
verify.close()
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test('runs task lifecycle commands through the real CLI path', async () => {
|
|
130
|
+
await withTempDb(async (dbPath) => {
|
|
131
|
+
const created = expectOk<Task>(
|
|
132
|
+
await run([
|
|
133
|
+
'--db',
|
|
134
|
+
dbPath,
|
|
135
|
+
'task',
|
|
136
|
+
'add',
|
|
137
|
+
'Build feature',
|
|
138
|
+
'-d',
|
|
139
|
+
'Do the thing',
|
|
140
|
+
'-c',
|
|
141
|
+
'recurring',
|
|
142
|
+
'-p',
|
|
143
|
+
'high',
|
|
144
|
+
'-a',
|
|
145
|
+
'alice',
|
|
146
|
+
'--project',
|
|
147
|
+
'Platform',
|
|
148
|
+
'-m',
|
|
149
|
+
'{"sprint":5}',
|
|
150
|
+
]),
|
|
151
|
+
)
|
|
152
|
+
expect(created.title).toBe('Build feature')
|
|
153
|
+
expect(created.description).toBe('Do the thing')
|
|
154
|
+
expect(created.priority).toBe('high')
|
|
155
|
+
expect(created.assignee).toBe('alice')
|
|
156
|
+
expect(created.project).toBe('Platform')
|
|
157
|
+
expect(created.metadata).toBe('{"sprint":5}')
|
|
158
|
+
|
|
159
|
+
const listed = expectOk<Task[]>(
|
|
160
|
+
await run(['--db', dbPath, 'task', 'list', '-c', 'recurring']),
|
|
161
|
+
)
|
|
162
|
+
expect(listed).toHaveLength(1)
|
|
163
|
+
expect(listed[0]!.id).toBe(created.id)
|
|
164
|
+
|
|
165
|
+
const viewed = expectOk<Task>(await run(['--db', dbPath, 'task', 'view', created.id]))
|
|
166
|
+
expect(viewed.id).toBe(created.id)
|
|
167
|
+
|
|
168
|
+
const updated = expectOk<Task>(
|
|
169
|
+
await run([
|
|
170
|
+
'--db',
|
|
171
|
+
dbPath,
|
|
172
|
+
'task',
|
|
173
|
+
'update',
|
|
174
|
+
created.id,
|
|
175
|
+
'--title',
|
|
176
|
+
'Modified feature',
|
|
177
|
+
'-d',
|
|
178
|
+
'Ship it',
|
|
179
|
+
'-p',
|
|
180
|
+
'urgent',
|
|
181
|
+
'-a',
|
|
182
|
+
'bob',
|
|
183
|
+
'--project',
|
|
184
|
+
'Infra',
|
|
185
|
+
'-m',
|
|
186
|
+
'{"sprint":6}',
|
|
187
|
+
]),
|
|
188
|
+
)
|
|
189
|
+
expect(updated.title).toBe('Modified feature')
|
|
190
|
+
expect(updated.description).toBe('Ship it')
|
|
191
|
+
expect(updated.priority).toBe('urgent')
|
|
192
|
+
expect(updated.assignee).toBe('bob')
|
|
193
|
+
expect(updated.project).toBe('Infra')
|
|
194
|
+
expect(updated.metadata).toBe('{"sprint":6}')
|
|
195
|
+
|
|
196
|
+
const moved = expectOk<Task>(
|
|
197
|
+
await run(['--db', dbPath, 'task', 'move', created.id, 'in-progress']),
|
|
198
|
+
)
|
|
199
|
+
expect(moved.column_id).toBeTruthy()
|
|
200
|
+
|
|
201
|
+
const assigned = expectOk<Task>(
|
|
202
|
+
await run(['--db', dbPath, 'task', 'assign', created.id, 'carol']),
|
|
203
|
+
)
|
|
204
|
+
expect(assigned.assignee).toBe('carol')
|
|
205
|
+
|
|
206
|
+
const prioritized = expectOk<Task>(
|
|
207
|
+
await run(['--db', dbPath, 'task', 'prioritize', created.id, 'low']),
|
|
208
|
+
)
|
|
209
|
+
expect(prioritized.priority).toBe('low')
|
|
210
|
+
|
|
211
|
+
const deleted = expectOk<Task>(await run(['--db', dbPath, 'task', 'delete', created.id]))
|
|
212
|
+
expect(deleted.id).toBe(created.id)
|
|
213
|
+
|
|
214
|
+
await expectKanbanError(
|
|
215
|
+
run(['--db', dbPath, 'task', 'view', created.id]),
|
|
216
|
+
ErrorCode.TASK_NOT_FOUND,
|
|
217
|
+
)
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
test('lists and filters tasks through the CLI path', async () => {
|
|
222
|
+
await withTempDb(async (dbPath) => {
|
|
223
|
+
expectOk<Task>(await run(['--db', dbPath, 'task', 'add', 'A', '-c', 'recurring']))
|
|
224
|
+
expectOk<Task>(await run(['--db', dbPath, 'task', 'add', 'B', '-c', 'done']))
|
|
225
|
+
|
|
226
|
+
const allTasks = expectOk<Task[]>(await run(['--db', dbPath, 'task', 'list']))
|
|
227
|
+
expect(allTasks).toHaveLength(2)
|
|
228
|
+
|
|
229
|
+
const recurring = expectOk<Task[]>(
|
|
230
|
+
await run(['--db', dbPath, 'task', 'list', '-c', 'recurring']),
|
|
231
|
+
)
|
|
232
|
+
expect(recurring).toHaveLength(1)
|
|
233
|
+
expect(recurring[0]!.title).toBe('A')
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test('raises CLI errors for missing task arguments', async () => {
|
|
238
|
+
await withTempDb(async (dbPath) => {
|
|
239
|
+
const missingTitle = await expectKanbanError(
|
|
240
|
+
run(['--db', dbPath, 'task', 'add']),
|
|
241
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
242
|
+
)
|
|
243
|
+
expect(missingTitle.message).toContain('Task title is required')
|
|
244
|
+
|
|
245
|
+
const missingId = await expectKanbanError(
|
|
246
|
+
run(['--db', dbPath, 'task', 'view']),
|
|
247
|
+
ErrorCode.MISSING_ARGUMENT,
|
|
248
|
+
)
|
|
249
|
+
expect(missingId.message).toContain('Task ID is required')
|
|
250
|
+
})
|
|
74
251
|
})
|
|
75
252
|
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { adfToPlainText, plainTextToAdf, type AdfDocument } from '../providers/jira-adf.ts'
|
|
3
|
+
|
|
4
|
+
describe('plainTextToAdf / adfToPlainText', () => {
|
|
5
|
+
test('empty doc round-trip', () => {
|
|
6
|
+
const doc = plainTextToAdf('')
|
|
7
|
+
expect(doc).toEqual({ version: 1, type: 'doc', content: [] })
|
|
8
|
+
expect(adfToPlainText(doc)).toBe('')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('single paragraph round-trip', () => {
|
|
12
|
+
const input = 'hello world'
|
|
13
|
+
const doc = plainTextToAdf(input)
|
|
14
|
+
expect(doc).toEqual({
|
|
15
|
+
version: 1,
|
|
16
|
+
type: 'doc',
|
|
17
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'hello world' }] }],
|
|
18
|
+
})
|
|
19
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('multi-paragraph round-trip (blank-line separated)', () => {
|
|
23
|
+
const input = 'first paragraph\n\nsecond paragraph\n\nthird'
|
|
24
|
+
const doc = plainTextToAdf(input)
|
|
25
|
+
expect(doc.content).toHaveLength(3)
|
|
26
|
+
expect(doc.content[0]).toEqual({
|
|
27
|
+
type: 'paragraph',
|
|
28
|
+
content: [{ type: 'text', text: 'first paragraph' }],
|
|
29
|
+
})
|
|
30
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('bullet list round-trip (- and * both accepted, - emitted)', () => {
|
|
34
|
+
const input = '- one\n* two\n- three'
|
|
35
|
+
const doc = plainTextToAdf(input)
|
|
36
|
+
expect(doc.content).toHaveLength(1)
|
|
37
|
+
const list = doc.content[0] as { type: string; content: unknown[] }
|
|
38
|
+
expect(list.type).toBe('bulletList')
|
|
39
|
+
expect(list.content).toHaveLength(3)
|
|
40
|
+
// Output always uses `- `.
|
|
41
|
+
expect(adfToPlainText(doc)).toBe('- one\n- two\n- three')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('ordered list round-trip starting at 1', () => {
|
|
45
|
+
const input = '1. first\n2. second\n3. third'
|
|
46
|
+
const doc = plainTextToAdf(input)
|
|
47
|
+
const list = doc.content[0] as {
|
|
48
|
+
type: string
|
|
49
|
+
attrs?: { order?: number }
|
|
50
|
+
content: unknown[]
|
|
51
|
+
}
|
|
52
|
+
expect(list.type).toBe('orderedList')
|
|
53
|
+
expect(list.attrs).toBeUndefined()
|
|
54
|
+
expect(list.content).toHaveLength(3)
|
|
55
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('ordered list preserves non-default attrs.order', () => {
|
|
59
|
+
const input = '5. fifth\n6. sixth'
|
|
60
|
+
const doc = plainTextToAdf(input)
|
|
61
|
+
const list = doc.content[0] as {
|
|
62
|
+
type: string
|
|
63
|
+
attrs?: { order?: number }
|
|
64
|
+
}
|
|
65
|
+
expect(list.type).toBe('orderedList')
|
|
66
|
+
expect(list.attrs?.order).toBe(5)
|
|
67
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('fenced code block round-trip without language', () => {
|
|
71
|
+
const input = '```\nconst x = 1\nconst y = 2\n```'
|
|
72
|
+
const doc = plainTextToAdf(input)
|
|
73
|
+
const code = doc.content[0] as {
|
|
74
|
+
type: string
|
|
75
|
+
attrs?: { language?: string }
|
|
76
|
+
}
|
|
77
|
+
expect(code.type).toBe('codeBlock')
|
|
78
|
+
expect(code.attrs).toBeUndefined()
|
|
79
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('fenced code block round-trip with language tag', () => {
|
|
83
|
+
const input = '```ts\nconst x: number = 1\n```'
|
|
84
|
+
const doc = plainTextToAdf(input)
|
|
85
|
+
const code = doc.content[0] as {
|
|
86
|
+
type: string
|
|
87
|
+
attrs?: { language?: string }
|
|
88
|
+
}
|
|
89
|
+
expect(code.type).toBe('codeBlock')
|
|
90
|
+
expect(code.attrs?.language).toBe('ts')
|
|
91
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('mixed content (paragraph + list + code block) round-trip', () => {
|
|
95
|
+
const input = 'intro paragraph\n\n- a\n- b\n\n```js\nconsole.log(1)\n```\n\nouttro'
|
|
96
|
+
const doc = plainTextToAdf(input)
|
|
97
|
+
expect(doc.content).toHaveLength(4)
|
|
98
|
+
expect(doc.content[0]?.type).toBe('paragraph')
|
|
99
|
+
expect(doc.content[1]?.type).toBe('bulletList')
|
|
100
|
+
expect(doc.content[2]?.type).toBe('codeBlock')
|
|
101
|
+
expect(doc.content[3]?.type).toBe('paragraph')
|
|
102
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('heading flattened to plain text (read path)', () => {
|
|
106
|
+
const doc: AdfDocument = {
|
|
107
|
+
version: 1,
|
|
108
|
+
type: 'doc',
|
|
109
|
+
content: [
|
|
110
|
+
{
|
|
111
|
+
type: 'heading',
|
|
112
|
+
attrs: { level: 2 },
|
|
113
|
+
content: [{ type: 'text', text: 'Big Title' }],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
type: 'paragraph',
|
|
117
|
+
content: [{ type: 'text', text: 'body' }],
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
}
|
|
121
|
+
// Heading renders as bare inner text (no `#` prefix).
|
|
122
|
+
expect(adfToPlainText(doc)).toBe('Big Title\n\nbody')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('unknown block node type is gracefully skipped, no throw', () => {
|
|
126
|
+
const doc: AdfDocument = {
|
|
127
|
+
version: 1,
|
|
128
|
+
type: 'doc',
|
|
129
|
+
content: [
|
|
130
|
+
{
|
|
131
|
+
type: 'paragraph',
|
|
132
|
+
content: [{ type: 'text', text: 'before' }],
|
|
133
|
+
},
|
|
134
|
+
{ type: 'mediaSingle', attrs: { layout: 'center' } },
|
|
135
|
+
{
|
|
136
|
+
type: 'paragraph',
|
|
137
|
+
content: [{ type: 'text', text: 'after' }],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
}
|
|
141
|
+
expect(() => adfToPlainText(doc)).not.toThrow()
|
|
142
|
+
expect(adfToPlainText(doc)).toBe('before\n\nafter')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('inline text marks are stripped on read', () => {
|
|
146
|
+
const doc: AdfDocument = {
|
|
147
|
+
version: 1,
|
|
148
|
+
type: 'doc',
|
|
149
|
+
content: [
|
|
150
|
+
{
|
|
151
|
+
type: 'paragraph',
|
|
152
|
+
content: [
|
|
153
|
+
{ type: 'text', text: 'bold', marks: [{ type: 'strong' }] },
|
|
154
|
+
{ type: 'text', text: ' normal' },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
}
|
|
159
|
+
expect(adfToPlainText(doc)).toBe('bold normal')
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test('bullet item containing digits-dot substring is still a bullet', () => {
|
|
163
|
+
const input = '- item containing 1. something'
|
|
164
|
+
const doc = plainTextToAdf(input)
|
|
165
|
+
expect(doc.content[0]?.type).toBe('bulletList')
|
|
166
|
+
expect(adfToPlainText(doc)).toBe(input)
|
|
167
|
+
})
|
|
168
|
+
})
|