diff --git a/test/commands/workspace-path-guard.test.ts b/test/commands/workspace-path-guard.test.ts new file mode 100644 index 00000000..71eb5c16 --- /dev/null +++ b/test/commands/workspace-path-guard.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; + +function readWorkspaceCommandTestSource(): string { + return fs.readFileSync(new URL('./workspace.test.ts', import.meta.url), 'utf-8'); +} + +describe('workspace command path expectation guardrails', () => { + it('keeps generated workspace folder expectations behind canonical path helpers', () => { + const source = readWorkspaceCommandTestSource(); + + expect(source).not.toMatch(/\.folders\)\.toEqual\(\s*\[/u); + expect(source).not.toMatch(/expect\(workspaceFolders\)\.toEqual\(\s*\[/u); + }); + + it('keeps opener launch expectations behind canonical path helpers', () => { + const source = readWorkspaceCommandTestSource(); + + expect(source).not.toMatch(/expect\([^)]*Launch\.args\)\.toEqual\(/u); + expect(source).not.toMatch(/expect\(fs\.realpathSync\.native\([^)]*Launch\.cwd\)\)\.toBe/u); + expect(source).not.toMatch(/getWorkspaceCodeWorkspacePath\(expectedExistingPath\(/u); + }); +}); diff --git a/test/commands/workspace.interactive.test.ts b/test/commands/workspace.interactive.test.ts index 1173f1ed..16599877 100644 --- a/test/commands/workspace.interactive.test.ts +++ b/test/commands/workspace.interactive.test.ts @@ -9,6 +9,7 @@ import { getWorkspaceLocalStatePath, parseWorkspaceLocalState, } from '../../src/core/workspace/index.js'; +import { expectedExistingPath } from '../helpers/workspace-paths.js'; vi.mock('@inquirer/prompts', () => ({ input: vi.fn(), @@ -88,10 +89,6 @@ describe('workspace command interactive flows', () => { return dir; } - function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; - } - function readLocalState(workspaceName: string) { const workspaceRoot = getManagedWorkspaceRoot(workspaceName); return parseWorkspaceLocalState( diff --git a/test/commands/workspace.test.ts b/test/commands/workspace.test.ts index 366c6f37..f025cb72 100644 --- a/test/commands/workspace.test.ts +++ b/test/commands/workspace.test.ts @@ -25,6 +25,12 @@ import { } from '../../src/core/workspace/index.js'; import { FileSystemUtils } from '../../src/utils/file-system.js'; import { runCLI, type RunCLIResult } from '../helpers/run-cli.js'; +import { + expectedExistingPath, + expectedWorkspaceCodeWorkspacePath, + expectedWorkspaceFolders, + expectWorkspaceLaunchLog, +} from '../helpers/workspace-paths.js'; describe('workspace command', () => { let tempDir: string; @@ -51,10 +57,6 @@ describe('workspace command', () => { return dir; } - function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; - } - function parseJson(result: RunCLIResult): any { try { return JSON.parse(result.stdout); @@ -197,19 +199,21 @@ describe('workspace command', () => { expect(fs.readFileSync(path.join(workspaceRoot, 'AGENTS.md'), 'utf-8')).toContain( 'OpenSpec Workspace Guidance' ); - expect(JSON.parse(fs.readFileSync(getWorkspaceCodeWorkspacePath(workspaceRoot, 'platform'), 'utf-8')).folders).toEqual([ - { - path: '.', - }, - { - name: 'api', - path: expectedApi, - }, - { - name: 'checkout', - path: expectedCheckout, - }, - ]); + expect(JSON.parse(fs.readFileSync(getWorkspaceCodeWorkspacePath(workspaceRoot, 'platform'), 'utf-8')).folders).toEqual( + expectedWorkspaceFolders([ + { + path: '.', + }, + { + name: 'api', + path: api, + }, + { + name: 'checkout', + path: checkout, + }, + ]) + ); const list = await runCLI(['workspace', 'ls', '--json'], { cwd: tempDir, env }); expect(list.exitCode).toBe(0); @@ -959,7 +963,6 @@ paths: it('opens a workspace through VS Code editor and agent overrides without changing stored preference', async () => { const api = mkdir('repos/api'); - const expectedApi = expectedExistingPath(api); const web = mkdir('repos/web'); const setup = await setupWorkspace('platform', [`api=${api}`, `web=${web}`], ['--opener', 'editor']); fs.rmSync(web, { recursive: true, force: true }); @@ -977,22 +980,22 @@ paths: const workspaceFolders = JSON.parse( fs.readFileSync(getWorkspaceCodeWorkspacePath(setup.workspace.root, 'platform'), 'utf-8') ).folders; - expect(workspaceFolders).toEqual([ - { - path: '.', - }, - { - name: 'api', - path: expectedApi, - }, - ]); - const editorLaunch = readLaunchLog(code.logPath); - expect(fs.realpathSync.native(editorLaunch.cwd)).toBe( - fs.realpathSync.native(setup.workspace.root) + expect(workspaceFolders).toEqual( + expectedWorkspaceFolders([ + { + path: '.', + }, + { + name: 'api', + path: api, + }, + ]) ); - expect(editorLaunch.args).toEqual([ - getWorkspaceCodeWorkspacePath(expectedExistingPath(setup.workspace.root), 'platform'), - ]); + const editorLaunch = readLaunchLog(code.logPath); + expectWorkspaceLaunchLog(editorLaunch, { + cwd: setup.workspace.root, + args: [{ workspaceFile: { root: setup.workspace.root, name: 'platform' } }], + }); const currentWorkspaceOpen = await runCLI(['workspace', 'open', '--editor', '--no-interactive'], { cwd: path.join(setup.workspace.root, WORKSPACE_CHANGES_DIR_NAME), @@ -1011,14 +1014,10 @@ paths: expect(codexOpen.exitCode).toBe(0); const codexLaunch = readLaunchLog(codex.logPath); - expect(fs.realpathSync.native(codexLaunch.cwd)).toBe( - fs.realpathSync.native(setup.workspace.root) - ); - expect(codexLaunch.args).toEqual([ - '--add-dir', - expectedApi, - 'Open this OpenSpec workspace.', - ]); + expectWorkspaceLaunchLog(codexLaunch, { + cwd: setup.workspace.root, + args: ['--add-dir', { existingPath: api }, 'Open this OpenSpec workspace.'], + }); expect(readLocalState(setup.workspace.root).preferred_opener).toEqual({ kind: 'editor', id: 'vscode', @@ -1116,7 +1115,7 @@ preferred_opener: expect(unavailable.exitCode).toBe(1); expect(unavailable.stderr).toContain("'code' was not found on PATH"); expect(unavailable.stderr).toContain( - getWorkspaceCodeWorkspacePath(expectedExistingPath(platform.workspace.root), 'platform') + expectedWorkspaceCodeWorkspacePath(platform.workspace.root, 'platform') ); }); diff --git a/test/core/workspace/foundation.test.ts b/test/core/workspace/foundation.test.ts index f06ee6cc..4efdec3d 100644 --- a/test/core/workspace/foundation.test.ts +++ b/test/core/workspace/foundation.test.ts @@ -48,6 +48,7 @@ import { writeWorkspaceLocalState, writeWorkspaceRegistryState, } from '../../../src/core/workspace/index.js'; +import { expectedExistingPath } from '../../helpers/workspace-paths.js'; describe('workspace foundation', () => { let tempDir: string; @@ -84,10 +85,6 @@ paths: {} return workspaceRoot; } - function expectedExistingPath(existingPath: string): string { - return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; - } - describe('path helpers', () => { it('exposes the workspace constants', () => { expect(WORKSPACE_METADATA_DIR_NAME).toBe('.openspec-workspace'); diff --git a/test/helpers/workspace-paths.ts b/test/helpers/workspace-paths.ts new file mode 100644 index 00000000..2779ceee --- /dev/null +++ b/test/helpers/workspace-paths.ts @@ -0,0 +1,75 @@ +import { expect } from 'vitest'; +import * as fs from 'node:fs'; + +import { getWorkspaceCodeWorkspacePath } from '../../src/core/workspace/index.js'; + +/** + * Workspace commands canonicalize existing filesystem paths before storing, + * reporting, or passing them to openers. On Windows, GitHub runners can expose + * temp paths through short aliases such as RUNNER~1 while Node's native + * realpath expands them to the long user path. Use these helpers for expected + * workspace command paths instead of comparing raw mkdtemp/os.tmpdir strings. + */ +export function expectedExistingPath(existingPath: string): string { + return process.platform === 'win32' ? fs.realpathSync.native(existingPath) : existingPath; +} + +function equivalentExistingPath(existingPath: string): string { + return fs.realpathSync.native(existingPath); +} + +export function expectedWorkspaceCodeWorkspacePath( + workspaceRoot: string, + workspaceName: string +): string { + return getWorkspaceCodeWorkspacePath(expectedExistingPath(workspaceRoot), workspaceName); +} + +export function expectedWorkspaceFolders(folders: T[]): T[] { + return folders.map((folder) => + folder.path === '.' + ? folder + : { + ...folder, + path: expectedExistingPath(folder.path), + } + ); +} + +type WorkspaceLaunchArgExpectation = + | string + | { + existingPath: string; + } + | { + workspaceFile: { + root: string; + name: string; + }; + }; + +function expectedWorkspaceLaunchArg(arg: WorkspaceLaunchArgExpectation): string { + if (typeof arg === 'string') { + return arg; + } + + if ('existingPath' in arg) { + return expectedExistingPath(arg.existingPath); + } + + return expectedWorkspaceCodeWorkspacePath(arg.workspaceFile.root, arg.workspaceFile.name); +} + +export function expectedWorkspaceLaunchArgs( + args: WorkspaceLaunchArgExpectation[] +): string[] { + return args.map(expectedWorkspaceLaunchArg); +} + +export function expectWorkspaceLaunchLog( + actual: { cwd: string; args: string[] }, + expected: { cwd: string; args: WorkspaceLaunchArgExpectation[] } +): void { + expect(equivalentExistingPath(actual.cwd)).toBe(equivalentExistingPath(expected.cwd)); + expect(actual.args).toEqual(expectedWorkspaceLaunchArgs(expected.args)); +}