@sqaoss/flowy 1.3.0 → 1.4.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.
@@ -1,4 +1,4 @@
1
- import { describe, expect, test, vi } from 'vitest'
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest'
2
2
 
3
3
  vi.mock('../util/config.ts', () => ({
4
4
  requireFeature: vi.fn(() => {
@@ -18,10 +18,14 @@ vi.mock('../util/format.ts', () => ({
18
18
  }))
19
19
 
20
20
  describe('task command', () => {
21
- test('exports a command group with 5 subcommands', async () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ test('exports a command group with 7 subcommands', async () => {
22
26
  const { taskCommand } = await import('./task.ts')
23
27
  expect(taskCommand.name()).toBe('task')
24
- expect(taskCommand.commands).toHaveLength(5)
28
+ expect(taskCommand.commands).toHaveLength(7)
25
29
 
26
30
  const names = taskCommand.commands.map((c) => c.name())
27
31
  expect(names).toContain('create')
@@ -29,6 +33,16 @@ describe('task command', () => {
29
33
  expect(names).toContain('show')
30
34
  expect(names).toContain('block')
31
35
  expect(names).toContain('unblock')
36
+ expect(names).toContain('update')
37
+ expect(names).toContain('delete')
38
+ })
39
+
40
+ test('create exposes both --description and --description-file options', async () => {
41
+ const { taskCommand } = await import('./task.ts')
42
+ const createCmd = taskCommand.commands.find((c) => c.name() === 'create')!
43
+ const optionFlags = createCmd.options.map((o) => o.long)
44
+ expect(optionFlags).toContain('--description')
45
+ expect(optionFlags).toContain('--description-file')
32
46
  })
33
47
 
34
48
  test('create calls outputError when no active feature', async () => {
@@ -80,4 +94,165 @@ describe('task command', () => {
80
94
  }),
81
95
  )
82
96
  })
97
+
98
+ test('update sends updateNode with only the title when title-only', async () => {
99
+ const { graphql } = await import('../util/client.ts')
100
+ const { output } = await import('../util/format.ts')
101
+ const { taskCommand } = await import('./task.ts')
102
+
103
+ vi.mocked(graphql).mockResolvedValueOnce({
104
+ updateNode: { id: 'task_1', title: 'New' },
105
+ })
106
+
107
+ const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
108
+ await updateCmd.parseAsync(['task_1', '--title', 'New'], { from: 'user' })
109
+
110
+ expect(graphql).toHaveBeenCalledWith(
111
+ expect.stringContaining('updateNode'),
112
+ { id: 'task_1', title: 'New' },
113
+ )
114
+ expect(output).toHaveBeenCalledWith({ id: 'task_1', title: 'New' })
115
+ })
116
+
117
+ test('update sends updateNode with only the description when description-only', async () => {
118
+ const { graphql } = await import('../util/client.ts')
119
+ const { taskCommand } = await import('./task.ts')
120
+
121
+ vi.mocked(graphql).mockResolvedValueOnce({
122
+ updateNode: { id: 'task_1' },
123
+ })
124
+
125
+ const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
126
+ await updateCmd.parseAsync(['task_1', '--description', 'Body'], {
127
+ from: 'user',
128
+ })
129
+
130
+ expect(graphql).toHaveBeenCalledWith(
131
+ expect.stringContaining('updateNode'),
132
+ {
133
+ id: 'task_1',
134
+ description: 'Body',
135
+ },
136
+ )
137
+ })
138
+
139
+ test('update sends updateNode with only the metadata when metadata-only', async () => {
140
+ const { graphql } = await import('../util/client.ts')
141
+ const { taskCommand } = await import('./task.ts')
142
+
143
+ vi.mocked(graphql).mockResolvedValueOnce({
144
+ updateNode: { id: 'task_1' },
145
+ })
146
+
147
+ const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
148
+ await updateCmd.parseAsync(['task_1', '--metadata', '{"k":"v"}'], {
149
+ from: 'user',
150
+ })
151
+
152
+ expect(graphql).toHaveBeenCalledWith(
153
+ expect.stringContaining('updateNode'),
154
+ {
155
+ id: 'task_1',
156
+ metadata: '{"k":"v"}',
157
+ },
158
+ )
159
+ })
160
+
161
+ test('update sends updateNode with combined fields', async () => {
162
+ const { graphql } = await import('../util/client.ts')
163
+ const { taskCommand } = await import('./task.ts')
164
+
165
+ vi.mocked(graphql).mockResolvedValueOnce({
166
+ updateNode: { id: 'task_1' },
167
+ })
168
+
169
+ const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
170
+ await updateCmd.parseAsync(
171
+ ['task_1', '--title', 'New', '--description', 'Body', '--metadata', '{}'],
172
+ { from: 'user' },
173
+ )
174
+
175
+ expect(graphql).toHaveBeenCalledWith(
176
+ expect.stringContaining('updateNode'),
177
+ {
178
+ id: 'task_1',
179
+ title: 'New',
180
+ description: 'Body',
181
+ metadata: '{}',
182
+ },
183
+ )
184
+ })
185
+
186
+ test('update surfaces NOT_FOUND via outputError with its code', async () => {
187
+ const { graphql } = await import('../util/client.ts')
188
+ const { outputError } = await import('../util/format.ts')
189
+ const { taskCommand } = await import('./task.ts')
190
+
191
+ const notFound = Object.assign(new Error('Node task_x not found'), {
192
+ code: 'NOT_FOUND',
193
+ })
194
+ vi.mocked(graphql).mockRejectedValueOnce(notFound)
195
+
196
+ const updateCmd = taskCommand.commands.find((c) => c.name() === 'update')!
197
+ await updateCmd.parseAsync(['task_x', '--title', 'New'], { from: 'user' })
198
+
199
+ expect(outputError).toHaveBeenCalledWith(
200
+ expect.objectContaining({ code: 'NOT_FOUND' }),
201
+ )
202
+ })
203
+
204
+ test('delete sends deleteNode mutation', async () => {
205
+ const { graphql } = await import('../util/client.ts')
206
+ const { output } = await import('../util/format.ts')
207
+ const { taskCommand } = await import('./task.ts')
208
+
209
+ vi.mocked(graphql).mockResolvedValueOnce({ deleteNode: true })
210
+
211
+ const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
212
+ await deleteCmd.parseAsync(['task_1'], { from: 'user' })
213
+
214
+ expect(graphql).toHaveBeenCalledWith(
215
+ expect.stringContaining('deleteNode'),
216
+ {
217
+ id: 'task_1',
218
+ },
219
+ )
220
+ expect(output).toHaveBeenCalledWith({ deleted: true })
221
+ })
222
+
223
+ test('delete surfaces CONFLICT via outputError with its code', async () => {
224
+ const { graphql } = await import('../util/client.ts')
225
+ const { outputError } = await import('../util/format.ts')
226
+ const { taskCommand } = await import('./task.ts')
227
+
228
+ const conflict = Object.assign(new Error('has children'), {
229
+ code: 'CONFLICT',
230
+ })
231
+ vi.mocked(graphql).mockRejectedValueOnce(conflict)
232
+
233
+ const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
234
+ await deleteCmd.parseAsync(['task_1'], { from: 'user' })
235
+
236
+ expect(outputError).toHaveBeenCalledWith(
237
+ expect.objectContaining({ code: 'CONFLICT' }),
238
+ )
239
+ })
240
+
241
+ test('delete surfaces NOT_FOUND via outputError with its code', async () => {
242
+ const { graphql } = await import('../util/client.ts')
243
+ const { outputError } = await import('../util/format.ts')
244
+ const { taskCommand } = await import('./task.ts')
245
+
246
+ const notFound = Object.assign(new Error('Node task_x not found'), {
247
+ code: 'NOT_FOUND',
248
+ })
249
+ vi.mocked(graphql).mockRejectedValueOnce(notFound)
250
+
251
+ const deleteCmd = taskCommand.commands.find((c) => c.name() === 'delete')!
252
+ await deleteCmd.parseAsync(['task_x'], { from: 'user' })
253
+
254
+ expect(outputError).toHaveBeenCalledWith(
255
+ expect.objectContaining({ code: 'NOT_FOUND' }),
256
+ )
257
+ })
83
258
  })
@@ -12,14 +12,21 @@ taskCommand
12
12
  .command('create')
13
13
  .description('Create a task in the active feature')
14
14
  .requiredOption('--title <title>', 'Task title')
15
- .requiredOption(
16
- '--description <desc>',
17
- 'Task description (text or file path)',
15
+ .option(
16
+ '--description <text>',
17
+ 'Task description, used verbatim (never read as a file path)',
18
+ )
19
+ .option(
20
+ '--description-file <path>',
21
+ 'Read the task description from a file, or "-" for stdin',
18
22
  )
19
23
  .action(async (opts) => {
20
24
  try {
21
25
  const featureId = requireFeature()
22
- const description = await resolveDescription(opts.description)
26
+ const description = await resolveDescription({
27
+ description: opts.description,
28
+ descriptionFile: opts.descriptionFile,
29
+ })
23
30
  const data = await graphql<{ createNode: { id: string } }>(
24
31
  `mutation CreateTask($type: String!, $title: String!, $description: String) {
25
32
  createNode(type: $type, title: $title, description: $description) {
@@ -86,6 +93,63 @@ taskCommand
86
93
  }
87
94
  })
88
95
 
96
+ taskCommand
97
+ .command('update')
98
+ .description('Update a task')
99
+ .argument('<id>', 'Task ID')
100
+ .option('--title <title>', 'New title')
101
+ .option(
102
+ '--description <text>',
103
+ 'New description, used verbatim (never read as a file path)',
104
+ )
105
+ .option(
106
+ '--description-file <path>',
107
+ 'Read the new description from a file, or "-" for stdin',
108
+ )
109
+ .option('--metadata <json>', 'New metadata as a JSON string')
110
+ .action(async (id: string, opts) => {
111
+ try {
112
+ const variables: Record<string, unknown> = { id }
113
+ if (opts.title != null) variables.title = opts.title
114
+ if (opts.description != null || opts.descriptionFile != null) {
115
+ variables.description = await resolveDescription({
116
+ description: opts.description,
117
+ descriptionFile: opts.descriptionFile,
118
+ })
119
+ }
120
+ if (opts.metadata != null) variables.metadata = opts.metadata
121
+ const data = await graphql<{ updateNode: unknown }>(
122
+ `mutation UpdateNode($id: String!, $title: String, $description: String, $metadata: String) {
123
+ updateNode(id: $id, title: $title, description: $description, metadata: $metadata) {
124
+ id type title description status metadata createdAt updatedAt
125
+ }
126
+ }`,
127
+ variables,
128
+ )
129
+ output(data.updateNode)
130
+ } catch (error) {
131
+ outputError(error)
132
+ }
133
+ })
134
+
135
+ taskCommand
136
+ .command('delete')
137
+ .description('Delete a task')
138
+ .argument('<id>', 'Task ID')
139
+ .action(async (id: string) => {
140
+ try {
141
+ const data = await graphql<{ deleteNode: boolean }>(
142
+ `mutation DeleteNode($id: String!) {
143
+ deleteNode(id: $id)
144
+ }`,
145
+ { id },
146
+ )
147
+ output({ deleted: data.deleteNode })
148
+ } catch (error) {
149
+ outputError(error)
150
+ }
151
+ })
152
+
89
153
  taskCommand
90
154
  .command('block')
91
155
  .description('Mark a task as blocking another')
package/src/index.test.ts CHANGED
@@ -39,6 +39,9 @@ vi.mock('./commands/billing.ts', () => ({
39
39
  vi.mock('./commands/key.ts', () => ({
40
40
  keyCommand: { name: () => 'key' },
41
41
  }))
42
+ vi.mock('./commands/serve.ts', () => ({
43
+ serveCommand: { name: () => 'serve' },
44
+ }))
42
45
 
43
46
  describe('index.ts command registration', () => {
44
47
  test('registers billing and key commands', async () => {
@@ -57,4 +60,17 @@ describe('index.ts command registration', () => {
57
60
  expect(indexSource).toContain('program.addCommand(billingCommand)')
58
61
  expect(indexSource).toContain('program.addCommand(keyCommand)')
59
62
  })
63
+
64
+ test('registers the serve command', async () => {
65
+ const { readFileSync } = await import('node:fs')
66
+ const indexSource = readFileSync(
67
+ new URL('./index.ts', import.meta.url).pathname,
68
+ 'utf-8',
69
+ )
70
+
71
+ expect(indexSource).toContain(
72
+ "import { serveCommand } from './commands/serve.ts'",
73
+ )
74
+ expect(indexSource).toContain('program.addCommand(serveCommand)')
75
+ })
60
76
  })
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import { initCommand } from './commands/init.ts'
19
19
  import { keyCommand } from './commands/key.ts'
20
20
  import { projectCommand } from './commands/project.ts'
21
21
  import { searchCommand } from './commands/search.ts'
22
+ import { serveCommand } from './commands/serve.ts'
22
23
  import { setupCommand } from './commands/setup.ts'
23
24
  import { statusCommand } from './commands/status.ts'
24
25
  import { taskCommand } from './commands/task.ts'
@@ -32,6 +33,7 @@ const program = new Command()
32
33
 
33
34
  program.addCommand(initCommand)
34
35
  program.addCommand(setupCommand)
36
+ program.addCommand(serveCommand)
35
37
  program.addCommand(clientCommand)
36
38
  program.addCommand(projectCommand)
37
39
  program.addCommand(featureCommand)
@@ -1,5 +1,25 @@
1
1
  import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
2
2
 
3
+ function ok(json: unknown) {
4
+ return {
5
+ ok: true,
6
+ status: 200,
7
+ headers: { get: () => 'application/json' },
8
+ json: () => Promise.resolve(json),
9
+ text: () => Promise.resolve(JSON.stringify(json)),
10
+ }
11
+ }
12
+
13
+ function httpError(status: number, body = '<html>error</html>') {
14
+ return {
15
+ ok: false,
16
+ status,
17
+ headers: { get: () => 'text/html' },
18
+ json: () => Promise.reject(new SyntaxError('Unexpected token <')),
19
+ text: () => Promise.resolve(body),
20
+ }
21
+ }
22
+
3
23
  beforeEach(() => {
4
24
  vi.doMock('./config.ts', () => ({
5
25
  getConfig: () => ({
@@ -18,12 +38,7 @@ describe('graphql client', () => {
18
38
  test('returns data on successful response', async () => {
19
39
  vi.stubGlobal(
20
40
  'fetch',
21
- vi.fn().mockResolvedValue({
22
- json: () =>
23
- Promise.resolve({
24
- data: { whoami: { id: '1' } },
25
- }),
26
- }),
41
+ vi.fn().mockResolvedValue(ok({ data: { whoami: { id: '1' } } })),
27
42
  )
28
43
 
29
44
  const { graphql } = await import('./client.ts')
@@ -34,17 +49,16 @@ describe('graphql client', () => {
34
49
  test('attaches extensions.code to the thrown error', async () => {
35
50
  vi.stubGlobal(
36
51
  'fetch',
37
- vi.fn().mockResolvedValue({
38
- json: () =>
39
- Promise.resolve({
40
- errors: [
41
- {
42
- message: 'Search query must be at least 3 characters',
43
- extensions: { code: 'VALIDATION_ERROR' },
44
- },
45
- ],
46
- }),
47
- }),
52
+ vi.fn().mockResolvedValue(
53
+ ok({
54
+ errors: [
55
+ {
56
+ message: 'Search query must be at least 3 characters',
57
+ extensions: { code: 'VALIDATION_ERROR' },
58
+ },
59
+ ],
60
+ }),
61
+ ),
48
62
  )
49
63
 
50
64
  const { graphql } = await import('./client.ts')
@@ -59,17 +73,16 @@ describe('graphql client', () => {
59
73
  test('throws original server message for unknown error codes', async () => {
60
74
  vi.stubGlobal(
61
75
  'fetch',
62
- vi.fn().mockResolvedValue({
63
- json: () =>
64
- Promise.resolve({
65
- errors: [
66
- {
67
- message: 'Something broke',
68
- extensions: { code: 'UNKNOWN_CODE' },
69
- },
70
- ],
71
- }),
72
- }),
76
+ vi.fn().mockResolvedValue(
77
+ ok({
78
+ errors: [
79
+ {
80
+ message: 'Something broke',
81
+ extensions: { code: 'UNKNOWN_CODE' },
82
+ },
83
+ ],
84
+ }),
85
+ ),
73
86
  )
74
87
 
75
88
  const { graphql } = await import('./client.ts')
@@ -81,12 +94,7 @@ describe('graphql client', () => {
81
94
  test('throws error message when extensions are absent', async () => {
82
95
  vi.stubGlobal(
83
96
  'fetch',
84
- vi.fn().mockResolvedValue({
85
- json: () =>
86
- Promise.resolve({
87
- errors: [{ message: 'Auth required' }],
88
- }),
89
- }),
97
+ vi.fn().mockResolvedValue(ok({ errors: [{ message: 'Auth required' }] })),
90
98
  )
91
99
 
92
100
  const { graphql } = await import('./client.ts')
@@ -97,18 +105,12 @@ describe('graphql client', () => {
97
105
 
98
106
  test('omits Authorization header when no apiKey configured', async () => {
99
107
  vi.doMock('./config.ts', () => ({
100
- getConfig: () => ({
101
- apiUrl: 'http://test/graphql',
102
- apiKey: '',
103
- }),
108
+ getConfig: () => ({ apiUrl: 'http://test/graphql', apiKey: '' }),
104
109
  }))
105
110
 
106
- const mockFetch = vi.fn().mockResolvedValue({
107
- json: () =>
108
- Promise.resolve({
109
- data: { whoami: { id: '1' } },
110
- }),
111
- })
111
+ const mockFetch = vi
112
+ .fn()
113
+ .mockResolvedValue(ok({ data: { whoami: { id: '1' } } }))
112
114
  vi.stubGlobal('fetch', mockFetch)
113
115
 
114
116
  const { graphql } = await import('./client.ts')
@@ -118,29 +120,30 @@ describe('graphql client', () => {
118
120
  expect(callHeaders).not.toHaveProperty('Authorization')
119
121
  })
120
122
 
121
- test('throws on network error', async () => {
123
+ test('throws a NETWORK_ERROR-coded error on fetch rejection', async () => {
122
124
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('fetch failed')))
123
125
 
124
126
  const { graphql } = await import('./client.ts')
125
- await expect(graphql('query { whoami { id } }')).rejects.toThrow(
126
- 'fetch failed',
127
- )
127
+ await expect(
128
+ graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
129
+ ).rejects.toMatchObject({
130
+ code: 'NETWORK_ERROR',
131
+ })
128
132
  })
129
133
 
130
134
  test('throws friendly message for SUBSCRIPTION_REQUIRED error code', async () => {
131
135
  vi.stubGlobal(
132
136
  'fetch',
133
- vi.fn().mockResolvedValue({
134
- json: () =>
135
- Promise.resolve({
136
- errors: [
137
- {
138
- message: 'Active subscription required.',
139
- extensions: { code: 'SUBSCRIPTION_REQUIRED' },
140
- },
141
- ],
142
- }),
143
- }),
137
+ vi.fn().mockResolvedValue(
138
+ ok({
139
+ errors: [
140
+ {
141
+ message: 'Active subscription required.',
142
+ extensions: { code: 'SUBSCRIPTION_REQUIRED' },
143
+ },
144
+ ],
145
+ }),
146
+ ),
144
147
  )
145
148
 
146
149
  const { graphql } = await import('./client.ts')
@@ -152,17 +155,16 @@ describe('graphql client', () => {
152
155
  test('throws friendly message for SUBSCRIPTION_EXPIRED error code', async () => {
153
156
  vi.stubGlobal(
154
157
  'fetch',
155
- vi.fn().mockResolvedValue({
156
- json: () =>
157
- Promise.resolve({
158
- errors: [
159
- {
160
- message: 'Subscription has expired.',
161
- extensions: { code: 'SUBSCRIPTION_EXPIRED' },
162
- },
163
- ],
164
- }),
165
- }),
158
+ vi.fn().mockResolvedValue(
159
+ ok({
160
+ errors: [
161
+ {
162
+ message: 'Subscription has expired.',
163
+ extensions: { code: 'SUBSCRIPTION_EXPIRED' },
164
+ },
165
+ ],
166
+ }),
167
+ ),
166
168
  )
167
169
 
168
170
  const { graphql } = await import('./client.ts')
@@ -174,17 +176,16 @@ describe('graphql client', () => {
174
176
  test('throws friendly message for SUBSCRIPTION_SUSPENDED error code', async () => {
175
177
  vi.stubGlobal(
176
178
  'fetch',
177
- vi.fn().mockResolvedValue({
178
- json: () =>
179
- Promise.resolve({
180
- errors: [
181
- {
182
- message: 'Subscription is suspended.',
183
- extensions: { code: 'SUBSCRIPTION_SUSPENDED' },
184
- },
185
- ],
186
- }),
187
- }),
179
+ vi.fn().mockResolvedValue(
180
+ ok({
181
+ errors: [
182
+ {
183
+ message: 'Subscription is suspended.',
184
+ extensions: { code: 'SUBSCRIPTION_SUSPENDED' },
185
+ },
186
+ ],
187
+ }),
188
+ ),
188
189
  )
189
190
 
190
191
  const { graphql } = await import('./client.ts')
@@ -192,4 +193,116 @@ describe('graphql client', () => {
192
193
  /suspended.*contact support/,
193
194
  )
194
195
  })
196
+
197
+ describe('transport hardening (F11)', () => {
198
+ test('throws a SERVER_ERROR-coded error on a non-retryable non-2xx (e.g. 500) without crashing on HTML body', async () => {
199
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue(httpError(500)))
200
+
201
+ const { graphql } = await import('./client.ts')
202
+ await expect(
203
+ graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
204
+ ).rejects.toMatchObject({ code: 'SERVER_ERROR' })
205
+ // Must surface a real message, not a raw SyntaxError about "<"
206
+ await expect(
207
+ graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
208
+ ).rejects.toThrow(/500/)
209
+ })
210
+
211
+ test('throws SERVER_ERROR (no crash) when a 200 body is not JSON', async () => {
212
+ vi.stubGlobal(
213
+ 'fetch',
214
+ vi.fn().mockResolvedValue({
215
+ ok: true,
216
+ status: 200,
217
+ headers: { get: () => 'text/html' },
218
+ json: () => Promise.reject(new SyntaxError('Unexpected token <')),
219
+ text: () => Promise.resolve('<html>proxy</html>'),
220
+ }),
221
+ )
222
+
223
+ const { graphql } = await import('./client.ts')
224
+ await expect(graphql('query { whoami { id } }')).rejects.toMatchObject({
225
+ code: 'SERVER_ERROR',
226
+ })
227
+ })
228
+
229
+ test('retries on a transient 429 then succeeds', async () => {
230
+ const mockFetch = vi
231
+ .fn()
232
+ .mockResolvedValueOnce(httpError(429))
233
+ .mockResolvedValueOnce(ok({ data: { whoami: { id: '1' } } }))
234
+ vi.stubGlobal('fetch', mockFetch)
235
+
236
+ const { graphql } = await import('./client.ts')
237
+ const result = await graphql('query { whoami { id } }', undefined, {
238
+ retryDelayMs: 0,
239
+ })
240
+ expect(result).toEqual({ whoami: { id: '1' } })
241
+ expect(mockFetch).toHaveBeenCalledTimes(2)
242
+ })
243
+
244
+ test('retries on 503 then gives up with a SERVER_ERROR-coded error', async () => {
245
+ const mockFetch = vi.fn().mockResolvedValue(httpError(503))
246
+ vi.stubGlobal('fetch', mockFetch)
247
+
248
+ const { graphql } = await import('./client.ts')
249
+ await expect(
250
+ graphql('query { whoami { id } }', undefined, {
251
+ retries: 2,
252
+ retryDelayMs: 0,
253
+ }),
254
+ ).rejects.toMatchObject({ code: 'SERVER_ERROR' })
255
+ // initial attempt + 2 retries = 3 calls
256
+ expect(mockFetch).toHaveBeenCalledTimes(3)
257
+ })
258
+
259
+ test('does NOT retry on a non-transient 400', async () => {
260
+ const mockFetch = vi.fn().mockResolvedValue(httpError(400))
261
+ vi.stubGlobal('fetch', mockFetch)
262
+
263
+ const { graphql } = await import('./client.ts')
264
+ await expect(
265
+ graphql('query { whoami { id } }', undefined, { retryDelayMs: 0 }),
266
+ ).rejects.toMatchObject({ code: 'SERVER_ERROR' })
267
+ expect(mockFetch).toHaveBeenCalledTimes(1)
268
+ })
269
+
270
+ test('aborts and throws NETWORK_ERROR on timeout', async () => {
271
+ const mockFetch = vi.fn().mockImplementation((_url, init) => {
272
+ return new Promise((_resolve, reject) => {
273
+ const signal = (init as { signal?: AbortSignal }).signal
274
+ signal?.addEventListener('abort', () => {
275
+ reject(
276
+ Object.assign(new Error('The operation was aborted'), {
277
+ name: 'AbortError',
278
+ }),
279
+ )
280
+ })
281
+ })
282
+ })
283
+ vi.stubGlobal('fetch', mockFetch)
284
+
285
+ const { graphql } = await import('./client.ts')
286
+ await expect(
287
+ graphql('query { whoami { id } }', undefined, {
288
+ timeoutMs: 10,
289
+ retries: 0,
290
+ retryDelayMs: 0,
291
+ }),
292
+ ).rejects.toMatchObject({ code: 'NETWORK_ERROR' })
293
+ })
294
+
295
+ test('passes a timeout signal to fetch', async () => {
296
+ const mockFetch = vi
297
+ .fn()
298
+ .mockResolvedValue(ok({ data: { whoami: { id: '1' } } }))
299
+ vi.stubGlobal('fetch', mockFetch)
300
+
301
+ const { graphql } = await import('./client.ts')
302
+ await graphql('query { whoami { id } }')
303
+
304
+ const init = mockFetch.mock.calls[0]![1]
305
+ expect(init.signal).toBeInstanceOf(AbortSignal)
306
+ })
307
+ })
195
308
  })