@sqaoss/flowy 1.4.0 → 1.6.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.
@@ -0,0 +1,287 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
+
3
+ vi.mock('../util/client.ts', () => ({
4
+ graphql: vi.fn(),
5
+ }))
6
+
7
+ vi.mock('../util/format.ts', () => ({
8
+ output: vi.fn(),
9
+ outputError: vi.fn(),
10
+ }))
11
+
12
+ vi.mock('node:fs', () => ({
13
+ readFileSync: vi.fn(),
14
+ }))
15
+
16
+ const FLOWY_KEY_FIELD = '__flowyKey'
17
+
18
+ /** A small representative backlog: 1 project, 1 feature, 2 tasks, 1 blocks edge. */
19
+ const MANIFEST = {
20
+ version: 1,
21
+ nodes: [
22
+ { key: 'proj', type: 'project', title: 'Demo' },
23
+ { key: 'feat-1', type: 'feature', title: 'Auth', parent: 'proj' },
24
+ {
25
+ key: 'task-1',
26
+ type: 'task',
27
+ title: 'Login',
28
+ parent: 'feat-1',
29
+ status: 'draft',
30
+ metadata: { priority: 'high' },
31
+ },
32
+ { key: 'task-2', type: 'task', title: 'Logout', parent: 'feat-1' },
33
+ ],
34
+ edges: [{ source: 'task-2', target: 'task-1', relation: 'blocks' }],
35
+ }
36
+
37
+ /** Parse the JSON metadata string a CLI mutation sent to the server. */
38
+ function metaOf(variables: Record<string, unknown>): Record<string, unknown> {
39
+ return JSON.parse(variables.metadata as string)
40
+ }
41
+
42
+ describe('import command', () => {
43
+ beforeEach(() => {
44
+ vi.clearAllMocks()
45
+ })
46
+
47
+ test('exports a single-argument import command', async () => {
48
+ const { importCommand } = await import('./import.ts')
49
+ expect(importCommand.name()).toBe('import')
50
+ })
51
+
52
+ test('metadata carries only the client-key, never edge data', async () => {
53
+ const { graphql } = await import('../util/client.ts')
54
+ const { readFileSync } = await import('node:fs')
55
+ const { importCommand } = await import('./import.ts')
56
+
57
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify(MANIFEST))
58
+ vi.mocked(graphql).mockImplementation(async (query: string) => {
59
+ if (query.includes('nodes(')) return { nodes: [] }
60
+ if (query.includes('createNode')) return { createNode: { id: 'srv_x' } }
61
+ if (query.includes('createEdge')) return { createEdge: {} }
62
+ return {}
63
+ })
64
+
65
+ await importCommand.parseAsync(['manifest.json'], { from: 'user' })
66
+
67
+ for (const [q, vars] of vi.mocked(graphql).mock.calls) {
68
+ if (!q.includes('createNode')) continue
69
+ const meta = metaOf(vars ?? {})
70
+ expect(typeof meta[FLOWY_KEY_FIELD]).toBe('string')
71
+ // The dropped edge-stamp hack must not reappear in any form.
72
+ expect(meta.__flowy).toBeUndefined()
73
+ expect(meta.edges).toBeUndefined()
74
+ }
75
+ })
76
+
77
+ test('first run: creates every node and edge, returns a key→id map', async () => {
78
+ const { graphql } = await import('../util/client.ts')
79
+ const { output } = await import('../util/format.ts')
80
+ const { readFileSync } = await import('node:fs')
81
+ const { importCommand } = await import('./import.ts')
82
+
83
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify(MANIFEST))
84
+
85
+ // No existing nodes for any type.
86
+ vi.mocked(graphql).mockImplementation(
87
+ async (query: string, variables?: Record<string, unknown>) => {
88
+ if (query.includes('nodes(')) return { nodes: [] }
89
+ if (query.includes('createNode')) {
90
+ const key = metaOf(variables ?? {})[FLOWY_KEY_FIELD] as string
91
+ return { createNode: { id: `srv_${key}` } }
92
+ }
93
+ if (query.includes('updateNode')) {
94
+ return { updateNode: { id: variables?.id } }
95
+ }
96
+ // No pre-existing nodes → the existing-edges query should never run.
97
+ if (query.includes('edges(')) {
98
+ throw new Error(
99
+ 'edges() should not be queried with no existing nodes',
100
+ )
101
+ }
102
+ if (query.includes('createEdge')) return { createEdge: {} }
103
+ return {}
104
+ },
105
+ )
106
+
107
+ await importCommand.parseAsync(['manifest.json'], { from: 'user' })
108
+
109
+ const calls = vi.mocked(graphql).mock.calls
110
+ const created = calls.filter(([q]) => q.includes('createNode'))
111
+ const updated = calls.filter(([q]) => q.includes('updateNode'))
112
+ const edges = calls.filter(([q]) => q.includes('createEdge'))
113
+
114
+ // All 4 nodes created, none updated on a clean import.
115
+ expect(created).toHaveLength(4)
116
+ expect(updated).toHaveLength(0)
117
+
118
+ // part_of edges (feat-1→proj, task-1→feat-1, task-2→feat-1) + 1 blocks = 4.
119
+ expect(edges).toHaveLength(4)
120
+
121
+ // Output is the key→id map.
122
+ const mapArg = vi.mocked(output).mock.calls.at(-1)?.[0] as {
123
+ map: Record<string, string>
124
+ }
125
+ expect(mapArg.map).toMatchObject({
126
+ proj: 'srv_proj',
127
+ 'feat-1': 'srv_feat-1',
128
+ 'task-1': 'srv_task-1',
129
+ 'task-2': 'srv_task-2',
130
+ })
131
+ })
132
+
133
+ test('re-import is idempotent: existing keys update, never re-create', async () => {
134
+ const { graphql } = await import('../util/client.ts')
135
+ const { readFileSync } = await import('node:fs')
136
+ const { importCommand } = await import('./import.ts')
137
+
138
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify(MANIFEST))
139
+
140
+ // Every manifest node already exists (carrying its client-key), and every
141
+ // edge already exists in the real edge model.
142
+ const existing = (type: string) => {
143
+ const byType: Record<string, Array<Record<string, unknown>>> = {
144
+ project: [node('proj', 'srv_proj')],
145
+ feature: [node('feat-1', 'srv_feat-1')],
146
+ task: [node('task-1', 'srv_task-1'), node('task-2', 'srv_task-2')],
147
+ }
148
+ return byType[type] ?? []
149
+ }
150
+ const edgesOf = (nodeId: string, relation: string) =>
151
+ EDGES.filter((e) => e.source === nodeId && e.relation === relation).map(
152
+ (e) => ({ id: e.target }),
153
+ )
154
+
155
+ vi.mocked(graphql).mockImplementation(
156
+ async (query: string, variables?: Record<string, unknown>) => {
157
+ if (query.includes('nodes(')) {
158
+ return { nodes: existing(variables?.type as string) }
159
+ }
160
+ if (query.includes('edges(')) {
161
+ return {
162
+ edges: edgesOf(
163
+ variables?.nodeId as string,
164
+ variables?.relation as string,
165
+ ),
166
+ }
167
+ }
168
+ if (query.includes('updateNode')) {
169
+ return { updateNode: { id: variables?.id } }
170
+ }
171
+ if (query.includes('createNode')) {
172
+ throw new Error('createNode must not be called on re-import')
173
+ }
174
+ if (query.includes('createEdge')) return { createEdge: {} }
175
+ return {}
176
+ },
177
+ )
178
+
179
+ await importCommand.parseAsync(['manifest.json'], { from: 'user' })
180
+
181
+ const calls = vi.mocked(graphql).mock.calls
182
+ const created = calls.filter(([q]) => q.includes('createNode'))
183
+ const updated = calls.filter(([q]) => q.includes('updateNode'))
184
+ const edges = calls.filter(([q]) => q.includes('createEdge'))
185
+
186
+ // No node is re-created; all four are updated in place.
187
+ expect(created).toHaveLength(0)
188
+ expect(updated).toHaveLength(4)
189
+
190
+ // Every edge already exists in the edge model → none re-created.
191
+ expect(edges).toHaveLength(0)
192
+ })
193
+
194
+ test('does not re-create an edge that already exists in the edge model', async () => {
195
+ const { graphql } = await import('../util/client.ts')
196
+ const { readFileSync } = await import('node:fs')
197
+ const { importCommand } = await import('./import.ts')
198
+
199
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify(MANIFEST))
200
+
201
+ const existing = (type: string) => {
202
+ const byType: Record<string, Array<Record<string, unknown>>> = {
203
+ project: [node('proj', 'srv_proj')],
204
+ feature: [node('feat-1', 'srv_feat-1')],
205
+ task: [node('task-1', 'srv_task-1'), node('task-2', 'srv_task-2')],
206
+ }
207
+ return byType[type] ?? []
208
+ }
209
+ // Only the part_of edges exist server-side; the blocks edge does not.
210
+ const present = EDGES.filter((e) => e.relation === 'part_of')
211
+ const edgesOf = (nodeId: string, relation: string) =>
212
+ present
213
+ .filter((e) => e.source === nodeId && e.relation === relation)
214
+ .map((e) => ({ id: e.target }))
215
+
216
+ vi.mocked(graphql).mockImplementation(
217
+ async (query: string, variables?: Record<string, unknown>) => {
218
+ if (query.includes('nodes(')) {
219
+ return { nodes: existing(variables?.type as string) }
220
+ }
221
+ if (query.includes('edges(')) {
222
+ return {
223
+ edges: edgesOf(
224
+ variables?.nodeId as string,
225
+ variables?.relation as string,
226
+ ),
227
+ }
228
+ }
229
+ if (query.includes('updateNode')) {
230
+ return { updateNode: { id: variables?.id } }
231
+ }
232
+ if (query.includes('createEdge')) return { createEdge: {} }
233
+ return {}
234
+ },
235
+ )
236
+
237
+ await importCommand.parseAsync(['manifest.json'], { from: 'user' })
238
+
239
+ const edges = vi
240
+ .mocked(graphql)
241
+ .mock.calls.filter(([q]) => q.includes('createEdge'))
242
+ expect(edges).toHaveLength(1)
243
+ expect(edges[0]?.[1]).toMatchObject({
244
+ sourceId: 'srv_task-2',
245
+ targetId: 'srv_task-1',
246
+ relation: 'blocks',
247
+ })
248
+ })
249
+
250
+ test('reports a parse error via outputError', async () => {
251
+ const { outputError } = await import('../util/format.ts')
252
+ const { readFileSync } = await import('node:fs')
253
+ const { importCommand } = await import('./import.ts')
254
+
255
+ vi.mocked(readFileSync).mockReturnValue('{not json')
256
+
257
+ await importCommand.parseAsync(['bad.json'], { from: 'user' })
258
+
259
+ expect(outputError).toHaveBeenCalledWith(
260
+ expect.objectContaining({
261
+ message: expect.stringMatching(/invalid json/i),
262
+ }),
263
+ )
264
+ })
265
+ })
266
+
267
+ /** The full edge set this manifest implies, expressed in SERVER ids. */
268
+ const EDGES = [
269
+ { source: 'srv_feat-1', target: 'srv_proj', relation: 'part_of' },
270
+ { source: 'srv_task-1', target: 'srv_feat-1', relation: 'part_of' },
271
+ { source: 'srv_task-2', target: 'srv_feat-1', relation: 'part_of' },
272
+ { source: 'srv_task-2', target: 'srv_task-1', relation: 'blocks' },
273
+ ]
274
+
275
+ /**
276
+ * Build an existing server node row stamped with its client-key only.
277
+ * Edges are NOT stored in metadata — they live in the real edge model and are
278
+ * mocked separately via the `edges()` query.
279
+ */
280
+ function node(key: string, id: string): Record<string, unknown> {
281
+ return {
282
+ id,
283
+ type: 'x',
284
+ title: key,
285
+ metadata: JSON.stringify({ [FLOWY_KEY_FIELD]: key }),
286
+ }
287
+ }
@@ -0,0 +1,181 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { Command } from 'commander'
3
+ import { graphql } from '../util/client.ts'
4
+ import { output, outputError } from '../util/format.ts'
5
+ import {
6
+ buildNodeMetadata,
7
+ type Manifest,
8
+ type ManifestNode,
9
+ parseManifest,
10
+ readClientKey,
11
+ } from '../util/manifest.ts'
12
+
13
+ const NODE_TYPES = ['project', 'feature', 'task'] as const
14
+
15
+ /** Relations import materializes; existing-edge dedup queries each of these. */
16
+ const RELATIONS = ['part_of', 'blocks'] as const
17
+
18
+ interface ServerNode {
19
+ id: string
20
+ type: string
21
+ title: string
22
+ metadata: string | null
23
+ }
24
+
25
+ interface DesiredEdge {
26
+ sourceKey: string
27
+ targetKey: string
28
+ relation: string
29
+ }
30
+
31
+ function edgeKey(source: string, target: string, relation: string): string {
32
+ return `${source}|${target}|${relation}`
33
+ }
34
+
35
+ /** All edges the manifest implies: implicit `part_of` from `parent` + explicit edges. */
36
+ function desiredEdges(manifest: Manifest): DesiredEdge[] {
37
+ const edges: DesiredEdge[] = []
38
+ const seen = new Set<string>()
39
+ const add = (sourceKey: string, targetKey: string, relation: string) => {
40
+ const k = edgeKey(sourceKey, targetKey, relation)
41
+ if (seen.has(k)) return
42
+ seen.add(k)
43
+ edges.push({ sourceKey, targetKey, relation })
44
+ }
45
+ for (const node of manifest.nodes) {
46
+ if (node.parent) add(node.key, node.parent, 'part_of')
47
+ }
48
+ for (const e of manifest.edges) add(e.source, e.target, e.relation)
49
+ return edges
50
+ }
51
+
52
+ /** Read every existing node, mapping its stored client-key to its server id. */
53
+ async function loadExisting(): Promise<Map<string, string>> {
54
+ const idByKey = new Map<string, string>()
55
+ for (const type of NODE_TYPES) {
56
+ const data = await graphql<{ nodes: ServerNode[] }>(
57
+ `query ImportExisting($type: String) {
58
+ nodes(type: $type) { id type title metadata }
59
+ }`,
60
+ { type },
61
+ )
62
+ for (const node of data.nodes) {
63
+ const key = readClientKey(node.metadata)
64
+ if (key) idByKey.set(key, node.id)
65
+ }
66
+ }
67
+ return idByKey
68
+ }
69
+
70
+ /**
71
+ * Collect the edges that already exist for the given nodes, as a set of
72
+ * `<sourceId>|<targetId>|<relation>` triples. Read back through the real edge
73
+ * model (`Query.edges`), so externally-created edges (e.g. `task block`) are
74
+ * recognized and never duplicated.
75
+ */
76
+ async function loadExistingEdges(nodeIds: string[]): Promise<Set<string>> {
77
+ const existing = new Set<string>()
78
+ for (const nodeId of nodeIds) {
79
+ for (const relation of RELATIONS) {
80
+ const data = await graphql<{ edges: Array<{ id: string }> }>(
81
+ `query ImportEdges($nodeId: String!, $relation: String!) {
82
+ edges(nodeId: $nodeId, relation: $relation, direction: "outgoing") { id }
83
+ }`,
84
+ { nodeId, relation },
85
+ )
86
+ for (const target of data.edges) {
87
+ existing.add(edgeKey(nodeId, target.id, relation))
88
+ }
89
+ }
90
+ }
91
+ return existing
92
+ }
93
+
94
+ const CREATE_NODE = `mutation ImportCreate($type: String!, $title: String!, $description: String, $status: String, $metadata: String) {
95
+ createNode(type: $type, title: $title, description: $description, status: $status, metadata: $metadata) { id }
96
+ }`
97
+
98
+ const UPDATE_NODE = `mutation ImportUpdate($id: String!, $title: String, $description: String, $status: String, $metadata: String) {
99
+ updateNode(id: $id, title: $title, description: $description, status: $status, metadata: $metadata) { id }
100
+ }`
101
+
102
+ const CREATE_EDGE = `mutation ImportEdge($sourceId: String!, $targetId: String!, $relation: String!) {
103
+ createEdge(sourceId: $sourceId, targetId: $targetId, relation: $relation) { sourceId targetId relation }
104
+ }`
105
+
106
+ async function upsertNode(
107
+ node: ManifestNode,
108
+ existingId: string | undefined,
109
+ ): Promise<string> {
110
+ const metadata = buildNodeMetadata(node.key, node.metadata)
111
+ if (existingId) {
112
+ await graphql<{ updateNode: { id: string } }>(UPDATE_NODE, {
113
+ id: existingId,
114
+ title: node.title,
115
+ description: node.description ?? null,
116
+ status: node.status ?? null,
117
+ metadata,
118
+ })
119
+ return existingId
120
+ }
121
+ const data = await graphql<{ createNode: { id: string } }>(CREATE_NODE, {
122
+ type: node.type,
123
+ title: node.title,
124
+ description: node.description ?? null,
125
+ status: node.status ?? null,
126
+ metadata,
127
+ })
128
+ return data.createNode.id
129
+ }
130
+
131
+ export const importCommand = new Command('import')
132
+ .description(
133
+ 'Ingest a manifest of projects/features/tasks + edges (idempotent by client-key)',
134
+ )
135
+ .argument('<manifest>', 'Path to a JSON manifest file')
136
+ .action(async (manifestPath: string) => {
137
+ try {
138
+ const manifest = parseManifest(readFileSync(manifestPath, 'utf-8'))
139
+ const existing = await loadExisting()
140
+
141
+ // Pass 1 — upsert every node, stamping its client-key into metadata.
142
+ // Known keys update, new keys create, so a re-import never duplicates.
143
+ const idByKey = new Map<string, string>()
144
+ for (const node of manifest.nodes) {
145
+ idByKey.set(node.key, await upsertNode(node, existing.get(node.key)))
146
+ }
147
+
148
+ // Dedup against edges that already exist server-side. Only nodes that
149
+ // pre-existed this import can already have edges, so query just those.
150
+ const preExistingIds = manifest.nodes
151
+ .filter((n) => existing.has(n.key))
152
+ .map((n) => idByKey.get(n.key))
153
+ .filter((id): id is string => id != null)
154
+ const present = await loadExistingEdges(preExistingIds)
155
+
156
+ // Pass 2 — materialize edges, deduped by (source,target,relation). Skip
157
+ // any edge that already exists so the non-idempotent createEdge is never
158
+ // asked to re-link.
159
+ let edgeCount = 0
160
+ for (const edge of desiredEdges(manifest)) {
161
+ const sourceId = idByKey.get(edge.sourceKey)
162
+ const targetId = idByKey.get(edge.targetKey)
163
+ if (!sourceId || !targetId) continue
164
+ if (present.has(edgeKey(sourceId, targetId, edge.relation))) continue
165
+ await graphql(CREATE_EDGE, {
166
+ sourceId,
167
+ targetId,
168
+ relation: edge.relation,
169
+ })
170
+ edgeCount++
171
+ }
172
+
173
+ output({
174
+ imported: idByKey.size,
175
+ edges: edgeCount,
176
+ map: Object.fromEntries(idByKey),
177
+ })
178
+ } catch (error) {
179
+ outputError(error)
180
+ }
181
+ })
@@ -6,6 +6,7 @@ vi.mock('../util/config.ts', () => ({
6
6
  'No active feature. Run "flowy feature set <name-or-id>" or set FLOWY_FEATURE.',
7
7
  )
8
8
  }),
9
+ resolveProject: vi.fn(() => ({ id: 'proj_active', name: 'active' })),
9
10
  }))
10
11
 
11
12
  vi.mock('../util/client.ts', () => ({
@@ -22,10 +23,10 @@ describe('task command', () => {
22
23
  vi.clearAllMocks()
23
24
  })
24
25
 
25
- test('exports a command group with 7 subcommands', async () => {
26
+ test('exports a command group with 8 subcommands', async () => {
26
27
  const { taskCommand } = await import('./task.ts')
27
28
  expect(taskCommand.name()).toBe('task')
28
- expect(taskCommand.commands).toHaveLength(7)
29
+ expect(taskCommand.commands).toHaveLength(8)
29
30
 
30
31
  const names = taskCommand.commands.map((c) => c.name())
31
32
  expect(names).toContain('create')
@@ -35,6 +36,7 @@ describe('task command', () => {
35
36
  expect(names).toContain('unblock')
36
37
  expect(names).toContain('update')
37
38
  expect(names).toContain('delete')
39
+ expect(names).toContain('deps')
38
40
  })
39
41
 
40
42
  test('create exposes both --description and --description-file options', async () => {
@@ -255,4 +257,140 @@ describe('task command', () => {
255
257
  expect.objectContaining({ code: 'NOT_FOUND' }),
256
258
  )
257
259
  })
260
+
261
+ test('show includes blockedBy and blocks in its output', async () => {
262
+ const { graphql } = await import('../util/client.ts')
263
+ const { output } = await import('../util/format.ts')
264
+ const { taskCommand } = await import('./task.ts')
265
+
266
+ vi.mocked(graphql).mockResolvedValueOnce({
267
+ node: { id: 'task_a', title: 'A', status: 'draft' },
268
+ blockedBy: [{ id: 'task_b', title: 'B', status: 'in_progress' }],
269
+ blocks: [{ id: 'task_c', title: 'C', status: 'draft' }],
270
+ })
271
+
272
+ const showCmd = taskCommand.commands.find((c) => c.name() === 'show')!
273
+ await showCmd.parseAsync(['task_a'], { from: 'user' })
274
+
275
+ expect(output).toHaveBeenCalledWith(
276
+ expect.objectContaining({
277
+ id: 'task_a',
278
+ blockedBy: [{ id: 'task_b', title: 'B', status: 'in_progress' }],
279
+ blocks: [{ id: 'task_c', title: 'C', status: 'draft' }],
280
+ }),
281
+ )
282
+ })
283
+
284
+ test('show queries edges for both directions of blocks', async () => {
285
+ const { graphql } = await import('../util/client.ts')
286
+ const { taskCommand } = await import('./task.ts')
287
+
288
+ vi.mocked(graphql).mockResolvedValueOnce({
289
+ node: { id: 'task_a', title: 'A', status: 'draft' },
290
+ blockedBy: [],
291
+ blocks: [],
292
+ })
293
+
294
+ const showCmd = taskCommand.commands.find((c) => c.name() === 'show')!
295
+ await showCmd.parseAsync(['task_a'], { from: 'user' })
296
+
297
+ const [query, variables] = vi.mocked(graphql).mock.calls[0]!
298
+ expect(query).toContain('blockedBy')
299
+ expect(query).toContain('blocks')
300
+ expect(variables).toMatchObject({ id: 'task_a' })
301
+ })
302
+
303
+ test('deps lists blockedBy and blocks for a task', async () => {
304
+ const { graphql } = await import('../util/client.ts')
305
+ const { output } = await import('../util/format.ts')
306
+ const { taskCommand } = await import('./task.ts')
307
+
308
+ vi.mocked(graphql).mockResolvedValueOnce({
309
+ blockedBy: [{ id: 'task_b', title: 'B', status: 'draft' }],
310
+ blocks: [{ id: 'task_c', title: 'C', status: 'done' }],
311
+ })
312
+
313
+ const depsCmd = taskCommand.commands.find((c) => c.name() === 'deps')!
314
+ await depsCmd.parseAsync(['task_a'], { from: 'user' })
315
+
316
+ expect(output).toHaveBeenCalledWith({
317
+ id: 'task_a',
318
+ blockedBy: [{ id: 'task_b', title: 'B', status: 'draft' }],
319
+ blocks: [{ id: 'task_c', title: 'C', status: 'done' }],
320
+ })
321
+ })
322
+
323
+ test('deps surfaces errors via outputError', async () => {
324
+ const { graphql } = await import('../util/client.ts')
325
+ const { outputError } = await import('../util/format.ts')
326
+ const { taskCommand } = await import('./task.ts')
327
+
328
+ vi.mocked(graphql).mockRejectedValueOnce(new Error('Node task_a not found'))
329
+
330
+ const depsCmd = taskCommand.commands.find((c) => c.name() === 'deps')!
331
+ await depsCmd.parseAsync(['task_a'], { from: 'user' })
332
+
333
+ expect(outputError).toHaveBeenCalledWith(
334
+ expect.objectContaining({ message: 'Node task_a not found' }),
335
+ )
336
+ })
337
+
338
+ test('list --ready queries readyTasks and prints them', async () => {
339
+ const { graphql } = await import('../util/client.ts')
340
+ const { output } = await import('../util/format.ts')
341
+ const { taskCommand } = await import('./task.ts')
342
+
343
+ vi.mocked(graphql).mockResolvedValueOnce({
344
+ readyTasks: [{ id: 'task_x', title: 'X', status: 'draft' }],
345
+ })
346
+
347
+ const listCmd = taskCommand.commands.find((c) => c.name() === 'list')!
348
+ await listCmd.parseAsync(['--ready'], { from: 'user' })
349
+
350
+ const [query] = vi.mocked(graphql).mock.calls[0]!
351
+ expect(query).toContain('readyTasks')
352
+ expect(output).toHaveBeenCalledWith([
353
+ { id: 'task_x', title: 'X', status: 'draft' },
354
+ ])
355
+ })
356
+
357
+ test('list --ready --project scopes readyTasks to the given project', async () => {
358
+ const { graphql } = await import('../util/client.ts')
359
+ const { taskCommand } = await import('./task.ts')
360
+
361
+ vi.mocked(graphql).mockResolvedValueOnce({ readyTasks: [] })
362
+
363
+ const listCmd = taskCommand.commands.find((c) => c.name() === 'list')!
364
+ await listCmd.parseAsync(['--ready', '--project', 'proj_42'], {
365
+ from: 'user',
366
+ })
367
+
368
+ const [query, variables] = vi.mocked(graphql).mock.calls[0]!
369
+ expect(query).toContain('readyTasks')
370
+ expect(variables).toMatchObject({ projectId: 'proj_42' })
371
+ })
372
+
373
+ test('list --all lists every task node', async () => {
374
+ const { graphql } = await import('../util/client.ts')
375
+ const { output } = await import('../util/format.ts')
376
+ const { taskCommand } = await import('./task.ts')
377
+
378
+ vi.mocked(graphql).mockResolvedValueOnce({
379
+ nodes: [
380
+ { id: 'task_1', type: 'task', title: 'One', status: 'draft' },
381
+ { id: 'task_2', type: 'task', title: 'Two', status: 'done' },
382
+ ],
383
+ })
384
+
385
+ const listCmd = taskCommand.commands.find((c) => c.name() === 'list')!
386
+ await listCmd.parseAsync(['--all'], { from: 'user' })
387
+
388
+ const [query, variables] = vi.mocked(graphql).mock.calls[0]!
389
+ expect(query).toContain('nodes')
390
+ expect(variables).toMatchObject({ type: 'task' })
391
+ expect(output).toHaveBeenCalledWith([
392
+ { id: 'task_1', type: 'task', title: 'One', status: 'draft' },
393
+ { id: 'task_2', type: 'task', title: 'Two', status: 'done' },
394
+ ])
395
+ })
258
396
  })