Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/test/githubOrgCustomAgentProvider.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { assert } from 'chai';6import { afterEach, beforeEach, suite, test, vi } from 'vitest';7import type { ExtensionContext } from 'vscode';8import { Scalar } from 'yaml';9import { PromptsType } from '../../../../platform/customInstructions/common/promptTypes';10import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';11import { CustomAgentDetails, CustomAgentListItem, CustomAgentListOptions } from '../../../../platform/github/common/githubService';12import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';13import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService';14import { MockWorkspaceService } from '../../../../platform/ignore/node/test/mockWorkspaceService';15import { ILogService } from '../../../../platform/log/common/logService';16import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';17import { URI } from '../../../../util/vs/base/common/uri';18import { parse } from '../../../../util/vs/base/common/yaml';19import { createExtensionUnitTestingServices } from '../../../test/node/services';20import { GitHubOrgChatResourcesService } from '../githubOrgChatResourcesService';21import { GitHubOrgCustomAgentProvider, looksLikeNumber, yamlString } from '../githubOrgCustomAgentProvider';22import { MockOctoKitService } from './mockOctoKitService';2324suite('GitHubOrgCustomAgentProvider', () => {25let disposables: DisposableStore;26let mockOctoKitService: MockOctoKitService;27let mockFileSystem: MockFileSystemService;28let mockGitService: MockGitService;29let mockWorkspaceService: MockWorkspaceService;30let mockExtensionContext: Partial<ExtensionContext>;31let mockAuthService: MockAuthenticationService;32let accessor: any;33let provider: GitHubOrgCustomAgentProvider;34let resourcesService: GitHubOrgChatResourcesService;3536const storagePath = '/tmp/test-storage';37const storageUri = URI.file(storagePath);3839beforeEach(() => {40vi.useFakeTimers();41disposables = new DisposableStore();4243// Create mocks for real GitHubOrgChatResourcesService44mockOctoKitService = new MockOctoKitService();45mockFileSystem = new MockFileSystemService();46mockGitService = new MockGitService();47mockWorkspaceService = new MockWorkspaceService();48mockExtensionContext = {49globalStorageUri: storageUri,50};51mockAuthService = new MockAuthenticationService();5253// Default: user is in 'testorg' and workspace belongs to 'testorg'54mockOctoKitService.setUserOrganizations(['testorg']);55mockWorkspaceService.setWorkspaceFolders([URI.file('/workspace')]);56mockGitService.setRepositoryFetchUrls({57rootUri: URI.file('/workspace'),58remoteFetchUrls: ['https://github.com/testorg/repo.git']59});6061// Set up testing services62const testingServiceCollection = createExtensionUnitTestingServices(disposables);63accessor = disposables.add(testingServiceCollection.createTestingAccessor());64});6566afterEach(() => {67vi.useRealTimers();68disposables.dispose();69mockOctoKitService.clearAgents();70});7172function createProvider() {73// Create the real GitHubOrgChatResourcesService with mocked dependencies74resourcesService = new GitHubOrgChatResourcesService(75mockAuthService as any,76mockExtensionContext as any,77mockFileSystem,78mockGitService,79accessor.get(ILogService),80mockOctoKitService,81mockWorkspaceService,82);83disposables.add(resourcesService);8485// Create provider with real resources service86provider = new GitHubOrgCustomAgentProvider(87mockOctoKitService,88accessor.get(ILogService),89resourcesService,90);91disposables.add(provider);92return provider;93}9495/**96* Advance timers and wait for polling callback to complete.97* Uses a small time advance to trigger the initial poll without infinite loops.98*/99async function waitForPolling(): Promise<void> {100// Advance just enough to let initial poll complete, but not trigger interval polls101await vi.advanceTimersByTimeAsync(10);102}103104/**105* Helper to pre-populate cache files in mock filesystem.106*/107function prepopulateCache(orgName: string, files: Map<string, string>): void {108const cacheDir = URI.file(`${storagePath}/github/${orgName}/agents`);109const dirEntries: [string, import('../../../../platform/filesystem/common/fileTypes').FileType][] = [];110for (const [filename, content] of files) {111mockFileSystem.mockFile(URI.joinPath(cacheDir, filename), content);112dirEntries.push([filename, 1 /* FileType.File */]);113}114mockFileSystem.mockDirectory(cacheDir, dirEntries);115}116117test('returns empty array when user has no organizations', async () => {118mockOctoKitService.setUserOrganizations([]);119mockWorkspaceService.setWorkspaceFolders([]);120const provider = createProvider();121122const agents = await provider.provideCustomAgents({}, {} as any);123124assert.deepEqual(agents, []);125});126127test('returns empty array when no organizations and no cached files', async () => {128// With no organizations and no cached files, should return empty129mockOctoKitService.setUserOrganizations([]);130mockWorkspaceService.setWorkspaceFolders([]);131const provider = createProvider();132133const agents = await provider.provideCustomAgents({}, {} as any);134135assert.deepEqual(agents, []);136});137138// todo: MockFileSystemService previously had a bug where deleted files would139// still show up when listing directories. This was fixed and caused this test140// to fail: test_agent.md is cleared from the cache in the first poll141test.skip('returns cached agents on first call', async () => {142// Set up file system mocks BEFORE creating provider to avoid race with background fetch143// Also prevent background fetch from interfering by having no organizations144mockOctoKitService.setUserOrganizations([]);145mockWorkspaceService.setWorkspaceFolders([]);146147// Pre-populate cache with org folder (but keep testorg folder structure)148const agentContent = `---149name: Test Agent150description: A test agent151---152Test prompt content`;153prepopulateCache('testorg', new Map([['test_agent.agent.md', agentContent]]));154155// Re-enable testorg for cache reading (user is in org, but no workspace repo)156mockOctoKitService.setUserOrganizations(['testorg']);157158const provider = createProvider();159160// Wait for initial poll attempt (won't fetch since no agents in API)161await waitForPolling();162163const agents = await provider.provideCustomAgents({}, {} as any);164165assert.equal(agents.length, 1);166const agentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');167assert.equal(agentName, 'test_agent');168});169170test('fetches and caches agents from API', async () => {171// Mock API response BEFORE creating provider172const mockAgent: CustomAgentListItem = {173name: 'api_agent',174repo_owner_id: 1,175repo_owner: 'testorg',176repo_id: 1,177repo_name: 'testrepo',178display_name: 'API Agent',179description: 'An agent from API',180tools: ['tool1'],181version: 'v1',182};183mockOctoKitService.setCustomAgents([mockAgent]);184185const mockDetails: CustomAgentDetails = {186...mockAgent,187prompt: 'API prompt content',188};189mockOctoKitService.setAgentDetails('api_agent', mockDetails);190191const provider = createProvider();192193// Wait for background fetch to complete194await waitForPolling();195196// Second call should return newly cached agents from memory197const agents2 = await provider.provideCustomAgents({}, {} as any);198assert.equal(agents2.length, 1);199const agentName2 = agents2[0].uri.path.split('/').pop()?.replace('.agent.md', '');200assert.equal(agentName2, 'api_agent');201202// Third call should also return from memory cache without file I/O203const agents3 = await provider.provideCustomAgents({}, {} as any);204assert.equal(agents3.length, 1);205const agentName3 = agents3[0].uri.path.split('/').pop()?.replace('.agent.md', '');206assert.equal(agentName3, 'api_agent');207});208209test('generates correct markdown format for agents', async () => {210const provider = createProvider();211212const mockAgent: CustomAgentListItem = {213name: 'full_agent',214repo_owner_id: 1,215repo_owner: 'testorg',216repo_id: 1,217repo_name: 'testrepo',218display_name: 'Full Agent',219description: 'A fully configured agent',220tools: ['tool1', 'tool2'],221version: 'v1',222argument_hint: 'Provide context',223target: 'vscode',224};225mockOctoKitService.setCustomAgents([mockAgent]);226227const mockDetails: CustomAgentDetails = {228...mockAgent,229prompt: 'Detailed prompt content',230model: 'gpt-4',231disable_model_invocation: true,232};233mockOctoKitService.setAgentDetails('full_agent', mockDetails);234235await provider.provideCustomAgents({}, {} as any);236await waitForPolling();237238// Check cached file content using the real service239const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'full_agent.agent.md');240241const expectedContent = `---242name: Full Agent243description: A fully configured agent244tools:245- tool1246- tool2247argument-hint: Provide context248target: vscode249model: gpt-4250disable-model-invocation: true251---252Detailed prompt content253`;254255assert.equal(content, expectedContent);256});257258test('generates markdown with user-invocable property', async () => {259const provider = createProvider();260261const mockAgent: CustomAgentListItem = {262name: 'invocable_agent',263repo_owner_id: 1,264repo_owner: 'testorg',265repo_id: 1,266repo_name: 'testrepo',267display_name: 'Invocable Agent',268description: 'An agent with user-invocable set',269tools: [],270version: 'v1',271};272mockOctoKitService.setCustomAgents([mockAgent]);273274const mockDetails: CustomAgentDetails = {275...mockAgent,276prompt: 'Invocable prompt content',277user_invocable: true,278};279mockOctoKitService.setAgentDetails('invocable_agent', mockDetails);280281await provider.provideCustomAgents({}, {} as any);282await waitForPolling();283284const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'invocable_agent.agent.md');285286const expectedContent = `---287name: Invocable Agent288description: An agent with user-invocable set289user-invocable: true290---291Invocable prompt content292`;293294assert.equal(content, expectedContent);295});296297test('generates markdown with false values for disable-model-invocation and user-invocable', async () => {298const provider = createProvider();299300const mockAgent: CustomAgentListItem = {301name: 'false_flags_agent',302repo_owner_id: 1,303repo_owner: 'testorg',304repo_id: 1,305repo_name: 'testrepo',306display_name: 'False Flags Agent',307description: 'Agent with false boolean flags',308tools: [],309version: 'v1',310};311mockOctoKitService.setCustomAgents([mockAgent]);312313const mockDetails: CustomAgentDetails = {314...mockAgent,315prompt: 'False flags prompt',316disable_model_invocation: false,317user_invocable: false,318};319mockOctoKitService.setAgentDetails('false_flags_agent', mockDetails);320321await provider.provideCustomAgents({}, {} as any);322await waitForPolling();323324const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'false_flags_agent.agent.md');325326const expectedContent = `---327name: False Flags Agent328description: Agent with false boolean flags329disable-model-invocation: false330user-invocable: false331---332False flags prompt333`;334335assert.equal(content, expectedContent);336});337338test('preserves agent name in filename', async () => {339// Note: The provider does NOT sanitize filenames - it uses the agent name directly.340// This test documents the actual behavior.341const provider = createProvider();342343const mockAgent: CustomAgentListItem = {344name: 'my-agent_name',345repo_owner_id: 1,346repo_owner: 'testorg',347repo_id: 1,348repo_name: 'testrepo',349display_name: 'My Agent',350description: 'Test filename',351tools: [],352version: 'v1',353};354mockOctoKitService.setCustomAgents([mockAgent]);355356const mockDetails: CustomAgentDetails = {357...mockAgent,358prompt: 'Prompt content',359};360mockOctoKitService.setAgentDetails('my-agent_name', mockDetails);361362await provider.provideCustomAgents({}, {} as any);363await waitForPolling();364365// File is created with the exact agent name (no sanitization)366const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'my-agent_name.agent.md');367assert.ok(content, 'File should exist with agent name as filename');368});369370test.skip('fires change event when cache is updated on first fetch', async () => {371const provider = createProvider();372373const mockAgent: CustomAgentListItem = {374name: 'changing_agent',375repo_owner_id: 1,376repo_owner: 'testorg',377repo_id: 1,378repo_name: 'testrepo',379display_name: 'Changing Agent',380description: 'Will change',381tools: [],382version: 'v1',383};384mockOctoKitService.setCustomAgents([mockAgent]);385386const mockDetails: CustomAgentDetails = {387...mockAgent,388prompt: 'Initial prompt',389};390mockOctoKitService.setAgentDetails('changing_agent', mockDetails);391392let eventFired = false;393provider.onDidChangeCustomAgents(() => {394eventFired = true;395});396397// First call triggers background fetch398await provider.provideCustomAgents({}, {} as any);399await waitForPolling();400401// Event should fire after initial successful fetch402assert.equal(eventFired, true);403});404405test('handles API errors gracefully', async () => {406const provider = createProvider();407408// Make the API throw an error409mockOctoKitService.getCustomAgents = async () => {410throw new Error('API Error');411};412413// Should not throw, should return empty array414const agents = await provider.provideCustomAgents({}, {} as any);415assert.deepEqual(agents, []);416});417418test('passes query options to API correctly', async () => {419const provider = createProvider();420421let capturedOptions: CustomAgentListOptions | undefined;422mockOctoKitService.getCustomAgents = async (owner: string, repo: string, options?: CustomAgentListOptions) => {423capturedOptions = options;424return [];425};426427await provider.provideCustomAgents({}, {} as any);428await waitForPolling();429430assert.ok(capturedOptions);431assert.deepEqual(capturedOptions.includeSources, ['org', 'enterprise']);432});433434test('prevents concurrent fetches when called multiple times rapidly', async () => {435const provider = createProvider();436437let apiCallCount = 0;438mockOctoKitService.getCustomAgents = async () => {439apiCallCount++;440// Simulate slow API call - use real timer for this441await new Promise(resolve => {442const realSetTimeout = globalThis.setTimeout;443realSetTimeout(resolve, 50);444});445return [];446};447448// Make multiple concurrent calls449const promise1 = provider.provideCustomAgents({}, {} as any);450const promise2 = provider.provideCustomAgents({}, {} as any);451const promise3 = provider.provideCustomAgents({}, {} as any);452453await Promise.all([promise1, promise2, promise3]);454await waitForPolling();455456// API should only be called once due to isFetching guard457assert.equal(apiCallCount, 1);458});459460test('handles partial agent detail fetch failures gracefully', async () => {461const agents: CustomAgentListItem[] = [462{463name: 'agent1',464repo_owner_id: 1,465repo_owner: 'testorg',466repo_id: 1,467repo_name: 'testrepo',468display_name: 'Agent 1',469description: 'First agent',470tools: [],471version: 'v1',472},473{474name: 'agent2',475repo_owner_id: 1,476repo_owner: 'testorg',477repo_id: 1,478repo_name: 'testrepo',479display_name: 'Agent 2',480description: 'Second agent',481tools: [],482version: 'v1',483},484];485mockOctoKitService.setCustomAgents(agents);486487// Set details for only the first agent (second will fail)488mockOctoKitService.setAgentDetails('agent1', {489...agents[0],490prompt: 'Agent 1 prompt',491});492493// Pre-populate file cache with the first agent to simulate previous successful state494const agentContent = `---495name: Agent 1496description: First agent497---498Agent 1 prompt`;499prepopulateCache('testorg', new Map([['agent1.agent.md', agentContent]]));500501const provider = createProvider();502await waitForPolling();503504// With error handling, partial failures skip cache update for that org505// So the existing file cache is returned with the one successful agent506const cachedAgents = await provider.provideCustomAgents({}, {} as any);507assert.equal(cachedAgents.length, 1);508const cachedAgentName = cachedAgents[0].uri.path.split('/').pop()?.replace('.agent.md', '');509assert.equal(cachedAgentName, 'agent1');510});511512test('caches agents in memory after first successful fetch', async () => {513// Initial setup with one agent BEFORE creating provider514const initialAgent: CustomAgentListItem = {515name: 'initial_agent',516repo_owner_id: 1,517repo_owner: 'testorg',518repo_id: 1,519repo_name: 'testrepo',520display_name: 'Initial Agent',521description: 'First agent',522tools: [],523version: 'v1',524};525mockOctoKitService.setCustomAgents([initialAgent]);526mockOctoKitService.setAgentDetails('initial_agent', {527...initialAgent,528prompt: 'Initial prompt',529});530531const provider = createProvider();532await waitForPolling();533534// After successful fetch, subsequent calls return from memory535const agents1 = await provider.provideCustomAgents({}, {} as any);536assert.equal(agents1.length, 1);537const agentName1 = agents1[0].uri.path.split('/').pop()?.replace('.agent.md', '');538assert.equal(agentName1, 'initial_agent');539540// Even if API is updated, memory cache is used541const newAgent: CustomAgentListItem = {542name: 'new_agent',543repo_owner_id: 1,544repo_owner: 'testorg',545repo_id: 1,546repo_name: 'testrepo',547display_name: 'New Agent',548description: 'Newly added agent',549tools: [],550version: 'v1',551};552mockOctoKitService.setCustomAgents([initialAgent, newAgent]);553mockOctoKitService.setAgentDetails('new_agent', {554...newAgent,555prompt: 'New prompt',556});557558// Memory cache returns old results without refetching559const agents2 = await provider.provideCustomAgents({}, {} as any);560assert.equal(agents2.length, 1);561const agentName2ForMemory = agents2[0].uri.path.split('/').pop()?.replace('.agent.md', '');562assert.equal(agentName2ForMemory, 'initial_agent');563});564565test('memory cache persists after first successful fetch', async () => {566// Initial setup with two agents BEFORE creating provider567const agents: CustomAgentListItem[] = [568{569name: 'agent1',570repo_owner_id: 1,571repo_owner: 'testorg',572repo_id: 1,573repo_name: 'testrepo',574display_name: 'Agent 1',575description: 'First agent',576tools: [],577version: 'v1',578},579{580name: 'agent2',581repo_owner_id: 1,582repo_owner: 'testorg',583repo_id: 1,584repo_name: 'testrepo',585display_name: 'Agent 2',586description: 'Second agent',587tools: [],588version: 'v1',589},590];591mockOctoKitService.setCustomAgents(agents);592mockOctoKitService.setAgentDetails('agent1', { ...agents[0], prompt: 'Prompt 1' });593mockOctoKitService.setAgentDetails('agent2', { ...agents[1], prompt: 'Prompt 2' });594595const provider = createProvider();596await waitForPolling();597598// Verify both agents are cached599const cachedAgents1 = await provider.provideCustomAgents({}, {} as any);600assert.equal(cachedAgents1.length, 2);601602// Remove one agent from API603mockOctoKitService.setCustomAgents([agents[0]]);604605// Memory cache still returns both agents (no refetch)606const cachedAgents2 = await provider.provideCustomAgents({}, {} as any);607assert.equal(cachedAgents2.length, 2);608const cachedAgent2Name1 = cachedAgents2[0].uri.path.split('/').pop()?.replace('.agent.md', '');609const cachedAgent2Name2 = cachedAgents2[1].uri.path.split('/').pop()?.replace('.agent.md', '');610assert.equal(cachedAgent2Name1, 'agent1');611assert.equal(cachedAgent2Name2, 'agent2');612});613614test.skip('does not fire change event when content is identical', async () => {615const provider = createProvider();616617const mockAgent: CustomAgentListItem = {618name: 'stable_agent',619repo_owner_id: 1,620repo_owner: 'testorg',621repo_id: 1,622repo_name: 'testrepo',623display_name: 'Stable Agent',624description: 'Unchanging agent',625tools: [],626version: 'v1',627};628mockOctoKitService.setCustomAgents([mockAgent]);629mockOctoKitService.setAgentDetails('stable_agent', {630...mockAgent,631prompt: 'Stable prompt',632});633634await provider.provideCustomAgents({}, {} as any);635await waitForPolling();636637let changeEventCount = 0;638provider.onDidChangeCustomAgents(() => {639changeEventCount++;640});641642// Fetch again with identical content643await provider.provideCustomAgents({}, {} as any);644await waitForPolling();645646// No change event should fire647assert.equal(changeEventCount, 0);648});649650test('memory cache persists even when API returns empty list', async () => {651// Setup with initial agents BEFORE creating provider652const mockAgent: CustomAgentListItem = {653name: 'temporary_agent',654repo_owner_id: 1,655repo_owner: 'testorg',656repo_id: 1,657repo_name: 'testrepo',658display_name: 'Temporary Agent',659description: 'Will be removed',660tools: [],661version: 'v1',662};663mockOctoKitService.setCustomAgents([mockAgent]);664mockOctoKitService.setAgentDetails('temporary_agent', {665...mockAgent,666prompt: 'Temporary prompt',667});668669const provider = createProvider();670await waitForPolling();671672// Verify agent is cached673const agents1 = await provider.provideCustomAgents({}, {} as any);674assert.equal(agents1.length, 1);675676// API now returns empty array677mockOctoKitService.setCustomAgents([]);678679// Memory cache still returns the agent (no refetch)680const agents2 = await provider.provideCustomAgents({}, {} as any);681assert.equal(agents2.length, 1);682const temporaryAgentName = agents2[0].uri.path.split('/').pop()?.replace('.agent.md', '');683assert.equal(temporaryAgentName, 'temporary_agent');684});685686test('generates markdown with only required fields', async () => {687const provider = createProvider();688689// Agent with minimal fields (no optional fields)690const mockAgent: CustomAgentListItem = {691name: 'minimal_agent',692repo_owner_id: 1,693repo_owner: 'testorg',694repo_id: 1,695repo_name: 'testrepo',696display_name: 'Minimal Agent',697description: 'Minimal description',698tools: [],699version: 'v1',700};701mockOctoKitService.setCustomAgents([mockAgent]);702703const mockDetails: CustomAgentDetails = {704...mockAgent,705prompt: 'Minimal prompt',706};707mockOctoKitService.setAgentDetails('minimal_agent', mockDetails);708709await provider.provideCustomAgents({}, {} as any);710await waitForPolling();711712const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'minimal_agent.agent.md');713assert.ok(content, 'Agent file should exist');714715// Should have name and description, but no tools (empty array)716assert.ok(content.includes('name: Minimal Agent'));717assert.ok(content.includes('description: Minimal description'));718assert.ok(!content.includes('tools:'));719assert.ok(!content.includes('argument-hint:'));720assert.ok(!content.includes('target:'));721assert.ok(!content.includes('model:'));722assert.ok(!content.includes('disable-model-invocation:'));723});724725test('excludes tools field when array contains only wildcard', async () => {726const provider = createProvider();727728const mockAgent: CustomAgentListItem = {729name: 'wildcard_agent',730repo_owner_id: 1,731repo_owner: 'testorg',732repo_id: 1,733repo_name: 'testrepo',734display_name: 'Wildcard Agent',735description: 'Agent with wildcard tools',736tools: ['*'],737version: 'v1',738};739mockOctoKitService.setCustomAgents([mockAgent]);740741const mockDetails: CustomAgentDetails = {742...mockAgent,743prompt: 'Wildcard prompt',744};745mockOctoKitService.setAgentDetails('wildcard_agent', mockDetails);746747await provider.provideCustomAgents({}, {} as any);748await waitForPolling();749750const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'wildcard_agent.agent.md');751assert.ok(content, 'Agent file should exist');752753// Tools field should be excluded when it's just ['*']754assert.ok(!content.includes('tools:'));755});756757// todo: MockFileSystemService previously had a bug where deleted files would758// still show up when listing directories. This was fixed and caused this test759// to fail: agent files are cleared from the cache in the first poll760test.skip('handles malformed frontmatter in cached files', async () => {761// Prevent background fetch from interfering762mockOctoKitService.setUserOrganizations([]);763mockWorkspaceService.setWorkspaceFolders([]);764765// Pre-populate cache with mixed valid and malformed content BEFORE creating provider766const validContent = `---767name: Valid Agent768description: A valid agent769---770Valid prompt`;771// File without frontmatter - parser extracts name from filename, description is empty772const noFrontmatterContent = `Just some content without any frontmatter`;773prepopulateCache('testorg', new Map([774['valid_agent.agent.md', validContent],775['no_frontmatter.agent.md', noFrontmatterContent],776]));777778// Re-enable testorg for cache reading779mockOctoKitService.setUserOrganizations(['testorg']);780781const provider = createProvider();782783// Wait for initial poll (which uses testorg)784await waitForPolling();785786const agents = await provider.provideCustomAgents({}, {} as any);787788// Parser is lenient - both agents are returned, one with empty description789assert.equal(agents.length, 2);790const validAgentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');791assert.equal(validAgentName, 'valid_agent');792const noFrontmatterAgentName = agents[1].uri.path.split('/').pop()?.replace('.agent.md', '');793assert.equal(noFrontmatterAgentName, 'no_frontmatter');794});795796test('fetches agents from preferred organization only', async () => {797// The service only fetches from the preferred organization, not all user organizations.798// Preferred org is determined by workspace repository or first user organization.799const provider = createProvider();800801// Set up multiple organizations - testorg is the default preferred org802mockOctoKitService.setUserOrganizations(['testorg', 'otherorg1', 'otherorg2']);803804const capturedOrgs: string[] = [];805mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {806capturedOrgs.push(owner);807return [];808};809810await provider.provideCustomAgents({}, {} as any);811await waitForPolling();812813// Should have fetched from only the preferred organization814assert.equal(capturedOrgs.length, 1);815assert.ok(capturedOrgs.includes('testorg'));816});817818test('generates markdown with long description on single line', async () => {819const provider = createProvider();820821// Agent with a very long description that would normally be wrapped at 80 characters822const longDescription = 'Just for fun agent that teaches computer science concepts (while pretending to plot world domination).';823const mockAgent: CustomAgentListItem = {824name: 'world_domination',825repo_owner_id: 1,826repo_owner: 'testorg',827repo_id: 1,828repo_name: 'testrepo',829display_name: 'World Domination',830description: longDescription,831tools: [],832version: 'v1',833};834mockOctoKitService.setCustomAgents([mockAgent]);835836const mockDetails: CustomAgentDetails = {837...mockAgent,838prompt: '# World Domination Agent\n\nYou are a world-class computer scientist.',839};840mockOctoKitService.setAgentDetails('world_domination', mockDetails);841842await provider.provideCustomAgents({}, {} as any);843await waitForPolling();844845const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'world_domination.agent.md');846847const expectedContent = `---848name: World Domination849description: Just for fun agent that teaches computer science concepts (while pretending to plot world domination).850---851# World Domination Agent852853You are a world-class computer scientist.854`;855856assert.equal(content, expectedContent);857});858859test('generates markdown with special characters properly escaped in description', async () => {860const provider = createProvider();861862// Agent with description containing YAML special characters that need proper handling863const descriptionWithSpecialChars = `Agent with "double quotes", 'single quotes', colons:, and #comments in the description`;864const mockAgent: CustomAgentListItem = {865name: 'special_chars_agent',866repo_owner_id: 1,867repo_owner: 'testorg',868repo_id: 1,869repo_name: 'testrepo',870display_name: 'Special Chars Agent',871description: descriptionWithSpecialChars,872tools: [],873version: 'v1',874};875mockOctoKitService.setCustomAgents([mockAgent]);876877const mockDetails: CustomAgentDetails = {878...mockAgent,879prompt: 'Test prompt with special characters',880};881mockOctoKitService.setAgentDetails('special_chars_agent', mockDetails);882883await provider.provideCustomAgents({}, {} as any);884await waitForPolling();885886const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'special_chars_agent.agent.md');887888const expectedContent = `---889name: Special Chars Agent890description: "Agent with \\"double quotes\\", 'single quotes', colons:, and #comments in the description"891---892Test prompt with special characters893`;894895assert.equal(content, expectedContent);896});897898test('generates markdown with multiline description containing newlines', async () => {899const provider = createProvider();900901// Agent with description containing actual newline characters902const descriptionWithNewlines = 'First line of description.\nSecond line of description.\nThird line.';903const mockAgent: CustomAgentListItem = {904name: 'multiline_agent',905repo_owner_id: 1,906repo_owner: 'testorg',907repo_id: 1,908repo_name: 'testrepo',909display_name: 'Multiline Agent',910description: descriptionWithNewlines,911tools: [],912version: 'v1',913};914mockOctoKitService.setCustomAgents([mockAgent]);915916const mockDetails: CustomAgentDetails = {917...mockAgent,918prompt: 'Test prompt',919};920mockOctoKitService.setAgentDetails('multiline_agent', mockDetails);921922await provider.provideCustomAgents({}, {} as any);923await waitForPolling();924925const content = await resourcesService.readCacheFile(PromptsType.agent, 'testorg', 'multiline_agent.agent.md');926927// Newlines should be escaped using double quotes to keep description on a single line928// (the custom YAML parser doesn't support multi-line strings)929const expectedContent = `---930name: Multiline Agent931description: "First line of description.\\nSecond line of description.\\nThird line."932---933Test prompt934`;935936assert.equal(content, expectedContent);937});938939test('aborts fetch if user signs out during process', async () => {940const provider = createProvider();941942// Setup multiple organizations to ensure we have multiple steps943mockOctoKitService.setUserOrganizations(['org1', 'org2']);944mockOctoKitService.getOrganizationRepositories = async (org) => ['repo'];945946// Mock getCustomAgents to simulate sign out after first org947let callCount = 0;948const originalGetCustomAgents = mockOctoKitService.getCustomAgents;949mockOctoKitService.getCustomAgents = async (owner, repo, options) => {950callCount++;951if (callCount === 1) {952// Sign out user after first call953mockOctoKitService.getCurrentAuthedUser = async () => undefined as any;954}955return originalGetCustomAgents.call(mockOctoKitService, owner, repo, options, {});956};957958await provider.provideCustomAgents({}, {} as any);959await waitForPolling();960961// Should have aborted after first org, so second org shouldn't be processed962assert.equal(callCount, 1);963});964965test('deduplicates enterprise agents that appear in multiple organizations', async () => {966// Setup multiple organizations BEFORE creating provider967mockOctoKitService.setUserOrganizations(['orgA', 'orgB']);968// Clear default workspace so getPreferredOrganizationName falls back to user organizations969mockWorkspaceService.setWorkspaceFolders([]);970971// Create an enterprise agent that will appear in both organizations972const enterpriseAgent: CustomAgentListItem = {973name: 'enterprise_agent',974repo_owner_id: 999,975repo_owner: 'enterprise_org',976repo_id: 123,977repo_name: 'enterprise_repo',978display_name: 'Enterprise Agent',979description: 'Shared enterprise agent',980tools: [],981version: 'v1.0',982};983984// Mock getCustomAgents to return the same enterprise agent for both orgs985mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {986// Both orgs return the same enterprise agent (same repo_owner, repo_name, name, version)987return [enterpriseAgent];988};989990mockOctoKitService.setAgentDetails('enterprise_agent', {991...enterpriseAgent,992prompt: 'Enterprise prompt',993});994995const provider = createProvider();996await waitForPolling();997998const agents = await provider.provideCustomAgents({}, {} as any);9991000// Should only have one agent, not two (deduped)1001assert.equal(agents.length, 1);1002const enterpriseAgentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');1003assert.equal(enterpriseAgentName, 'enterprise_agent');10041005// Verify it was only written to one org directory1006// Check which org has the agent file1007const orgAContent = await resourcesService.readCacheFile(PromptsType.agent, 'orga', 'enterprise_agent.agent.md');1008const orgBContent = await resourcesService.readCacheFile(PromptsType.agent, 'orgb', 'enterprise_agent.agent.md');1009const orgAHasAgent = orgAContent !== undefined;1010const orgBHasAgent = orgBContent !== undefined;10111012// Agent should be in exactly one org directory (the first one processed)1013assert.ok(orgAHasAgent && !orgBHasAgent, 'Enterprise agent should only be cached in first org');1014});10151016test('deduplicates agents with same repo regardless of version', async () => {1017// Set up mocks BEFORE creating provider1018mockOctoKitService.setUserOrganizations(['orgA', 'orgB']);10191020// Create agents with same name but different versions1021const agentV1: CustomAgentListItem = {1022name: 'versioned_agent',1023repo_owner_id: 999,1024repo_owner: 'enterprise_org',1025repo_id: 123,1026repo_name: 'enterprise_repo',1027display_name: 'Versioned Agent',1028description: 'Agent version 1',1029tools: [],1030version: 'v1.0',1031};10321033const agentV2: CustomAgentListItem = {1034name: 'versioned_agent',1035repo_owner_id: 999,1036repo_owner: 'enterprise_org',1037repo_id: 123,1038repo_name: 'enterprise_repo',1039display_name: 'Versioned Agent',1040description: 'Agent version 2',1041tools: [],1042version: 'v2.0',1043};10441045let callCount = 0;1046mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {1047callCount++;1048if (callCount === 1) {1049// First org returns v1 and v21050return [agentV1, agentV2];1051} else {1052// Second org also returns both versions1053return [agentV1, agentV2];1054}1055};10561057mockOctoKitService.getCustomAgentDetails = async (owner: string, repo: string, agentName: string, version?: string) => {1058if (version === 'v1.0') {1059return { ...agentV1, prompt: 'Version 1 prompt' };1060} else if (version === 'v2.0') {1061return { ...agentV2, prompt: 'Version 2 prompt' };1062}1063return undefined;1064};10651066const provider = createProvider();1067await waitForPolling();10681069const agents = await provider.provideCustomAgents({}, {} as any);10701071// Different versions are deduplicated, only the first one is kept1072assert.equal(agents.length, 1);1073const versionedAgentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');1074assert.equal(versionedAgentName, 'versioned_agent');1075});10761077test('handles agents with same name but different repo owners from single org', async () => {1078// Set up mocks BEFORE creating provider1079// This tests the case where a single org returns agents from different repo owners1080// (e.g., an org-specific agent and an enterprise agent with the same name)1081mockOctoKitService.setUserOrganizations(['testorg']);10821083// Agents with same name but different repo owners as returned by API for single org1084const orgAAgent: CustomAgentListItem = {1085name: 'shared_agent',1086repo_owner_id: 1,1087repo_owner: 'testorg',1088repo_id: 10,1089repo_name: 'org_repo',1090display_name: 'Org Agent',1091description: 'Agent from org repo',1092tools: [],1093version: 'v1.0',1094};10951096const enterpriseAgent: CustomAgentListItem = {1097name: 'shared_agent',1098repo_owner_id: 999,1099repo_owner: 'enterprise_org',1100repo_id: 100,1101repo_name: 'enterprise_repo',1102display_name: 'Enterprise Agent',1103description: 'Agent from enterprise',1104tools: [],1105version: 'v1.0',1106};11071108// API returns both agents for single org (enterprise agents are included via includeSources)1109mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {1110return [orgAAgent, enterpriseAgent];1111};11121113mockOctoKitService.getCustomAgentDetails = async (owner: string, repo: string, agentName: string, version?: string) => {1114// The API is called with the repo_owner, not the org name1115if (owner === 'testorg') {1116return { ...orgAAgent, prompt: 'Org prompt' };1117} else if (owner === 'enterprise_org') {1118return { ...enterpriseAgent, prompt: 'Enterprise prompt' };1119}1120return undefined;1121};11221123const provider = createProvider();1124await waitForPolling();11251126const agents = await provider.provideCustomAgents({}, {} as any);11271128// Since both agents have the same name, only one file is written (last one wins)1129// The filename is just `${agent.name}.agent.md`, so both would write to same file1130assert.equal(agents.length, 1);1131const agentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');1132assert.equal(agentName, 'shared_agent');1133});11341135test('deduplicates enterprise agents even when API returns them in different order', async () => {1136// Set up mocks BEFORE creating provider1137mockOctoKitService.setUserOrganizations(['orgA', 'orgB', 'orgC']);11381139const enterpriseAgent1: CustomAgentListItem = {1140name: 'enterprise_agent1',1141repo_owner_id: 999,1142repo_owner: 'enterprise_org',1143repo_id: 123,1144repo_name: 'enterprise_repo',1145display_name: 'Enterprise Agent 1',1146description: 'First enterprise agent',1147tools: [],1148version: 'v1.0',1149};11501151const enterpriseAgent2: CustomAgentListItem = {1152name: 'enterprise_agent2',1153repo_owner_id: 999,1154repo_owner: 'enterprise_org',1155repo_id: 123,1156repo_name: 'enterprise_repo',1157display_name: 'Enterprise Agent 2',1158description: 'Second enterprise agent',1159tools: [],1160version: 'v1.0',1161};11621163let callCount = 0;1164mockOctoKitService.getCustomAgents = async (owner: string, repo: string) => {1165callCount++;1166// Return agents in different orders for different orgs1167if (callCount === 1) {1168return [enterpriseAgent1, enterpriseAgent2];1169} else if (callCount === 2) {1170return [enterpriseAgent2, enterpriseAgent1]; // Reversed order1171} else {1172return [enterpriseAgent1, enterpriseAgent2];1173}1174};11751176mockOctoKitService.getCustomAgentDetails = async (owner: string, repo: string, agentName: string, version?: string) => {1177if (agentName === 'enterprise_agent1') {1178return { ...enterpriseAgent1, prompt: 'Prompt 1' };1179} else if (agentName === 'enterprise_agent2') {1180return { ...enterpriseAgent2, prompt: 'Prompt 2' };1181}1182return undefined;1183};11841185const provider = createProvider();1186await waitForPolling();11871188const agents = await provider.provideCustomAgents({}, {} as any);11891190// Should have exactly 2 agents, not 6 (2 agents x 3 orgs)1191assert.equal(agents.length, 2);11921193// Verify both agent names are present1194const agentNames = agents.map(a => a.uri.path.split('/').pop()?.replace('.agent.md', '')).sort();1195assert.deepEqual(agentNames, ['enterprise_agent1', 'enterprise_agent2']);1196});11971198test('deduplication key does not include version so different versions are deduplicated', async () => {1199// Set up mocks BEFORE creating provider1200mockOctoKitService.setUserOrganizations(['orgA']);12011202// Same agent with two different versions1203const agentV1: CustomAgentListItem = {1204name: 'multi_version_agent',1205repo_owner_id: 999,1206repo_owner: 'enterprise_org',1207repo_id: 123,1208repo_name: 'enterprise_repo',1209display_name: 'Multi Version Agent',1210description: 'Agent with multiple versions',1211tools: [],1212version: 'v1.0',1213};12141215const agentV2: CustomAgentListItem = {1216...agentV1,1217version: 'v2.0',1218};12191220mockOctoKitService.getCustomAgents = async () => {1221return [agentV1, agentV2];1222};12231224mockOctoKitService.getCustomAgentDetails = async (owner: string, repo: string, agentName: string, version?: string) => {1225if (version === 'v1.0') {1226return { ...agentV1, prompt: 'Prompt for v1' };1227} else if (version === 'v2.0') {1228return { ...agentV2, prompt: 'Prompt for v2' };1229}1230return undefined;1231};12321233const provider = createProvider();1234await waitForPolling();12351236const agents = await provider.provideCustomAgents({}, {} as any);12371238// Different versions are deduplicated, only the first one is kept1239assert.equal(agents.length, 1);1240const multiVersionAgentName = agents[0].uri.path.split('/').pop()?.replace('.agent.md', '');1241assert.equal(multiVersionAgentName, 'multi_version_agent');1242});1243});12441245suite('looksLikeNumber', () => {12461247test('returns false for empty string', () => {1248assert.strictEqual(looksLikeNumber(''), false);1249});12501251test('returns true for integers', () => {1252assert.strictEqual(looksLikeNumber('0'), true);1253assert.strictEqual(looksLikeNumber('123'), true);1254assert.strictEqual(looksLikeNumber('-456'), true);1255});12561257test('returns true for decimals', () => {1258assert.strictEqual(looksLikeNumber('3.14'), true);1259assert.strictEqual(looksLikeNumber('-0.5'), true);1260assert.strictEqual(looksLikeNumber('.5'), true);1261});12621263test('returns false for non-numeric strings', () => {1264assert.strictEqual(looksLikeNumber('abc'), false);1265assert.strictEqual(looksLikeNumber('12abc'), false);1266assert.strictEqual(looksLikeNumber('hello'), false);1267});12681269test('returns false for special number representations', () => {1270// These don't match the regex /^-?\d*\.?\d+$/1271assert.strictEqual(looksLikeNumber('1e10'), false);1272assert.strictEqual(looksLikeNumber('1.5e-3'), false);1273assert.strictEqual(looksLikeNumber('Infinity'), false);1274assert.strictEqual(looksLikeNumber('-Infinity'), false);1275assert.strictEqual(looksLikeNumber('NaN'), false);1276});12771278test('returns false for hex/octal representations', () => {1279assert.strictEqual(looksLikeNumber('0x1F'), false);1280assert.strictEqual(looksLikeNumber('0o17'), false);1281assert.strictEqual(looksLikeNumber('0b101'), false);1282});12831284test('returns false for strings with spaces', () => {1285assert.strictEqual(looksLikeNumber(' 123'), false);1286assert.strictEqual(looksLikeNumber('123 '), false);1287});1288});12891290suite('yamlString', () => {12911292test('returns plain string for simple text', () => {1293const result = yamlString('hello');1294assert.strictEqual(result, 'hello');1295});12961297test('returns plain string for text with spaces', () => {1298const result = yamlString('hello world');1299assert.strictEqual(result, 'hello world');1300});13011302suite('quoting for special characters', () => {13031304test('quotes strings containing hash (comment)', () => {1305const result = yamlString('value with # hash');1306assert.ok(result instanceof Scalar);1307assert.strictEqual(result.value, 'value with # hash');1308assert.strictEqual(result.type, Scalar.QUOTE_SINGLE);1309});13101311test('quotes strings containing colon', () => {1312const result = yamlString('key: value');1313assert.ok(result instanceof Scalar);1314assert.strictEqual(result.value, 'key: value');1315});13161317test('quotes strings containing brackets', () => {1318const result = yamlString('array [1, 2]');1319assert.ok(result instanceof Scalar);1320assert.strictEqual(result.value, 'array [1, 2]');1321});13221323test('quotes strings containing braces', () => {1324const result = yamlString('object {a: 1}');1325assert.ok(result instanceof Scalar);1326assert.strictEqual(result.value, 'object {a: 1}');1327});13281329test('quotes strings containing comma', () => {1330const result = yamlString('a, b, c');1331assert.ok(result instanceof Scalar);1332assert.strictEqual(result.value, 'a, b, c');1333});13341335test('quotes strings containing newline', () => {1336const result = yamlString('line1\nline2');1337assert.ok(result instanceof Scalar);1338assert.strictEqual(result.value, 'line1\nline2');1339// Newlines require double quotes for escape sequence support1340assert.strictEqual(result.type, Scalar.QUOTE_DOUBLE);1341});13421343test('quotes strings containing carriage return', () => {1344const result = yamlString('line1\rline2');1345assert.ok(result instanceof Scalar);1346assert.strictEqual(result.value, 'line1\rline2');1347// Carriage returns require double quotes for escape sequence support1348assert.strictEqual(result.type, Scalar.QUOTE_DOUBLE);1349});1350});13511352suite('quoting for values starting with quotes', () => {13531354test('quotes strings starting with single quote', () => {1355const result = yamlString(`'quoted value`);1356assert.ok(result instanceof Scalar);1357assert.strictEqual(result.value, `'quoted value`);1358});13591360test('quotes strings starting with double quote', () => {1361const result = yamlString(`"quoted value`);1362assert.ok(result instanceof Scalar);1363assert.strictEqual(result.value, `"quoted value`);1364});1365});13661367suite('quoting for whitespace', () => {13681369test('quotes strings with leading space', () => {1370const result = yamlString(' leading space');1371assert.ok(result instanceof Scalar);1372assert.strictEqual(result.value, ' leading space');1373});13741375test('quotes strings with trailing space', () => {1376const result = yamlString('trailing space ');1377assert.ok(result instanceof Scalar);1378assert.strictEqual(result.value, 'trailing space ');1379});1380});13811382suite('quoting for YAML keywords', () => {13831384test('quotes "true" to preserve as string', () => {1385const result = yamlString('true');1386assert.ok(result instanceof Scalar);1387assert.strictEqual(result.value, 'true');1388});13891390test('quotes "false" to preserve as string', () => {1391const result = yamlString('false');1392assert.ok(result instanceof Scalar);1393assert.strictEqual(result.value, 'false');1394});13951396test('quotes "null" to preserve as string', () => {1397const result = yamlString('null');1398assert.ok(result instanceof Scalar);1399assert.strictEqual(result.value, 'null');1400});14011402test('quotes "~" to preserve as string', () => {1403const result = yamlString('~');1404assert.ok(result instanceof Scalar);1405assert.strictEqual(result.value, '~');1406});14071408test('does not quote "True" (case sensitive)', () => {1409const result = yamlString('True');1410assert.strictEqual(result, 'True');1411});14121413test('does not quote "FALSE" (case sensitive)', () => {1414const result = yamlString('FALSE');1415assert.strictEqual(result, 'FALSE');1416});1417});14181419suite('quoting for numeric strings', () => {14201421test('quotes integer strings', () => {1422const result = yamlString('123');1423assert.ok(result instanceof Scalar);1424assert.strictEqual(result.value, '123');1425});14261427test('quotes negative integers', () => {1428const result = yamlString('-456');1429assert.ok(result instanceof Scalar);1430assert.strictEqual(result.value, '-456');1431});14321433test('quotes decimal strings', () => {1434const result = yamlString('3.14');1435assert.ok(result instanceof Scalar);1436assert.strictEqual(result.value, '3.14');1437});14381439test('does not quote non-numeric strings that look similar', () => {1440const result = yamlString('v1.0');1441assert.strictEqual(result, 'v1.0');1442});1443});14441445suite('quote type selection', () => {14461447test('uses single quotes by default when quoting', () => {1448const result = yamlString('value with # hash');1449assert.ok(result instanceof Scalar);1450assert.strictEqual(result.type, Scalar.QUOTE_SINGLE);1451});14521453test('does not quote string with only single quote (no special chars)', () => {1454// `it's a value` has no special YAML characters, so no quoting is needed1455const result = yamlString(`it's a value`);1456assert.strictEqual(result, `it's a value`);1457});14581459test('uses double quotes when value has single quote and special chars', () => {1460const result = yamlString(`it's a value: with colon`);1461assert.ok(result instanceof Scalar);1462assert.strictEqual(result.type, Scalar.QUOTE_DOUBLE);1463});1464});1465});14661467suite('yamlString round-trip with custom YAML parser', () => {1468/**1469* These tests verify that values processed by yamlString() can be1470* correctly parsed back by the custom YAML parser in yaml.ts1471*/14721473function roundTrip(value: string): string | undefined {1474const yamlValue = yamlString(value);1475let yamlStr: string;14761477if (yamlValue instanceof Scalar) {1478// Simulate how YAML library would stringify this1479if (yamlValue.type === Scalar.QUOTE_SINGLE) {1480yamlStr = `'${value}'`;1481} else {1482// Double quotes - need to escape internal double quotes1483yamlStr = `"${value.replace(/"/g, '\\"')}"`;1484}1485} else {1486yamlStr = value;1487}14881489// Parse as a simple key-value YAML1490const yaml = `key: ${yamlStr}`;1491const parsed = parse(yaml);14921493if (parsed?.type === 'object' && parsed.properties.length > 0) {1494const prop = parsed.properties[0];1495if (prop.value.type === 'string') {1496return prop.value.value;1497}1498}1499return undefined;1500}15011502test('round-trips plain string', () => {1503assert.strictEqual(roundTrip('hello world'), 'hello world');1504});15051506test('round-trips string with hash', () => {1507assert.strictEqual(roundTrip('value # comment'), 'value # comment');1508});15091510test('round-trips string with colon', () => {1511assert.strictEqual(roundTrip('key: value'), 'key: value');1512});15131514test('round-trips boolean keyword as string', () => {1515assert.strictEqual(roundTrip('true'), 'true');1516assert.strictEqual(roundTrip('false'), 'false');1517});15181519test('round-trips null keyword as string', () => {1520assert.strictEqual(roundTrip('null'), 'null');1521});15221523test('round-trips numeric string', () => {1524assert.strictEqual(roundTrip('123'), '123');1525assert.strictEqual(roundTrip('3.14'), '3.14');1526});15271528test('round-trips string with leading/trailing whitespace', () => {1529assert.strictEqual(roundTrip(' padded '), ' padded ');1530});15311532test('round-trips string with single quotes (no special chars)', () => {1533// Apostrophes without other special chars don't need quoting1534assert.strictEqual(roundTrip(`it's working`), `it's working`);1535});15361537test('round-trips string with single quotes and special chars', () => {1538// When both single quote and special char are present, double quotes are used1539assert.strictEqual(roundTrip(`it's a value: with colon`), `it's a value: with colon`);1540});1541});154215431544