diff --git a/README.md b/README.md index e35353d..313cfd2 100644 --- a/README.md +++ b/README.md @@ -147,12 +147,12 @@ vscode: #### Template file -Create `.allagents/vscode-template.json` for VSCode-specific settings, launch configurations, extensions, and extra folders. The template supports `{repo:../path}` placeholders that resolve to absolute paths using repository paths from workspace.yaml. +Create `.allagents/template.code-workspace` for VSCode-specific settings, launch configurations, extensions, and extra folders. The template supports `{path:../...}` placeholders that resolve to absolute paths using repository paths from workspace.yaml. ```json { "folders": [ - { "path": "{repo:../Shared}", "name": "SharedLib" } + { "path": "{path:../Shared}", "name": "SharedLib" } ], "settings": { "cSpell.words": ["myterm"], @@ -164,7 +164,7 @@ Create `.allagents/vscode-template.json` for VSCode-specific settings, launch co { "type": "node", "name": "dev", - "cwd": "{repo:../myapp}/src", + "cwd": "{path:../myapp}/src", "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"] } diff --git a/bun.lock b/bun.lock index a63222a..80c0504 100644 --- a/bun.lock +++ b/bun.lock @@ -12,6 +12,7 @@ "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "js-yaml": "^4.1.0", + "json5": "^2.2.3", "zod": "^3.22.4", }, "devDependencies": { @@ -19,6 +20,7 @@ "@j178/prek": "^0.3.0", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", + "@types/json5": "^2.2.0", "@types/node": "^20.11.5", "shx": "^0.4.0", "typescript": "^5.3.3", @@ -64,6 +66,8 @@ "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/json5": ["@types/json5@2.2.0", "", { "dependencies": { "json5": "*" } }, "sha512-NrVug5woqbvNZ0WX+Gv4R+L4TGddtmFek2u8RtccAgFZWtS9QXF2xCXY22/M4nzkaKF0q9Fc6M/5rxLDhfwc/A=="], + "@types/node": ["@types/node@20.19.30", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -176,6 +180,8 @@ "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "lru-cache": ["lru-cache@11.2.4", "", {}, "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg=="], diff --git a/package.json b/package.json index 56de693..e56e8bc 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "fast-glob": "^3.3.3", "gray-matter": "^4.0.3", "js-yaml": "^4.1.0", + "json5": "^2.2.3", "zod": "^3.22.4" }, "devDependencies": { @@ -55,6 +56,7 @@ "@j178/prek": "^0.3.0", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", + "@types/json5": "^2.2.0", "@types/node": "^20.11.5", "shx": "^0.4.0", "typescript": "^5.3.3" diff --git a/src/cli/tui/wizard.ts b/src/cli/tui/wizard.ts index a6de452..ce9bb8c 100644 --- a/src/cli/tui/wizard.ts +++ b/src/cli/tui/wizard.ts @@ -108,7 +108,6 @@ export async function runWizard(): Promise { const cache = new TuiCache(); let context = await getTuiContext(process.cwd(), cache); - // biome-ignore lint/correctness/noConstantCondition: intentional wizard loop while (true) { p.note(buildSummary(context), 'Workspace'); diff --git a/src/core/sync.ts b/src/core/sync.ts index 4031baf..2c21d15 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,6 +1,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { rm, unlink, rmdir, copyFile } from 'node:fs/promises'; import { join, resolve, dirname, relative } from 'node:path'; +import JSON5 from 'json5'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE, AGENT_FILES, getHomeDir } from '../constants.js'; import { parseWorkspaceConfig } from '../utils/workspace-parser.js'; import type { @@ -815,7 +816,7 @@ function buildPluginSkillNameMaps( return pluginMaps; } -const VSCODE_TEMPLATE_FILE = 'vscode-template.json'; +const VSCODE_TEMPLATE_FILE = 'template.code-workspace'; /** * Generate a VSCode .code-workspace file from workspace config. @@ -827,11 +828,17 @@ function generateVscodeWorkspaceFile( ): void { const configDir = join(workspacePath, CONFIG_DIR); - // Load template if it exists + // Load template if it exists (supports JSON with comments via JSON5) const templatePath = join(configDir, VSCODE_TEMPLATE_FILE); let template: Record | undefined; if (existsSync(templatePath)) { - template = JSON.parse(readFileSync(templatePath, 'utf-8')); + try { + template = JSON5.parse(readFileSync(templatePath, 'utf-8')); + } catch (error) { + throw new Error( + `Failed to parse ${templatePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } } const content = generateVscodeWorkspace({ diff --git a/src/core/vscode-workspace.ts b/src/core/vscode-workspace.ts index eda27c9..622e257 100644 --- a/src/core/vscode-workspace.ts +++ b/src/core/vscode-workspace.ts @@ -1,4 +1,4 @@ -import { resolve, basename } from 'node:path'; +import { resolve, basename, isAbsolute } from 'node:path'; import type { Repository, VscodeConfig } from '../models/workspace-config.js'; /** @@ -26,26 +26,72 @@ const DEFAULT_SETTINGS: Record = { }; /** - * Recursively substitute {repo:../path} placeholders in all string values + * Map for path placeholder resolution. + * Keys are relative paths from workspace.yaml (e.g., "../Glow") */ -export function substituteRepoPlaceholders( +export type PathPlaceholderMap = Map; + +/** + * Build a placeholder map from repositories using path as the lookup key. + * + * @param repositories - Repository list from workspace.yaml + * @param workspacePath - Workspace root for resolving relative paths + * @returns Map of relative paths to absolute paths + * + * @example + * // Given: { path: "../Glow" } and workspacePath: "/home/user/workspace" + * // Result: Map { "../Glow" => "/home/user/Glow" } + */ +export function buildPathPlaceholderMap( + repositories: Repository[], + workspacePath: string, +): PathPlaceholderMap { + const map = new Map(); + + for (const repo of repositories) { + const absolutePath = resolve(workspacePath, repo.path); + map.set(repo.path, absolutePath); + } + + return map; +} + +/** + * Recursively substitute {path:...} placeholders and normalize backslashes to forward slashes. + * + * Placeholder format: {path:../Glow} where the value matches a repository path from workspace.yaml + * + * @example + * // Given repositories: [{ path: "../Glow" }] + * "{path:../Glow}/src" → "/home/user/Glow/src" + * "D:\\GitHub\\Glow" → "D:/GitHub/Glow" + */ +export function substitutePathPlaceholders( obj: T, - repoMap: Map, + pathMap: PathPlaceholderMap, ): T { if (typeof obj === 'string') { - return obj.replace(/\{repo:([^}]+)\}/g, (_match, repoPath: string) => { - return repoMap.get(repoPath) ?? `{repo:${repoPath}}`; - }) as T; + // First substitute placeholders, then normalize backslashes to forward slashes + const substituted = obj.replace(/\{path:([^}]+)\}/g, (_match, pathKey: string) => { + const resolved = pathMap.get(pathKey); + if (resolved) { + return resolved; + } + // Keep unresolved placeholders for debugging + return `{path:${pathKey}}`; + }); + // Normalize all backslashes to forward slashes (cross-platform compatible) + return substituted.replace(/\\/g, '/') as T; } if (Array.isArray(obj)) { - return obj.map((item) => substituteRepoPlaceholders(item, repoMap)) as T; + return obj.map((item) => substitutePathPlaceholders(item, pathMap)) as T; } if (obj !== null && typeof obj === 'object') { const result: Record = {}; for (const [key, value] of Object.entries(obj)) { - result[key] = substituteRepoPlaceholders(value, repoMap); + result[key] = substitutePathPlaceholders(value, pathMap); } return result as T; } @@ -61,15 +107,12 @@ export function generateVscodeWorkspace( ): Record { const { workspacePath, repositories, template } = input; - // Build repo path map for placeholder substitution - const repoMap = new Map(); - for (const repo of repositories) { - repoMap.set(repo.path, resolve(workspacePath, repo.path)); - } + // Build path placeholder map for substitution + const pathMap = buildPathPlaceholderMap(repositories, workspacePath); // Substitute placeholders in template const resolvedTemplate = template - ? substituteRepoPlaceholders(template, repoMap) + ? substitutePathPlaceholders(template, pathMap) : undefined; // Build folders list @@ -78,10 +121,12 @@ export function generateVscodeWorkspace( // 0. Current workspace folder folders.push({ path: '.' }); + // Track absolute path for deduplication + seenPaths.add(resolve(workspacePath, '.')); // 1. Repository folders (from workspace.yaml) for (const repo of repositories) { - const absolutePath = resolve(workspacePath, repo.path); + const absolutePath = resolve(workspacePath, repo.path).replace(/\\/g, '/'); folders.push({ path: absolutePath }); seenPaths.add(absolutePath); } @@ -89,11 +134,15 @@ export function generateVscodeWorkspace( // 2. Template folders (deduplicated against repo folders, preserve optional name) if (resolvedTemplate && Array.isArray(resolvedTemplate.folders)) { for (const folder of resolvedTemplate.folders as WorkspaceFolder[]) { - if (!seenPaths.has(folder.path)) { - const entry: WorkspaceFolder = { path: folder.path }; + const rawPath = folder.path as string; + const normalizedPath = (typeof rawPath === 'string' && !isAbsolute(rawPath) + ? resolve(workspacePath, rawPath) + : rawPath).replace(/\\/g, '/'); + if (!seenPaths.has(normalizedPath)) { + const entry: WorkspaceFolder = { path: normalizedPath }; if (folder.name) entry.name = folder.name; folders.push(entry); - seenPaths.add(folder.path); + seenPaths.add(normalizedPath); } } } diff --git a/src/core/workspace.ts b/src/core/workspace.ts index a5988c1..4eb6638 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -173,8 +173,46 @@ export async function initWorkspace( // Write workspace.yaml await writeFile(configPath, workspaceYamlContent, 'utf-8'); - // Parse config to check repositories and clients + // Parse config to check repositories and clients (needed before copying template) const parsed = load(workspaceYamlContent) as Record; + const clients = (parsed?.clients as string[]) ?? []; + + // Copy template.code-workspace from source if it exists and vscode client is configured + const VSCODE_TEMPLATE_FILE = 'template.code-workspace'; + if (clients.includes('vscode') && options.from) { + const targetTemplatePath = join(configDir, VSCODE_TEMPLATE_FILE); + if (!existsSync(targetTemplatePath)) { + if (isGitHubUrl(options.from)) { + // Fetch template from GitHub + const parsedUrl = parseGitHubUrl(options.from); + if (parsedUrl) { + const basePath = parsedUrl.subpath || ''; + // Remove workspace.yaml from the base path if present + const baseDir = basePath.replace(/\/?\.allagents\/workspace\.yaml$/, '') + .replace(/\/?workspace\.yaml$/, ''); + const templatePath = baseDir + ? `${baseDir}/${CONFIG_DIR}/${VSCODE_TEMPLATE_FILE}` + : `${CONFIG_DIR}/${VSCODE_TEMPLATE_FILE}`; + const templateContent = await fetchFileFromGitHub( + parsedUrl.owner, + parsedUrl.repo, + templatePath, + parsedUrl.branch, + ); + if (templateContent) { + await writeFile(targetTemplatePath, templateContent, 'utf-8'); + } + } + } else if (sourceDir) { + // Copy template from local source + const sourceTemplatePath = join(sourceDir, CONFIG_DIR, VSCODE_TEMPLATE_FILE); + if (existsSync(sourceTemplatePath)) { + await copyFile(sourceTemplatePath, targetTemplatePath); + } + } + } + } + const repositories = (parsed?.repositories as unknown[]) ?? []; const hasRepositories = repositories.length > 0; @@ -241,7 +279,6 @@ export async function initWorkspace( } // If claude is a client and CLAUDE.md doesn't exist, copy AGENTS.md to CLAUDE.md - const clients = (parsed?.clients as string[]) ?? []; if ( clients.includes('claude') && !copiedAgentFiles.includes('CLAUDE.md') && diff --git a/tests/cli/plugin-scope.test.ts b/tests/cli/plugin-scope.test.ts deleted file mode 100644 index d340149..0000000 --- a/tests/cli/plugin-scope.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, rm, mkdir, writeFile, readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { load } from 'js-yaml'; -import type { WorkspaceConfig } from '../../src/models/workspace-config.js'; - -describe('plugin install --scope integration', () => { - let tempHome: string; - let originalHome: string; - - beforeEach(async () => { - tempHome = await mkdtemp(join(tmpdir(), 'allagents-scope-')); - originalHome = process.env.HOME || ''; - process.env.HOME = tempHome; - }); - - afterEach(async () => { - process.env.HOME = originalHome; - await rm(tempHome, { recursive: true, force: true }); - }); - - test('user scope: addUserPlugin + syncUserWorkspace installs to home', async () => { - // Create a local plugin - const pluginDir = join(tempHome, 'test-plugin'); - const skillDir = join(pluginDir, 'skills', 'test-skill'); - await mkdir(skillDir, { recursive: true }); - await writeFile(join(skillDir, 'SKILL.md'), '---\nname: test-skill\ndescription: A test\n---\nContent'); - - const { addUserPlugin } = await import('../../src/core/user-workspace.js'); - const { syncUserWorkspace } = await import('../../src/core/sync.js'); - - const result = await addUserPlugin(pluginDir); - expect(result.success).toBe(true); - - const syncResult = await syncUserWorkspace(); - expect(syncResult.success).toBe(true); - expect(existsSync(join(tempHome, '.claude', 'skills', 'test-skill'))).toBe(true); - }); - - test('project scope: addPlugin auto-creates workspace.yaml when missing', async () => { - // Create a local plugin so addPlugin has something valid to add - const pluginDir = join(tempHome, 'my-plugin'); - await mkdir(pluginDir, { recursive: true }); - - const { addPlugin } = await import('../../src/core/workspace-modify.js'); - const configPath = join(tempHome, '.allagents', 'workspace.yaml'); - - // workspace.yaml should not exist yet - expect(existsSync(configPath)).toBe(false); - - const result = await addPlugin(pluginDir, tempHome); - expect(result.success).toBe(true); - - // workspace.yaml should now exist with the plugin added - expect(existsSync(configPath)).toBe(true); - - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; - expect(config.repositories).toEqual([]); - expect(config.plugins).toContain(pluginDir); - expect(config.clients).toEqual(['claude', 'copilot', 'codex', 'opencode']); - }); - - test('user scope uninstall: removeUserPlugin + syncUserWorkspace purges files', async () => { - // Create a local plugin - const pluginDir = join(tempHome, 'test-plugin'); - const skillDir = join(pluginDir, 'skills', 'test-skill'); - await mkdir(skillDir, { recursive: true }); - await writeFile(join(skillDir, 'SKILL.md'), '---\nname: test-skill\ndescription: A test\n---\nContent'); - - const { addUserPlugin, removeUserPlugin } = await import('../../src/core/user-workspace.js'); - const { syncUserWorkspace } = await import('../../src/core/sync.js'); - - // Install - await addUserPlugin(pluginDir); - await syncUserWorkspace(); - expect(existsSync(join(tempHome, '.claude', 'skills', 'test-skill'))).toBe(true); - - // Uninstall - const result = await removeUserPlugin(pluginDir); - expect(result.success).toBe(true); - await syncUserWorkspace(); - expect(existsSync(join(tempHome, '.claude', 'skills', 'test-skill'))).toBe(false); - }); -}); diff --git a/tests/cli/plugin-uninstall-scope.test.ts b/tests/cli/plugin-uninstall-scope.test.ts deleted file mode 100644 index 2407f1b..0000000 --- a/tests/cli/plugin-uninstall-scope.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { dump } from 'js-yaml'; - -describe('plugin uninstall smart scope resolution', () => { - let tempHome: string; - let tempProject: string; - let originalHome: string; - - beforeEach(async () => { - tempHome = await mkdtemp(join(tmpdir(), 'allagents-uninstall-home-')); - tempProject = await mkdtemp(join(tmpdir(), 'allagents-uninstall-proj-')); - originalHome = process.env.HOME || ''; - process.env.HOME = tempHome; - }); - - afterEach(async () => { - process.env.HOME = originalHome; - await rm(tempHome, { recursive: true, force: true }); - await rm(tempProject, { recursive: true, force: true }); - }); - - async function setupProjectWorkspace(plugins: string[]) { - const configDir = join(tempProject, '.allagents'); - await mkdir(configDir, { recursive: true }); - await writeFile( - join(configDir, 'workspace.yaml'), - dump({ repositories: [], plugins, clients: ['claude'] }, { lineWidth: -1 }), - ); - } - - async function setupUserWorkspace(plugins: string[]) { - const configDir = join(tempHome, '.allagents'); - await mkdir(configDir, { recursive: true }); - await writeFile( - join(configDir, 'workspace.yaml'), - dump({ repositories: [], plugins, clients: ['claude'] }, { lineWidth: -1 }), - ); - } - - test('hasPlugin returns true for exact match in project scope', async () => { - await setupProjectWorkspace(['my-plugin@marketplace']); - const { hasPlugin } = await import('../../src/core/workspace-modify.js'); - expect(await hasPlugin('my-plugin@marketplace', tempProject)).toBe(true); - }); - - test('hasPlugin returns true for partial match in project scope', async () => { - await setupProjectWorkspace(['my-plugin@marketplace']); - const { hasPlugin } = await import('../../src/core/workspace-modify.js'); - expect(await hasPlugin('my-plugin', tempProject)).toBe(true); - }); - - test('hasPlugin returns false when plugin not in project scope', async () => { - await setupProjectWorkspace(['other-plugin@marketplace']); - const { hasPlugin } = await import('../../src/core/workspace-modify.js'); - expect(await hasPlugin('my-plugin', tempProject)).toBe(false); - }); - - test('hasPlugin returns false when no project workspace exists', async () => { - const { hasPlugin } = await import('../../src/core/workspace-modify.js'); - expect(await hasPlugin('my-plugin', tempProject)).toBe(false); - }); - - test('hasUserPlugin returns true for exact match in user scope', async () => { - await setupUserWorkspace(['my-plugin@marketplace']); - const { hasUserPlugin } = await import('../../src/core/user-workspace.js'); - expect(await hasUserPlugin('my-plugin@marketplace')).toBe(true); - }); - - test('hasUserPlugin returns true for partial match in user scope', async () => { - await setupUserWorkspace(['my-plugin@marketplace']); - const { hasUserPlugin } = await import('../../src/core/user-workspace.js'); - expect(await hasUserPlugin('my-plugin')).toBe(true); - }); - - test('hasUserPlugin returns false when plugin not in user scope', async () => { - await setupUserWorkspace(['other-plugin@marketplace']); - const { hasUserPlugin } = await import('../../src/core/user-workspace.js'); - expect(await hasUserPlugin('my-plugin')).toBe(false); - }); - - test('hasUserPlugin returns false when no user workspace exists', async () => { - const { hasUserPlugin } = await import('../../src/core/user-workspace.js'); - expect(await hasUserPlugin('my-plugin')).toBe(false); - }); - - test('removePlugin succeeds when plugin is in project scope', async () => { - await setupProjectWorkspace(['my-plugin@marketplace']); - const { removePlugin } = await import('../../src/core/workspace-modify.js'); - const result = await removePlugin('my-plugin@marketplace', tempProject); - expect(result.success).toBe(true); - }); - - test('removeUserPlugin succeeds when plugin is in user scope', async () => { - await setupUserWorkspace(['my-plugin@marketplace']); - const { removeUserPlugin } = await import('../../src/core/user-workspace.js'); - const result = await removeUserPlugin('my-plugin@marketplace'); - expect(result.success).toBe(true); - }); - - test('removePlugin fails when plugin only in user scope', async () => { - await setupProjectWorkspace([]); - await setupUserWorkspace(['my-plugin@marketplace']); - const { removePlugin } = await import('../../src/core/workspace-modify.js'); - const result = await removePlugin('my-plugin@marketplace', tempProject); - expect(result.success).toBe(false); - expect(result.error).toContain('not found'); - }); -}); diff --git a/tests/e2e/vscode-workspace-setup.test.ts b/tests/e2e/vscode-workspace-setup.test.ts index 8c136fe..f6f4fc6 100644 --- a/tests/e2e/vscode-workspace-setup.test.ts +++ b/tests/e2e/vscode-workspace-setup.test.ts @@ -2,8 +2,6 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; import { join, basename } from 'node:path'; import { tmpdir } from 'node:os'; -import { generateVscodeWorkspace, getWorkspaceOutputPath } from '../../src/core/vscode-workspace.js'; -import { parseWorkspaceConfig } from '../../src/utils/workspace-parser.js'; import { syncWorkspace } from '../../src/core/sync.js'; describe('vscode workspace setup e2e', () => { @@ -18,131 +16,6 @@ describe('vscode workspace setup e2e', () => { rmSync(testDir, { recursive: true, force: true }); }); - test('full generation with template and placeholder substitution', async () => { - writeFileSync( - join(testDir, '.allagents', 'workspace.yaml'), - `repositories: - - path: ../Glow - description: Main project - - path: ../Glow.Shared - description: Shared library -plugins: [] -clients: - - copilot - - claude -vscode: - output: glow -`, - ); - - writeFileSync( - join(testDir, '.allagents', 'vscode-template.json'), - JSON.stringify({ - folders: [ - { path: '{repo:../Glow.Shared}', name: 'Glow.Shared' }, - { path: '/some/other/path', name: 'Extra' }, - ], - settings: { - 'cSpell.words': ['clusterer', 'polylines'], - '[vue]': { - 'editor.defaultFormatter': 'esbenp.prettier-vscode', - 'editor.formatOnSave': true, - }, - 'chat.agent.maxRequests': 999, - }, - launch: { - configurations: [ - { - type: 'node', - request: 'launch', - name: 'watch-dsk', - cwd: '{repo:../Glow}/DotNet/HTML/Client/Client', - runtimeExecutable: 'npm', - runtimeArgs: ['run', 'watch-dsk'], - }, - ], - }, - extensions: { - recommendations: ['dbaeumer.vscode-eslint', 'esbenp.prettier-vscode'], - }, - }, null, 2), - ); - - const config = await parseWorkspaceConfig(join(testDir, '.allagents', 'workspace.yaml')); - const template = JSON.parse( - readFileSync(join(testDir, '.allagents', 'vscode-template.json'), 'utf-8'), - ); - - const content = generateVscodeWorkspace({ - workspacePath: testDir, - repositories: config.repositories, - template, - }); - - // Verify folders: 2 repos + 1 extra (Glow.Shared deduplicated) - const folders = content.folders as Array<{ path: string; name?: string }>; - expect(folders).toHaveLength(4); // ".", ../Glow, ../Glow.Shared (from repo), /some/other/path - expect(folders[0].path).toBe('.'); - expect(folders[1].path).toContain('/Glow'); - expect(folders[2].path).toContain('/Glow.Shared'); - expect(folders[3].path).toBe('/some/other/path'); - - // All repo paths should be absolute - expect(folders[1].path.startsWith('/')).toBe(true); - expect(folders[2].path.startsWith('/')).toBe(true); - - // Verify settings from template - const settings = content.settings as Record; - expect(settings['cSpell.words']).toEqual(['clusterer', 'polylines']); - expect(settings['chat.agent.maxRequests']).toBe(999); - - // Verify launch config placeholder was substituted - const launch = content.launch as { configurations: Array<{ cwd: string }> }; - expect(launch.configurations[0].cwd).toContain('/Glow/DotNet/HTML/Client/Client'); - expect(launch.configurations[0].cwd.startsWith('/')).toBe(true); - expect(launch.configurations[0].cwd).not.toContain('{repo:'); - - // Verify extensions pass through - const extensions = content.extensions as { recommendations: string[] }; - expect(extensions.recommendations).toHaveLength(2); - - // Verify output path - const outputPath = getWorkspaceOutputPath(testDir, config.vscode); - expect(outputPath).toContain('glow.code-workspace'); - - // Write and re-read to verify valid JSON - writeFileSync(outputPath, JSON.stringify(content, null, '\t') + '\n'); - const written = JSON.parse(readFileSync(outputPath, 'utf-8')); - expect(written.folders).toHaveLength(4); - }); - - test('generation without template uses defaults', async () => { - writeFileSync( - join(testDir, '.allagents', 'workspace.yaml'), - `repositories: - - path: ../myrepo -plugins: [] -clients: - - claude -`, - ); - - const config = await parseWorkspaceConfig(join(testDir, '.allagents', 'workspace.yaml')); - const content = generateVscodeWorkspace({ - workspacePath: testDir, - repositories: config.repositories, - template: undefined, - }); - - const folders = content.folders as Array<{ path: string }>; - expect(folders).toHaveLength(2); - expect(folders[0].path).toBe('.'); - expect(folders[1].path).toContain('/myrepo'); - expect(content.settings).toEqual({ 'chat.agent.maxRequests': 999 }); - expect(content.launch).toBeUndefined(); - expect(content.extensions).toBeUndefined(); - }); - test('sync generates .code-workspace when vscode client is configured', async () => { writeFileSync( join(testDir, '.allagents', 'workspace.yaml'), diff --git a/tests/integration/skill-duplicate-handling.test.ts b/tests/integration/skill-duplicate-handling.test.ts deleted file mode 100644 index eae1abc..0000000 --- a/tests/integration/skill-duplicate-handling.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Integration tests for duplicate skill handling in the full sync flow. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, rm, mkdir, writeFile, readdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { syncWorkspace } from '../../src/core/sync.js'; -import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../../src/constants.js'; -import { getShortId } from '../../src/utils/hash.js'; - -describe('Skill duplicate handling', () => { - let testDir: string; - - beforeEach(async () => { - testDir = await mkdtemp(join(tmpdir(), 'allagents-skill-dup-')); - }); - - afterEach(async () => { - await rm(testDir, { recursive: true, force: true }); - }); - - async function createPlugin(path: string, name: string, skills: string[]): Promise { - await mkdir(path, { recursive: true }); - await writeFile(join(path, 'plugin.json'), JSON.stringify({ name, version: '1.0.0' })); - for (const skill of skills) { - const skillDir = join(path, 'skills', skill); - await mkdir(skillDir, { recursive: true }); - await writeFile(join(skillDir, 'SKILL.md'), `---\nname: ${skill}\ndescription: Test skill\n---\n# ${skill}`); - } - } - - async function createWorkspaceConfig(plugins: string[]): Promise { - await mkdir(join(testDir, CONFIG_DIR), { recursive: true }); - await writeFile( - join(testDir, CONFIG_DIR, WORKSPACE_CONFIG_FILE), - `repositories: []\nplugins:\n${plugins.map((p) => ` - ${p}`).join('\n')}\nclients:\n - claude`, - ); - } - - async function getSyncedSkills(): Promise { - const dir = join(testDir, '.claude', 'skills'); - return existsSync(dir) ? readdir(dir) : []; - } - - it('should keep original names when no conflicts', async () => { - await createPlugin(join(testDir, 'plugin-a'), 'plugin-a', ['skill-a']); - await createPlugin(join(testDir, 'plugin-b'), 'plugin-b', ['skill-b']); - await createWorkspaceConfig(['./plugin-a', './plugin-b']); - - const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - - const skills = await getSyncedSkills(); - expect(skills.sort()).toEqual(['skill-a', 'skill-b']); - }); - - it('should qualify with plugin name when skill folders conflict', async () => { - await createPlugin(join(testDir, 'alpha'), 'alpha', ['common']); - await createPlugin(join(testDir, 'beta'), 'beta', ['common']); - await createWorkspaceConfig(['./alpha', './beta']); - - const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - - const skills = await getSyncedSkills(); - expect(skills.sort()).toEqual(['alpha_common', 'beta_common']); - }); - - it('should only rename conflicting skills', async () => { - await createPlugin(join(testDir, 'plugin-a'), 'plugin-a', ['unique-a', 'shared']); - await createPlugin(join(testDir, 'plugin-b'), 'plugin-b', ['shared', 'unique-b']); - await createWorkspaceConfig(['./plugin-a', './plugin-b']); - - const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - - const skills = await getSyncedSkills(); - expect(skills).toContain('unique-a'); - expect(skills).toContain('unique-b'); - expect(skills).toContain('plugin-a_shared'); - expect(skills).toContain('plugin-b_shared'); - expect(skills).not.toContain('shared'); - }); - - it('should add hash prefix when both skill and plugin names conflict', async () => { - const path1 = join(testDir, 'vendor', 'my-plugin'); - const path2 = join(testDir, 'local', 'my-plugin'); - await createPlugin(path1, 'my-plugin', ['build']); - await createPlugin(path2, 'my-plugin', ['build']); - await createWorkspaceConfig(['./vendor/my-plugin', './local/my-plugin']); - - const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - - const skills = await getSyncedSkills(); - const hash1 = getShortId('./vendor/my-plugin'); - const hash2 = getShortId('./local/my-plugin'); - expect(skills).toContain(`${hash1}_my-plugin_build`); - expect(skills).toContain(`${hash2}_my-plugin_build`); - }); - - it('should handle mixed conflict levels', async () => { - await createPlugin(join(testDir, 'unique-plugin'), 'unique-plugin', ['unique']); - await createPlugin(join(testDir, 'alpha'), 'alpha', ['shared']); - await createPlugin(join(testDir, 'beta'), 'beta', ['shared']); - await createPlugin(join(testDir, 'path-a', 'fork'), 'fork', ['common']); - await createPlugin(join(testDir, 'path-b', 'fork'), 'fork', ['common']); - await createWorkspaceConfig([ - './unique-plugin', - './alpha', - './beta', - './path-a/fork', - './path-b/fork', - ]); - - const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - - const skills = await getSyncedSkills(); - expect(skills).toContain('unique'); // no conflict - expect(skills).toContain('alpha_shared'); // plugin conflict - expect(skills).toContain('beta_shared'); - const hashA = getShortId('./path-a/fork'); - const hashB = getShortId('./path-b/fork'); - expect(skills).toContain(`${hashA}_fork_common`); // full conflict - expect(skills).toContain(`${hashB}_fork_common`); - }); - - it('should revert to original name when conflict resolves', async () => { - await createPlugin(join(testDir, 'plugin-a'), 'plugin-a', ['coding']); - await createPlugin(join(testDir, 'plugin-b'), 'plugin-b', ['coding']); - await createWorkspaceConfig(['./plugin-a', './plugin-b']); - - await syncWorkspace(testDir); - let skills = await getSyncedSkills(); - expect(skills).toContain('plugin-a_coding'); - expect(skills).toContain('plugin-b_coding'); - - // Remove conflict - await createWorkspaceConfig(['./plugin-a']); - await syncWorkspace(testDir); - - skills = await getSyncedSkills(); - expect(skills).toContain('coding'); - expect(skills).not.toContain('plugin-a_coding'); - }); - - it('should use directory name when plugin.json is missing', async () => { - const dir1 = join(testDir, 'tools-alpha'); - const dir2 = join(testDir, 'tools-beta'); - await mkdir(join(dir1, 'skills', 'shared'), { recursive: true }); - await mkdir(join(dir2, 'skills', 'shared'), { recursive: true }); - await writeFile(join(dir1, 'skills', 'shared', 'SKILL.md'), '---\nname: shared\ndescription: Test\n---'); - await writeFile(join(dir2, 'skills', 'shared', 'SKILL.md'), '---\nname: shared\ndescription: Test\n---'); - await createWorkspaceConfig(['./tools-alpha', './tools-beta']); - - const result = await syncWorkspace(testDir); - expect(result.success).toBe(true); - - const skills = await getSyncedSkills(); - expect(skills).toContain('tools-alpha_shared'); - expect(skills).toContain('tools-beta_shared'); - }); -}); diff --git a/tests/unit/cli/workspace-setup.test.ts b/tests/unit/cli/workspace-setup.test.ts index 98aa7bf..75e508b 100644 --- a/tests/unit/cli/workspace-setup.test.ts +++ b/tests/unit/cli/workspace-setup.test.ts @@ -39,8 +39,10 @@ clients: const folders = result.folders as Array<{ path: string }>; expect(folders[0].path).toBe('.'); + // On Windows paths are absolute (C:\...) not starting with / + // Just verify they are not relative (don't start with . or ..) for (const folder of folders.slice(1)) { - expect(folder.path.startsWith('/')).toBe(true); + expect(folder.path.startsWith('.')).toBe(false); } expect(result.settings).toEqual({ 'chat.agent.maxRequests': 999 }); @@ -64,18 +66,18 @@ clients: }, launch: { configurations: [ - { type: 'node', name: 'dev', cwd: '{repo:../myrepo}/src' }, + { type: 'node', name: 'dev', cwd: '{path:../myrepo}/src' }, ], }, }; writeFileSync( - join(testDir, '.allagents', 'vscode-template.json'), + join(testDir, '.allagents', 'template.code-workspace'), JSON.stringify(template, null, 2), ); const config = await parseWorkspaceConfig(join(testDir, '.allagents', 'workspace.yaml')); const templateContent = JSON.parse( - readFileSync(join(testDir, '.allagents', 'vscode-template.json'), 'utf-8'), + readFileSync(join(testDir, '.allagents', 'template.code-workspace'), 'utf-8'), ); const result = generateVscodeWorkspace({ @@ -90,10 +92,12 @@ clients: expect(settings['cSpell.words']).toEqual(['myword']); expect(settings['chat.agent.maxRequests']).toBe(50); - // Launch config with substituted path + // Launch config with substituted path - should contain repo name and src const launch = result.launch as { configurations: Array<{ cwd: string }> }; - expect(launch.configurations[0].cwd).toContain('/myrepo/src'); - expect(launch.configurations[0].cwd.startsWith('/')).toBe(true); + expect(launch.configurations[0].cwd).toContain('myrepo'); + expect(launch.configurations[0].cwd).toContain('src'); + // Should not contain the placeholder anymore + expect(launch.configurations[0].cwd).not.toContain('{path:'); }); test('uses vscode.output for filename', async () => { diff --git a/tests/unit/cli/workspace-sync-both.test.ts b/tests/unit/cli/workspace-sync-both.test.ts deleted file mode 100644 index 845d1a3..0000000 --- a/tests/unit/cli/workspace-sync-both.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; -import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { initWorkspace } from '../../../src/core/workspace.js'; -import { addUserPlugin } from '../../../src/core/user-workspace.js'; -import { syncUserWorkspace, syncWorkspace } from '../../../src/core/sync.js'; - -describe('workspace sync both scopes', () => { - let tempHome: string; - let tempProject: string; - let originalHome: string; - - beforeEach(async () => { - tempHome = await mkdtemp(join(tmpdir(), 'allagents-both-home-')); - tempProject = await mkdtemp(join(tmpdir(), 'allagents-both-proj-')); - originalHome = process.env.HOME || ''; - process.env.HOME = tempHome; - }); - - afterEach(async () => { - process.env.HOME = originalHome; - await rm(tempHome, { recursive: true, force: true }); - await rm(tempProject, { recursive: true, force: true }); - }); - - test('syncs user workspace when only user config exists', async () => { - const pluginDir = join(tempHome, 'test-plugin'); - const skillDir = join(pluginDir, 'skills', 'test-skill'); - await mkdir(skillDir, { recursive: true }); - await writeFile(join(skillDir, 'SKILL.md'), '---\nname: test-skill\ndescription: A test\n---\nContent'); - await addUserPlugin(pluginDir); - - const result = await syncUserWorkspace(); - expect(result.success).toBe(true); - expect(result.totalCopied).toBeGreaterThan(0); - expect(existsSync(join(tempHome, '.claude', 'skills', 'test-skill'))).toBe(true); - - const projResult = await syncWorkspace(tempProject); - expect(projResult.success).toBe(false); - }); - - test('syncs both when both configs exist', async () => { - const userPluginDir = join(tempHome, 'user-plugin'); - const userSkillDir = join(userPluginDir, 'skills', 'user-skill'); - await mkdir(userSkillDir, { recursive: true }); - await writeFile(join(userSkillDir, 'SKILL.md'), '---\nname: user-skill\ndescription: User skill\n---\nContent'); - await addUserPlugin(userPluginDir); - - await initWorkspace(tempProject); - const projPluginDir = join(tempHome, 'proj-plugin'); - const projSkillDir = join(projPluginDir, 'skills', 'proj-skill'); - await mkdir(projSkillDir, { recursive: true }); - await writeFile(join(projSkillDir, 'SKILL.md'), '---\nname: proj-skill\ndescription: Project skill\n---\nContent'); - const { addPlugin } = await import('../../../src/core/workspace-modify.js'); - await addPlugin(projPluginDir, tempProject); - - const userResult = await syncUserWorkspace(); - expect(userResult.success).toBe(true); - const projResult = await syncWorkspace(tempProject); - expect(projResult.success).toBe(true); - - expect(existsSync(join(tempHome, '.claude', 'skills', 'user-skill'))).toBe(true); - expect(existsSync(join(tempProject, '.claude', 'skills', 'proj-skill'))).toBe(true); - }); -}); diff --git a/tests/unit/core/vscode-workspace.test.ts b/tests/unit/core/vscode-workspace.test.ts index 955e2ce..ab8b04c 100644 --- a/tests/unit/core/vscode-workspace.test.ts +++ b/tests/unit/core/vscode-workspace.test.ts @@ -1,14 +1,21 @@ import { describe, expect, test } from 'bun:test'; +import { resolve, join } from 'node:path'; +import { tmpdir } from 'node:os'; import { + buildPathPlaceholderMap, generateVscodeWorkspace, getWorkspaceOutputPath, - substituteRepoPlaceholders, + substitutePathPlaceholders, } from '../../../src/core/vscode-workspace.js'; +// Use tmpdir for cross-platform test paths +const testBase = join(tmpdir(), 'allagents-test'); + describe('generateVscodeWorkspace', () => { test('generates workspace with repository folders resolved to absolute paths', () => { + const workspacePath = join(testBase, 'myapp'); const result = generateVscodeWorkspace({ - workspacePath: '/home/user/projects/myapp', + workspacePath, repositories: [ { path: '../backend' }, { path: '../frontend' }, @@ -18,14 +25,14 @@ describe('generateVscodeWorkspace', () => { expect(result.folders).toEqual([ { path: '.' }, - { path: '/home/user/projects/backend' }, - { path: '/home/user/projects/frontend' }, + { path: resolve(workspacePath, '../backend').replace(/\\/g, '/') }, + { path: resolve(workspacePath, '../frontend').replace(/\\/g, '/') }, ]); }); test('applies default settings when no template', () => { const result = generateVscodeWorkspace({ - workspacePath: '/home/user/projects/myapp', + workspacePath: join(testBase, 'myapp'), repositories: [], template: undefined, }); @@ -36,33 +43,37 @@ describe('generateVscodeWorkspace', () => { }); test('merges template folders after repo folders, deduplicating by path', () => { + const workspacePath = join(testBase, 'myapp'); + const sharedPath = resolve(workspacePath, '../shared').replace(/\\/g, '/'); + const extraPath = join(testBase, 'extra').replace(/\\/g, '/'); + const result = generateVscodeWorkspace({ - workspacePath: '/home/user/projects/myapp', + workspacePath, repositories: [ { path: '../backend' }, { path: '../shared' }, ], template: { folders: [ - { path: '/home/user/projects/shared', name: 'SharedLib' }, - { path: '/home/user/projects/extra', name: 'ExtraLib' }, + { path: sharedPath, name: 'SharedLib' }, // duplicate of ../shared + { path: extraPath, name: 'ExtraLib' }, ], }, }); - // ../shared resolves to /home/user/projects/shared — template duplicate removed - // /extra is not a duplicate, so it's kept with its name + // ../shared resolves to sharedPath — template duplicate removed + // extra is not a duplicate, so it's kept with its name expect(result.folders).toEqual([ { path: '.' }, - { path: '/home/user/projects/backend' }, - { path: '/home/user/projects/shared' }, - { path: '/home/user/projects/extra', name: 'ExtraLib' }, + { path: resolve(workspacePath, '../backend').replace(/\\/g, '/') }, + { path: sharedPath }, + { path: extraPath, name: 'ExtraLib' }, ]); }); test('uses template settings verbatim, no defaults injected', () => { const result = generateVscodeWorkspace({ - workspacePath: '/home/user/projects/myapp', + workspacePath: join(testBase, 'myapp'), repositories: [], template: { settings: { @@ -92,7 +103,7 @@ describe('generateVscodeWorkspace', () => { }; const result = generateVscodeWorkspace({ - workspacePath: '/home/user/projects/myapp', + workspacePath: join(testBase, 'myapp'), repositories: [], template, }); @@ -102,67 +113,99 @@ describe('generateVscodeWorkspace', () => { }); }); -describe('substituteRepoPlaceholders', () => { - const repoMap = new Map([ - ['../Glow', '/home/user/projects/Glow'], - ['../Glow.Shared', '/home/user/projects/Glow.Shared'], +describe('substitutePathPlaceholders', () => { + // Use resolved paths for the map values (with forward slashes for consistency) + const glowPath = resolve(testBase, 'Glow').replace(/\\/g, '/'); + const glowSharedPath = resolve(testBase, 'Glow.Shared').replace(/\\/g, '/'); + const pathMap = new Map([ + ['../Glow', glowPath], + ['../Glow.Shared', glowSharedPath], ]); - test('substitutes {repo:..} in string values', () => { - const input = { cwd: '{repo:../Glow}/DotNet/Client' }; - const result = substituteRepoPlaceholders(input, repoMap); - expect(result.cwd).toBe('/home/user/projects/Glow/DotNet/Client'); + test('substitutes {path:..} in string values', () => { + const input = { cwd: '{path:../Glow}/DotNet/Client' }; + const result = substitutePathPlaceholders(input, pathMap); + expect(result.cwd).toBe(`${glowPath}/DotNet/Client`); }); test('substitutes in nested objects', () => { const input = { launch: { configurations: [ - { cwd: '{repo:../Glow}/src', name: 'dev' }, + { cwd: '{path:../Glow}/src', name: 'dev' }, ], }, }; - const result = substituteRepoPlaceholders(input, repoMap); - expect(result.launch.configurations[0].cwd).toBe('/home/user/projects/Glow/src'); + const result = substitutePathPlaceholders(input, pathMap); + expect(result.launch.configurations[0].cwd).toBe(`${glowPath}/src`); expect(result.launch.configurations[0].name).toBe('dev'); }); test('substitutes in folder path entries', () => { const input = { folders: [ - { path: '{repo:../Glow.Shared}', name: 'Shared' }, + { path: '{path:../Glow.Shared}', name: 'Shared' }, ], }; - const result = substituteRepoPlaceholders(input, repoMap); - expect(result.folders[0].path).toBe('/home/user/projects/Glow.Shared'); + const result = substitutePathPlaceholders(input, pathMap); + expect(result.folders[0].path).toBe(glowSharedPath); }); test('leaves strings without placeholders unchanged', () => { const input = { name: 'no placeholders here' }; - const result = substituteRepoPlaceholders(input, repoMap); + const result = substitutePathPlaceholders(input, pathMap); expect(result.name).toBe('no placeholders here'); }); test('leaves non-string values unchanged', () => { const input = { count: 999, enabled: true, items: [1, 2, 3] }; - const result = substituteRepoPlaceholders(input, repoMap); + const result = substitutePathPlaceholders(input, pathMap); expect(result).toEqual(input); }); }); +describe('buildPathPlaceholderMap', () => { + test('registers repositories by relative path', () => { + const workspacePath = join(testBase, 'workspace'); + const map = buildPathPlaceholderMap( + [{ path: '../Glow' }, { path: '../Glow.Shared' }], + workspacePath, + ); + + expect(map.get('../Glow')).toBe(resolve(workspacePath, '../Glow')); + expect(map.get('../Glow.Shared')).toBe(resolve(workspacePath, '../Glow.Shared')); + }); + + test('resolves paths to absolute paths', () => { + const workspacePath = join(testBase, 'workspace'); + const map = buildPathPlaceholderMap( + [{ path: '../Glow', repo: 'WiseTechGlobal/Glow' }], + workspacePath, + ); + + // Path should be absolute (not relative) + const resolved = map.get('../Glow'); + expect(resolved).toBeDefined(); + expect(resolved).not.toContain('..'); + }); +}); + describe('getWorkspaceOutputPath', () => { test('uses vscode.output from config', () => { - const result = getWorkspaceOutputPath('/home/user/myapp', { output: 'glow' }); - expect(result).toBe('/home/user/myapp/glow.code-workspace'); + const workspacePath = join(testBase, 'myapp'); + const result = getWorkspaceOutputPath(workspacePath, { output: 'glow' }); + expect(result).toBe(join(workspacePath, 'glow.code-workspace')); }); test('defaults to dirname when no config', () => { - const result = getWorkspaceOutputPath('/home/user/myapp', undefined); - expect(result).toBe('/home/user/myapp/myapp.code-workspace'); + const workspacePath = join(testBase, 'myapp'); + const result = getWorkspaceOutputPath(workspacePath, undefined); + expect(result).toBe(join(workspacePath, 'myapp.code-workspace')); }); test('does not double-add .code-workspace extension', () => { - const result = getWorkspaceOutputPath('/home/user/myapp', { output: 'test.code-workspace' }); - expect(result).toBe('/home/user/myapp/test.code-workspace'); + const workspacePath = join(testBase, 'myapp'); + const result = getWorkspaceOutputPath(workspacePath, { output: 'test.code-workspace' }); + expect(result).toBe(join(workspacePath, 'test.code-workspace')); }); }); diff --git a/tests/models/client-mapping.test.ts b/tests/unit/models/client-mapping.test.ts similarity index 100% rename from tests/models/client-mapping.test.ts rename to tests/unit/models/client-mapping.test.ts