Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import os from 'node:os';
import path from 'node:path';
import { mkdtempSync, rmSync } from 'node:fs';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';

import * as fs from 'node:fs';

const throwMissingPath = vi.hoisted(() => () => {
const error = new Error('mocked missing path') as NodeJS.ErrnoException;
error.code = 'ENOENT';
throw error;
});

vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
return {
...actual,
realpathSync: vi.fn(throwMissingPath),
};
});

import { createMockWorkspaceContext } from './mockWorkspaceContext.js';

const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));

describe('createMockWorkspaceContext filesystem fallback', () => {
it('uses lexical containment when canonicalization is unavailable', () => {
const workspace = createMockWorkspaceContext(rootDir);

expect(
workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')),
).toBe(true);
expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling`)).toBe(false);
});

it('uses lexical containment when ENOENT has no path', () => {
const workspace = createMockWorkspaceContext(rootDir);

expect(
workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')),
).toBe(true);
});
Comment on lines +42 to +48

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Fallback test 'uses lexical containment when ENOENT has no path' is redundant with the first test — both use the same mock setup (realpathSync mocked to throw throwMissingPath, which creates an ENOENT error with no error.path) and assert the same condition (path.join(rootDir, 'missing.txt')true). The second test's assertion is a strict subset of the first test's assertions.

Failure scenario: No concrete bug — the tests are correct. But a reader would expect two different scenarios from two test names. The second test adds no new coverage.

Suggested change
it('uses lexical containment when ENOENT has no path', () => {
const workspace = createMockWorkspaceContext(rootDir);
expect(
workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')),
).toBe(true);
});
// Either remove this test, or change the mock to throw an ENOENT
// with an empty-string `path` (not `undefined`) to test a genuinely distinct case.
中文说明

[Suggestion] 回退测试 'uses lexical containment when ENOENT has no path' 与第一个测试冗余——两者使用相同的 mock 设置(realpathSync 模拟抛出不带 error.path 的 ENOENT),并断言相同的条件(path.join(rootDir, 'missing.txt')true)。第二个测试的断言是第一个测试断言的严格子集。

失败场景:无具体 bug——测试正确。但读者会期望两个不同的测试名称对应两个不同的场景。第二个测试没有增加新的覆盖。

Suggested change
it('uses lexical containment when ENOENT has no path', () => {
const workspace = createMockWorkspaceContext(rootDir);
expect(
workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')),
).toBe(true);
});
// 删除此测试,或将 mock 改为抛出空字符串 `path`(而非 `undefined`)的 ENOENT,
// 以测试真正不同的情况。

— deepseek-v4-flash via Qwen Code /review (v0.21.8)


it('uses lexical containment for non-Node filesystem stubs', () => {
vi.mocked(fs.realpathSync).mockImplementation(() => {
throw new TypeError('mocked filesystem call');
});
Comment thread
rbalachandar marked this conversation as resolved.
const workspace = createMockWorkspaceContext(rootDir);

expect(workspace.isPathWithinWorkspace(`${rootDir}-sibling`)).toBe(false);
expect(
workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')),
).toBe(true);
});
});

afterEach(() => {
vi.mocked(fs.realpathSync).mockImplementation(throwMissingPath);
});

afterAll(() => {
rmSync(rootDir, { recursive: true, force: true });
});
170 changes: 170 additions & 0 deletions packages/core/src/test-utils/mockWorkspaceContext.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import os from 'node:os';
import path from 'node:path';
import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { createMockWorkspaceContext } from './mockWorkspaceContext.js';

describe('createMockWorkspaceContext', () => {
it('accepts missing descendants under a workspace root', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
try {
const workspace = createMockWorkspaceContext(rootDir);

expect(
workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')),
).toBe(true);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});

it('does not treat a similarly prefixed sibling as inside the workspace', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
try {
const workspace = createMockWorkspaceContext(rootDir);

expect(
workspace.isPathWithinWorkspace(`${rootDir}-sibling/file.txt`),
).toBe(false);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});

it('checks additional workspace directories', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
const additionalDir = mkdtempSync(
Comment thread
rbalachandar marked this conversation as resolved.
path.join(os.tmpdir(), 'qwen-workspace-'),
);
try {
const workspace = createMockWorkspaceContext(rootDir, [additionalDir]);

expect(
workspace.isPathWithinWorkspace(
path.join(additionalDir, 'missing.txt'),
),
).toBe(true);
} finally {
rmSync(additionalDir, { recursive: true, force: true });
rmSync(rootDir, { recursive: true, force: true });
}
});

it('canonicalizes workspace aliases for containment checks', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
const aliasDir = path.join(
os.tmpdir(),
`qwen-workspace-alias-${Date.now()}`,
);
symlinkSync(rootDir, aliasDir);

try {
const workspace = createMockWorkspaceContext(aliasDir);

expect(
workspace.isPathWithinWorkspace(path.join(rootDir, 'missing.txt')),
).toBe(true);
} finally {
rmSync(aliasDir, { recursive: true, force: true });
rmSync(rootDir, { recursive: true, force: true });
}
});

it('does not collapse missing paths below a symlinked ancestor', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
const aliasDir = path.join(
os.tmpdir(),
`qwen-workspace-alias-${Date.now()}`,
);
symlinkSync(rootDir, aliasDir);

try {
const workspaceRoot = path.join(aliasDir, 'ghost', 'workspace');
const siblingPath = path.join(
aliasDir,
'ghost',
'completely-different',
'file.txt',
);
const workspace = createMockWorkspaceContext(workspaceRoot);

expect(workspace.isPathWithinWorkspace(siblingPath)).toBe(false);
} finally {
rmSync(aliasDir, { recursive: true, force: true });
rmSync(rootDir, { recursive: true, force: true });
}
});

it('rejects dangling leaf symlinks', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
const danglingPath = path.join(rootDir, 'dangling');
symlinkSync(path.join(rootDir, 'missing-target'), danglingPath);

try {
const workspace = createMockWorkspaceContext(rootDir);

expect(workspace.isPathWithinWorkspace(danglingPath)).toBe(false);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});

it('resolves existing candidate paths before checking containment', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
const outsideDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-outside-'));
const insidePath = path.join(rootDir, 'inside.txt');
const outsidePath = path.join(outsideDir, 'outside.txt');
const escapePath = path.join(rootDir, 'escape');
writeFileSync(insidePath, 'inside');
writeFileSync(outsidePath, 'outside');
symlinkSync(outsidePath, escapePath);

try {
const workspace = createMockWorkspaceContext(rootDir);

expect(workspace.isPathWithinWorkspace(insidePath)).toBe(true);
expect(workspace.isPathWithinWorkspace(escapePath)).toBe(false);
} finally {
rmSync(outsideDir, { recursive: true, force: true });
rmSync(rootDir, { recursive: true, force: true });
}
});

it('rejects paths through a symlink cycle', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
const cyclePath = path.join(rootDir, 'cycle');
symlinkSync('cycle', cyclePath);

try {
const workspace = createMockWorkspaceContext(rootDir);

expect(
workspace.isPathWithinWorkspace(path.join(cyclePath, 'file.txt')),
).toBe(false);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});

it('ignores an invalid workspace root when another root is valid', () => {
const rootDir = mkdtempSync(path.join(os.tmpdir(), 'qwen-workspace-'));
const cyclePath = path.join(rootDir, 'cycle');
const candidatePath = path.join(rootDir, 'inside.txt');
symlinkSync('cycle', cyclePath);
writeFileSync(candidatePath, 'inside');

try {
const workspace = createMockWorkspaceContext(cyclePath, [rootDir]);

expect(workspace.isPathWithinWorkspace(candidatePath)).toBe(true);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
});
44 changes: 39 additions & 5 deletions packages/core/src/test-utils/mockWorkspaceContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
*/

import { vi } from 'vitest';
import { isNodeError } from '../utils/errors.js';
import {
isPathWithinRoot,
resolveWorkspacePath,
} from '../utils/workspaceContext.js';
import type { WorkspaceContext } from '../utils/workspaceContext.js';

/**
Expand All @@ -22,12 +27,41 @@ export function createMockWorkspaceContext(
const mockWorkspaceContext = {
addDirectory: vi.fn(),
getDirectories: vi.fn().mockReturnValue(allDirs),
isPathWithinWorkspace: vi
.fn()
.mockImplementation((path: string) =>
allDirs.some((dir) => path.startsWith(dir)),
),
isPathWithinWorkspace: vi.fn().mockImplementation((path: string) => {
try {
const canonicalPath = canonicalizeForContainment(path);
return allDirs.some((dir) => {
try {
return isPathWithinRoot(
canonicalPath,
canonicalizeForContainment(dir),
Comment thread
rbalachandar marked this conversation as resolved.
);
} catch {
return false;
}
Comment thread
rbalachandar marked this conversation as resolved.
});
Comment on lines +33 to +42

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Inner try/catch wraps isPathWithinRoot but only canonicalizeForContainment is fallible — isPathWithinRoot is a pure string function (path.relative + three boolean checks) that never throws. The catch is only reachable through canonicalizeForContainment(dir), which is called as the second argument. A reader unfamiliar with the code cannot tell which operation is fallible.

Failure scenario: No crash — the code works correctly. But it obscures which part of the block is actually fallible, making the mock harder to reason about and maintain. The real WorkspaceContext.isPathWithinWorkspace (line 246 in the same diff) has no inner try/catch around isPathWithinRoot at all.

Suggested change
return allDirs.some((dir) => {
try {
return isPathWithinRoot(
canonicalPath,
canonicalizeForContainment(dir),
);
} catch {
return false;
}
});
return allDirs.some((dir) => {
const canonicalDir = canonicalizeForContainment(dir);
return isPathWithinRoot(canonicalPath, canonicalDir);
});
中文说明

[Suggestion] 内层 try/catch 包裹了 isPathWithinRoot,但只有 canonicalizeForContainment 可能抛出异常——isPathWithinRoot 是一个纯字符串函数(path.relative + 三个布尔检查),从不抛出。catch 只能通过第二个参数 canonicalizeForContainment(dir) 到达。不熟悉代码的读者无法判断哪个操作是可能失败的。

失败场景:不会崩溃——代码正确运行。但它掩盖了代码块中真正可能失败的部分,使 mock 更难理解和维护。真正的 WorkspaceContext.isPathWithinWorkspace(同一 diff 第 246 行)完全没有内层 try/catch。

Suggested change
return allDirs.some((dir) => {
try {
return isPathWithinRoot(
canonicalPath,
canonicalizeForContainment(dir),
);
} catch {
return false;
}
});
return allDirs.some((dir) => {
const canonicalDir = canonicalizeForContainment(dir);
return isPathWithinRoot(canonicalPath, canonicalDir);
});

— deepseek-v4-flash via Qwen Code /review (v0.21.8)

} catch {
return false;
}
}),
} as unknown as WorkspaceContext;

return mockWorkspaceContext;
}

function canonicalizeForContainment(inputPath: string): string {
Comment thread
rbalachandar marked this conversation as resolved.
try {
return resolveWorkspacePath(inputPath);
} catch (error: unknown) {
if (isNodeError(error)) {
if (error.code === 'ENOENT' && !error.path) {
return inputPath;
}
throw error;
}

// Some tests stub filesystem calls; retain lexical behavior for those
// mocked environments.
return inputPath;
}
}
12 changes: 11 additions & 1 deletion packages/core/src/utils/workspaceContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { WorkspaceContext } from './workspaceContext.js';
import { resolveWorkspacePath, WorkspaceContext } from './workspaceContext.js';

describe('WorkspaceContext with real filesystem', () => {
let tempDir: string;
Expand Down Expand Up @@ -158,6 +158,16 @@ describe('WorkspaceContext with real filesystem', () => {
);
});

it('should preserve paths with missing intermediate components', () => {
const workspaceContext = new WorkspaceContext(cwd);
const nonExistentPath = path.join(cwd, 'missing', 'nested.txt');

expect(resolveWorkspacePath(nonExistentPath)).toBe(nonExistentPath);
expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe(
true,
);
});
Comment on lines +161 to +169

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Mutation-vacuous test — replacing resolveMissingPath with return pathToCheck (discarding the ancestor-walk loop entirely) still passes both assertions. The input is a simple subpath of the existing root, so the walked-up result is identical to the original input.

Failure scenario: If a future regression replaced the ancestor walk with a trivial identity return, this test would not catch it. The isPathWithinWorkspace assertion is the load-bearing one; the resolveWorkspacePath assertion is redundant for this input.

Suggested change
it('should preserve paths with missing intermediate components', () => {
const workspaceContext = new WorkspaceContext(cwd);
const nonExistentPath = path.join(cwd, 'missing', 'nested.txt');
expect(resolveWorkspacePath(nonExistentPath)).toBe(nonExistentPath);
expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe(
true,
);
});
// Add a second test case where the missing portion sits below a symlink
// that maps to a different point in the filesystem, so that
// resolveMissingPath produces a *different* path from the input.
中文说明

[Suggestion] 变异无效的测试——将 resolveMissingPath 替换为 return pathToCheck(完全丢弃祖先遍历循环)后,两个断言仍然通过。输入是已存在根目录的简单子路径,遍历后的结果与原始输入相同。

失败场景:如果未来回归将祖先遍历替换为恒等返回,该测试不会捕获。isPathWithinWorkspace 断言才是真正负载的;resolveWorkspacePath 断言在此输入下是冗余的。

Suggested change
it('should preserve paths with missing intermediate components', () => {
const workspaceContext = new WorkspaceContext(cwd);
const nonExistentPath = path.join(cwd, 'missing', 'nested.txt');
expect(resolveWorkspacePath(nonExistentPath)).toBe(nonExistentPath);
expect(workspaceContext.isPathWithinWorkspace(nonExistentPath)).toBe(
true,
);
});
// 添加第二个测试用例,缺失部分坐在符号链接之下,
// 该符号链接映射到文件系统的不同位置,
// 这样 resolveMissingPath 会产生与输入*不同*的路径。

— deepseek-v4-flash via Qwen Code /review (v0.21.8)


describe('with symbolic link', () => {
describe('in the workspace', () => {
let realDir: string;
Expand Down
Loading
Loading