Path: blob/main/src/vs/platform/agentHost/test/common/agentHostFileSystemProvider.test.ts
13399 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 'assert';6import { VSBuffer } from '../../../../base/common/buffer.js';7import { URI } from '../../../../base/common/uri.js';8import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';9import { FileType } from '../../../files/common/files.js';10import { AgentHostFileSystemProvider, agentHostRemotePath, agentHostUri, type IRemoteFilesystemConnection } from '../../common/agentHostFileSystemProvider.js';11import { AGENT_HOST_LABEL_FORMATTER, AGENT_HOST_SCHEME, agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../../common/agentHostUri.js';12import { ContentEncoding, type ResourceListResult, type ResourceReadResult } from '../../common/state/protocol/commands.js';1314suite('AgentHostFileSystemProvider - URI helpers', () => {1516ensureNoDisposablesAreLeakedInTestSuite();1718test('agentHostUri builds correct URI', () => {19const uri = agentHostUri('localhost', '/home/user/project');20assert.strictEqual(uri.scheme, AGENT_HOST_SCHEME);21assert.strictEqual(uri.authority, 'localhost');22// path encodes file scheme: /file//home/user/project23assert.ok(uri.path.includes('/home/user/project'));24});2526test('agentHostRemotePath extracts the original path', () => {27const uri = agentHostUri('host', '/some/path');28assert.strictEqual(agentHostRemotePath(uri), '/some/path');29});3031test('agentHostRemotePath round-trips with agentHostUri', () => {32const original = '/home/user/project';33const uri = agentHostUri('host', original);34assert.strictEqual(agentHostRemotePath(uri), original);35});36});3738suite('AgentHostAuthority - encoding', () => {3940ensureNoDisposablesAreLeakedInTestSuite();4142test('purely alphanumeric address is returned as-is', () => {43assert.strictEqual(agentHostAuthority('localhost'), 'localhost');44});4546test('normal host:port address uses human-readable encoding', () => {47assert.strictEqual(agentHostAuthority('localhost:8081'), 'localhost__8081');48assert.strictEqual(agentHostAuthority('192.168.1.1:8080'), '192.168.1.1__8080');49assert.strictEqual(agentHostAuthority('my-host:9090'), 'my-host__9090');50assert.strictEqual(agentHostAuthority('host.name:80'), 'host.name__80');51});5253test('address with underscore falls through to base64', () => {54const authority = agentHostAuthority('host_name:8080');55assert.ok(authority.startsWith('b64-'), `expected base64 for underscore address, got: ${authority}`);56});5758test('address with exotic characters is base64-encoded', () => {59assert.ok(agentHostAuthority('user@host:8080').startsWith('b64-'));60assert.ok(agentHostAuthority('host with spaces').startsWith('b64-'));61assert.ok(agentHostAuthority('http://myhost:3000').startsWith('b64-'));62});6364test('ws:// prefix is normalized so authority matches bare address', () => {65assert.strictEqual(agentHostAuthority('ws://127.0.0.1:8080'), agentHostAuthority('127.0.0.1:8080'));66assert.strictEqual(agentHostAuthority('ws://localhost:9090'), agentHostAuthority('localhost:9090'));67});6869test('different addresses produce different authorities', () => {70const cases = ['localhost:8080', 'localhost:8081', '192.168.1.1:8080', 'host-name:80', 'host.name:80', 'host_name:80', 'user@host:8080'];71const results = cases.map(agentHostAuthority);72const unique = new Set(results);73assert.strictEqual(unique.size, cases.length, 'all authorities must be unique');74});7576test('authority is valid in a URI authority position', () => {77const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces', '192.168.1.1:9090'];78for (const address of addresses) {79const authority = agentHostAuthority(address);80const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority, path: '/test' });81assert.strictEqual(uri.authority, authority, `authority for '${address}' must round-trip through URI`);82}83});8485test('authority is valid in a URI scheme position', () => {86const addresses = ['localhost', 'localhost:8081', 'user@host:8080', 'host with spaces'];87for (const address of addresses) {88const authority = agentHostAuthority(address);89const scheme = `remote-${authority}-copilot`;90const uri = URI.from({ scheme, path: '/test' });91assert.strictEqual(uri.scheme, scheme, `scheme for '${address}' must round-trip through URI`);92}93});94});9596suite('toAgentHostUri / fromAgentHostUri', () => {9798ensureNoDisposablesAreLeakedInTestSuite();99100test('round-trips a file URI', () => {101const original = URI.file('/home/user/project/file.ts');102const wrapped = toAgentHostUri(original, 'my-server');103assert.strictEqual(wrapped.scheme, AGENT_HOST_SCHEME);104assert.strictEqual(wrapped.authority, 'my-server');105106const unwrapped = fromAgentHostUri(wrapped);107assert.strictEqual(unwrapped.scheme, 'file');108assert.strictEqual(unwrapped.path, original.path);109});110111test('round-trips a URI with authority', () => {112const original = URI.from({ scheme: 'agenthost-content', authority: 'session1', path: '/snap/before' });113const wrapped = toAgentHostUri(original, 'remote-host');114const unwrapped = fromAgentHostUri(wrapped);115assert.strictEqual(unwrapped.scheme, 'agenthost-content');116assert.strictEqual(unwrapped.authority, 'session1');117assert.strictEqual(unwrapped.path, '/snap/before');118});119120test('local authority returns original URI unchanged', () => {121const original = URI.file('/workspace/test.ts');122const result = toAgentHostUri(original, 'local');123assert.strictEqual(result.toString(), original.toString());124});125126test('agentHostUri for root path produces valid encoded URI', () => {127const authority = agentHostAuthority('localhost:8089');128const uri = agentHostUri(authority, '/');129assert.strictEqual(uri.scheme, AGENT_HOST_SCHEME);130assert.strictEqual(uri.authority, authority);131// The decoded path should be root132assert.strictEqual(fromAgentHostUri(uri).path, '/');133});134135test('fromAgentHostUri handles malformed path gracefully', () => {136const uri = URI.from({ scheme: AGENT_HOST_SCHEME, authority: 'host', path: '/file' });137const result = fromAgentHostUri(uri);138// Should not throw - falls back to extracting scheme only139assert.strictEqual(result.scheme, 'file');140});141});142143suite('AGENT_HOST_LABEL_FORMATTER', () => {144145ensureNoDisposablesAreLeakedInTestSuite();146147/**148* Replicates the stripPathSegments logic from the label service to149* verify that the formatter's configuration is consistent with the150* URI encoding.151*/152function stripPath(path: string, segments: number): string {153let pos = 0;154for (let i = 0; i < segments; i++) {155const next = path.indexOf('/', pos + 1);156if (next === -1) {157break;158}159pos = next;160}161return path.substring(pos);162}163164test('stripPathSegments matches URI encoding for file URIs', () => {165const authority = agentHostAuthority('localhost:8089');166const originalPath = '/Users/roblou/code/vscode';167const encodedUri = agentHostUri(authority, originalPath);168169const stripped = stripPath(encodedUri.path, AGENT_HOST_LABEL_FORMATTER.formatting.stripPathSegments!);170assert.strictEqual(stripped, originalPath);171});172173test('stripPathSegments matches URI encoding with authority', () => {174const originalUri = URI.from({ scheme: 'agenthost-content', authority: 'myhost', path: '/snap/before' });175const encodedUri = toAgentHostUri(originalUri, 'remote-host');176177const stripped = stripPath(encodedUri.path, AGENT_HOST_LABEL_FORMATTER.formatting.stripPathSegments!);178assert.strictEqual(stripped, '/snap/before');179});180});181182suite('AgentHostFileSystemProvider - synthetic content schemes', () => {183184const disposables = ensureNoDisposablesAreLeakedInTestSuite();185186/**187* Stub connection that records the URIs it's asked about and returns188* canned data, so we can assert on the URIs the provider passes through.189*/190class StubConnection implements IRemoteFilesystemConnection {191readonly readCalls: URI[] = [];192readonly listCalls: URI[] = [];193readResult: ResourceReadResult = { data: 'stub-content', encoding: ContentEncoding.Utf8, contentType: 'text/plain' };194195async resourceRead(uri: URI): Promise<ResourceReadResult> {196this.readCalls.push(uri);197return this.readResult;198}199async resourceList(uri: URI): Promise<ResourceListResult> {200this.listCalls.push(uri);201return { entries: [] };202}203async resourceWrite(): Promise<{}> { return {}; }204async resourceDelete(): Promise<{}> { return {}; }205async resourceMove(): Promise<{}> { return {}; }206}207208function setup() {209const provider = disposables.add(new AgentHostFileSystemProvider());210const connection = new StubConnection();211disposables.add(provider.registerAuthority('local', connection));212return { provider, connection };213}214215// Regression: AHPFileSystemProvider.stat() used to fall through to216// _listDirectory(parent) for any URI whose decoded scheme wasn't217// session-db, which fails with "Directory not found" for synthetic218// content URIs that have no real parent directory. The diff editor219// stats every URI before reading it, so this broke "open diff of a220// modified file" entirely. The fix is the scheme allowlist in stat().221222test('stat returns File for git-blob: URIs without listing the parent', async () => {223const { provider, connection } = setup();224const inner = URI.from({ scheme: 'git-blob', authority: 'sess1', path: '/sha/encoded/file.ts' });225const wrapped = toAgentHostUri(inner, 'local');226227const stat = await provider.stat(wrapped);228229assert.strictEqual(stat.type, FileType.File);230assert.deepStrictEqual(connection.listCalls, [], 'stat must not list a synthetic parent directory');231});232233test('stat returns File for session-db: URIs (parity with git-blob)', async () => {234const { provider, connection } = setup();235const inner = URI.from({ scheme: 'session-db', authority: 'sess1', path: '/snap/some-blob' });236const wrapped = toAgentHostUri(inner, 'local');237238const stat = await provider.stat(wrapped);239240assert.strictEqual(stat.type, FileType.File);241assert.deepStrictEqual(connection.listCalls, []);242});243244test('stat still lists parent for ordinary file: URIs', async () => {245// Use a non-local authority so the URI actually goes through the246// agent-host wrapping (toAgentHostUri short-circuits 'local'247// + file:// to return the URI unchanged).248const provider = disposables.add(new AgentHostFileSystemProvider());249const connection = new StubConnection();250disposables.add(provider.registerAuthority('remote', connection));251const wrapped = agentHostUri('remote', '/some/file.ts');252253try {254await provider.stat(wrapped);255} catch {256// Either FileNotFound or EntryNotFound is fine — we only257// care that the provider tried to list the parent (rather258// than treating this as a synthetic content URI).259}260assert.strictEqual(connection.listCalls.length, 1);261});262263test('readFile passes the decoded synthetic URI through to the connection', async () => {264const { provider, connection } = setup();265const inner = URI.from({ scheme: 'git-blob', authority: 'sess1', path: '/sha/encoded/file.ts' });266const wrapped = toAgentHostUri(inner, 'local');267268const bytes = await provider.readFile(wrapped);269270assert.strictEqual(VSBuffer.wrap(bytes).toString(), 'stub-content');271assert.deepStrictEqual(connection.readCalls.map(u => u.toString()), [inner.toString()]);272});273274test('full stat-then-read round-trip mirrors the diff editor flow', async () => {275// This is the exact sequence the workbench's TextFileEditorModel276// goes through when DiffEditorInput.createModel resolves: stat277// the URI, then read the file. Pre-fix this combo failed at the278// stat step before readFile was even called.279const { provider } = setup();280const inner = URI.from({ scheme: 'git-blob', authority: 'sess1', path: '/sha/encoded/file.ts' });281const wrapped = toAgentHostUri(inner, 'local');282283const stat = await provider.stat(wrapped);284assert.strictEqual(stat.type, FileType.File);285const bytes = await provider.readFile(wrapped);286assert.strictEqual(VSBuffer.wrap(bytes).toString(), 'stub-content');287});288});289290291