@vacbo/opencode-anthropic-fix 0.0.44 → 0.1.1
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 +19 -0
- package/dist/bun-proxy.mjs +282 -55
- package/dist/opencode-anthropic-auth-cli.mjs +194 -55
- package/dist/opencode-anthropic-auth-plugin.js +1816 -594
- package/package.json +1 -1
- package/src/__tests__/billing-edge-cases.test.ts +84 -0
- package/src/__tests__/bun-proxy.parallel.test.ts +460 -0
- package/src/__tests__/debug-gating.test.ts +76 -0
- package/src/__tests__/decomposition-smoke.test.ts +92 -0
- package/src/__tests__/fingerprint-regression.test.ts +1 -1
- package/src/__tests__/helpers/conversation-history.smoke.test.ts +338 -0
- package/src/__tests__/helpers/conversation-history.ts +376 -0
- package/src/__tests__/helpers/deferred.smoke.test.ts +161 -0
- package/src/__tests__/helpers/deferred.ts +122 -0
- package/src/__tests__/helpers/in-memory-storage.smoke.test.ts +166 -0
- package/src/__tests__/helpers/in-memory-storage.ts +152 -0
- package/src/__tests__/helpers/mock-bun-proxy.smoke.test.ts +92 -0
- package/src/__tests__/helpers/mock-bun-proxy.ts +229 -0
- package/src/__tests__/helpers/plugin-fetch-harness.smoke.test.ts +337 -0
- package/src/__tests__/helpers/plugin-fetch-harness.ts +401 -0
- package/src/__tests__/helpers/sse.smoke.test.ts +243 -0
- package/src/__tests__/helpers/sse.ts +288 -0
- package/src/__tests__/index.parallel.test.ts +711 -0
- package/src/__tests__/sanitization-regex.test.ts +65 -0
- package/src/__tests__/state-bounds.test.ts +110 -0
- package/src/account-identity.test.ts +213 -0
- package/src/account-identity.ts +108 -0
- package/src/accounts.dedup.test.ts +696 -0
- package/src/accounts.test.ts +2 -1
- package/src/accounts.ts +485 -191
- package/src/bun-fetch.test.ts +379 -0
- package/src/bun-fetch.ts +447 -174
- package/src/bun-proxy.ts +289 -57
- package/src/circuit-breaker.test.ts +274 -0
- package/src/circuit-breaker.ts +235 -0
- package/src/cli.test.ts +1 -0
- package/src/cli.ts +37 -18
- package/src/commands/router.ts +25 -5
- package/src/env.ts +1 -0
- package/src/headers/billing.ts +31 -13
- package/src/index.ts +224 -247
- package/src/oauth.ts +7 -1
- package/src/parent-pid-watcher.test.ts +219 -0
- package/src/parent-pid-watcher.ts +99 -0
- package/src/plugin-helpers.ts +112 -0
- package/src/refresh-helpers.ts +169 -0
- package/src/refresh-lock.test.ts +36 -9
- package/src/refresh-lock.ts +2 -2
- package/src/request/body.history.test.ts +398 -0
- package/src/request/body.ts +200 -13
- package/src/request/metadata.ts +6 -2
- package/src/response/index.ts +1 -1
- package/src/response/mcp.ts +60 -31
- package/src/response/streaming.test.ts +382 -0
- package/src/response/streaming.ts +403 -76
- package/src/storage.test.ts +127 -104
- package/src/storage.ts +152 -62
- package/src/system-prompt/builder.ts +33 -3
- package/src/system-prompt/sanitize.ts +12 -2
- package/src/token-refresh.test.ts +84 -1
- package/src/token-refresh.ts +14 -8
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Factory functions for creating conversation state in tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for building Anthropic Messages API compatible
|
|
5
|
+
* conversation objects, messages, tool_use blocks, and tool_result blocks.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* const conversation = makeConversation({
|
|
10
|
+
* messages: [
|
|
11
|
+
* makeMessage({ role: 'user', content: 'Hello' }),
|
|
12
|
+
* makeMessage({
|
|
13
|
+
* role: 'assistant',
|
|
14
|
+
* content: [makeToolUse({ name: 'read_file', input: { path: 'test.txt' } })]
|
|
15
|
+
* }),
|
|
16
|
+
* makeMessage({
|
|
17
|
+
* role: 'user',
|
|
18
|
+
* content: [makeToolResult({ toolUseId: 'tu_123', content: 'file contents' })]
|
|
19
|
+
* })
|
|
20
|
+
* ]
|
|
21
|
+
* });
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { randomUUID } from "node:crypto";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Types
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export type MessageRole = "user" | "assistant";
|
|
32
|
+
|
|
33
|
+
export interface TextBlock {
|
|
34
|
+
type: "text";
|
|
35
|
+
text: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ToolUseBlock {
|
|
39
|
+
type: "tool_use";
|
|
40
|
+
id: string;
|
|
41
|
+
name: string;
|
|
42
|
+
input: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ToolResultBlock {
|
|
46
|
+
type: "tool_result";
|
|
47
|
+
tool_use_id: string;
|
|
48
|
+
content: string | Array<TextBlock | ImageBlock>;
|
|
49
|
+
is_error?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ImageBlock {
|
|
53
|
+
type: "image";
|
|
54
|
+
source: {
|
|
55
|
+
type: "base64";
|
|
56
|
+
media_type: string;
|
|
57
|
+
data: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export type MessageContent = string | Array<TextBlock | ToolUseBlock | ToolResultBlock | ImageBlock>;
|
|
62
|
+
|
|
63
|
+
export interface Message {
|
|
64
|
+
role: MessageRole;
|
|
65
|
+
content: MessageContent;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface Conversation {
|
|
69
|
+
messages: Message[];
|
|
70
|
+
metadata?: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ConversationFactory {
|
|
74
|
+
/** Generate unique IDs (default: true) */
|
|
75
|
+
generateIds?: boolean;
|
|
76
|
+
/** Default prefix for generated IDs */
|
|
77
|
+
idPrefix?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface MakeConversationOptions extends ConversationFactory {
|
|
81
|
+
/** Pre-populated messages array */
|
|
82
|
+
messages?: Message[];
|
|
83
|
+
/** Optional conversation metadata */
|
|
84
|
+
metadata?: Record<string, unknown>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface MakeMessageOptions {
|
|
88
|
+
/** Message role (user or assistant) */
|
|
89
|
+
role?: MessageRole;
|
|
90
|
+
/** Message content - string or content blocks array */
|
|
91
|
+
content?: MessageContent;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface MakeToolUseOptions {
|
|
95
|
+
/** Unique tool use ID (auto-generated if not provided) */
|
|
96
|
+
id?: string;
|
|
97
|
+
/** Tool name */
|
|
98
|
+
name?: string;
|
|
99
|
+
/** Tool input parameters */
|
|
100
|
+
input?: Record<string, unknown>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface MakeToolResultOptions {
|
|
104
|
+
/** ID of the tool_use this result corresponds to */
|
|
105
|
+
toolUseId?: string;
|
|
106
|
+
/** Result content - string or content blocks */
|
|
107
|
+
content?: string | Array<TextBlock | ImageBlock>;
|
|
108
|
+
/** Whether this result represents an error */
|
|
109
|
+
isError?: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// ID Generation
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
let idCounter = 0;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generate a unique tool use ID.
|
|
120
|
+
*
|
|
121
|
+
* @param prefix - ID prefix (default: "tu")
|
|
122
|
+
* @returns Unique ID string
|
|
123
|
+
*/
|
|
124
|
+
export function generateToolUseId(prefix = "tu"): string {
|
|
125
|
+
return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 12)}_${++idCounter}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Reset the ID counter for deterministic tests.
|
|
130
|
+
*/
|
|
131
|
+
export function resetIdCounter(): void {
|
|
132
|
+
idCounter = 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Factory Functions
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Create a conversation object with messages array.
|
|
141
|
+
*
|
|
142
|
+
* @param opts - Conversation options
|
|
143
|
+
* @returns Conversation object
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* const conversation = makeConversation({
|
|
148
|
+
* messages: [makeMessage({ role: 'user', content: 'Hello' })],
|
|
149
|
+
* metadata: { sessionId: 'abc123' }
|
|
150
|
+
* });
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export function makeConversation(opts: MakeConversationOptions = {}): Conversation {
|
|
154
|
+
return {
|
|
155
|
+
messages: opts.messages ?? [],
|
|
156
|
+
metadata: opts.metadata,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a message with role and content.
|
|
162
|
+
*
|
|
163
|
+
* @param opts - Message options
|
|
164
|
+
* @returns Message object
|
|
165
|
+
*
|
|
166
|
+
* @example
|
|
167
|
+
* ```ts
|
|
168
|
+
* const msg = makeMessage({ role: 'user', content: 'Hello Claude' });
|
|
169
|
+
* const assistantMsg = makeMessage({
|
|
170
|
+
* role: 'assistant',
|
|
171
|
+
* content: [{ type: 'text', text: 'Hello!' }]
|
|
172
|
+
* });
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
export function makeMessage(opts: MakeMessageOptions = {}): Message {
|
|
176
|
+
const role = opts.role ?? "user";
|
|
177
|
+
const content = opts.content ?? "";
|
|
178
|
+
|
|
179
|
+
return { role, content };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Create a text content block.
|
|
184
|
+
*
|
|
185
|
+
* @param text - Text content
|
|
186
|
+
* @returns TextBlock object
|
|
187
|
+
*/
|
|
188
|
+
export function makeTextBlock(text: string): TextBlock {
|
|
189
|
+
return { type: "text", text };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create a tool_use block with id, name, and input.
|
|
194
|
+
*
|
|
195
|
+
* @param opts - Tool use options
|
|
196
|
+
* @returns ToolUseBlock object
|
|
197
|
+
*
|
|
198
|
+
* @example
|
|
199
|
+
* ```ts
|
|
200
|
+
* const toolUse = makeToolUse({
|
|
201
|
+
* name: 'read_file',
|
|
202
|
+
* input: { path: 'src/index.ts' }
|
|
203
|
+
* });
|
|
204
|
+
* ```
|
|
205
|
+
*/
|
|
206
|
+
export function makeToolUse(opts: MakeToolUseOptions = {}): ToolUseBlock {
|
|
207
|
+
return {
|
|
208
|
+
type: "tool_use",
|
|
209
|
+
id: opts.id ?? generateToolUseId(),
|
|
210
|
+
name: opts.name ?? "unnamed_tool",
|
|
211
|
+
input: opts.input ?? {},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Create a tool_result block with tool_use_id and content.
|
|
217
|
+
*
|
|
218
|
+
* @param opts - Tool result options
|
|
219
|
+
* @returns ToolResultBlock object
|
|
220
|
+
*
|
|
221
|
+
* @example
|
|
222
|
+
* ```ts
|
|
223
|
+
* const toolResult = makeToolResult({
|
|
224
|
+
* toolUseId: 'tu_abc123',
|
|
225
|
+
* content: 'File contents here'
|
|
226
|
+
* });
|
|
227
|
+
* ```
|
|
228
|
+
*/
|
|
229
|
+
export function makeToolResult(opts: MakeToolResultOptions = {}): ToolResultBlock {
|
|
230
|
+
return {
|
|
231
|
+
type: "tool_result",
|
|
232
|
+
tool_use_id: opts.toolUseId ?? generateToolUseId("tr"),
|
|
233
|
+
content: opts.content ?? "",
|
|
234
|
+
is_error: opts.isError ?? false,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Validation Helpers
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Validate that a tool_use and tool_result pair match.
|
|
244
|
+
*
|
|
245
|
+
* @param toolUse - The tool_use block
|
|
246
|
+
* @param toolResult - The tool_result block
|
|
247
|
+
* @returns True if the pair is valid
|
|
248
|
+
*/
|
|
249
|
+
export function validateToolPair(toolUse: ToolUseBlock, toolResult: ToolResultBlock): boolean {
|
|
250
|
+
return toolUse.id === toolResult.tool_use_id;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Find the tool_result corresponding to a tool_use in a message array.
|
|
255
|
+
*
|
|
256
|
+
* @param messages - Array of messages to search
|
|
257
|
+
* @param toolUseId - The tool_use ID to find the result for
|
|
258
|
+
* @returns The matching ToolResultBlock or undefined
|
|
259
|
+
*/
|
|
260
|
+
export function findToolResult(messages: Message[], toolUseId: string): ToolResultBlock | undefined {
|
|
261
|
+
for (const message of messages) {
|
|
262
|
+
if (typeof message.content === "string") continue;
|
|
263
|
+
|
|
264
|
+
for (const block of message.content) {
|
|
265
|
+
if (block.type === "tool_result" && block.tool_use_id === toolUseId) {
|
|
266
|
+
return block;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if all tool_use blocks in a conversation have matching tool_results.
|
|
275
|
+
*
|
|
276
|
+
* @param conversation - The conversation to validate
|
|
277
|
+
* @returns Object with validation results
|
|
278
|
+
*/
|
|
279
|
+
export function validateConversationTools(conversation: Conversation): {
|
|
280
|
+
valid: boolean;
|
|
281
|
+
unmatchedToolUses: ToolUseBlock[];
|
|
282
|
+
unmatchedToolResults: ToolResultBlock[];
|
|
283
|
+
} {
|
|
284
|
+
const toolUses: ToolUseBlock[] = [];
|
|
285
|
+
const toolResults: ToolResultBlock[] = [];
|
|
286
|
+
|
|
287
|
+
// Collect all tool_use and tool_result blocks
|
|
288
|
+
for (const message of conversation.messages) {
|
|
289
|
+
if (typeof message.content === "string") continue;
|
|
290
|
+
|
|
291
|
+
for (const block of message.content) {
|
|
292
|
+
if (block.type === "tool_use") {
|
|
293
|
+
toolUses.push(block);
|
|
294
|
+
} else if (block.type === "tool_result") {
|
|
295
|
+
toolResults.push(block);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const unmatchedToolUses = toolUses.filter((tu) => !toolResults.some((tr) => tr.tool_use_id === tu.id));
|
|
301
|
+
|
|
302
|
+
const unmatchedToolResults = toolResults.filter((tr) => !toolUses.some((tu) => tu.id === tr.tool_use_id));
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
valid: unmatchedToolUses.length === 0 && unmatchedToolResults.length === 0,
|
|
306
|
+
unmatchedToolUses,
|
|
307
|
+
unmatchedToolResults,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// Convenience Builders
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create a complete tool call exchange: assistant tool_use + user tool_result.
|
|
317
|
+
*
|
|
318
|
+
* @param toolName - Name of the tool
|
|
319
|
+
* @param toolInput - Tool input parameters
|
|
320
|
+
* @param resultContent - Result content string
|
|
321
|
+
* @returns Array of [toolUse, toolResult] pair
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* ```ts
|
|
325
|
+
* const [toolUse, toolResult] = makeToolExchange(
|
|
326
|
+
* 'read_file',
|
|
327
|
+
* { path: 'test.txt' },
|
|
328
|
+
* 'file contents'
|
|
329
|
+
* );
|
|
330
|
+
* ```
|
|
331
|
+
*/
|
|
332
|
+
export function makeToolExchange(
|
|
333
|
+
toolName: string,
|
|
334
|
+
toolInput: Record<string, unknown>,
|
|
335
|
+
resultContent: string,
|
|
336
|
+
): [ToolUseBlock, ToolResultBlock] {
|
|
337
|
+
const toolUse = makeToolUse({ name: toolName, input: toolInput });
|
|
338
|
+
const toolResult = makeToolResult({ toolUseId: toolUse.id, content: resultContent });
|
|
339
|
+
return [toolUse, toolResult];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Create a conversation with a complete tool call flow.
|
|
344
|
+
*
|
|
345
|
+
* @param userPrompt - Initial user message
|
|
346
|
+
* @param toolName - Tool to call
|
|
347
|
+
* @param toolInput - Tool input
|
|
348
|
+
* @param toolOutput - Tool output
|
|
349
|
+
* @returns Conversation with complete message flow
|
|
350
|
+
*
|
|
351
|
+
* @example
|
|
352
|
+
* ```ts
|
|
353
|
+
* const conv = makeToolConversation(
|
|
354
|
+
* 'Read the file',
|
|
355
|
+
* 'read_file',
|
|
356
|
+
* { path: 'test.txt' },
|
|
357
|
+
* 'file contents'
|
|
358
|
+
* );
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
export function makeToolConversation(
|
|
362
|
+
userPrompt: string,
|
|
363
|
+
toolName: string,
|
|
364
|
+
toolInput: Record<string, unknown>,
|
|
365
|
+
toolOutput: string,
|
|
366
|
+
): Conversation {
|
|
367
|
+
const [toolUse, toolResult] = makeToolExchange(toolName, toolInput, toolOutput);
|
|
368
|
+
|
|
369
|
+
return makeConversation({
|
|
370
|
+
messages: [
|
|
371
|
+
makeMessage({ role: "user", content: userPrompt }),
|
|
372
|
+
makeMessage({ role: "assistant", content: [toolUse] }),
|
|
373
|
+
makeMessage({ role: "user", content: [toolResult] }),
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createDeferred, createDeferredQueue, nextTick } from "./deferred";
|
|
3
|
+
|
|
4
|
+
describe("deferred helpers", () => {
|
|
5
|
+
describe("createDeferred", () => {
|
|
6
|
+
it("should resolve with expected value", async () => {
|
|
7
|
+
const deferred = createDeferred<string>();
|
|
8
|
+
|
|
9
|
+
deferred.resolve("expected-value");
|
|
10
|
+
|
|
11
|
+
const result = await deferred.promise;
|
|
12
|
+
expect(result).toBe("expected-value");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("should reject with expected reason", async () => {
|
|
16
|
+
const deferred = createDeferred<string>();
|
|
17
|
+
const error = new Error("test-error");
|
|
18
|
+
|
|
19
|
+
deferred.reject(error);
|
|
20
|
+
|
|
21
|
+
await expect(deferred.promise).rejects.toBe(error);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should track settled state", async () => {
|
|
25
|
+
const deferred = createDeferred<string>();
|
|
26
|
+
|
|
27
|
+
expect(deferred.settled).toBe(false);
|
|
28
|
+
|
|
29
|
+
deferred.resolve("done");
|
|
30
|
+
|
|
31
|
+
expect(deferred.settled).toBe(true);
|
|
32
|
+
await deferred.promise;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should ignore second resolve", async () => {
|
|
36
|
+
const deferred = createDeferred<string>();
|
|
37
|
+
|
|
38
|
+
deferred.resolve("first");
|
|
39
|
+
deferred.resolve("second");
|
|
40
|
+
|
|
41
|
+
const result = await deferred.promise;
|
|
42
|
+
expect(result).toBe("first");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should ignore reject after resolve", async () => {
|
|
46
|
+
const deferred = createDeferred<string>();
|
|
47
|
+
|
|
48
|
+
deferred.resolve("value");
|
|
49
|
+
deferred.reject(new Error("ignored"));
|
|
50
|
+
|
|
51
|
+
const result = await deferred.promise;
|
|
52
|
+
expect(result).toBe("value");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("createDeferredQueue", () => {
|
|
57
|
+
it("should resolve deferreds in FIFO order", async () => {
|
|
58
|
+
const queue = createDeferredQueue<string>();
|
|
59
|
+
|
|
60
|
+
const d1 = queue.enqueue();
|
|
61
|
+
const d2 = queue.enqueue();
|
|
62
|
+
const d3 = queue.enqueue();
|
|
63
|
+
|
|
64
|
+
expect(queue.pending).toBe(3);
|
|
65
|
+
|
|
66
|
+
queue.resolveNext("first");
|
|
67
|
+
queue.resolveNext("second");
|
|
68
|
+
queue.resolveNext("third");
|
|
69
|
+
|
|
70
|
+
expect(queue.pending).toBe(0);
|
|
71
|
+
|
|
72
|
+
const results = await Promise.all([d1.promise, d2.promise, d3.promise]);
|
|
73
|
+
|
|
74
|
+
expect(results).toEqual(["first", "second", "third"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should reject deferreds in FIFO order", async () => {
|
|
78
|
+
const queue = createDeferredQueue<string>();
|
|
79
|
+
|
|
80
|
+
const d1 = queue.enqueue();
|
|
81
|
+
const d2 = queue.enqueue();
|
|
82
|
+
|
|
83
|
+
const error1 = new Error("error-1");
|
|
84
|
+
const error2 = new Error("error-2");
|
|
85
|
+
|
|
86
|
+
queue.rejectNext(error1);
|
|
87
|
+
queue.rejectNext(error2);
|
|
88
|
+
|
|
89
|
+
await expect(d1.promise).rejects.toBe(error1);
|
|
90
|
+
await expect(d2.promise).rejects.toBe(error2);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("should return false when resolving empty queue", () => {
|
|
94
|
+
const queue = createDeferredQueue<string>();
|
|
95
|
+
|
|
96
|
+
const result = queue.resolveNext("value");
|
|
97
|
+
|
|
98
|
+
expect(result).toBe(false);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("should return false when rejecting empty queue", () => {
|
|
102
|
+
const queue = createDeferredQueue<string>();
|
|
103
|
+
|
|
104
|
+
const result = queue.rejectNext(new Error("test"));
|
|
105
|
+
|
|
106
|
+
expect(result).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("should handle mixed resolve and reject", async () => {
|
|
110
|
+
const queue = createDeferredQueue<string>();
|
|
111
|
+
|
|
112
|
+
const d1 = queue.enqueue();
|
|
113
|
+
const d2 = queue.enqueue();
|
|
114
|
+
const d3 = queue.enqueue();
|
|
115
|
+
|
|
116
|
+
queue.resolveNext("success");
|
|
117
|
+
queue.rejectNext(new Error("failure"));
|
|
118
|
+
queue.resolveNext("another-success");
|
|
119
|
+
|
|
120
|
+
const r1 = await d1.promise;
|
|
121
|
+
expect(r1).toBe("success");
|
|
122
|
+
|
|
123
|
+
await expect(d2.promise).rejects.toThrow("failure");
|
|
124
|
+
|
|
125
|
+
const r3 = await d3.promise;
|
|
126
|
+
expect(r3).toBe("another-success");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe("nextTick", () => {
|
|
131
|
+
it("should allow pending promises to settle", async () => {
|
|
132
|
+
const deferred = createDeferred<string>();
|
|
133
|
+
let resolved = false;
|
|
134
|
+
|
|
135
|
+
deferred.promise.then(() => {
|
|
136
|
+
resolved = true;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
deferred.resolve("done");
|
|
140
|
+
|
|
141
|
+
expect(resolved).toBe(false);
|
|
142
|
+
|
|
143
|
+
await nextTick();
|
|
144
|
+
|
|
145
|
+
expect(resolved).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should resolve after microtask queue", async () => {
|
|
149
|
+
const order: string[] = [];
|
|
150
|
+
|
|
151
|
+
Promise.resolve().then(() => order.push("promise-1"));
|
|
152
|
+
Promise.resolve().then(() => order.push("promise-2"));
|
|
153
|
+
|
|
154
|
+
await nextTick();
|
|
155
|
+
|
|
156
|
+
order.push("after-nextTick");
|
|
157
|
+
|
|
158
|
+
expect(order).toEqual(["promise-1", "promise-2", "after-nextTick"]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Controllable promises for concurrency tests.
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for creating deferred promises that can be resolved/rejected
|
|
5
|
+
* externally, and a FIFO queue for managing multiple deferred promises.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* const deferred = createDeferred<string>();
|
|
10
|
+
* setTimeout(() => deferred.resolve('done'), 100);
|
|
11
|
+
* const result = await deferred.promise; // 'done'
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Deferred<T> {
|
|
16
|
+
/** The promise that will resolve/reject when called */
|
|
17
|
+
promise: Promise<T>;
|
|
18
|
+
/** Resolve the promise with a value */
|
|
19
|
+
resolve: (value: T | PromiseLike<T>) => void;
|
|
20
|
+
/** Reject the promise with a reason */
|
|
21
|
+
reject: (reason?: unknown) => void;
|
|
22
|
+
/** Whether the promise has settled (resolved or rejected) */
|
|
23
|
+
settled: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a deferred promise with external resolve/reject controls.
|
|
28
|
+
*
|
|
29
|
+
* Similar to Promise.withResolvers() but with additional settled tracking.
|
|
30
|
+
*
|
|
31
|
+
* @returns Deferred object with promise, resolve, reject, and settled state
|
|
32
|
+
*/
|
|
33
|
+
export function createDeferred<T>(): Deferred<T> {
|
|
34
|
+
let resolve: (value: T | PromiseLike<T>) => void;
|
|
35
|
+
let reject: (reason?: unknown) => void;
|
|
36
|
+
let settled = false;
|
|
37
|
+
|
|
38
|
+
const promise = new Promise<T>((res, rej) => {
|
|
39
|
+
resolve = (value) => {
|
|
40
|
+
if (!settled) {
|
|
41
|
+
settled = true;
|
|
42
|
+
res(value);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
reject = (reason) => {
|
|
46
|
+
if (!settled) {
|
|
47
|
+
settled = true;
|
|
48
|
+
rej(reason);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
promise,
|
|
55
|
+
resolve: resolve!,
|
|
56
|
+
reject: reject!,
|
|
57
|
+
get settled() {
|
|
58
|
+
return settled;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DeferredQueue<T> {
|
|
64
|
+
/** Add a new deferred to the queue */
|
|
65
|
+
enqueue: () => Deferred<T>;
|
|
66
|
+
/** Resolve the next deferred in FIFO order */
|
|
67
|
+
resolveNext: (value: T | PromiseLike<T>) => boolean;
|
|
68
|
+
/** Reject the next deferred in FIFO order */
|
|
69
|
+
rejectNext: (reason?: unknown) => boolean;
|
|
70
|
+
/** Number of pending deferreds in the queue */
|
|
71
|
+
pending: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates a FIFO queue of deferred promises.
|
|
76
|
+
*
|
|
77
|
+
* Useful for testing ordered async operations like request queues,
|
|
78
|
+
* sequential processing, or race conditions.
|
|
79
|
+
*
|
|
80
|
+
* @returns Queue with enqueue, resolveNext, rejectNext, and pending count
|
|
81
|
+
*/
|
|
82
|
+
export function createDeferredQueue<T>(): DeferredQueue<T> {
|
|
83
|
+
const queue: Deferred<T>[] = [];
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
enqueue: () => {
|
|
87
|
+
const deferred = createDeferred<T>();
|
|
88
|
+
queue.push(deferred);
|
|
89
|
+
return deferred;
|
|
90
|
+
},
|
|
91
|
+
resolveNext: (value) => {
|
|
92
|
+
const next = queue.shift();
|
|
93
|
+
if (next) {
|
|
94
|
+
next.resolve(value);
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
},
|
|
99
|
+
rejectNext: (reason) => {
|
|
100
|
+
const next = queue.shift();
|
|
101
|
+
if (next) {
|
|
102
|
+
next.reject(reason);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
},
|
|
107
|
+
get pending() {
|
|
108
|
+
return queue.length;
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Waits for one microtask tick.
|
|
115
|
+
*
|
|
116
|
+
* Useful for allowing pending promises to settle before assertions.
|
|
117
|
+
*
|
|
118
|
+
* @returns Promise that resolves after one microtask
|
|
119
|
+
*/
|
|
120
|
+
export function nextTick(): Promise<void> {
|
|
121
|
+
return Promise.resolve();
|
|
122
|
+
}
|