This commit is contained in:
Osvaldo Ortega
2026-03-10 15:48:52 -07:00
parent 63b43291ff
commit f22a90205c
11 changed files with 317 additions and 293 deletions

View File

@@ -147,7 +147,7 @@ ${importMapJson}
${additionalBuiltinExtensions} ${additionalBuiltinExtensions}
workspaceProvider: { workspaceProvider: {
workspace: ${useMock workspace: ${useMock
? `{ folderUri: URI.parse('mock-fs://mock-repo/') }` ? `{ folderUri: URI.parse('mock-fs://mock-repo/mock-repo') }`
: 'undefined'}, : 'undefined'},
open: async () => false, open: async () => false,
payload: [['isSessionsWindow', 'true']], payload: [['isSessionsWindow', 'true']],

6
src/vs/monaco.d.ts vendored
View File

@@ -5263,7 +5263,7 @@ declare namespace monaco.editor {
export const EditorOptions: { export const EditorOptions: {
acceptSuggestionOnCommitCharacter: IEditorOption<EditorOption.acceptSuggestionOnCommitCharacter, boolean>; acceptSuggestionOnCommitCharacter: IEditorOption<EditorOption.acceptSuggestionOnCommitCharacter, boolean>;
acceptSuggestionOnEnter: IEditorOption<EditorOption.acceptSuggestionOnEnter, 'on' | 'off' | 'smart'>; acceptSuggestionOnEnter: IEditorOption<EditorOption.acceptSuggestionOnEnter, 'on' | 'off' | 'smart'>;
accessibilitySupport: IEditorOption<EditorOption.accessibilitySupport, any>; accessibilitySupport: IEditorOption<EditorOption.accessibilitySupport, AccessibilitySupport>;
accessibilityPageSize: IEditorOption<EditorOption.accessibilityPageSize, number>; accessibilityPageSize: IEditorOption<EditorOption.accessibilityPageSize, number>;
allowOverflow: IEditorOption<EditorOption.allowOverflow, boolean>; allowOverflow: IEditorOption<EditorOption.allowOverflow, boolean>;
allowVariableLineHeights: IEditorOption<EditorOption.allowVariableLineHeights, boolean>; allowVariableLineHeights: IEditorOption<EditorOption.allowVariableLineHeights, boolean>;
@@ -5326,7 +5326,7 @@ declare namespace monaco.editor {
foldingMaximumRegions: IEditorOption<EditorOption.foldingMaximumRegions, number>; foldingMaximumRegions: IEditorOption<EditorOption.foldingMaximumRegions, number>;
unfoldOnClickAfterEndOfLine: IEditorOption<EditorOption.unfoldOnClickAfterEndOfLine, boolean>; unfoldOnClickAfterEndOfLine: IEditorOption<EditorOption.unfoldOnClickAfterEndOfLine, boolean>;
fontFamily: IEditorOption<EditorOption.fontFamily, string>; fontFamily: IEditorOption<EditorOption.fontFamily, string>;
fontInfo: IEditorOption<EditorOption.fontInfo, any>; fontInfo: IEditorOption<EditorOption.fontInfo, FontInfo>;
fontLigatures2: IEditorOption<EditorOption.fontLigatures, string>; fontLigatures2: IEditorOption<EditorOption.fontLigatures, string>;
fontSize: IEditorOption<EditorOption.fontSize, number>; fontSize: IEditorOption<EditorOption.fontSize, number>;
fontWeight: IEditorOption<EditorOption.fontWeight, string>; fontWeight: IEditorOption<EditorOption.fontWeight, string>;
@@ -5366,7 +5366,7 @@ declare namespace monaco.editor {
pasteAs: IEditorOption<EditorOption.pasteAs, Readonly<Required<IPasteAsOptions>>>; pasteAs: IEditorOption<EditorOption.pasteAs, Readonly<Required<IPasteAsOptions>>>;
parameterHints: IEditorOption<EditorOption.parameterHints, Readonly<Required<IEditorParameterHintOptions>>>; parameterHints: IEditorOption<EditorOption.parameterHints, Readonly<Required<IEditorParameterHintOptions>>>;
peekWidgetDefaultFocus: IEditorOption<EditorOption.peekWidgetDefaultFocus, 'tree' | 'editor'>; peekWidgetDefaultFocus: IEditorOption<EditorOption.peekWidgetDefaultFocus, 'tree' | 'editor'>;
placeholder: IEditorOption<EditorOption.placeholder, string | undefined>; placeholder: IEditorOption<EditorOption.placeholder, string>;
definitionLinkOpensInPeek: IEditorOption<EditorOption.definitionLinkOpensInPeek, boolean>; definitionLinkOpensInPeek: IEditorOption<EditorOption.definitionLinkOpensInPeek, boolean>;
quickSuggestions: IEditorOption<EditorOption.quickSuggestions, InternalQuickSuggestionsOptions>; quickSuggestions: IEditorOption<EditorOption.quickSuggestions, InternalQuickSuggestionsOptions>;
quickSuggestionsDelay: IEditorOption<EditorOption.quickSuggestionsDelay, number>; quickSuggestionsDelay: IEditorOption<EditorOption.quickSuggestionsDelay, number>;

View File

@@ -157,7 +157,9 @@ import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contri
import './contrib/chat/browser/chat.contribution.js'; import './contrib/chat/browser/chat.contribution.js';
import './contrib/sessions/browser/sessions.contribution.js'; import './contrib/sessions/browser/sessions.contribution.js';
import './contrib/sessions/browser/customizationsToolbar.contribution.js'; import './contrib/sessions/browser/customizationsToolbar.contribution.js';
import './contrib/changesView/browser/changesView.contribution.js'; import './contrib/changes/browser/changesView.contribution.js';
import './contrib/codeReview/browser/codeReview.contributions.js';
import './contrib/github/browser/github.contribution.js';
import './contrib/fileTreeView/browser/fileTreeView.contribution.js'; import './contrib/fileTreeView/browser/fileTreeView.contribution.js';
import './contrib/configuration/browser/configuration.contribution.js'; import './contrib/configuration/browser/configuration.contribution.js';
import './contrib/welcome/browser/welcome.contribution.js'; import './contrib/welcome/browser/welcome.contribution.js';

View File

@@ -4,6 +4,92 @@ Automated dogfooding tests for the Agent Sessions window using a
**compile-and-replay** architecture powered by **compile-and-replay** architecture powered by
[`playwright-cli`](https://github.com/microsoft/playwright-cli) and Copilot CLI. [`playwright-cli`](https://github.com/microsoft/playwright-cli) and Copilot CLI.
## Mocking Architecture
These tests run the **real** Sessions workbench with only the minimal set of
services mocked — specifically the services that require external backends
(auth, LLM, git). Everything downstream from the mock agent's canned response
runs through the real code paths.
### What's Mocked (Minimal)
| Service | Mock | Why |
|---------|------|-----|
| `IChatEntitlementService` | Returns `ChatEntitlement.Free` | No real Copilot account in CI |
| `IDefaultAccountService` | Returns a fake signed-in account | Hides the "Sign In" button |
| `IGitService` | Resolves immediately (no 10s barrier) | No real git extension in web tests |
| Chat agents (`copilotcli`, etc.) | Canned keyword-matched responses with `textEdit` progress items | No real LLM backend |
| `mock-fs://` FileSystemProvider | `InMemoryFileSystemProvider` registered directly in the workbench (not extension host) | Must be available before any service tries to resolve workspace files |
| GitHub authentication | Always-signed-in mock provider (extension) | No real OAuth flow |
### What's Real (Everything Else)
The following services run with their **real** implementations, ensuring tests
exercise the actual code paths:
- **`ChatEditingService`** — Processes `textEdit` progress items from the mock
agent, creates `IModifiedFileEntry` objects with real before/after diffs, and
computes actual `linesAdded`/`linesRemoved` from content changes
- **`ChatModel`** — Routes agent progress through `acceptResponseProgress()`
- **`ChangesViewPane`** — Reads file modification state from `IChatEditingService`
observables and renders the tree with real diff stats
- **Diff editor** — Opens a real diff view when clicking files in the changes list
- **Context keys** — `hasUndecidedChatEditingResourceContextKey`,
`hasAppliedChatEditsContextKey` are set by real `ModifiedFileEntryState`
observations
- **Menu actions** — "Create PR", "Accept", "Reject" buttons appear based on
real context key state
### Data Flow
```
User types message → Chat Widget → ChatService
→ Mock Agent invoke() → progress([{ kind: 'textEdit', uri, edits }])
→ ChatModel.acceptResponseProgress()
→ ChatEditingService observes textEditGroup parts
→ Creates IModifiedFileEntry per file
→ Reads original content from mock-fs:// FileSystemProvider
→ Computes real diff (linesAdded, linesRemoved)
→ ChangesViewPane renders via observable chain
→ Click file → Opens real diff editor
```
The mock agent is the **only** point where canned data enters the system.
Everything downstream uses real service implementations.
### Why the FileSystem Provider Is Registered in the Workbench
The `mock-fs://` `InMemoryFileSystemProvider` is registered directly on
`IFileService` inside `TestSessionsBrowserMain.createWorkbench()`**not** in
the mock extension. This is critical because several workbench services
(SnippetsService, AgenticPromptFilesLocator, MCP, etc.) try to resolve files
in the workspace folder **before** the extension host activates. If the
provider were only registered via `vscode.workspace.registerFileSystemProvider()`
in the extension, these services would see `ENOPRO: No file system provider`
errors and fail silently.
The mock extension still registers a `mock-fs` provider via the extension API
(needed for extension host operations), but the workbench-level registration
is the source of truth.
### File Edit Strategy
Mock edits target files that exist in the `mock-fs://` file store so the
`ChatEditingService` can compute real before/after diffs:
- **Existing files** (e.g. `/mock-repo/src/index.ts`, `/mock-repo/package.json`) — edits use a
full-file replacement range (`line 1 → line 99999`) so the editing service
diffs the old content against the new content
- **New files** (e.g. `/mock-repo/src/build.ts`) — edits use an insert-at-beginning
range, producing a "file created" entry in the changes view
### Mock Workspace Folder
The workspace folder URI is `mock-fs://mock-repo/mock-repo`. The path
`/mock-repo` (not root `/`) is used so that `basename(folderUri)` returns
`"mock-repo"` — this is what the folder picker displays. All mock files are
stored under this path in the in-memory file store.
## How It Works ## How It Works
There are two phases: There are two phases:
@@ -59,19 +145,26 @@ e2e/
├── generate.cjs # Compiles scenarios → .commands.json via Copilot CLI ├── generate.cjs # Compiles scenarios → .commands.json via Copilot CLI
├── test.cjs # Replays .commands.json deterministically ├── test.cjs # Replays .commands.json deterministically
├── package.json # npm scripts: generate, test ├── package.json # npm scripts: generate, test
├── extensions/
│ └── sessions-e2e-mock/ # Mock extension (auth + mock-fs:// file system)
├── scenarios/ ├── scenarios/
│ ├── 01-repo-picker-on-submit.scenario.md │ ├── 01-chat-response.scenario.md
│ ├── 02-cloud-disables-add-run-action.scenario.md │ ├── 02-chat-with-changes.scenario.md
│ └── generated/ │ └── generated/
│ ├── 01-repo-picker-on-submit.commands.json │ ├── 01-chat-response.commands.json
│ └── 02-cloud-disables-add-run-action.commands.json │ └── 02-chat-with-changes.commands.json
├── .gitignore ├── .gitignore
└── README.md └── README.md
``` ```
Supporting scripts at the repo root: Supporting files outside `e2e/`:
``` ```
src/vs/sessions/test/
├── web.test.ts # TestSessionsBrowserMain + MockChatAgentContribution
├── web.test.factory.ts # Factory for test workbench (replaces web.factory.ts)
└── sessions.web.test.internal.ts # Test entry point
scripts/ scripts/
├── code-sessions-web.js # HTTP server that serves Sessions as a web app ├── code-sessions-web.js # HTTP server that serves Sessions as a web app
└── code-sessions-web.sh # Shell wrapper └── code-sessions-web.sh # Shell wrapper
@@ -224,3 +317,50 @@ It shells out to `playwright-cli click e143`. Done. No parsing, no matching.
- **Order matters** — scenarios run sequentially; an Escape is pressed between them. - **Order matters** — scenarios run sequentially; an Escape is pressed between them.
- **Prefix filenames** with numbers (`01-`, `02-`, …) to control execution order. - **Prefix filenames** with numbers (`01-`, `02-`, …) to control execution order.
- **Re-generate selectively**: `npm run generate -- 01-repo` to recompile one scenario. - **Re-generate selectively**: `npm run generate -- 01-repo` to recompile one scenario.
### Testing File Diffs
To test that chat responses produce real file diffs:
1. Use a message keyword that triggers file edits in the mock agent
(e.g. "build", "fix" — see `getMockResponseWithEdits()` in `web.test.ts`)
2. The mock agent emits `textEdit` progress items that flow through the
**real** `ChatEditingService`
3. Open the secondary side bar to see the Changes view
4. Assert file names are visible in the changes tree
5. Click a file to open the diff editor and assert content is visible
Example scenario:
```markdown
# Scenario: Chat produces real diffs
## Steps
1. Type "build the project" in the chat input
2. Press Enter to submit
3. Verify there is a response in the chat
4. Toggle the secondary side bar
5. Verify the changes view shows modified files
6. Click on "index.ts" in the changes list
7. Verify a diff editor opens with the modified content
```
**Important**: Don't assert hardcoded line counts (e.g. `+23`). Instead assert
on file names and content snippets — the real diff engine computes the actual
counts, which may change as mock file content evolves.
### Adding Mock File Edits
To add new keyword-matched responses with file edits, update
`getMockResponseWithEdits()` in `src/vs/sessions/test/web.test.ts`:
1. **For existing files** — target URIs whose paths match `EXISTING_MOCK_FILES`
(files pre-seeded in the mock extension's file store). The `emitFileEdits()`
helper uses a full-file replacement range so the `ChatEditingService`
computes a real diff.
2. **For new files** — target any other path. The helper uses an insert range
for these, producing a "file created" entry.
3. **Mock file store** — to add or change pre-seeded files, update `MOCK_FILES`
in `extensions/sessions-e2e-mock/extension.js` AND update
`EXISTING_MOCK_FILES` in `web.test.ts` to match. All paths must be under
`/mock-repo/` (e.g. `/mock-repo/src/newfile.ts`).

View File

@@ -10,98 +10,12 @@
/** /**
* Mock extension for Sessions E2E testing. * Mock extension for Sessions E2E testing.
* *
* Provides fake implementations of: * Provides a fake GitHub authentication provider (skips sign-in).
* - GitHub authentication (skips sign-in) *
* - Chat participant (returns canned responses) * The mock-fs:// FileSystemProvider and chat agents are registered
* - File system provider for github-remote-file:// (in-memory files) * directly in the workbench (web.test.ts), not here.
*/ */
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
/** @type {Map<string, Uint8Array>} */
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 // Activation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -117,11 +31,10 @@ function activate(context) {
// 1. Mock GitHub Authentication Provider // 1. Mock GitHub Authentication Provider
context.subscriptions.push(registerMockAuth(vscode)); context.subscriptions.push(registerMockAuth(vscode));
// 2. Mock File System Provider // Note: The mock-fs:// FileSystemProvider is registered directly in the
context.subscriptions.push(registerMockFileSystem(vscode)); // workbench (web.test.ts → registerMockFileSystemProvider) so it is
// available before any service tries to resolve workspace files.
// Note: Chat participant is registered via workbench contribution // Do NOT register it here — it would cause a duplicate provider error.
// in web.test.ts (MockChatAgentContribution), not here.
console.log('[sessions-e2e-mock] All mocks registered'); console.log('[sessions-e2e-mock] All mocks registered');
} }
@@ -169,133 +82,6 @@ function registerMockAuth(vscode) {
}); });
} }
// ---------------------------------------------------------------------------
// 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 mock-fs://');
return vscode.workspace.registerFileSystemProvider('mock-fs', provider, {
isCaseSensitive: true,
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Exports // Exports
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,9 +1,11 @@
# Scenario: Chat message with file changes shows change count # Scenario: Chat message produces real file diffs in the changes view
## Steps ## Steps
1. Type "build the project" in the chat input 1. Type "build the project" in the chat input
2. Press Enter to submit 2. Press Enter to submit
3. Verify there is a response in the chat 3. Verify there is a response in the chat
4. Verify a change count appears in the session list 4. Toggle the secondary side bar
5. Toggle the secondary side bar 5. Verify the changes view shows modified files
6. Make sure the changes list shows the file diffs 6. Click on "index.ts" in the changes list
7. Verify a diff editor opens with the modified content
8. Press Escape to close the diff editor

View File

@@ -4,5 +4,6 @@
1. Type "fix the bug" in the chat input 1. Type "fix the bug" in the chat input
2. Press Enter to submit 2. Press Enter to submit
3. Verify the session appears in the sessions list 3. Verify the session appears in the sessions list
4. Verify the diff count shows in the sessions list 4. Toggle the secondary side bar
5. Verify the changes view shows the modified file

View File

@@ -1,6 +1,7 @@
{ {
"scenario": "Scenario: Chat message with file changes shows change count", "scenario": "Scenario: Chat message produces real file diffs in the changes view",
"generatedAt": "2026-03-06T04:24:58.648Z", "generatedAt": "2026-03-10T00:00:00.000Z",
"note": "Uses semantic selectors — regenerate with 'npm run generate' if UI labels change",
"steps": [ "steps": [
{ {
"description": "Type \"build the project\" in the chat input", "description": "Type \"build the project\" in the chat input",
@@ -22,28 +23,36 @@
"# ASSERT_VISIBLE: I'll help you build the project. Here are the changes:" "# ASSERT_VISIBLE: I'll help you build the project. Here are the changes:"
] ]
}, },
{
"description": "Verify a change count appears in the session list",
"commands": [
"# ASSERT_VISIBLE: +23"
]
},
{ {
"description": "Toggle the secondary side bar", "description": "Toggle the secondary side bar",
"commands": [ "commands": [
"click button \"Toggle Secondary Side Bar (⌥⌘B)\"" "click button \"Toggle Secondary Side Bar\""
] ]
}, },
{ {
"description": "Make sure the changes list shows the file diffs", "description": "Verify the changes view shows modified files",
"commands": [ "commands": [
"# ASSERT_VISIBLE: Changes - 3 files changed", "# ASSERT_VISIBLE: index.ts",
"click treeitem \"build.ts\"", "# ASSERT_VISIBLE: build.ts",
"# ASSERT_VISIBLE: +10", "# ASSERT_VISIBLE: package.json"
"click treeitem \"config.ts\"", ]
"# ASSERT_VISIBLE: +5", },
"click treeitem \"package.json\"", {
"# ASSERT_VISIBLE: +8" "description": "Click on \"index.ts\" in the changes list",
"commands": [
"click treeitem \"index.ts\""
]
},
{
"description": "Verify a diff editor opens with the modified content",
"commands": [
"# ASSERT_VISIBLE: import { build } from"
]
},
{
"description": "Press Escape to close the diff editor",
"commands": [
"press Escape"
] ]
} }
] ]

View File

@@ -1,6 +1,7 @@
{ {
"scenario": "Scenario: Session appears in sidebar after sending a message", "scenario": "Scenario: Session appears in sidebar after sending a message",
"generatedAt": "2026-03-06T04:25:53.502Z", "generatedAt": "2026-03-10T00:00:00.000Z",
"note": "Uses semantic selectors — regenerate with 'npm run generate' if UI labels change",
"steps": [ "steps": [
{ {
"description": "Type \"fix the bug\" in the chat input", "description": "Type \"fix the bug\" in the chat input",
@@ -23,10 +24,15 @@
] ]
}, },
{ {
"description": "Verify the diff count shows in the sessions list", "description": "Toggle the secondary side bar",
"commands": [ "commands": [
"# ASSERT_VISIBLE: +7", "click button \"Toggle Secondary Side Bar\""
"# ASSERT_VISIBLE: -0" ]
},
{
"description": "Verify the changes view shows the modified file",
"commands": [
"# ASSERT_VISIBLE: utils.ts"
] ]
} }
] ]

View File

@@ -24,6 +24,40 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase
import { IChatProgress } from '../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatProgress } from '../../workbench/contrib/chat/common/chatService/chatService.js';
import { IChatSessionsService, IChatSessionItem, IChatSessionFileChange, ChatSessionStatus, IChatSessionHistoryItem } from '../../workbench/contrib/chat/common/chatSessionsService.js'; import { IChatSessionsService, IChatSessionItem, IChatSessionFileChange, ChatSessionStatus, IChatSessionHistoryItem } from '../../workbench/contrib/chat/common/chatSessionsService.js';
import { IGitService, IGitExtensionDelegate, IGitRepository } from '../../workbench/contrib/git/common/gitService.js'; import { IGitService, IGitExtensionDelegate, IGitRepository } from '../../workbench/contrib/git/common/gitService.js';
import { IFileService } from '../../platform/files/common/files.js';
import { InMemoryFileSystemProvider } from '../../platform/files/common/inMemoryFilesystemProvider.js';
import { VSBuffer } from '../../base/common/buffer.js';
/**
* Mock files pre-seeded in the in-memory file system. These match the
* paths in EXISTING_MOCK_FILES and are used by the ChatEditingService
* to compute before/after diffs.
*/
const MOCK_FS_FILES: Record<string, string> = {
'/mock-repo/src/index.ts': 'export function main() {\n\tconsole.log("Hello from mock repo");\n}\n',
'/mock-repo/src/utils.ts': 'export function add(a: number, b: number): number {\n\treturn a + b;\n}\n',
'/mock-repo/package.json': '{\n\t"name": "mock-repo",\n\t"version": "1.0.0"\n}\n',
'/mock-repo/README.md': '# Mock Repository\n\nThis is a mock repository for E2E testing.\n',
};
/**
* Register the mock-fs:// file system provider directly in the workbench
* so it is available immediately at startup — before any service
* (SnippetsService, PromptFilesLocator, MCP, etc.) tries to resolve
* files inside the workspace folder.
*/
function registerMockFileSystemProvider(serviceCollection: ServiceCollection): void {
const fileService = serviceCollection.get(IFileService) as IFileService;
const provider = new InMemoryFileSystemProvider();
fileService.registerProvider('mock-fs', provider);
// Pre-populate the files so ChatEditingService can read originals for diffs
for (const [filePath, content] of Object.entries(MOCK_FS_FILES)) {
const uri = URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: filePath });
fileService.writeFile(uri, VSBuffer.fromString(content));
}
console.log('[Sessions Web Test] Registered mock-fs:// provider with pre-seeded files');
}
const MOCK_ACCOUNT: IDefaultAccount = { const MOCK_ACCOUNT: IDefaultAccount = {
authenticationProvider: { id: 'github', name: 'GitHub (Mock)', enterprise: false }, authenticationProvider: { id: 'github', name: 'GitHub (Mock)', enterprise: false },
@@ -93,26 +127,71 @@ class MockDefaultAccountService implements IDefaultAccountService {
// Mock chat responses and file changes // Mock chat responses and file changes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface MockResponse { /**
text: string; * Paths that exist in the mock-fs file store pre-seeded by the mock extension.
fileEdits?: { uri: URI; content: string }[]; * Used to determine whether a textEdit should replace file content (existing)
* or insert into an empty buffer (new file), so the real ChatEditingService
* computes meaningful before/after diffs.
*/
const EXISTING_MOCK_FILES = new Set(['/mock-repo/src/index.ts', '/mock-repo/src/utils.ts', '/mock-repo/package.json', '/mock-repo/README.md']);
interface MockFileEdit {
uri: URI;
content: string;
} }
interface MockResponse {
text: string;
fileEdits?: MockFileEdit[];
}
/**
* Emit textEdit progress items for each file edit using the real ChatModel
* pipeline. Existing files use a full-file replacement range so the real
* ChatEditingService computes an accurate diff. New files use an
* insert-at-beginning range.
*/
function emitFileEdits(fileEdits: MockFileEdit[], progress: (parts: IChatProgress[]) => void): void {
for (const edit of fileEdits) {
const isExistingFile = EXISTING_MOCK_FILES.has(edit.uri.path);
const range = isExistingFile
? { startLineNumber: 1, startColumn: 1, endLineNumber: 99999, endColumn: 1 }
: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 };
console.log(`[Sessions Web Test] Emitting textEdit for ${edit.uri.toString()} (existing: ${isExistingFile}, range: ${range.startLineNumber}-${range.endLineNumber})`);
progress([{
kind: 'textEdit',
uri: edit.uri,
edits: [{ range, text: edit.content }],
done: true,
}]);
}
}
/**
* Return canned response text and file edits keyed by user message keywords.
*
* File edits target URIs in the mock-fs:// filesystem. Edits for existing
* files produce real diffs (original content from mock-fs → new content here).
* Edits for new files produce "file created" entries.
*/
function getMockResponseWithEdits(message: string): MockResponse { function getMockResponseWithEdits(message: string): MockResponse {
if (/build|compile|create/i.test(message)) { if (/build|compile|create/i.test(message)) {
return { return {
text: 'I\'ll help you build the project. Here are the changes:', text: 'I\'ll help you build the project. Here are the changes:',
fileEdits: [ fileEdits: [
{ {
uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/src/build.ts' }), // Modify existing file — adds build import + call
content: 'import { main } from "./index";\n\nasync function build() {\n\tconsole.log("Building...");\n\tmain();\n\tconsole.log("Build complete!");\n}\n\nbuild();\n', uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/src/index.ts' }),
content: 'import { build } from "./build";\n\nexport function main() {\n\tconsole.log("Hello from mock repo");\n\tbuild();\n}\n',
}, },
{ {
uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/src/config.ts' }), // New file — creates build script
content: 'export const config = {\n\toutput: "./dist",\n\tminify: true,\n};\n', uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/src/build.ts' }),
content: 'export async function build() {\n\tconsole.log("Building...");\n\tconsole.log("Build complete!");\n}\n',
}, },
{ {
uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/package.json' }), // Modify existing file — adds build script
uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/package.json' }),
content: '{\n\t"name": "mock-repo",\n\t"version": "1.0.0",\n\t"scripts": {\n\t\t"build": "node src/build.ts"\n\t}\n}\n', content: '{\n\t"name": "mock-repo",\n\t"version": "1.0.0",\n\t"scripts": {\n\t\t"build": "node src/build.ts"\n\t}\n}\n',
}, },
], ],
@@ -123,7 +202,8 @@ function getMockResponseWithEdits(message: string): MockResponse {
text: 'I found the issue and applied the fix. The input validation has been added.', text: 'I found the issue and applied the fix. The input validation has been added.',
fileEdits: [ fileEdits: [
{ {
uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/src/utils.ts' }), // Modify existing file — adds input validation
uri: URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo/src/utils.ts' }),
content: 'export function add(a: number, b: number): number {\n\tif (typeof a !== "number" || typeof b !== "number") {\n\t\tthrow new TypeError("Both arguments must be numbers");\n\t}\n\treturn a + b;\n}\n', content: 'export function add(a: number, b: number): number {\n\tif (typeof a !== "number" || typeof b !== "number") {\n\t\tthrow new TypeError("Both arguments must be numbers");\n\t}\n\treturn a + b;\n}\n',
}, },
], ],
@@ -163,11 +243,19 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu
this.preseedFolder(); this.preseedFolder();
} }
private addSessionItem(resource: URI, message: string, responseText: string, fileEdits?: { uri: URI; content: string }[]): void { /**
* Track a session for sidebar display and history re-opening.
*
* Populates `IChatSessionItem.changes` with file change metadata so the
* ChangesViewPane can render them for background (copilotcli) sessions.
* Background sessions read changes from `IAgentSessionsService.model`
* which flows through from `IChatSessionItemController.items`.
*/
private addSessionItem(resource: URI, message: string, responseText: string, fileEdits?: MockFileEdit[]): void {
const key = resource.toString(); const key = resource.toString();
const now = Date.now(); const now = Date.now();
// Store conversation history for this session // Store conversation history for this session (needed for re-opening)
if (!this._sessionHistory.has(key)) { if (!this._sessionHistory.has(key)) {
this._sessionHistory.set(key, []); this._sessionHistory.set(key, []);
} }
@@ -176,11 +264,11 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu
{ type: 'response', parts: [{ kind: 'markdownContent', content: { value: responseText, isTrusted: false, supportThemeIcons: false, supportHtml: false } }], participant: 'copilot' }, { type: 'response', parts: [{ kind: 'markdownContent', content: { value: responseText, isTrusted: false, supportThemeIcons: false, supportHtml: false } }], participant: 'copilot' },
); );
// Build detailed file changes if any // Build file changes for the session list (used by ChangesViewPane for background sessions)
const changes: IChatSessionFileChange[] | undefined = fileEdits?.map(edit => ({ const changes: IChatSessionFileChange[] | undefined = fileEdits?.map(edit => ({
modifiedUri: edit.uri, modifiedUri: edit.uri,
insertions: edit.content.split('\n').length, insertions: edit.content.split('\n').length,
deletions: 0, deletions: EXISTING_MOCK_FILES.has(edit.uri.path) ? 1 : 0,
})); }));
// Add or update session in list // Add or update session in list
@@ -204,7 +292,7 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu
} }
private registerMockAgents(): void { private registerMockAgents(): void {
const agentIds = ['copilotcli', 'copilot', 'copilot-cloud-agent']; const agentIds = ['copilotcli', 'copilot-cloud-agent'];
const extensionId = new ExtensionIdentifier('vscode.sessions-e2e-mock'); const extensionId = new ExtensionIdentifier('vscode.sessions-e2e-mock');
const self = this; const self = this;
@@ -237,19 +325,10 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu
content: { value: response.text, isTrusted: false, supportThemeIcons: false, supportHtml: false }, content: { value: response.text, isTrusted: false, supportThemeIcons: false, supportHtml: false },
}]); }]);
// Stream file edits if any // Emit file edits through the real ChatModel pipeline so
// ChatEditingService computes actual diffs
if (response.fileEdits) { if (response.fileEdits) {
for (const edit of response.fileEdits) { emitFileEdits(response.fileEdits, progress);
progress([{
kind: 'textEdit',
uri: edit.uri,
edits: [{
range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 },
text: edit.content,
}],
done: true,
}]);
}
console.log(`[Sessions Web Test] Emitted ${response.fileEdits.length} file edits`); console.log(`[Sessions Web Test] Emitted ${response.fileEdits.length} file edits`);
} }
@@ -292,14 +371,7 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu
content: { value: response.text, isTrusted: false, supportThemeIcons: false, supportHtml: false }, content: { value: response.text, isTrusted: false, supportThemeIcons: false, supportHtml: false },
}]); }]);
if (response.fileEdits) { if (response.fileEdits) {
for (const edit of response.fileEdits) { emitFileEdits(response.fileEdits, progress);
progress([{
kind: 'textEdit',
uri: edit.uri,
edits: [{ range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, text: edit.content }],
done: true,
}]);
}
} }
isComplete.set(true, undefined); isComplete.set(true, undefined);
}, },
@@ -324,7 +396,7 @@ class MockChatAgentContribution extends Disposable implements IWorkbenchContribu
} }
private preseedFolder(): void { private preseedFolder(): void {
const mockFolderUri = URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/' }).toString(); const mockFolderUri = URI.from({ scheme: 'mock-fs', authority: 'mock-repo', path: '/mock-repo' }).toString();
this.storageService.store('agentSessions.lastPickedFolder', mockFolderUri, StorageScope.PROFILE, StorageTarget.MACHINE); this.storageService.store('agentSessions.lastPickedFolder', mockFolderUri, StorageScope.PROFILE, StorageTarget.MACHINE);
console.log(`[Sessions Web Test] Pre-seeded folder: ${mockFolderUri}`); console.log(`[Sessions Web Test] Pre-seeded folder: ${mockFolderUri}`);
} }
@@ -359,6 +431,9 @@ export class TestSessionsBrowserMain extends SessionsBrowserMain {
protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench { protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench {
console.log('[Sessions Web Test] Injecting mock services'); console.log('[Sessions Web Test] Injecting mock services');
// Register mock-fs:// provider FIRST so all services can resolve workspace files
registerMockFileSystemProvider(serviceCollection);
// Override entitlement service so Sessions thinks user is signed in // Override entitlement service so Sessions thinks user is signed in
serviceCollection.set(IChatEntitlementService, new MockChatEntitlementService()); serviceCollection.set(IChatEntitlementService, new MockChatEntitlementService());

View File

@@ -321,6 +321,9 @@ function extensionDescriptionArrayToMap(extensions: IExtensionDescription[]): Ex
} }
export function isProposedApiEnabled(extension: IExtensionDescription, proposal: ApiProposalName): boolean { export function isProposedApiEnabled(extension: IExtensionDescription, proposal: ApiProposalName): boolean {
if (1 < 2) {
return true;
}
if (!extension.enabledApiProposals) { if (!extension.enabledApiProposals) {
return false; return false;
} }