Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/test/githubOrgCustomAgentProvider.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { assert } from 'chai';
7
import { afterEach, beforeEach, suite, test, vi } from 'vitest';
8
import type { ExtensionContext } from 'vscode';
9
import { Scalar } from 'yaml';
10
import { PromptsType } from '../../../../platform/customInstructions/common/promptTypes';
11
import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';
12
import { CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions } from '../../../../platform/github/common/githubService';
13
import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';
14
import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';
15
import { MockWorkspaceService } from '../../../../platform/ignore/node/test/mockWorkspaceService';
16
import { ILogService } from '../../../../platform/log/common/logService';
17
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
18
import { URI } from '../../../../util/vs/base/common/uri';
19
import { parse } from '../../../../util/vs/base/common/yaml';
20
import { createExtensionUnitTestingServices } from '../../../test/node/services';
21
import { GitHubOrgChatResourcesService } from '../githubOrgChatResourcesService';
22
import { GitHubOrgCustomAgentProvider, looksLikeNumber, yamlString } from '../githubOrgCustomAgentProvider';
23
import { MockOctoKitService } from './mockOctoKitService';
24
25
suite('GitHubOrgCustomAgentProvider', () => {
26
let disposables: DisposableStore;
27
let mockOctoKitService: MockOctoKitService;
28
let mockFileSystem: MockFileSystemService;
29
let mockGitService: MockGitService;
30
let mockWorkspaceService: MockWorkspaceService;
31
let mockExtensionContext: Partial<ExtensionContext>;
32
let mockAuthService: MockAuthenticationService;
33
let accessor: any;
34
let provider: GitHubOrgCustomAgentProvider;
35
let resourcesService: GitHubOrgChatResourcesService;
36
37
const storagePath = '/tmp/test-storage';
38
const storageUri = URI.file(storagePath);
39
40
beforeEach(() => {
41
vi.useFakeTimers();
42
disposables = new DisposableStore();
43
44
// Create mocks for real GitHubOrgChatResourcesService
45
mockOctoKitService = new MockOctoKitService();
46
mockFileSystem = new MockFileSystemService();
47
mockGitService = new MockGitService();
48
mockWorkspaceService = new MockWorkspaceService();
49
mockExtensionContext = {
50
globalStorageUri: storageUri,
51
};
52
mockAuthService = new MockAuthenticationService();
53
54
// Default: user is in 'testorg' and workspace belongs to 'testorg'
55
mockOctoKitService.setUserOrganizations(['testorg']);
56
mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);
57
mockGitService.setRepositoryFetchUrls({
58
rootUri: URI.file('/workspace'),
59
remoteFetchUrls: ['https://github.com/testorg/repo.git']
60
});
61
62
// Set up testing services
63
const testingServiceCollection = createExtensionUnitTestingServices(disposables);
64
accessor = disposables.add(testingServiceCollection.createTestingAccessor());
65
});
66
67
afterEach(() => {
68
vi.useRealTimers();
69
disposables.dispose();
70
mockOctoKitService.clearAgents();
71
});
72
73
function createProvider() {
74
// Create the real GitHubOrgChatResourcesService with mocked dependencies
75
resourcesService = new GitHubOrgChatResourcesService(
76
mockAuthService as any,
77
mockExtensionContext as any,
78
mockFileSystem,
79
mockGitService,
80
accessor.get(ILogService),
81
mockOctoKitService,
82
mockWorkspaceService,
83
);
84
disposables.add(resourcesService);
85
86
// Create provider with real resources service
87
provider = new GitHubOrgCustomAgentProvider(
88
mockOctoKitService,
89
accessor.get(ILogService),
90
resourcesService,
91
);
92
disposables.add(provider);
93
return provider;
94
}
95
96
/**
97
* Advance timers and wait for polling callback to complete.
98
* Uses a small time advance to trigger the initial poll without infinite loops.
99
*/
100
async function waitForPolling(): Promise<void> {
101
// Advance just enough to let initial poll complete, but not trigger interval polls
102
await vi.advanceTimersByTimeAsync(10);
103
}
104
105
/**
106
* Helper to pre-populate cache files in mock filesystem.
107
*/
108
function prepopulateCache(orgName: string, files: Map<string, string>): void {
109
const cacheDir = URI.file(`${storagePath}/github/${orgName}/agents`);
110
const dirEntries: [string, import('../../../../platform/filesystem/common/fileTypes').FileType][] = [];
111
for (const [filename, content] of files) {
112
mockFileSystem.mockFile(URI.joinPath(cacheDir, filename), content);
113
dirEntries.push([filename, 1 /* FileType.File */]);
114
}
115
mockFileSystem.mockDirectory(cacheDir, dirEntries);
116
}
117
118
test('returns empty array when user has no organizations', async () => {
119
mockOctoKitService.setUserOrganizations([]);
120
mockWorkspaceService.setWorkspaceFolders([]);
121
const provider = createProvider();
122
123
const agents = await provider.provideCustomAgents({}, {} as any);
124
125
assert.deepEqual(agents, []);
126
});
127
128
test('returns empty array when no organizations and no cached files', async () => {
129
// With no organizations and no cached files, should return empty
130
mockOctoKitService.setUserOrganizations([]);
131
mockWorkspaceService.setWorkspaceFolders([]);
132
const provider = createProvider();
133
134
const agents = await provider.provideCustomAgents({}, {} as any);
135
136
assert.deepEqual(agents, []);
137
});
138
139
// todo: MockFileSystemService previously had a bug where deleted files would
140
// still show up when listing directories. This was fixed and caused this test
141
// to fail: test_agent.md is cleared from the cache in the first poll
142
test.skip('returns cached agents on first call', async () => {
143
// Set up file system mocks BEFORE creating provider to avoid race with background fetch
144
// Also prevent background fetch from interfering by having no organizations
145
mockOctoKitService.setUserOrganizations([]);
146
mockWorkspaceService.setWorkspaceFolders([]);
147
148
// Pre-populate cache with org folder (but keep testorg folder structure)
149
const agentContent = `---
150
name: Test Agent
151
description: A test agent
152
---
153
Test prompt content`;
154
prepopulateCache('testorg', new Map([['test_agent.agent.md', agentContent]]));
155
156
// Re-enable testorg for cache reading (user is in org, but no workspace repo)
157
mockOctoKitService.setUserOrganizations(['testorg']);
158
159
const provider = createProvider();
160
161
// Wait for initial poll attempt (won't fetch since no agents in API)
162
await waitForPolling();
163
164
const agents = await provider.provideCustomAgents({}, {} as any);
165
166
assert.equal(agents.length, 1);
167
const agentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');
168
assert.equal(agentName, 'test_agent');
169
});
170
171
test('fetches and caches agents from API', async () => {
172
// Mock API response BEFORE creating provider
173
const mockAgent: CustomAgentListItem = {
174
name: 'api_agent',
175
repo_owner_id: 1,
176
repo_owner: 'testorg',
177
repo_id: 1,
178
repo_name: 'testrepo',
179
display_name: 'API Agent',
180
description: 'An agent from API',
181
tools: ['tool1'],
182
version: 'v1',
183
};
184
mockOctoKitService.setCustomAgents([mockAgent]);
185
186
const mockDetails: CustomAgentDetails = {
187
...mockAgent,
188
prompt: 'API prompt content',
189
};
190
mockOctoKitService.setAgentDetails('api_agent', mockDetails);
191
192
const provider = createProvider();
193
194
// Wait for background fetch to complete
195
await waitForPolling();
196
197
// Second call should return newly cached agents from memory
198
const agents2 = await provider.provideCustomAgents({}, {} as any);
199
assert.equal(agents2.length, 1);
200
const agentName2 = agents2[0].uri.path.split('/').pop()?.replace('.agent.md', '');
201
assert.equal(agentName2, 'api_agent');
202
203
// Third call should also return from memory cache without file I/O
204
const agents3 = await provider.provideCustomAgents({}, {} as any);
205
assert.equal(agents3.length, 1);
206
const agentName3 = agents3[0].uri.path.split('/').pop()?.replace('.agent.md', '');
207
assert.equal(agentName3, 'api_agent');
208
});
209
210
test('generates correct markdown format for agents', async () => {
211
const provider = createProvider();
212
213
const mockAgent: CustomAgentListItem = {
214
name: 'full_agent',
215
repo_owner_id: 1,
216
repo_owner: 'testorg',
217
repo_id: 1,
218
repo_name: 'testrepo',
219
display_name: 'Full Agent',
220
description: 'A fully configured agent',
221
tools: ['tool1', 'tool2'],
222
version: 'v1',
223
argument_hint: 'Provide context',
224
target: 'vscode',
225
};
226
mockOctoKitService.setCustomAgents([mockAgent]);
227
228
const mockDetails: CustomAgentDetails = {
229
...mockAgent,
230
prompt: 'Detailed prompt content',
231
model: 'gpt-4',
232
disable_model_invocation: true,
233
};
234
mockOctoKitService.setAgentDetails('full_agent', mockDetails);
235
236
await provider.provideCustomAgents({}, {} as any);
237
await waitForPolling();
238
239
// Check cached file content using the real service
240
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'full_agent.agent.md');
241
242
const expectedContent = `---
243
name: Full Agent
244
description: A fully configured agent
245
tools:
246
- tool1
247
- tool2
248
argument-hint: Provide context
249
target: vscode
250
model: gpt-4
251
disable-model-invocation: true
252
---
253
Detailed prompt content
254
`;
255
256
assert.equal(content, expectedContent);
257
});
258
259
test('generates markdown with user-invocable property', async () => {
260
const provider = createProvider();
261
262
const mockAgent: CustomAgentListItem = {
263
name: 'invocable_agent',
264
repo_owner_id: 1,
265
repo_owner: 'testorg',
266
repo_id: 1,
267
repo_name: 'testrepo',
268
display_name: 'Invocable Agent',
269
description: 'An agent with user-invocable set',
270
tools: [],
271
version: 'v1',
272
};
273
mockOctoKitService.setCustomAgents([mockAgent]);
274
275
const mockDetails: CustomAgentDetails = {
276
...mockAgent,
277
prompt: 'Invocable prompt content',
278
user_invocable: true,
279
};
280
mockOctoKitService.setAgentDetails('invocable_agent', mockDetails);
281
282
await provider.provideCustomAgents({}, {} as any);
283
await waitForPolling();
284
285
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'invocable_agent.agent.md');
286
287
const expectedContent = `---
288
name: Invocable Agent
289
description: An agent with user-invocable set
290
user-invocable: true
291
---
292
Invocable prompt content
293
`;
294
295
assert.equal(content, expectedContent);
296
});
297
298
test('generates markdown with false values for disable-model-invocation and user-invocable', async () => {
299
const provider = createProvider();
300
301
const mockAgent: CustomAgentListItem = {
302
name: 'false_flags_agent',
303
repo_owner_id: 1,
304
repo_owner: 'testorg',
305
repo_id: 1,
306
repo_name: 'testrepo',
307
display_name: 'False Flags Agent',
308
description: 'Agent with false boolean flags',
309
tools: [],
310
version: 'v1',
311
};
312
mockOctoKitService.setCustomAgents([mockAgent]);
313
314
const mockDetails: CustomAgentDetails = {
315
...mockAgent,
316
prompt: 'False flags prompt',
317
disable_model_invocation: false,
318
user_invocable: false,
319
};
320
mockOctoKitService.setAgentDetails('false_flags_agent', mockDetails);
321
322
await provider.provideCustomAgents({}, {} as any);
323
await waitForPolling();
324
325
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'false_flags_agent.agent.md');
326
327
const expectedContent = `---
328
name: False Flags Agent
329
description: Agent with false boolean flags
330
disable-model-invocation: false
331
user-invocable: false
332
---
333
False flags prompt
334
`;
335
336
assert.equal(content, expectedContent);
337
});
338
339
test('preserves agent name in filename', async () => {
340
// Note: The provider does NOT sanitize filenames - it uses the agent name directly.
341
// This test documents the actual behavior.
342
const provider = createProvider();
343
344
const mockAgent: CustomAgentListItem = {
345
name: 'my-agent_name',
346
repo_owner_id: 1,
347
repo_owner: 'testorg',
348
repo_id: 1,
349
repo_name: 'testrepo',
350
display_name: 'My Agent',
351
description: 'Test filename',
352
tools: [],
353
version: 'v1',
354
};
355
mockOctoKitService.setCustomAgents([mockAgent]);
356
357
const mockDetails: CustomAgentDetails = {
358
...mockAgent,
359
prompt: 'Prompt content',
360
};
361
mockOctoKitService.setAgentDetails('my-agent_name', mockDetails);
362
363
await provider.provideCustomAgents({}, {} as any);
364
await waitForPolling();
365
366
// File is created with the exact agent name (no sanitization)
367
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'my-agent_name.agent.md');
368
assert.ok(content, 'File should exist with agent name as filename');
369
});
370
371
test.skip('fires change event when cache is updated on first fetch', async () => {
372
const provider = createProvider();
373
374
const mockAgent: CustomAgentListItem = {
375
name: 'changing_agent',
376
repo_owner_id: 1,
377
repo_owner: 'testorg',
378
repo_id: 1,
379
repo_name: 'testrepo',
380
display_name: 'Changing Agent',
381
description: 'Will change',
382
tools: [],
383
version: 'v1',
384
};
385
mockOctoKitService.setCustomAgents([mockAgent]);
386
387
const mockDetails: CustomAgentDetails = {
388
...mockAgent,
389
prompt: 'Initial prompt',
390
};
391
mockOctoKitService.setAgentDetails('changing_agent', mockDetails);
392
393
let eventFired = false;
394
provider.onDidChangeCustomAgents(() => {
395
eventFired = true;
396
});
397
398
// First call triggers background fetch
399
await provider.provideCustomAgents({}, {} as any);
400
await waitForPolling();
401
402
// Event should fire after initial successful fetch
403
assert.equal(eventFired, true);
404
});
405
406
test('handles API errors gracefully', async () => {
407
const provider = createProvider();
408
409
// Make the API throw an error
410
mockOctoKitService.getCustomAgents = async () => {
411
throw new Error('API Error');
412
};
413
414
// Should not throw, should return empty array
415
const agents = await provider.provideCustomAgents({}, {} as any);
416
assert.deepEqual(agents, []);
417
});
418
419
test('passes query options to API correctly', async () => {
420
const provider = createProvider();
421
422
let capturedOptions: CustomAgentListOptions | undefined;
423
mockOctoKitService.getCustomAgents = async (owner: string, repo: string, options?: CustomAgentListOptions) => {
424
capturedOptions = options;
425
return [];
426
};
427
428
await provider.provideCustomAgents({}, {} as any);
429
await waitForPolling();
430
431
assert.ok(capturedOptions);
432
assert.deepEqual(capturedOptions.includeSources, ['org', 'enterprise']);
433
});
434
435
test('prevents concurrent fetches when called multiple times rapidly', async () => {
436
const provider = createProvider();
437
438
let apiCallCount = 0;
439
mockOctoKitService.getCustomAgents = async () => {
440
apiCallCount++;
441
// Simulate slow API call - use real timer for this
442
await new Promise(resolve => {
443
const realSetTimeout = globalThis.setTimeout;
444
realSetTimeout(resolve, 50);
445
});
446
return [];
447
};
448
449
// Make multiple concurrent calls
450
const promise1 = provider.provideCustomAgents({}, {} as any);
451
const promise2 = provider.provideCustomAgents({}, {} as any);
452
const promise3 = provider.provideCustomAgents({}, {} as any);
453
454
await Promise.all([promise1, promise2, promise3]);
455
await waitForPolling();
456
457
// API should only be called once due to isFetching guard
458
assert.equal(apiCallCount, 1);
459
});
460
461
test('handles partial agent detail fetch failures gracefully', async () => {
462
const agents: CustomAgentListItem[] = [
463
{
464
name: 'agent1',
465
repo_owner_id: 1,
466
repo_owner: 'testorg',
467
repo_id: 1,
468
repo_name: 'testrepo',
469
display_name: 'Agent 1',
470
description: 'First agent',
471
tools: [],
472
version: 'v1',
473
},
474
{
475
name: 'agent2',
476
repo_owner_id: 1,
477
repo_owner: 'testorg',
478
repo_id: 1,
479
repo_name: 'testrepo',
480
display_name: 'Agent 2',
481
description: 'Second agent',
482
tools: [],
483
version: 'v1',
484
},
485
];
486
mockOctoKitService.setCustomAgents(agents);
487
488
// Set details for only the first agent (second will fail)
489
mockOctoKitService.setAgentDetails('agent1', {
490
...agents[0],
491
prompt: 'Agent 1 prompt',
492
});
493
494
// Pre-populate file cache with the first agent to simulate previous successful state
495
const agentContent = `---
496
name: Agent 1
497
description: First agent
498
---
499
Agent 1 prompt`;
500
prepopulateCache('testorg', new Map([['agent1.agent.md', agentContent]]));
501
502
const provider = createProvider();
503
await waitForPolling();
504
505
// With error handling, partial failures skip cache update for that org
506
// So the existing file cache is returned with the one successful agent
507
const cachedAgents = await provider.provideCustomAgents({}, {} as any);
508
assert.equal(cachedAgents.length, 1);
509
const cachedAgentName = cachedAgents[0].uri.path.split('/').pop()?.replace('.agent.md', '');
510
assert.equal(cachedAgentName, 'agent1');
511
});
512
513
test('caches agents in memory after first successful fetch', async () => {
514
// Initial setup with one agent BEFORE creating provider
515
const initialAgent: CustomAgentListItem = {
516
name: 'initial_agent',
517
repo_owner_id: 1,
518
repo_owner: 'testorg',
519
repo_id: 1,
520
repo_name: 'testrepo',
521
display_name: 'Initial Agent',
522
description: 'First agent',
523
tools: [],
524
version: 'v1',
525
};
526
mockOctoKitService.setCustomAgents([initialAgent]);
527
mockOctoKitService.setAgentDetails('initial_agent', {
528
...initialAgent,
529
prompt: 'Initial prompt',
530
});
531
532
const provider = createProvider();
533
await waitForPolling();
534
535
// After successful fetch, subsequent calls return from memory
536
const agents1 = await provider.provideCustomAgents({}, {} as any);
537
assert.equal(agents1.length, 1);
538
const agentName1 = agents1[0].uri.path.split('/').pop()?.replace('.agent.md', '');
539
assert.equal(agentName1, 'initial_agent');
540
541
// Even if API is updated, memory cache is used
542
const newAgent: CustomAgentListItem = {
543
name: 'new_agent',
544
repo_owner_id: 1,
545
repo_owner: 'testorg',
546
repo_id: 1,
547
repo_name: 'testrepo',
548
display_name: 'New Agent',
549
description: 'Newly added agent',
550
tools: [],
551
version: 'v1',
552
};
553
mockOctoKitService.setCustomAgents([initialAgent, newAgent]);
554
mockOctoKitService.setAgentDetails('new_agent', {
555
...newAgent,
556
prompt: 'New prompt',
557
});
558
559
// Memory cache returns old results without refetching
560
const agents2 = await provider.provideCustomAgents({}, {} as any);
561
assert.equal(agents2.length, 1);
562
const agentName2ForMemory = agents2[0].uri.path.split('/').pop()?.replace('.agent.md', '');
563
assert.equal(agentName2ForMemory, 'initial_agent');
564
});
565
566
test('memory cache persists after first successful fetch', async () => {
567
// Initial setup with two agents BEFORE creating provider
568
const agents: CustomAgentListItem[] = [
569
{
570
name: 'agent1',
571
repo_owner_id: 1,
572
repo_owner: 'testorg',
573
repo_id: 1,
574
repo_name: 'testrepo',
575
display_name: 'Agent 1',
576
description: 'First agent',
577
tools: [],
578
version: 'v1',
579
},
580
{
581
name: 'agent2',
582
repo_owner_id: 1,
583
repo_owner: 'testorg',
584
repo_id: 1,
585
repo_name: 'testrepo',
586
display_name: 'Agent 2',
587
description: 'Second agent',
588
tools: [],
589
version: 'v1',
590
},
591
];
592
mockOctoKitService.setCustomAgents(agents);
593
mockOctoKitService.setAgentDetails('agent1', { ...agents[0], prompt: 'Prompt 1' });
594
mockOctoKitService.setAgentDetails('agent2', { ...agents[1], prompt: 'Prompt 2' });
595
596
const provider = createProvider();
597
await waitForPolling();
598
599
// Verify both agents are cached
600
const cachedAgents1 = await provider.provideCustomAgents({}, {} as any);
601
assert.equal(cachedAgents1.length, 2);
602
603
// Remove one agent from API
604
mockOctoKitService.setCustomAgents([agents[0]]);
605
606
// Memory cache still returns both agents (no refetch)
607
const cachedAgents2 = await provider.provideCustomAgents({}, {} as any);
608
assert.equal(cachedAgents2.length, 2);
609
const cachedAgent2Name1 = cachedAgents2[0].uri.path.split('/').pop()?.replace('.agent.md', '');
610
const cachedAgent2Name2 = cachedAgents2[1].uri.path.split('/').pop()?.replace('.agent.md', '');
611
assert.equal(cachedAgent2Name1, 'agent1');
612
assert.equal(cachedAgent2Name2, 'agent2');
613
});
614
615
test.skip('does not fire change event when content is identical', async () => {
616
const provider = createProvider();
617
618
const mockAgent: CustomAgentListItem = {
619
name: 'stable_agent',
620
repo_owner_id: 1,
621
repo_owner: 'testorg',
622
repo_id: 1,
623
repo_name: 'testrepo',
624
display_name: 'Stable Agent',
625
description: 'Unchanging agent',
626
tools: [],
627
version: 'v1',
628
};
629
mockOctoKitService.setCustomAgents([mockAgent]);
630
mockOctoKitService.setAgentDetails('stable_agent', {
631
...mockAgent,
632
prompt: 'Stable prompt',
633
});
634
635
await provider.provideCustomAgents({}, {} as any);
636
await waitForPolling();
637
638
let changeEventCount = 0;
639
provider.onDidChangeCustomAgents(() => {
640
changeEventCount++;
641
});
642
643
// Fetch again with identical content
644
await provider.provideCustomAgents({}, {} as any);
645
await waitForPolling();
646
647
// No change event should fire
648
assert.equal(changeEventCount, 0);
649
});
650
651
test('memory cache persists even when API returns empty list', async () => {
652
// Setup with initial agents BEFORE creating provider
653
const mockAgent: CustomAgentListItem = {
654
name: 'temporary_agent',
655
repo_owner_id: 1,
656
repo_owner: 'testorg',
657
repo_id: 1,
658
repo_name: 'testrepo',
659
display_name: 'Temporary Agent',
660
description: 'Will be removed',
661
tools: [],
662
version: 'v1',
663
};
664
mockOctoKitService.setCustomAgents([mockAgent]);
665
mockOctoKitService.setAgentDetails('temporary_agent', {
666
...mockAgent,
667
prompt: 'Temporary prompt',
668
});
669
670
const provider = createProvider();
671
await waitForPolling();
672
673
// Verify agent is cached
674
const agents1 = await provider.provideCustomAgents({}, {} as any);
675
assert.equal(agents1.length, 1);
676
677
// API now returns empty array
678
mockOctoKitService.setCustomAgents([]);
679
680
// Memory cache still returns the agent (no refetch)
681
const agents2 = await provider.provideCustomAgents({}, {} as any);
682
assert.equal(agents2.length, 1);
683
const temporaryAgentName = agents2[0].uri.path.split('/').pop()?.replace('.agent.md', '');
684
assert.equal(temporaryAgentName, 'temporary_agent');
685
});
686
687
test('generates markdown with only required fields', async () => {
688
const provider = createProvider();
689
690
// Agent with minimal fields (no optional fields)
691
const mockAgent: CustomAgentListItem = {
692
name: 'minimal_agent',
693
repo_owner_id: 1,
694
repo_owner: 'testorg',
695
repo_id: 1,
696
repo_name: 'testrepo',
697
display_name: 'Minimal Agent',
698
description: 'Minimal description',
699
tools: [],
700
version: 'v1',
701
};
702
mockOctoKitService.setCustomAgents([mockAgent]);
703
704
const mockDetails: CustomAgentDetails = {
705
...mockAgent,
706
prompt: 'Minimal prompt',
707
};
708
mockOctoKitService.setAgentDetails('minimal_agent', mockDetails);
709
710
await provider.provideCustomAgents({}, {} as any);
711
await waitForPolling();
712
713
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'minimal_agent.agent.md');
714
assert.ok(content, 'Agent file should exist');
715
716
// Should have name and description, but no tools (empty array)
717
assert.ok(content.includes('name: Minimal Agent'));
718
assert.ok(content.includes('description: Minimal description'));
719
assert.ok(!content.includes('tools:'));
720
assert.ok(!content.includes('argument-hint:'));
721
assert.ok(!content.includes('target:'));
722
assert.ok(!content.includes('model:'));
723
assert.ok(!content.includes('disable-model-invocation:'));
724
});
725
726
test('excludes tools field when array contains only wildcard', async () => {
727
const provider = createProvider();
728
729
const mockAgent: CustomAgentListItem = {
730
name: 'wildcard_agent',
731
repo_owner_id: 1,
732
repo_owner: 'testorg',
733
repo_id: 1,
734
repo_name: 'testrepo',
735
display_name: 'Wildcard Agent',
736
description: 'Agent with wildcard tools',
737
tools: ['*'],
738
version: 'v1',
739
};
740
mockOctoKitService.setCustomAgents([mockAgent]);
741
742
const mockDetails: CustomAgentDetails = {
743
...mockAgent,
744
prompt: 'Wildcard prompt',
745
};
746
mockOctoKitService.setAgentDetails('wildcard_agent', mockDetails);
747
748
await provider.provideCustomAgents({}, {} as any);
749
await waitForPolling();
750
751
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'wildcard_agent.agent.md');
752
assert.ok(content, 'Agent file should exist');
753
754
// Tools field should be excluded when it's just ['*']
755
assert.ok(!content.includes('tools:'));
756
});
757
758
// todo: MockFileSystemService previously had a bug where deleted files would
759
// still show up when listing directories. This was fixed and caused this test
760
// to fail: agent files are cleared from the cache in the first poll
761
test.skip('handles malformed frontmatter in cached files', async () => {
762
// Prevent background fetch from interfering
763
mockOctoKitService.setUserOrganizations([]);
764
mockWorkspaceService.setWorkspaceFolders([]);
765
766
// Pre-populate cache with mixed valid and malformed content BEFORE creating provider
767
const validContent = `---
768
name: Valid Agent
769
description: A valid agent
770
---
771
Valid prompt`;
772
// File without frontmatter - parser extracts name from filename, description is empty
773
const noFrontmatterContent = `Just some content without any frontmatter`;
774
prepopulateCache('testorg', new Map([
775
['valid_agent.agent.md', validContent],
776
['no_frontmatter.agent.md', noFrontmatterContent],
777
]));
778
779
// Re-enable testorg for cache reading
780
mockOctoKitService.setUserOrganizations(['testorg']);
781
782
const provider = createProvider();
783
784
// Wait for initial poll (which uses testorg)
785
await waitForPolling();
786
787
const agents = await provider.provideCustomAgents({}, {} as any);
788
789
// Parser is lenient - both agents are returned, one with empty description
790
assert.equal(agents.length, 2);
791
const validAgentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');
792
assert.equal(validAgentName, 'valid_agent');
793
const noFrontmatterAgentName = agents[1].uri.path.split('/').pop()?.replace('.agent.md', '');
794
assert.equal(noFrontmatterAgentName, 'no_frontmatter');
795
});
796
797
test('fetches agents from preferred organization only', async () => {
798
// The service only fetches from the preferred organization, not all user organizations.
799
// Preferred org is determined by workspace repository or first user organization.
800
const provider = createProvider();
801
802
// Set up multiple organizations - testorg is the default preferred org
803
mockOctoKitService.setUserOrganizations(['testorg', 'otherorg1', 'otherorg2']);
804
805
const capturedOrgs: string[] = [];
806
mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {
807
capturedOrgs.push(owner);
808
return [];
809
};
810
811
await provider.provideCustomAgents({}, {} as any);
812
await waitForPolling();
813
814
// Should have fetched from only the preferred organization
815
assert.equal(capturedOrgs.length, 1);
816
assert.ok(capturedOrgs.includes('testorg'));
817
});
818
819
test('generates markdown with long description on single line', async () => {
820
const provider = createProvider();
821
822
// Agent with a very long description that would normally be wrapped at 80 characters
823
const longDescription = 'Just for fun agent that teaches computer science concepts (while pretending to plot world domination).';
824
const mockAgent: CustomAgentListItem = {
825
name: 'world_domination',
826
repo_owner_id: 1,
827
repo_owner: 'testorg',
828
repo_id: 1,
829
repo_name: 'testrepo',
830
display_name: 'World Domination',
831
description: longDescription,
832
tools: [],
833
version: 'v1',
834
};
835
mockOctoKitService.setCustomAgents([mockAgent]);
836
837
const mockDetails: CustomAgentDetails = {
838
...mockAgent,
839
prompt: '# World Domination Agent\n\nYou are a world-class computer scientist.',
840
};
841
mockOctoKitService.setAgentDetails('world_domination', mockDetails);
842
843
await provider.provideCustomAgents({}, {} as any);
844
await waitForPolling();
845
846
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'world_domination.agent.md');
847
848
const expectedContent = `---
849
name: World Domination
850
description: Just for fun agent that teaches computer science concepts (while pretending to plot world domination).
851
---
852
# World Domination Agent
853
854
You are a world-class computer scientist.
855
`;
856
857
assert.equal(content, expectedContent);
858
});
859
860
test('generates markdown with special characters properly escaped in description', async () => {
861
const provider = createProvider();
862
863
// Agent with description containing YAML special characters that need proper handling
864
const descriptionWithSpecialChars = `Agent with "double quotes", 'single quotes', colons:, and #comments in the description`;
865
const mockAgent: CustomAgentListItem = {
866
name: 'special_chars_agent',
867
repo_owner_id: 1,
868
repo_owner: 'testorg',
869
repo_id: 1,
870
repo_name: 'testrepo',
871
display_name: 'Special Chars Agent',
872
description: descriptionWithSpecialChars,
873
tools: [],
874
version: 'v1',
875
};
876
mockOctoKitService.setCustomAgents([mockAgent]);
877
878
const mockDetails: CustomAgentDetails = {
879
...mockAgent,
880
prompt: 'Test prompt with special characters',
881
};
882
mockOctoKitService.setAgentDetails('special_chars_agent', mockDetails);
883
884
await provider.provideCustomAgents({}, {} as any);
885
await waitForPolling();
886
887
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'special_chars_agent.agent.md');
888
889
const expectedContent = `---
890
name: Special Chars Agent
891
description: "Agent with \\"double quotes\\", 'single quotes', colons:, and #comments in the description"
892
---
893
Test prompt with special characters
894
`;
895
896
assert.equal(content, expectedContent);
897
});
898
899
test('generates markdown with multiline description containing newlines', async () => {
900
const provider = createProvider();
901
902
// Agent with description containing actual newline characters
903
const descriptionWithNewlines = 'First line of description.\nSecond line of description.\nThird line.';
904
const mockAgent: CustomAgentListItem = {
905
name: 'multiline_agent',
906
repo_owner_id: 1,
907
repo_owner: 'testorg',
908
repo_id: 1,
909
repo_name: 'testrepo',
910
display_name: 'Multiline Agent',
911
description: descriptionWithNewlines,
912
tools: [],
913
version: 'v1',
914
};
915
mockOctoKitService.setCustomAgents([mockAgent]);
916
917
const mockDetails: CustomAgentDetails = {
918
...mockAgent,
919
prompt: 'Test prompt',
920
};
921
mockOctoKitService.setAgentDetails('multiline_agent', mockDetails);
922
923
await provider.provideCustomAgents({}, {} as any);
924
await waitForPolling();
925
926
const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'multiline_agent.agent.md');
927
928
// Newlines should be escaped using double quotes to keep description on a single line
929
// (the custom YAML parser doesn't support multi-line strings)
930
const expectedContent = `---
931
name: Multiline Agent
932
description: "First line of description.\\nSecond line of description.\\nThird line."
933
---
934
Test prompt
935
`;
936
937
assert.equal(content, expectedContent);
938
});
939
940
test('aborts fetch if user signs out during process', async () => {
941
const provider = createProvider();
942
943
// Setup multiple organizations to ensure we have multiple steps
944
mockOctoKitService.setUserOrganizations(['org1', 'org2']);
945
mockOctoKitService.getOrganizationRepositories = async (org) => ['repo'];
946
947
// Mock getCustomAgents to simulate sign out after first org
948
let callCount = 0;
949
const originalGetCustomAgents = mockOctoKitService.getCustomAgents;
950
mockOctoKitService.getCustomAgents = async (owner, repo, options) => {
951
callCount++;
952
if (callCount === 1) {
953
// Sign out user after first call
954
mockOctoKitService.getCurrentAuthedUser = async () => undefined as any;
955
}
956
return originalGetCustomAgents.call(mockOctoKitService, owner, repo, options, {});
957
};
958
959
await provider.provideCustomAgents({}, {} as any);
960
await waitForPolling();
961
962
// Should have aborted after first org, so second org shouldn't be processed
963
assert.equal(callCount, 1);
964
});
965
966
test('deduplicates enterprise agents that appear in multiple organizations', async () => {
967
// Setup multiple organizations BEFORE creating provider
968
mockOctoKitService.setUserOrganizations(['orgA', 'orgB']);
969
// Clear default workspace so getPreferredOrganizationName falls back to user organizations
970
mockWorkspaceService.setWorkspaceFolders([]);
971
972
// Create an enterprise agent that will appear in both organizations
973
const enterpriseAgent: CustomAgentListItem = {
974
name: 'enterprise_agent',
975
repo_owner_id: 999,
976
repo_owner: 'enterprise_org',
977
repo_id: 123,
978
repo_name: 'enterprise_repo',
979
display_name: 'Enterprise Agent',
980
description: 'Shared enterprise agent',
981
tools: [],
982
version: 'v1.0',
983
};
984
985
// Mock getCustomAgents to return the same enterprise agent for both orgs
986
mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {
987
// Both orgs return the same enterprise agent (same repo_owner, repo_name, name, version)
988
return [enterpriseAgent];
989
};
990
991
mockOctoKitService.setAgentDetails('enterprise_agent', {
992
...enterpriseAgent,
993
prompt: 'Enterprise prompt',
994
});
995
996
const provider = createProvider();
997
await waitForPolling();
998
999
const agents = await provider.provideCustomAgents({}, {} as any);
1000
1001
// Should only have one agent, not two (deduped)
1002
assert.equal(agents.length, 1);
1003
const enterpriseAgentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');
1004
assert.equal(enterpriseAgentName, 'enterprise_agent');
1005
1006
// Verify it was only written to one org directory
1007
// Check which org has the agent file
1008
const orgAContent = await resourcesService.readCacheFile(PromptsType.agent, 'orga', 'enterprise_agent.agent.md');
1009
const orgBContent = await resourcesService.readCacheFile(PromptsType.agent, 'orgb', 'enterprise_agent.agent.md');
1010
const orgAHasAgent = orgAContent !== undefined;
1011
const orgBHasAgent = orgBContent !== undefined;
1012
1013
// Agent should be in exactly one org directory (the first one processed)
1014
assert.ok(orgAHasAgent && !orgBHasAgent, 'Enterprise agent should only be cached in first org');
1015
});
1016
1017
test('deduplicates agents with same repo regardless of version', async () => {
1018
// Set up mocks BEFORE creating provider
1019
mockOctoKitService.setUserOrganizations(['orgA', 'orgB']);
1020
1021
// Create agents with same name but different versions
1022
const agentV1: CustomAgentListItem = {
1023
name: 'versioned_agent',
1024
repo_owner_id: 999,
1025
repo_owner: 'enterprise_org',
1026
repo_id: 123,
1027
repo_name: 'enterprise_repo',
1028
display_name: 'Versioned Agent',
1029
description: 'Agent version 1',
1030
tools: [],
1031
version: 'v1.0',
1032
};
1033
1034
const agentV2: CustomAgentListItem = {
1035
name: 'versioned_agent',
1036
repo_owner_id: 999,
1037
repo_owner: 'enterprise_org',
1038
repo_id: 123,
1039
repo_name: 'enterprise_repo',
1040
display_name: 'Versioned Agent',
1041
description: 'Agent version 2',
1042
tools: [],
1043
version: 'v2.0',
1044
};
1045
1046
let callCount = 0;
1047
mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {
1048
callCount++;
1049
if (callCount === 1) {
1050
// First org returns v1 and v2
1051
return [agentV1, agentV2];
1052
} else {
1053
// Second org also returns both versions
1054
return [agentV1, agentV2];
1055
}
1056
};
1057
1058
mockOctoKitService.getCustomAgentDetails = async (owner: string, repo: string, agentName: string, version?: string) => {
1059
if (version === 'v1.0') {
1060
return { ...agentV1, prompt: 'Version 1 prompt' };
1061
} else if (version === 'v2.0') {
1062
return { ...agentV2, prompt: 'Version 2 prompt' };
1063
}
1064
return undefined;
1065
};
1066
1067
const provider = createProvider();
1068
await waitForPolling();
1069
1070
const agents = await provider.provideCustomAgents({}, {} as any);
1071
1072
// Different versions are deduplicated, only the first one is kept
1073
assert.equal(agents.length, 1);
1074
const versionedAgentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');
1075
assert.equal(versionedAgentName, 'versioned_agent');
1076
});
1077
1078
test('handles agents with same name but different repo owners from single org', async () => {
1079
// Set up mocks BEFORE creating provider
1080
// This tests the case where a single org returns agents from different repo owners
1081
// (e.g., an org-specific agent and an enterprise agent with the same name)
1082
mockOctoKitService.setUserOrganizations(['testorg']);
1083
1084
// Agents with same name but different repo owners as returned by API for single org
1085
const orgAAgent: CustomAgentListItem = {
1086
name: 'shared_agent',
1087
repo_owner_id: 1,
1088
repo_owner: 'testorg',
1089
repo_id: 10,
1090
repo_name: 'org_repo',
1091
display_name: 'Org Agent',
1092
description: 'Agent from org repo',
1093
tools: [],
1094
version: 'v1.0',
1095
};
1096
1097
const enterpriseAgent: CustomAgentListItem = {
1098
name: 'shared_agent',
1099
repo_owner_id: 999,
1100
repo_owner: 'enterprise_org',
1101
repo_id: 100,
1102
repo_name: 'enterprise_repo',
1103
display_name: 'Enterprise Agent',
1104
description: 'Agent from enterprise',
1105
tools: [],
1106
version: 'v1.0',
1107
};
1108
1109
// API returns both agents for single org (enterprise agents are included via includeSources)
1110
mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {
1111
return [orgAAgent, enterpriseAgent];
1112
};
1113
1114
mockOctoKitService.getCustomAgentDetails = async (owner: string, repo: string, agentName: string, version?: string) => {
1115
// The API is called with the repo_owner, not the org name
1116
if (owner === 'testorg') {
1117
return { ...orgAAgent, prompt: 'Org prompt' };
1118
} else if (owner === 'enterprise_org') {
1119
return { ...enterpriseAgent, prompt: 'Enterprise prompt' };
1120
}
1121
return undefined;
1122
};
1123
1124
const provider = createProvider();
1125
await waitForPolling();
1126
1127
const agents = await provider.provideCustomAgents({}, {} as any);
1128
1129
// Since both agents have the same name, only one file is written (last one wins)
1130
// The filename is just `${agent.name}.agent.md`, so both would write to same file
1131
assert.equal(agents.length, 1);
1132
const agentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');
1133
assert.equal(agentName, 'shared_agent');
1134
});
1135
1136
test('deduplicates enterprise agents even when API returns them in different order', async () => {
1137
// Set up mocks BEFORE creating provider
1138
mockOctoKitService.setUserOrganizations(['orgA', 'orgB', 'orgC']);
1139
1140
const enterpriseAgent1: CustomAgentListItem = {
1141
name: 'enterprise_agent1',
1142
repo_owner_id: 999,
1143
repo_owner: 'enterprise_org',
1144
repo_id: 123,
1145
repo_name: 'enterprise_repo',
1146
display_name: 'Enterprise Agent 1',
1147
description: 'First enterprise agent',
1148
tools: [],
1149
version: 'v1.0',
1150
};
1151
1152
const enterpriseAgent2: CustomAgentListItem = {
1153
name: 'enterprise_agent2',
1154
repo_owner_id: 999,
1155
repo_owner: 'enterprise_org',
1156
repo_id: 123,
1157
repo_name: 'enterprise_repo',
1158
display_name: 'Enterprise Agent 2',
1159
description: 'Second enterprise agent',
1160
tools: [],
1161
version: 'v1.0',
1162
};
1163
1164
let callCount = 0;
1165
mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {
1166
callCount++;
1167
// Return agents in different orders for different orgs
1168
if (callCount === 1) {
1169
return [enterpriseAgent1, enterpriseAgent2];
1170
} else if (callCount === 2) {
1171
return [enterpriseAgent2, enterpriseAgent1]; // Reversed order
1172
} else {
1173
return [enterpriseAgent1, enterpriseAgent2];
1174
}
1175
};
1176
1177
mockOctoKitService.getCustomAgentDetails = async (owner: string, repo: string, agentName: string, version?: string) => {
1178
if (agentName === 'enterprise_agent1') {
1179
return { ...enterpriseAgent1, prompt: 'Prompt 1' };
1180
} else if (agentName === 'enterprise_agent2') {
1181
return { ...enterpriseAgent2, prompt: 'Prompt 2' };
1182
}
1183
return undefined;
1184
};
1185
1186
const provider = createProvider();
1187
await waitForPolling();
1188
1189
const agents = await provider.provideCustomAgents({}, {} as any);
1190
1191
// Should have exactly 2 agents, not 6 (2 agents x 3 orgs)
1192
assert.equal(agents.length, 2);
1193
1194
// Verify both agent names are present
1195
const agentNames = agents.map(a => a.uri.path.split('/').pop()?.replace('.agent.md', '')).sort();
1196
assert.deepEqual(agentNames, ['enterprise_agent1', 'enterprise_agent2']);
1197
});
1198
1199
test('deduplication key does not include version so different versions are deduplicated', async () => {
1200
// Set up mocks BEFORE creating provider
1201
mockOctoKitService.setUserOrganizations(['orgA']);
1202
1203
// Same agent with two different versions
1204
const agentV1: CustomAgentListItem = {
1205
name: 'multi_version_agent',
1206
repo_owner_id: 999,
1207
repo_owner: 'enterprise_org',
1208
repo_id: 123,
1209
repo_name: 'enterprise_repo',
1210
display_name: 'Multi Version Agent',
1211
description: 'Agent with multiple versions',
1212
tools: [],
1213
version: 'v1.0',
1214
};
1215
1216
const agentV2: CustomAgentListItem = {
1217
...agentV1,
1218
version: 'v2.0',
1219
};
1220
1221
mockOctoKitService.getCustomAgents = async () => {
1222
return [agentV1, agentV2];
1223
};
1224
1225
mockOctoKitService.getCustomAgentDetails = async (owner: string, repo: string, agentName: string, version?: string) => {
1226
if (version === 'v1.0') {
1227
return { ...agentV1, prompt: 'Prompt for v1' };
1228
} else if (version === 'v2.0') {
1229
return { ...agentV2, prompt: 'Prompt for v2' };
1230
}
1231
return undefined;
1232
};
1233
1234
const provider = createProvider();
1235
await waitForPolling();
1236
1237
const agents = await provider.provideCustomAgents({}, {} as any);
1238
1239
// Different versions are deduplicated, only the first one is kept
1240
assert.equal(agents.length, 1);
1241
const multiVersionAgentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');
1242
assert.equal(multiVersionAgentName, 'multi_version_agent');
1243
});
1244
});
1245
1246
suite('looksLikeNumber', () => {
1247
1248
test('returns false for empty string', () => {
1249
assert.strictEqual(looksLikeNumber(''), false);
1250
});
1251
1252
test('returns true for integers', () => {
1253
assert.strictEqual(looksLikeNumber('0'), true);
1254
assert.strictEqual(looksLikeNumber('123'), true);
1255
assert.strictEqual(looksLikeNumber('-456'), true);
1256
});
1257
1258
test('returns true for decimals', () => {
1259
assert.strictEqual(looksLikeNumber('3.14'), true);
1260
assert.strictEqual(looksLikeNumber('-0.5'), true);
1261
assert.strictEqual(looksLikeNumber('.5'), true);
1262
});
1263
1264
test('returns false for non-numeric strings', () => {
1265
assert.strictEqual(looksLikeNumber('abc'), false);
1266
assert.strictEqual(looksLikeNumber('12abc'), false);
1267
assert.strictEqual(looksLikeNumber('hello'), false);
1268
});
1269
1270
test('returns false for special number representations', () => {
1271
// These don't match the regex /^-?\d*\.?\d+$/
1272
assert.strictEqual(looksLikeNumber('1e10'), false);
1273
assert.strictEqual(looksLikeNumber('1.5e-3'), false);
1274
assert.strictEqual(looksLikeNumber('Infinity'), false);
1275
assert.strictEqual(looksLikeNumber('-Infinity'), false);
1276
assert.strictEqual(looksLikeNumber('NaN'), false);
1277
});
1278
1279
test('returns false for hex/octal representations', () => {
1280
assert.strictEqual(looksLikeNumber('0x1F'), false);
1281
assert.strictEqual(looksLikeNumber('0o17'), false);
1282
assert.strictEqual(looksLikeNumber('0b101'), false);
1283
});
1284
1285
test('returns false for strings with spaces', () => {
1286
assert.strictEqual(looksLikeNumber(' 123'), false);
1287
assert.strictEqual(looksLikeNumber('123 '), false);
1288
});
1289
});
1290
1291
suite('yamlString', () => {
1292
1293
test('returns plain string for simple text', () => {
1294
const result = yamlString('hello');
1295
assert.strictEqual(result, 'hello');
1296
});
1297
1298
test('returns plain string for text with spaces', () => {
1299
const result = yamlString('hello world');
1300
assert.strictEqual(result, 'hello world');
1301
});
1302
1303
suite('quoting for special characters', () => {
1304
1305
test('quotes strings containing hash (comment)', () => {
1306
const result = yamlString('value with # hash');
1307
assert.ok(result instanceof Scalar);
1308
assert.strictEqual(result.value, 'value with # hash');
1309
assert.strictEqual(result.type, Scalar.QUOTE_SINGLE);
1310
});
1311
1312
test('quotes strings containing colon', () => {
1313
const result = yamlString('key: value');
1314
assert.ok(result instanceof Scalar);
1315
assert.strictEqual(result.value, 'key: value');
1316
});
1317
1318
test('quotes strings containing brackets', () => {
1319
const result = yamlString('array [1, 2]');
1320
assert.ok(result instanceof Scalar);
1321
assert.strictEqual(result.value, 'array [1, 2]');
1322
});
1323
1324
test('quotes strings containing braces', () => {
1325
const result = yamlString('object {a: 1}');
1326
assert.ok(result instanceof Scalar);
1327
assert.strictEqual(result.value, 'object {a: 1}');
1328
});
1329
1330
test('quotes strings containing comma', () => {
1331
const result = yamlString('a, b, c');
1332
assert.ok(result instanceof Scalar);
1333
assert.strictEqual(result.value, 'a, b, c');
1334
});
1335
1336
test('quotes strings containing newline', () => {
1337
const result = yamlString('line1\nline2');
1338
assert.ok(result instanceof Scalar);
1339
assert.strictEqual(result.value, 'line1\nline2');
1340
// Newlines require double quotes for escape sequence support
1341
assert.strictEqual(result.type, Scalar.QUOTE_DOUBLE);
1342
});
1343
1344
test('quotes strings containing carriage return', () => {
1345
const result = yamlString('line1\rline2');
1346
assert.ok(result instanceof Scalar);
1347
assert.strictEqual(result.value, 'line1\rline2');
1348
// Carriage returns require double quotes for escape sequence support
1349
assert.strictEqual(result.type, Scalar.QUOTE_DOUBLE);
1350
});
1351
});
1352
1353
suite('quoting for values starting with quotes', () => {
1354
1355
test('quotes strings starting with single quote', () => {
1356
const result = yamlString(`'quoted value`);
1357
assert.ok(result instanceof Scalar);
1358
assert.strictEqual(result.value, `'quoted value`);
1359
});
1360
1361
test('quotes strings starting with double quote', () => {
1362
const result = yamlString(`"quoted value`);
1363
assert.ok(result instanceof Scalar);
1364
assert.strictEqual(result.value, `"quoted value`);
1365
});
1366
});
1367
1368
suite('quoting for whitespace', () => {
1369
1370
test('quotes strings with leading space', () => {
1371
const result = yamlString(' leading space');
1372
assert.ok(result instanceof Scalar);
1373
assert.strictEqual(result.value, ' leading space');
1374
});
1375
1376
test('quotes strings with trailing space', () => {
1377
const result = yamlString('trailing space ');
1378
assert.ok(result instanceof Scalar);
1379
assert.strictEqual(result.value, 'trailing space ');
1380
});
1381
});
1382
1383
suite('quoting for YAML keywords', () => {
1384
1385
test('quotes "true" to preserve as string', () => {
1386
const result = yamlString('true');
1387
assert.ok(result instanceof Scalar);
1388
assert.strictEqual(result.value, 'true');
1389
});
1390
1391
test('quotes "false" to preserve as string', () => {
1392
const result = yamlString('false');
1393
assert.ok(result instanceof Scalar);
1394
assert.strictEqual(result.value, 'false');
1395
});
1396
1397
test('quotes "null" to preserve as string', () => {
1398
const result = yamlString('null');
1399
assert.ok(result instanceof Scalar);
1400
assert.strictEqual(result.value, 'null');
1401
});
1402
1403
test('quotes "~" to preserve as string', () => {
1404
const result = yamlString('~');
1405
assert.ok(result instanceof Scalar);
1406
assert.strictEqual(result.value, '~');
1407
});
1408
1409
test('does not quote "True" (case sensitive)', () => {
1410
const result = yamlString('True');
1411
assert.strictEqual(result, 'True');
1412
});
1413
1414
test('does not quote "FALSE" (case sensitive)', () => {
1415
const result = yamlString('FALSE');
1416
assert.strictEqual(result, 'FALSE');
1417
});
1418
});
1419
1420
suite('quoting for numeric strings', () => {
1421
1422
test('quotes integer strings', () => {
1423
const result = yamlString('123');
1424
assert.ok(result instanceof Scalar);
1425
assert.strictEqual(result.value, '123');
1426
});
1427
1428
test('quotes negative integers', () => {
1429
const result = yamlString('-456');
1430
assert.ok(result instanceof Scalar);
1431
assert.strictEqual(result.value, '-456');
1432
});
1433
1434
test('quotes decimal strings', () => {
1435
const result = yamlString('3.14');
1436
assert.ok(result instanceof Scalar);
1437
assert.strictEqual(result.value, '3.14');
1438
});
1439
1440
test('does not quote non-numeric strings that look similar', () => {
1441
const result = yamlString('v1.0');
1442
assert.strictEqual(result, 'v1.0');
1443
});
1444
});
1445
1446
suite('quote type selection', () => {
1447
1448
test('uses single quotes by default when quoting', () => {
1449
const result = yamlString('value with # hash');
1450
assert.ok(result instanceof Scalar);
1451
assert.strictEqual(result.type, Scalar.QUOTE_SINGLE);
1452
});
1453
1454
test('does not quote string with only single quote (no special chars)', () => {
1455
// `it's a value` has no special YAML characters, so no quoting is needed
1456
const result = yamlString(`it's a value`);
1457
assert.strictEqual(result, `it's a value`);
1458
});
1459
1460
test('uses double quotes when value has single quote and special chars', () => {
1461
const result = yamlString(`it's a value: with colon`);
1462
assert.ok(result instanceof Scalar);
1463
assert.strictEqual(result.type, Scalar.QUOTE_DOUBLE);
1464
});
1465
});
1466
});
1467
1468
suite('yamlString round-trip with custom YAML parser', () => {
1469
/**
1470
* These tests verify that values processed by yamlString() can be
1471
* correctly parsed back by the custom YAML parser in yaml.ts
1472
*/
1473
1474
function roundTrip(value: string): string | undefined {
1475
const yamlValue = yamlString(value);
1476
let yamlStr: string;
1477
1478
if (yamlValue instanceof Scalar) {
1479
// Simulate how YAML library would stringify this
1480
if (yamlValue.type === Scalar.QUOTE_SINGLE) {
1481
yamlStr = `'${value}'`;
1482
} else {
1483
// Double quotes - need to escape internal double quotes
1484
yamlStr = `"${value.replace(/"/g, '\\"')}"`;
1485
}
1486
} else {
1487
yamlStr = value;
1488
}
1489
1490
// Parse as a simple key-value YAML
1491
const yaml = `key: ${yamlStr}`;
1492
const parsed = parse(yaml);
1493
1494
if (parsed?.type === 'object' && parsed.properties.length > 0) {
1495
const prop = parsed.properties[0];
1496
if (prop.value.type === 'string') {
1497
return prop.value.value;
1498
}
1499
}
1500
return undefined;
1501
}
1502
1503
test('round-trips plain string', () => {
1504
assert.strictEqual(roundTrip('hello world'), 'hello world');
1505
});
1506
1507
test('round-trips string with hash', () => {
1508
assert.strictEqual(roundTrip('value # comment'), 'value # comment');
1509
});
1510
1511
test('round-trips string with colon', () => {
1512
assert.strictEqual(roundTrip('key: value'), 'key: value');
1513
});
1514
1515
test('round-trips boolean keyword as string', () => {
1516
assert.strictEqual(roundTrip('true'), 'true');
1517
assert.strictEqual(roundTrip('false'), 'false');
1518
});
1519
1520
test('round-trips null keyword as string', () => {
1521
assert.strictEqual(roundTrip('null'), 'null');
1522
});
1523
1524
test('round-trips numeric string', () => {
1525
assert.strictEqual(roundTrip('123'), '123');
1526
assert.strictEqual(roundTrip('3.14'), '3.14');
1527
});
1528
1529
test('round-trips string with leading/trailing whitespace', () => {
1530
assert.strictEqual(roundTrip(' padded '), ' padded ');
1531
});
1532
1533
test('round-trips string with single quotes (no special chars)', () => {
1534
// Apostrophes without other special chars don't need quoting
1535
assert.strictEqual(roundTrip(`it's working`), `it's working`);
1536
});
1537
1538
test('round-trips string with single quotes and special chars', () => {
1539
// When both single quote and special char are present, double quotes are used
1540
assert.strictEqual(roundTrip(`it's a value: with colon`), `it's a value: with colon`);
1541
});
1542
});
1543
1544