From fafc62f5ac2a8fbbafb2d349e1511c3ef9e2b241 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega Date: Thu, 5 Mar 2026 11:34:34 -0800 Subject: [PATCH] Add mock web extension for E2E testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New extension at extensions/sessions-e2e-mock/ provides: - Mock GitHub auth provider (fake token, skips sign-in) - Mock chat participant (canned responses based on input keywords) - Mock file system for github-remote-file:// (in-memory files) Server loads the extension when --mock flag is passed. The generate and test runners both use --mock automatically. New npm scripts: - serve:mock — opens Sessions in browser with mocks loaded Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/code-sessions-web.js | 16 +- src/vs/sessions/test/e2e/common.cjs | 6 +- .../extensions/sessions-e2e-mock/extension.js | 303 ++++++++++++++++++ .../extensions/sessions-e2e-mock/package.json | 27 ++ src/vs/sessions/test/e2e/generate.cjs | 2 +- src/vs/sessions/test/e2e/package.json | 1 + src/vs/sessions/test/e2e/test.cjs | 2 +- 7 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js create mode 100644 src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/package.json diff --git a/scripts/code-sessions-web.js b/scripts/code-sessions-web.js index 26eab07a215..8ce70b14703 100644 --- a/scripts/code-sessions-web.js +++ b/scripts/code-sessions-web.js @@ -15,7 +15,7 @@ const APP_ROOT = path.join(__dirname, '..'); async function main() { const args = minimist(process.argv.slice(2), { - boolean: ['help', 'no-open', 'skip-welcome'], + boolean: ['help', 'no-open', 'skip-welcome', 'mock'], string: ['host', 'port'], }); @@ -24,7 +24,9 @@ async function main() { './scripts/code-sessions-web.sh [options]\n' + ' --host Host to bind to (default: localhost)\n' + ' --port Port to bind to (default: 8081)\n' + - ' --no-open Do not open browser automatically\n' + ' --no-open Do not open browser automatically\n' + + ' --skip-welcome Skip the sessions welcome overlay\n' + + ' --mock Load mock extension for E2E testing\n' ); return; } @@ -50,7 +52,7 @@ async function main() { // Serve the sessions workbench HTML at the root if (url.pathname === '/' || url.pathname === '/index.html') { res.writeHead(200, { 'Content-Type': 'text/html' }); - res.end(getSessionsHTML(HOST, PORT, cssModules)); + res.end(getSessionsHTML(HOST, PORT, cssModules, args['mock'])); return; } @@ -95,7 +97,7 @@ async function main() { process.on('SIGTERM', () => { server.close(); process.exit(0); }); } -function getSessionsHTML(host, port, cssModules) { +function getSessionsHTML(host, port, cssModules, useMock) { const baseUrl = `http://${host}:${port}`; const fileRoot = `${baseUrl}/out`; @@ -112,6 +114,11 @@ function getSessionsHTML(host, port, cssModules) { } const importMapJson = JSON.stringify({ imports }, null, 2); + // When --mock is passed, load the E2E mock extension + const additionalBuiltinExtensions = useMock + ? `additionalBuiltinExtensions: [{ scheme: 'http', authority: '${host}:${port}', path: '/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock' }],` + : ''; + return ` @@ -137,6 +144,7 @@ ${importMapJson} nameLong: 'Sessions (Web)', enableTelemetry: false, }, + ${additionalBuiltinExtensions} workspaceProvider: { workspace: undefined, open: async () => false, diff --git a/src/vs/sessions/test/e2e/common.cjs b/src/vs/sessions/test/e2e/common.cjs index a670bc558ef..1b6680c3393 100644 --- a/src/vs/sessions/test/e2e/common.cjs +++ b/src/vs/sessions/test/e2e/common.cjs @@ -90,10 +90,12 @@ function getSnapshot() { // Server management // --------------------------------------------------------------------------- -function startServer(port) { +function startServer(port, { mock = false } = {}) { + const args = ['--no-open', '--port', String(port)]; + if (mock) { args.push('--mock'); } const server = cp.spawn(process.execPath, [ path.join(APP_ROOT, 'scripts', 'code-sessions-web.js'), - '--no-open', '--port', String(port), + ...args, ], { cwd: APP_ROOT, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js b/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js new file mode 100644 index 00000000000..07ee80fa5e2 --- /dev/null +++ b/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/extension.js @@ -0,0 +1,303 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// @ts-check + +/// + +/** + * Mock extension for Sessions E2E testing. + * + * Provides fake implementations of: + * - GitHub authentication (skips sign-in) + * - Chat participant (returns canned responses) + * - File system provider for github-remote-file:// (in-memory files) + */ + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- + +/** @type {Map} */ +const fileStore = new Map(); + +// Pre-populate a fake repo +const MOCK_FILES = { + '/src/index.ts': 'export function main() {\n\tconsole.log("Hello from mock repo");\n}\n', + '/src/utils.ts': 'export function add(a: number, b: number): number {\n\treturn a + b;\n}\n', + '/package.json': '{\n\t"name": "mock-repo",\n\t"version": "1.0.0"\n}\n', + '/README.md': '# Mock Repository\n\nThis is a mock repository for E2E testing.\n', +}; + +for (const [path, content] of Object.entries(MOCK_FILES)) { + fileStore.set(path, new TextEncoder().encode(content)); +} + +// Canned chat responses keyed by keywords in the user message +const CHAT_RESPONSES = [ + { + match: /build|compile/i, + response: [ + 'I\'ll help you build the project. Here are the changes:', + '', + '```typescript', + '// src/build.ts', + 'import { main } from "./index";', + '', + 'async function build() {', + '\tconsole.log("Building...");', + '\tmain();', + '\tconsole.log("Build complete!");', + '}', + '', + 'build();', + '```', + '', + 'I\'ve created a new build script that imports and runs the main function.', + ].join('\n'), + }, + { + match: /fix|bug/i, + response: [ + 'I found the issue. Here\'s the fix:', + '', + '```diff', + '- export function add(a: number, b: number): number {', + '+ export function add(a: number, b: number): number {', + '+ if (typeof a !== "number" || typeof b !== "number") {', + '+ throw new TypeError("Both arguments must be numbers");', + '+ }', + ' return a + b;', + ' }', + '```', + '', + 'Added input validation to prevent NaN results.', + ].join('\n'), + }, + { + match: /explain/i, + response: [ + 'This project has a simple structure:', + '', + '- **src/index.ts** — Main entry point with a `main()` function', + '- **src/utils.ts** — Utility functions like `add()`', + '- **package.json** — Project metadata', + '', + 'The `main()` function logs a greeting to the console.', + ].join('\n'), + }, +]; + +const DEFAULT_RESPONSE = [ + 'I understand your request. Let me work on that.', + '', + 'Here\'s what I\'d suggest:', + '', + '1. Review the current codebase structure', + '2. Make the necessary changes', + '3. Run the tests to verify', + '', + 'Would you like me to proceed?', +].join('\n'); + +// --------------------------------------------------------------------------- +// Activation +// --------------------------------------------------------------------------- + +/** + * @param {import('vscode').ExtensionContext} context + */ +function activate(context) { + const vscode = require('vscode'); + + console.log('[sessions-e2e-mock] Activating mock extension'); + + // 1. Mock GitHub Authentication Provider + context.subscriptions.push(registerMockAuth(vscode)); + + // 2. Mock Chat Participant + context.subscriptions.push(registerMockChat(vscode)); + + // 3. Mock File System Provider + context.subscriptions.push(registerMockFileSystem(vscode)); + + console.log('[sessions-e2e-mock] All mocks registered'); +} + +// --------------------------------------------------------------------------- +// Mock Authentication Provider +// --------------------------------------------------------------------------- + +/** + * @param {typeof import('vscode')} vscode + * @returns {import('vscode').Disposable} + */ +function registerMockAuth(vscode) { + const sessionChangeEmitter = new vscode.EventEmitter(); + + /** @type {import('vscode').AuthenticationSession} */ + const mockSession = { + id: 'mock-session-1', + accessToken: 'gho_mock_e2e_test_token_00000000000000000000', + account: { + id: 'e2e-test-user', + label: 'E2E Test User', + }, + scopes: ['read:user', 'repo', 'workflow'], + }; + + /** @type {import('vscode').AuthenticationProvider} */ + const provider = { + onDidChangeSessions: sessionChangeEmitter.event, + async getSessions(_scopes, _options) { + return [mockSession]; + }, + async createSession(_scopes, _options) { + sessionChangeEmitter.fire({ added: [mockSession], removed: [], changed: [] }); + return mockSession; + }, + async removeSession(_sessionId) { + sessionChangeEmitter.fire({ added: [], removed: [mockSession], changed: [] }); + }, + }; + + console.log('[sessions-e2e-mock] Registering mock GitHub auth provider'); + return vscode.authentication.registerAuthenticationProvider('github', 'GitHub (Mock)', provider, { + supportsMultipleAccounts: false, + }); +} + +// --------------------------------------------------------------------------- +// Mock Chat Participant +// --------------------------------------------------------------------------- + +/** + * @param {typeof import('vscode')} vscode + * @returns {import('vscode').Disposable} + */ +function registerMockChat(vscode) { + const participant = vscode.chat.createChatParticipant('copilot', async (request, _context, response, _token) => { + const userMessage = request.prompt || ''; + console.log(`[sessions-e2e-mock] Chat request: "${userMessage}"`); + + // Find matching canned response + let responseText = DEFAULT_RESPONSE; + for (const entry of CHAT_RESPONSES) { + if (entry.match.test(userMessage)) { + responseText = entry.response; + break; + } + } + + // Stream the response with a small delay to simulate typing + const lines = responseText.split('\n'); + for (const line of lines) { + response.markdown(line + '\n'); + } + + return { metadata: { mock: true } }; + }); + + participant.iconPath = new vscode.ThemeIcon('copilot'); + + console.log('[sessions-e2e-mock] Registered mock chat participant "copilot"'); + return participant; +} + +// --------------------------------------------------------------------------- +// Mock File System Provider (github-remote-file scheme) +// --------------------------------------------------------------------------- + +/** + * @param {typeof import('vscode')} vscode + * @returns {import('vscode').Disposable} + */ +function registerMockFileSystem(vscode) { + const fileChangeEmitter = new vscode.EventEmitter(); + + /** @type {import('vscode').FileSystemProvider} */ + const provider = { + onDidChangeFile: fileChangeEmitter.event, + + stat(uri) { + const filePath = uri.path; + if (fileStore.has(filePath)) { + return { + type: vscode.FileType.File, + ctime: 0, + mtime: Date.now(), + size: fileStore.get(filePath).byteLength, + }; + } + // Check if it's a directory (any file starts with this path) + const dirPrefix = filePath.endsWith('/') ? filePath : filePath + '/'; + const isDir = filePath === '/' || [...fileStore.keys()].some(k => k.startsWith(dirPrefix)); + if (isDir) { + return { type: vscode.FileType.Directory, ctime: 0, mtime: Date.now(), size: 0 }; + } + throw vscode.FileSystemError.FileNotFound(uri); + }, + + readDirectory(uri) { + const dirPath = uri.path.endsWith('/') ? uri.path : uri.path + '/'; + const entries = new Map(); + for (const filePath of fileStore.keys()) { + if (!filePath.startsWith(dirPath)) { continue; } + const relative = filePath.slice(dirPath.length); + const parts = relative.split('/'); + if (parts.length === 1) { + entries.set(parts[0], vscode.FileType.File); + } else { + entries.set(parts[0], vscode.FileType.Directory); + } + } + return [...entries.entries()]; + }, + + readFile(uri) { + const content = fileStore.get(uri.path); + if (!content) { throw vscode.FileSystemError.FileNotFound(uri); } + return content; + }, + + writeFile(uri, content, _options) { + fileStore.set(uri.path, content); + fileChangeEmitter.fire([{ type: vscode.FileChangeType.Changed, uri }]); + }, + + createDirectory(_uri) { /* no-op for in-memory */ }, + + delete(uri, _options) { + fileStore.delete(uri.path); + fileChangeEmitter.fire([{ type: vscode.FileChangeType.Deleted, uri }]); + }, + + rename(oldUri, newUri, _options) { + const content = fileStore.get(oldUri.path); + if (!content) { throw vscode.FileSystemError.FileNotFound(oldUri); } + fileStore.delete(oldUri.path); + fileStore.set(newUri.path, content); + fileChangeEmitter.fire([ + { type: vscode.FileChangeType.Deleted, uri: oldUri }, + { type: vscode.FileChangeType.Created, uri: newUri }, + ]); + }, + + watch(_uri, _options) { + return { dispose() { } }; + }, + }; + + console.log('[sessions-e2e-mock] Registering mock file system for github-remote-file://'); + return vscode.workspace.registerFileSystemProvider('github-remote-file', provider, { + isCaseSensitive: true, + }); +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +module.exports = { activate }; diff --git a/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/package.json b/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/package.json new file mode 100644 index 00000000000..e04c255137a --- /dev/null +++ b/src/vs/sessions/test/e2e/extensions/sessions-e2e-mock/package.json @@ -0,0 +1,27 @@ +{ + "name": "sessions-e2e-mock", + "displayName": "Sessions E2E Mock", + "description": "Mock auth, chat participant, and file system for E2E testing", + "version": "0.0.1", + "publisher": "vscode", + "engines": { + "vscode": "*" + }, + "extensionKind": ["ui", "workspace"], + "browser": "./extension.js", + "activationEvents": ["*"], + "capabilities": { + "virtualWorkspaces": true, + "untrustedWorkspaces": { + "supported": true + } + }, + "contributes": { + "authentication": [ + { + "id": "github", + "label": "GitHub (Mock)" + } + ] + } +} diff --git a/src/vs/sessions/test/e2e/generate.cjs b/src/vs/sessions/test/e2e/generate.cjs index 1a89123457a..613b1232b4c 100644 --- a/src/vs/sessions/test/e2e/generate.cjs +++ b/src/vs/sessions/test/e2e/generate.cjs @@ -155,7 +155,7 @@ async function main() { // Start web server console.log(`Starting sessions web server on port ${PORT}…`); - const server = startServer(PORT); + const server = startServer(PORT, { mock: true }); await waitForServer(`http://localhost:${PORT}/`, 30_000); console.log('Server ready.'); diff --git a/src/vs/sessions/test/e2e/package.json b/src/vs/sessions/test/e2e/package.json index 8265f5b8a5a..4cd5eb5fdf8 100644 --- a/src/vs/sessions/test/e2e/package.json +++ b/src/vs/sessions/test/e2e/package.json @@ -5,6 +5,7 @@ "description": "Automated E2E tests for the Agent Sessions window using playwright-cli", "scripts": { "serve": "node ../../../../../scripts/code-sessions-web.js --port 9222 --skip-welcome", + "serve:mock": "node ../../../../../scripts/code-sessions-web.js --port 9222 --skip-welcome --mock", "generate": "node generate.cjs", "test": "node test.cjs" }, diff --git a/src/vs/sessions/test/e2e/test.cjs b/src/vs/sessions/test/e2e/test.cjs index cce9f929fdd..f99898b25a2 100644 --- a/src/vs/sessions/test/e2e/test.cjs +++ b/src/vs/sessions/test/e2e/test.cjs @@ -129,7 +129,7 @@ async function main() { // Start web server console.log(`Starting sessions web server on port ${PORT}…`); - const server = startServer(PORT); + const server = startServer(PORT, { mock: true }); await waitForServer(`http://localhost:${PORT}/`, 30_000); console.log('Server ready.\n');