diff --git a/.github/workflows/screenshot-test.yml b/.github/workflows/screenshot-test.yml index 4cd928df194..c6dfff673ed 100644 --- a/.github/workflows/screenshot-test.yml +++ b/.github/workflows/screenshot-test.yml @@ -26,10 +26,10 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - # Depth 2 gives us the test-merge commit plus its parents, which is - # enough for the merge-base lookup below (the target-branch tip is a - # direct parent). Full clone would be wasteful for this large repo. - fetch-depth: 2 + # Need enough history for the merge-base lookup below to succeed even + # when the target branch has advanced since the PR was opened. Full + # clone would be wasteful for this large repo, so cap at 50. + fetch-depth: 50 - name: Setup Node.js uses: actions/setup-node@v6 @@ -63,7 +63,8 @@ jobs: - name: Capture screenshots run: ./node_modules/.bin/component-explorer render --project ./test/componentFixtures/component-explorer.json - - name: Log fixture errors + - name: Check fixture errors + id: fixture_errors if: always() run: | MANIFEST="test/componentFixtures/.screenshots/current/manifest.json" @@ -71,7 +72,11 @@ jobs: echo "::warning::No manifest found — render may have failed entirely" exit 0 fi - ERRORS=$(node -e " + # Log per-fixture errors but do not fail here — let later steps run + # (artifact upload, diff, PR comment) so failures are debuggable. + # The final "Fail if fixtures had errors" step turns this into a + # job failure. + if node -e " const m = require('./$MANIFEST'); const errs = m.fixtures.filter(f => f.hasError); if (!errs.length) { console.log('No fixture errors.'); process.exit(0); } @@ -84,8 +89,12 @@ jobs: for (const e of f.events) { console.log(' event: ' + JSON.stringify(e)); } } } - ") - echo "$ERRORS" + process.exit(1); + "; then + echo "has_errors=false" >> "$GITHUB_OUTPUT" + else + echo "has_errors=true" >> "$GITHUB_OUTPUT" + fi - name: Check blocks-ci screenshots id: blocks-ci @@ -127,7 +136,7 @@ jobs: # the target-branch tip at PR creation time and can be stale, # causing unrelated target-branch commits to show up as diffs. TARGET_REF="origin/${{ github.event.pull_request.base.ref }}" - git fetch --no-tags --depth=1 origin "${{ github.event.pull_request.base.ref }}" + git fetch --no-tags --depth=50 origin "${{ github.event.pull_request.base.ref }}" BASE_SHA=$(git merge-base "${{ github.sha }}" "$TARGET_REF") else # For push events, diff against the parent commit. @@ -319,6 +328,12 @@ jobs: diff -u test/componentFixtures/blocks-ci-screenshots.md /tmp/blocks-ci-updated.md || true exit 1 + - name: Fail if fixtures had errors + if: always() && steps.fixture_errors.outputs.has_errors == 'true' + run: | + echo "::error::One or more component fixtures failed to render. See the 'Check fixture errors' step for details." + exit 1 + # - name: Prepare explorer artifact # run: | # mkdir -p /tmp/explorer-artifact/screenshot-report diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index 66234c9fc6c..a70f89c1638 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -207,32 +207,32 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid const itemActions: ICommentItemActions = { editAction: undefined!, convertAction: undefined, removeAction: undefined! }; - itemActions.editAction = new Action( + itemActions.editAction = this._eventStore.add(new Action( 'agentFeedback.widget.edit', nls.localize('editComment', "Edit"), ThemeIcon.asClassName(Codicon.edit), true, (): void => { this._startEditing(comment, text, itemActions); }, - ); + )); actionBar.push(itemActions.editAction, { icon: true, label: false }); if (comment.canConvertToAgentFeedback) { - itemActions.convertAction = new Action( + itemActions.convertAction = this._eventStore.add(new Action( 'agentFeedback.widget.convert', nls.localize('convertComment', "Convert to Agent Feedback"), ThemeIcon.asClassName(Codicon.check), true, () => this._convertToAgentFeedback(comment), - ); + )); actionBar.push(itemActions.convertAction, { icon: true, label: false }); } - itemActions.removeAction = new Action( + itemActions.removeAction = this._eventStore.add(new Action( 'agentFeedback.widget.remove', nls.localize('removeComment', "Remove"), ThemeIcon.asClassName(Codicon.close), true, () => this._removeComment(comment), - ); + )); actionBar.push(itemActions.removeAction, { icon: true, label: false }); itemHeader.appendChild(actionBarContainer); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts new file mode 100644 index 00000000000..d8a488939d3 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -0,0 +1,213 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IMenu, IMenuItem, IMenuService, MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IUpdateService, StateType } from '../../../../../platform/update/common/update.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { ISharedWebContentExtractorService } from '../../../../../platform/webContentExtractor/common/webContentExtractor.js'; +import { IAccessibleViewService } from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IDecorationsService } from '../../../../services/decorations/common/decorations.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; +import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; +import { IViewDescriptorService } from '../../../../common/views.js'; +import { ISCMService } from '../../../../contrib/scm/common/scm.js'; +import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IChatAccessibilityService, IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; +import { IChatAttachmentResolveService } from '../../../../contrib/chat/browser/attachments/chatAttachmentResolveService.js'; +import { IChatAttachmentWidgetRegistry } from '../../../../contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; +import { IChatContextPickService } from '../../../../contrib/chat/browser/attachments/chatContextPickService.js'; +import { IChatContextService } from '../../../../contrib/chat/browser/contextContrib/chatContextService.js'; +import { IChatImageCarouselService } from '../../../../contrib/chat/browser/chatImageCarouselService.js'; +import { IChatInputNotificationService } from '../../../../contrib/chat/browser/widget/input/chatInputNotificationService.js'; +import { IChatMarkdownAnchorService } from '../../../../contrib/chat/browser/widget/chatContentParts/chatMarkdownAnchorService.js'; +import { IChatWidgetHistoryService } from '../../../../contrib/chat/common/widget/chatWidgetHistoryService.js'; +import { IChatModeService } from '../../../../contrib/chat/common/chatModes.js'; +import { MockChatModeService } from '../../../../contrib/chat/test/common/mockChatModeService.js'; +import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { Target } from '../../../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { ILanguageModelsService } from '../../../../contrib/chat/common/languageModels.js'; +import { ChatAgentService, IChatAgent, IChatAgentNameService, IChatAgentService } from '../../../../contrib/chat/common/participants/chatAgents.js'; +import { MockChatService } from '../../../../contrib/chat/test/common/chatService/mockChatService.js'; +import { ILanguageModelToolsService } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; +import { IArtifactSourceGroup, IChatArtifacts, IChatArtifactsService } from '../../../../contrib/chat/common/tools/chatArtifactsService.js'; +import { IChatTodo, IChatTodoListService } from '../../../../contrib/chat/common/tools/chatTodoListService.js'; +import { ServiceRegistration, registerWorkbenchServices } from '../fixtureUtils.js'; + +/** + * A minimal IMenuService implementation backed by an in-memory map. Tests can + * register menu items with addItem() before the component renders the menu. + */ +export class FixtureMenuService implements IMenuService { + declare readonly _serviceBrand: undefined; + private readonly _items = new Map(); + constructor( + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @ICommandService private readonly _commandService: ICommandService, + ) { } + addItem(menuId: MenuId, item: IMenuItem): void { + const key = menuId.id; + let items = this._items.get(key); + if (!items) { + items = []; + this._items.set(key, items); + } + items.push(item); + } + createMenu(id: MenuId): IMenu { + const actions: [string, MenuItemAction[]][] = []; + for (const item of this._items.get(id.id) ?? []) { + const group = item.group ?? ''; + let entry = actions.find(a => a[0] === group); + if (!entry) { + entry = [group, []]; + actions.push(entry); + } + entry[1].push(new MenuItemAction(item.command, item.alt, {}, undefined, undefined, this._contextKeyService, this._commandService)); + } + return { onDidChange: Event.None, dispose() { }, getActions: () => actions }; + } + getMenuActions() { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +export interface IChatFixtureServicesOptions { + /** Observable backing IChatArtifactsService.getArtifacts().artifactGroups. */ + readonly artifactGroups?: IObservable; + /** Initial todos returned from IChatTodoListService.getTodos(). */ + readonly todos?: readonly IChatTodo[]; +} + +/** + * Registers the wide set of service mocks needed to instantiate chat widgets + * (input part, list widget, content parts). All of these are no-op mocks + * suitable for fixtures. + * + * Callers can override any service by registering it again after this call. + */ +export function registerChatFixtureServices(reg: ServiceRegistration, options: IChatFixtureServicesOptions = {}): void { + registerWorkbenchServices(reg); + reg.define(IMenuService, FixtureMenuService); + reg.define(IMarkdownRendererService, MarkdownRendererService); + reg.define(IListService, ListService); + + reg.defineInstance(IDecorationsService, new class extends mock() { override onDidChangeDecorations = Event.None; }()); + reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); + reg.defineInstance(IFileService, new class extends mock() { override onDidFilesChange = Event.None; override onDidRunOperation = Event.None; override hasProvider() { return false; } }()); + reg.defineInstance(IEditorService, new class extends mock() { override onDidActiveEditorChange = Event.None; }()); + reg.defineInstance(IExtensionService, new class extends mock() { override readonly onDidChangeExtensions = Event.None; }()); + reg.defineInstance(IPathService, new class extends mock() { }()); + reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override async getCurrentExperiments() { return []; } override async getTreatment() { return undefined; } override onDidRefetchAssignments = Event.None; }()); + reg.defineInstance(IWorkspaceContextService, new class extends mock() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }()); + reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { override onDidChangePartVisibility = Event.None; override onDidChangeWindowMaximized = Event.None; override isVisible() { return true; } }()); + reg.defineInstance(IViewDescriptorService, new class extends mock() { override onDidChangeLocation = Event.None; }()); + reg.defineInstance(INotebookDocumentService, new class extends mock() { }()); + reg.defineInstance(ISCMService, new class extends mock() { + override readonly onDidAddRepository = Event.None; + override readonly onDidRemoveRepository = Event.None; + override readonly repositories = []; + override readonly repositoryCount = 0; + }()); + reg.defineInstance(IFileDialogService, new class extends mock() { }()); + reg.defineInstance(IProductService, new class extends mock() { }()); + reg.defineInstance(IUpdateService, new class extends mock() { override onStateChange = Event.None; override get state() { return { type: StateType.Uninitialized as const }; } }()); + reg.defineInstance(IUriIdentityService, new class extends mock() { }()); + reg.defineInstance(IActionWidgetService, new class extends mock() { override show() { } override hide() { } override get isVisible() { return false; } }()); + reg.defineInstance(ISharedWebContentExtractorService, new class extends mock() { }()); + reg.defineInstance(IAccessibleViewService, new class extends mock() { override getOpenAriaHint() { return null; } }()); + + // Chat services + reg.define(IChatAgentService, class FixtureChatAgentService extends ChatAgentService { + override getDefaultAgent(): IChatAgent { + // eslint-disable-next-line local/code-no-dangerous-type-assertions + return { fullName: 'GitHub Copilot', id: 'githubCopilot' } as unknown as IChatAgent; + } + }); + reg.defineInstance(IChatAgentNameService, new class extends mock() { + override getAgentNameRestriction() { return true; } + }()); + reg.define(IChatService, MockChatService); + reg.defineInstance(IChatWidgetService, new class extends mock() { + override readonly lastFocusedWidget = undefined; + override readonly onDidAddWidget = Event.None; + override readonly onDidBackgroundSession = Event.None; + override readonly onDidChangeFocusedWidget = Event.None; + override readonly onDidChangeFocusedSession = Event.None; + override getAllWidgets(): readonly IChatWidget[] { return []; } + override getWidgetByInputUri() { return undefined; } + override getWidgetBySessionResource() { return undefined; } + override getWidgetsByLocations() { return []; } + override register() { return { dispose() { } }; } + }()); + reg.defineInstance(IChatAccessibilityService, new class extends mock() { + override acceptRequest() { } + override disposeRequest() { } + override acceptResponse() { } + override acceptElicitation() { } + }()); + reg.defineInstance(IWorkbenchEnvironmentService, new class extends mock() { + override readonly isExtensionDevelopment = false; + override readonly isBuilt = true; + override readonly isSessionsWindow = false; + }()); + reg.defineInstance(IChatSessionsService, new class extends mock() { override getAllChatSessionContributions() { return []; } override readonly onDidChangeSessionOptions = Event.None; override readonly onDidChangeOptionGroups = Event.None; override readonly onDidChangeAvailability = Event.None; override getCustomAgentTargetForSessionType() { return Target.Undefined; } override requiresCustomModelsForSessionType() { return false; } override getOptionGroupsForSessionType() { return []; } }()); + reg.defineInstance(IChatEntitlementService, new class extends mock() { }()); + reg.defineInstance(IChatModeService, new MockChatModeService()); + reg.defineInstance(ILanguageModelsService, new class extends mock() { override onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } }()); + reg.defineInstance(ILanguageModelToolsService, new class extends mock() { override onDidChangeTools = Event.None; override onDidPrepareToolCallBecomeUnresponsive = Event.None; override getTools() { return []; } }()); + reg.defineInstance(IChatContextService, new class extends mock() { }()); + reg.defineInstance(IChatContextPickService, new class extends mock() { }()); + reg.defineInstance(IChatAttachmentWidgetRegistry, new class extends mock() { }()); + reg.defineInstance(IChatAttachmentResolveService, new class extends mock() { }()); + reg.defineInstance(IChatWidgetHistoryService, new class extends mock() { override getHistory() { return []; } override readonly onDidChangeHistory = Event.None; }()); + reg.defineInstance(IChatImageCarouselService, new class extends mock() { }()); + reg.defineInstance(IChatMarkdownAnchorService, new class extends mock() { override register() { return { dispose() { } }; } }()); + reg.defineInstance(IChatInputNotificationService, new class extends mock() { + override readonly onDidChange = Event.None; + override getActiveNotification() { return undefined; } + }()); + reg.defineInstance(IAgentSessionsService, new class extends mock() { override readonly model = new class extends mock() { override readonly onDidChangeSessions = Event.None; }(); }()); + + const artifactGroups = options.artifactGroups ?? observableValue('artifactGroups', []); + reg.defineInstance(IChatArtifactsService, new class extends mock() { + override getArtifacts(): IChatArtifacts { + return new class extends mock() { + override readonly artifactGroups = artifactGroups; + override setAgentArtifacts() { } + override clearAgentArtifacts() { } + override clearSubagentArtifacts() { } + override migrate() { } + }(); + } + }()); + + const todos = [...(options.todos ?? [])]; + reg.defineInstance(IChatTodoListService, new class extends mock() { + override readonly onDidUpdateTodos = Event.None; + override getTodos() { return [...todos]; } + override setTodos() { } + override migrateTodos() { } + }()); +} diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts index 30a8371b08a..9a163c8e644 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts @@ -8,90 +8,20 @@ import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { IMenuService, IMenu, MenuId, MenuItemAction, IMenuItem } from '../../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; - -import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { IFileService } from '../../../../../platform/files/common/files.js'; -import { ISharedWebContentExtractorService } from '../../../../../platform/webContentExtractor/common/webContentExtractor.js'; -import { IDecorationsService } from '../../../../services/decorations/common/decorations.js'; -import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; -import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; -import { IChatWidgetHistoryService } from '../../../../contrib/chat/common/widget/chatWidgetHistoryService.js'; -import { IChatContextPickService } from '../../../../contrib/chat/browser/attachments/chatContextPickService.js'; -import { IWorkspaceContextService, IWorkspace } from '../../../../../platform/workspace/common/workspace.js'; -import { IViewDescriptorService } from '../../../../common/views.js'; import { IChatWidget } from '../../../../contrib/chat/browser/chat.js'; -import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; -import { IChatAttachmentResolveService } from '../../../../contrib/chat/browser/attachments/chatAttachmentResolveService.js'; -import { IChatAttachmentWidgetRegistry } from '../../../../contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; -import { IChatContextService } from '../../../../contrib/chat/browser/contextContrib/chatContextService.js'; -import { IChatImageCarouselService } from '../../../../contrib/chat/browser/chatImageCarouselService.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../contrib/chat/browser/widget/input/chatInputPart.js'; -import { IArtifactSourceGroup, IChatArtifacts, IChatArtifactsService } from '../../../../contrib/chat/common/tools/chatArtifactsService.js'; +import { IArtifactSourceGroup } from '../../../../contrib/chat/common/tools/chatArtifactsService.js'; import { ChatEditingSessionState, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../../contrib/chat/common/editing/chatEditingService.js'; import { IChatRequestDisablement } from '../../../../contrib/chat/common/model/chatModel.js'; -import { IChatTodo, IChatTodoListService } from '../../../../contrib/chat/common/tools/chatTodoListService.js'; +import { IChatTodo } from '../../../../contrib/chat/common/tools/chatTodoListService.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; -import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; -import { IChatModeService } from '../../../../contrib/chat/common/chatModes.js'; -import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; -import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; -import { ILanguageModelsService } from '../../../../contrib/chat/common/languageModels.js'; -import { IChatAgentService } from '../../../../contrib/chat/common/participants/chatAgents.js'; -import { ILanguageModelToolsService } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; -import { IEditorService } from '../../../../services/editor/common/editorService.js'; -import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; -import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; -import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IUpdateService, StateType } from '../../../../../platform/update/common/update.js'; -import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; -import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; -import { ISCMService } from '../../../../contrib/scm/common/scm.js'; -import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { FixtureMenuService, registerChatFixtureServices } from './chatFixtureUtils.js'; import '../../../../contrib/chat/browser/widget/media/chat.css'; -import { MockChatModeService } from '../../../../contrib/chat/test/common/mockChatModeService.js'; - -class FixtureMenuService implements IMenuService { - declare readonly _serviceBrand: undefined; - private readonly _items = new Map(); - constructor( - @IContextKeyService private readonly _contextKeyService: IContextKeyService, - @ICommandService private readonly _commandService: ICommandService, - ) { } - addItem(menuId: MenuId, item: IMenuItem): void { - const key = menuId.id; - let items = this._items.get(key); - if (!items) { - items = []; - this._items.set(key, items); - } - items.push(item); - } - createMenu(id: MenuId): IMenu { - const actions: [string, MenuItemAction[]][] = []; - for (const item of this._items.get(id.id) ?? []) { - const group = item.group ?? ''; - let entry = actions.find(a => a[0] === group); - if (!entry) { - entry = [group, []]; - actions.push(entry); - } - entry[1].push(new MenuItemAction(item.command, item.alt, {}, undefined, undefined, this._contextKeyService, this._commandService)); - } - return { onDidChange: Event.None, dispose() { }, getActions: () => actions }; - } - getMenuActions() { return []; } - getMenuContexts() { return new Set(); } - resetHiddenStates() { } -} interface ChatInputFixtureOptions { readonly artifacts?: readonly { label: string; uri: string; type: 'devServer' | 'screenshot' | 'plan' | undefined }[]; @@ -108,63 +38,7 @@ async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { - registerWorkbenchServices(reg); - reg.define(IMenuService, FixtureMenuService); - reg.defineInstance(IDecorationsService, new class extends mock() { override onDidChangeDecorations = Event.None; }()); - reg.defineInstance(ITextFileService, new class extends mock() { override readonly untitled = new class extends mock() { override readonly onDidChangeLabel = Event.None; }(); }()); - reg.defineInstance(ILanguageModelsService, new class extends mock() { override onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } }()); - reg.defineInstance(IFileService, new class extends mock() { override onDidFilesChange = Event.None; override onDidRunOperation = Event.None; }()); - reg.defineInstance(IEditorService, new class extends mock() { override onDidActiveEditorChange = Event.None; }()); - reg.defineInstance(IChatAgentService, new class extends mock() { override onDidChangeAgents = Event.None; override getAgents() { return []; } override getActivatedAgents() { return []; } }()); - reg.defineInstance(ISharedWebContentExtractorService, new class extends mock() { }()); - reg.defineInstance(IWorkbenchAssignmentService, new class extends mock() { override async getCurrentExperiments() { return []; } override async getTreatment() { return undefined; } override onDidRefetchAssignments = Event.None; }()); - reg.defineInstance(IChatEntitlementService, new class extends mock() { }()); - reg.defineInstance(IChatModeService, new MockChatModeService()); - reg.defineInstance(ILanguageModelToolsService, new class extends mock() { override onDidChangeTools = Event.None; override getTools() { return []; } }()); - reg.defineInstance(IChatService, new class extends mock() { override onDidSubmitRequest = Event.None; }()); - reg.defineInstance(IChatSessionsService, new class extends mock() { override getAllChatSessionContributions() { return []; } override readonly onDidChangeSessionOptions = Event.None; override readonly onDidChangeOptionGroups = Event.None; override readonly onDidChangeAvailability = Event.None; }()); - reg.defineInstance(IChatContextService, new class extends mock() { }()); - reg.defineInstance(IAgentSessionsService, new class extends mock() { override readonly model = new class extends mock() { override readonly onDidChangeSessions = Event.None; }(); }()); - reg.defineInstance(IWorkspaceContextService, new class extends mock() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }()); - reg.defineInstance(IWorkbenchLayoutService, new class extends mock() { override onDidChangePartVisibility = Event.None; override onDidChangeWindowMaximized = Event.None; override isVisible() { return true; } }()); - reg.defineInstance(IViewDescriptorService, new class extends mock() { override onDidChangeLocation = Event.None; }()); - reg.defineInstance(IChatAttachmentWidgetRegistry, new class extends mock() { }()); - reg.defineInstance(IChatAttachmentResolveService, new class extends mock() { }()); - reg.defineInstance(IExtensionService, new class extends mock() { override readonly onDidChangeExtensions = Event.None; }()); - reg.defineInstance(IPathService, new class extends mock() { }()); - reg.defineInstance(IChatWidgetHistoryService, new class extends mock() { override getHistory() { return []; } override readonly onDidChangeHistory = Event.None; }()); - reg.defineInstance(IChatContextPickService, new class extends mock() { }()); - reg.defineInstance(IListService, new ListService()); - reg.defineInstance(INotebookDocumentService, new class extends mock() { }()); - reg.defineInstance(ISCMService, new class extends mock() { - override readonly onDidAddRepository = Event.None; - override readonly onDidRemoveRepository = Event.None; - override readonly repositories = []; - override readonly repositoryCount = 0; - }()); - reg.defineInstance(IActionWidgetService, new class extends mock() { override show() { } override hide() { } override get isVisible() { return false; } }()); - reg.defineInstance(IFileDialogService, new class extends mock() { }()); - reg.defineInstance(IProductService, new class extends mock() { }()); - reg.defineInstance(IChatImageCarouselService, new class extends mock() { }()); - reg.defineInstance(IUpdateService, new class extends mock() { override onStateChange = Event.None; override get state() { return { type: StateType.Uninitialized as const }; } }()); - reg.defineInstance(IUriIdentityService, new class extends mock() { }()); - reg.defineInstance(IChatArtifactsService, new class extends mock() { - override getArtifacts(): IChatArtifacts { - return new class extends mock() { - override readonly artifactGroups = artifactsObs; - override setAgentArtifacts() { } - override clearAgentArtifacts() { } - override clearSubagentArtifacts() { } - override migrate() { } - }(); - } - }()); - reg.defineInstance(IChatTodoListService, new class extends mock() { - override readonly onDidUpdateTodos = Event.None; - override getTodos() { return [...todos]; } - override setTodos() { } - override migrateTodos() { } - }()); + registerChatFixtureServices(reg, { artifactGroups: artifactsObs, todos }); }, }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts new file mode 100644 index 00000000000..3c8f4937e16 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { IMenuService, MenuId } from '../../../../../platform/actions/common/actions.js'; +import { ChatRequestTextPart } from '../../../../contrib/chat/common/requestParser/chatParserTypes.js'; +import { ChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; +import { ChatViewModel } from '../../../../contrib/chat/common/model/chatViewModel.js'; +import { ChatListWidget } from '../../../../contrib/chat/browser/widget/chatListWidget.js'; +import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../contrib/chat/browser/widget/input/chatInputPart.js'; +import { IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; +import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { ChatToolInvocation } from '../../../../contrib/chat/common/model/chatProgressTypes/chatToolInvocation.js'; +import { IToolData, ToolDataSource } from '../../../../contrib/chat/common/tools/languageModelToolsService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../../contrib/chat/common/constants.js'; +import { MockChatService } from '../../../../contrib/chat/test/common/chatService/mockChatService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { FixtureMenuService, registerChatFixtureServices } from './chatFixtureUtils.js'; + +import '../../../../contrib/chat/browser/widget/media/chat.css'; + +interface IFixtureMessage { + readonly user: string; // user prompt text + readonly assistant?: ReadonlyArray< + | { kind: 'markdown'; text: string } + | { kind: 'progress'; text: string } + | { kind: 'terminalConfirmation'; command: string; title?: string } + >; + readonly responseComplete?: boolean; +} + +interface IChatWidgetFixtureOptions { + readonly messages: ReadonlyArray; + readonly withInput?: boolean; +} + +function makeUserMessage(text: string) { + return { + text, + parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, 1, 1, text.length + 1), text)], + }; +} + +async function renderChatWidget(context: ComponentFixtureContext, options: IChatWidgetFixtureOptions): Promise { + const { container, disposableStore } = context; + + const widgetHolder: { current: IChatWidget | undefined } = { current: undefined }; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: context.theme, + additionalServices: (reg) => { + registerChatFixtureServices(reg); + // Override widget service so the chat list renderer can route tool + // confirmations to the carousel attached to our input part. + reg.defineInstance(IChatWidgetService, new class extends mock() { + override readonly lastFocusedWidget = undefined; + override readonly onDidAddWidget = Event.None; + override readonly onDidBackgroundSession = Event.None; + override readonly onDidChangeFocusedWidget = Event.None; + override readonly onDidChangeFocusedSession = Event.None; + override getAllWidgets() { return widgetHolder.current ? [widgetHolder.current] : []; } + override getWidgetByInputUri() { return undefined; } + override getWidgetBySessionResource() { return widgetHolder.current; } + override getWidgetsByLocations() { return []; } + override register() { return { dispose() { } }; } + }()); + }, + }); + + const configService = instantiationService.get(IConfigurationService) as TestConfigurationService; + await configService.setUserConfiguration('chat', { + editor: { fontSize: 13, fontFamily: 'default', fontWeight: 'default', lineHeight: 0, wordWrap: 'off' }, + }); + await configService.setUserConfiguration('editor', { fontFamily: 'monospace', fontLigatures: false }); + configService.setUserConfiguration(ChatConfiguration.ToolConfirmationCarousel, true); + + // Build a real ChatModel populated with hand-crafted requests/responses, then drive a + // real ChatViewModel + ChatListWidget — the same components used in production. + const chatService = instantiationService.get(IChatService) as MockChatService; + const model = disposableStore.add(instantiationService.createInstance( + ChatModel, + undefined, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + chatService.addSession(model); + + const fixtureToolData: IToolData = { + id: 'fixture.terminalTool', + displayName: 'Terminal', + modelDescription: 'Run a command in the terminal', + source: ToolDataSource.Internal, + }; + + for (const message of options.messages) { + const request = model.addRequest(makeUserMessage(message.user), { variables: [] }, 0); + const response = request.response!; + for (const part of message.assistant ?? []) { + if (part.kind === 'markdown') { + model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(part.text) }); + } else if (part.kind === 'progress') { + model.acceptResponseProgress(request, { kind: 'progressMessage', content: new MarkdownString(part.text) }); + } else if (part.kind === 'terminalConfirmation') { + const title = part.title ?? `Run pwsh command?`; + const toolInvocation = new ChatToolInvocation( + { + invocationMessage: new MarkdownString(`Running \`${part.command}\``), + pastTenseMessage: new MarkdownString(`Ran \`${part.command}\``), + confirmationMessages: { title, message: new MarkdownString(`\`${part.command}\``) }, + toolSpecificData: { + kind: 'terminal', + commandLine: { original: part.command }, + language: 'pwsh', + }, + }, + fixtureToolData, + generateUuid(), + undefined, + { command: part.command }, + ); + model.acceptResponseProgress(request, toolInvocation); + } + } + if (message.responseComplete !== false) { + response.complete(); + } + } + + const viewModel = disposableStore.add(instantiationService.createInstance(ChatViewModel, model, undefined)); + + container.style.width = '720px'; + container.style.height = '600px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; + container.classList.add('monaco-workbench'); + + // Mirror the product DOM ancestry: the chat widget lives inside + // `.part.auxiliarybar > .content`, where auxiliaryBarPart.css recolors + // inline editors with `--vscode-sideBar-background` (used by the carousel). + const auxBar = dom.$('.part.auxiliarybar'); + auxBar.style.width = '100%'; + auxBar.style.height = '100%'; + const auxContent = dom.$('.content'); + auxContent.style.width = '100%'; + auxContent.style.height = '100%'; + auxBar.appendChild(auxContent); + container.appendChild(auxBar); + + const session = dom.$('.interactive-session'); + auxContent.appendChild(session); + + // Build the input part FIRST so the widget (with its inputPart) is registered + // in IChatWidgetService before the list widget renders. The renderer queries + // the service synchronously when routing tool confirmations to the carousel. + let inputPart: ChatInputPart | undefined; + if (options.withInput) { + const menuService = instantiationService.get(IMenuService) as FixtureMenuService; + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.attachContext', title: '+', icon: Codicon.add }, group: 'navigation', order: -1 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.openModePicker', title: 'Agent' }, group: 'navigation', order: 1 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.openModelPicker', title: 'GPT-5.3-Codex' }, group: 'navigation', order: 3 }); + menuService.addItem(MenuId.ChatInput, { command: { id: 'workbench.action.chat.configureTools', title: '', icon: Codicon.settingsGear }, group: 'navigation', order: 100 }); + menuService.addItem(MenuId.ChatExecute, { command: { id: 'workbench.action.chat.submit', title: 'Send', icon: Codicon.arrowUp }, group: 'navigation', order: 4 }); + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openSessionTargetPicker', title: 'Local' }, group: 'navigation', order: 0 }); + menuService.addItem(MenuId.ChatInputSecondary, { command: { id: 'workbench.action.chat.openPermissionPicker', title: 'Default Approvals' }, group: 'navigation', order: 10 }); + + const inputOptions: IChatInputPartOptions = { + renderFollowups: false, + renderInputToolbarBelowInput: false, + renderWorkingSet: false, + menus: { executeToolbar: MenuId.ChatExecute, telemetrySource: 'fixture' }, + widgetViewKindTag: 'view', + inputEditorMinLines: 2, + }; + const inputStyles: IChatInputStyles = { + overlayBackground: 'var(--vscode-editor-background)', + listForeground: 'var(--vscode-foreground)', + listBackground: 'var(--vscode-editor-background)', + }; + + inputPart = disposableStore.add(instantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, inputOptions, inputStyles, false)); + } + + const fixtureWidget = new class extends mock() { + override readonly onDidChangeViewModel = new Emitter().event; + override readonly viewModel = viewModel; + override readonly contribs = []; + override readonly location = ChatAgentLocation.Chat; + override readonly viewContext = {}; + override readonly inputPart = inputPart!; + }(); + widgetHolder.current = fixtureWidget; + + if (inputPart) { + inputPart.render(session, '', fixtureWidget); + inputPart.layout(720); + await new Promise(r => setTimeout(r, 50)); + inputPart.layout(720); + } + + const listContainer = dom.$('.interactive-list'); + listContainer.style.flex = '1 1 auto'; + listContainer.style.minHeight = '0'; + listContainer.style.position = 'relative'; + // Prepend the list before the input so the visual order matches production. + session.insertBefore(listContainer, session.firstChild); + + const listWidget = disposableStore.add(instantiationService.createInstance( + ChatListWidget, + listContainer, + { + currentChatMode: () => ChatModeKind.Agent, + defaultElementHeight: 120, + renderStyle: 'compact', + styles: { + listForeground: 'var(--vscode-foreground)', + listBackground: 'var(--vscode-editor-background)', + }, + location: ChatAgentLocation.Chat, + rendererOptions: { + progressMessageAtBottomOfResponse: mode => mode !== ChatModeKind.Ask, + }, + }, + )); + listWidget.setViewModel(viewModel); + listWidget.setVisible(true); + listWidget.refresh(); + + const listHeight = options.withInput ? 420 : 600; + listWidget.layout(listHeight, 720); + + // Allow the renderer to flush its async progressive rendering pass. + await new Promise(r => setTimeout(r, 100)); + listWidget.layout(listHeight, 720); + listWidget.scrollTop = 0; +} + +const SIMPLE_QA: IFixtureMessage[] = [ + { + user: 'Add a fibonacci function to fibon.ts', + assistant: [ + { kind: 'markdown', text: 'I added a recursive `fibonacci(n)` to `fibon.ts`. Note that recursion is exponential — for large `n` consider an iterative version.' }, + ], + }, +]; + +const PENDING_TOOL_APPROVAL: IFixtureMessage[] = [ + { + user: 'run git init', + assistant: [ + { kind: 'terminalConfirmation', command: 'git init' }, + ], + responseComplete: false, + }, +]; + +const STREAMING: IFixtureMessage[] = [ + { + user: 'Search the workspace for TODO comments', + assistant: [ + { kind: 'progress', text: 'Searching workspace for `TODO` comments...' }, + ], + responseComplete: false, + }, +]; + +const MULTI_TURN: IFixtureMessage[] = [ + { + user: 'What does this project do?', + assistant: [ + { kind: 'markdown', text: 'This project is **Visual Studio Code**, a free source-code editor made by Microsoft for Windows, Linux and macOS.' }, + ], + }, + { + user: 'Where is the entrypoint?', + assistant: [ + { kind: 'markdown', text: 'The desktop entrypoint is in `src/vs/code/electron-main/main.ts`. The browser/server entrypoints live under `src/vs/server/`.' }, + ], + }, + { + user: 'Thanks!', + assistant: [ + { kind: 'markdown', text: 'You are welcome — let me know if you have more questions.' }, + ], + }, +]; + +export default defineThemedFixtureGroup({ path: 'chat/widget/' }, { + SimpleQA: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: SIMPLE_QA }) }), + Streaming: defineComponentFixture({ labels: { kind: 'animated' }, render: ctx => renderChatWidget(ctx, { messages: STREAMING }) }), + PendingToolApproval: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: PENDING_TOOL_APPROVAL }) }), + PendingToolApprovalWithInput: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: PENDING_TOOL_APPROVAL, withInput: true }) }), + MultiTurn: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: MULTI_TURN }) }), + WithInput: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: MULTI_TURN, withInput: true }) }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditor.fixture.ts index 60f1d288d9e..cf8c005f0ac 100644 --- a/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/editor/multiDiffEditor.fixture.ts @@ -8,12 +8,17 @@ import { Event, ValueWithChangeEvent } from '../../../../../base/common/event.js import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { createTimeout, timeout } from '../../../../../base/common/async.js'; import { MultiDiffEditorWidget } from '../../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; import { IDocumentDiffItem, IMultiDiffEditorModel } from '../../../../../editor/browser/widget/multiDiffEditor/model.js'; import { IResourceLabel as IMultiDiffResourceLabel, IWorkbenchUIElementFactory } from '../../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; import { RefCounted } from '../../../../../editor/browser/widget/diffEditor/utils.js'; import { IDiffProviderFactoryService } from '../../../../../editor/browser/widget/diffEditor/diffProviderFactoryService.js'; import { TestDiffProviderFactoryService } from '../../../../../editor/test/browser/diff/testDiffProviderFactoryService.js'; +import { IDocumentDiff, IDocumentDiffProvider, IDocumentDiffProviderOptions } from '../../../../../editor/common/diff/documentDiffProvider.js'; +import { linesDiffComputers } from '../../../../../editor/common/diff/linesDiffComputers.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IEditorProgressService } from '../../../../../platform/progress/common/progress.js'; import { IWorkspaceContextService, IWorkspace } from '../../../../../platform/workspace/common/workspace.js'; @@ -22,6 +27,7 @@ import { IDecorationsService } from '../../../../services/decorations/common/dec import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; class FixtureWorkbenchUIElementFactory implements IWorkbenchUIElementFactory { constructor( @@ -110,10 +116,57 @@ function renderMultiDiffEditor({ container, disposableStore, theme }: ComponentF container.style.height = '600px'; container.style.border = '1px solid var(--vscode-editorWidget-border)'; - const instantiationService = createEditorServices(disposableStore, { + const instantiationService = createCommonServices(disposableStore, theme, new TestDiffProviderFactoryService()); + const { widget, textModels } = createWidget(instantiationService, disposableStore, container); + const { doc1, doc2, doc3 } = createDocuments(instantiationService, textModels); + + const model: IMultiDiffEditorModel = { + documents: ValueWithChangeEvent.const([doc1, doc2, doc3]), + }; + + const viewModel = widget.createViewModel(model); + widget.setViewModel(viewModel); + widget.layout(new Dimension(800, 600)); +} + +class DelayedDiffProviderFactoryService implements IDiffProviderFactoryService { + declare readonly _serviceBrand: undefined; + constructor(private readonly _delayMs: number) { } + createDiffProvider(): IDocumentDiffProvider { + return new DelayedDocumentDiffProvider(this._delayMs); + } +} + +class DelayedDocumentDiffProvider implements IDocumentDiffProvider { + readonly onDidChange: Event = () => toDisposable(() => { }); + constructor(private readonly _delayMs: number) { } + + async computeDiff(original: ITextModel, modified: ITextModel, options: IDocumentDiffProviderOptions, _cancellationToken: CancellationToken): Promise { + await timeout(this._delayMs); + if (_cancellationToken.isCancellationRequested || original.isDisposed() || modified.isDisposed()) { + return ({ + changes: [], + quitEarly: true, + identical: false, + moves: [], + + }); + } + const result = linesDiffComputers.getDefault().computeDiff(original.getLinesContent(), modified.getLinesContent(), options); + return { + changes: result.changes, + quitEarly: result.hitTimeout, + identical: original.getValue() === modified.getValue(), + moves: result.moves, + }; + } +} + +function createCommonServices(disposableStore: DisposableStore, theme: ComponentFixtureContext['theme'], diffProviderFactory: IDiffProviderFactoryService) { + return createEditorServices(disposableStore, { colorTheme: theme, additionalServices: (reg) => { - reg.define(IDiffProviderFactoryService, TestDiffProviderFactoryService); + reg.defineInstance(IDiffProviderFactoryService, diffProviderFactory); reg.definePartialInstance(IEditorProgressService, { show: () => ({ total: () => { }, worked: () => { }, done: () => { } }), }); @@ -124,47 +177,108 @@ function renderMultiDiffEditor({ container, disposableStore, theme }: ComponentF registerWorkbenchServices(reg); }, }); +} +function createWidget(instantiationService: IInstantiationService, disposableStore: DisposableStore, container: HTMLElement) { const uiFactory = instantiationService.createInstance(FixtureWorkbenchUIElementFactory); - const widget = disposableStore.add(instantiationService.createInstance( MultiDiffEditorWidget, container, uiFactory, )); - - // Text models must be disposed after the widget releases its references. - // DisposableStore disposes in insertion order, so we add a cleanup disposable - // after the widget that first clears the view model, then disposes text models. const textModels = new DisposableStore(); disposableStore.add(toDisposable(() => { widget.setViewModel(undefined); textModels.dispose(); })); + return { widget, textModels }; +} +function createDocuments(instantiationService: TestInstantiationService, textModels: DisposableStore) { const original1 = textModels.add(createTextModel(instantiationService, ORIGINAL_CODE_1, URI.parse('inmemory://original/greet.ts'), 'typescript')); const modified1 = textModels.add(createTextModel(instantiationService, MODIFIED_CODE_1, URI.parse('inmemory://modified/greet.ts'), 'typescript')); - const original2 = textModels.add(createTextModel(instantiationService, ORIGINAL_CODE_2, URI.parse('inmemory://original/config.ts'), 'typescript')); const modified2 = textModels.add(createTextModel(instantiationService, MODIFIED_CODE_2, URI.parse('inmemory://modified/config.ts'), 'typescript')); - const original3 = textModels.add(createTextModel(instantiationService, ORIGINAL_CODE_3, URI.parse('inmemory://original/server.ts'), 'typescript')); const modified3 = textModels.add(createTextModel(instantiationService, MODIFIED_CODE_3, URI.parse('inmemory://modified/server.ts'), 'typescript')); - - const documents: RefCounted[] = [ - RefCounted.createOfNonDisposable({ original: original1, modified: modified1 }, { dispose() { } }), - RefCounted.createOfNonDisposable({ original: original2, modified: modified2 }, { dispose() { } }), - RefCounted.createOfNonDisposable({ original: original3, modified: modified3 }, { dispose() { } }), - ]; - - const model: IMultiDiffEditorModel = { - documents: ValueWithChangeEvent.const(documents), + return { + doc1: RefCounted.createOfNonDisposable({ original: original1, modified: modified1 }, { dispose() { } }), + doc2: RefCounted.createOfNonDisposable({ original: original2, modified: modified2 }, { dispose() { } }), + doc3: RefCounted.createOfNonDisposable({ original: original3, modified: modified3 }, { dispose() { } }), }; +} - const viewModel = widget.createViewModel(model); - widget.setViewModel(viewModel); +function renderMultiDiffEditorIncrementalUpdate() { + return ({ container, disposableStore, theme }: ComponentFixtureContext) => { + container.style.width = '800px'; + container.style.height = '600px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; - widget.layout(new Dimension(800, 600)); + // First file: sync diffs (already resolved). Files 2+3: 800ms delay. + const delayedFactory = new DelayedDiffProviderFactoryService(800); + const instantiationService = createCommonServices(disposableStore, theme, delayedFactory); + const { widget, textModels } = createWidget(instantiationService, disposableStore, container); + const { doc1, doc2, doc3 } = createDocuments(instantiationService, textModels); + + // Start with only doc1 — its diff resolves immediately (800ms virtual) + const documents = new ValueWithChangeEvent[]>([doc1]); + const model: IMultiDiffEditorModel = { documents }; + const viewModel = widget.createViewModel(model); + widget.setViewModel(viewModel); + widget.layout(new Dimension(800, 600)); + + + // At T=900ms: add doc2 and doc3. Their diffs take 800ms (resolve at T=1700ms). + // The 1s gate means they appear at min(T=1700ms, T=1900ms) = T=1700ms. + disposableStore.add(createTimeout(900, () => { + documents.value = [doc1, doc2, doc3]; + })); + }; +} + +function renderMultiDiffEditorDocumentSwap() { + return ({ container, disposableStore, theme }: ComponentFixtureContext) => { + container.style.width = '800px'; + container.style.height = '600px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const delayedFactory = new DelayedDiffProviderFactoryService(800); + const instantiationService = createCommonServices(disposableStore, theme, delayedFactory); + const { widget, textModels } = createWidget(instantiationService, disposableStore, container); + + const makeDoc = (origText: string, modText: string, name: string) => { + const original = textModels.add(createTextModel(instantiationService, origText, URI.parse(`inmemory://original/${name}`), 'typescript')); + const modified = textModels.add(createTextModel(instantiationService, modText, URI.parse(`inmemory://modified/${name}`), 'typescript')); + return RefCounted.createOfNonDisposable({ original, modified }, { dispose() { } }); + }; + + // Each document has exactly one line change. + const codeA_orig = 'const greeting = "hello";'; + const codeA_mod = 'const greeting = "hi";'; + const codeB_orig = 'const port = 3000;'; + const codeB_mod = 'const port = 8080;'; + const codeD_orig = 'const env = "development";'; + const codeD_mod = 'const env = "production";'; + + const docA = makeDoc(codeA_orig, codeA_mod, 'greet.ts'); + const docB = makeDoc(codeB_orig, codeB_mod, 'config.ts'); + + // Start with A and B + const documents = new ValueWithChangeEvent[]>([docA, docB]); + const model: IMultiDiffEditorModel = { documents }; + const viewModel = widget.createViewModel(model); + widget.setViewModel(viewModel); + widget.layout(new Dimension(800, 600)); + + // At T=900ms: replace with A, C, D. + // C has the same content as B but a different URI. + // D is a new document. + disposableStore.add(createTimeout(900, () => { + const docC = makeDoc(codeB_orig, codeB_mod, 'config-v2.ts'); + const docD = makeDoc(codeD_orig, codeD_mod, 'server.ts'); + documents.value = [docA, docC, docD]; + })); + }; } export default defineThemedFixtureGroup({ path: 'editor/' }, { @@ -172,4 +286,34 @@ export default defineThemedFixtureGroup({ path: 'editor/' }, { labels: { kind: 'screenshot' }, render: (context) => renderMultiDiffEditor(context), }), + MultiDiffEditorIncrementalPending: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: true, durationMs: 1200 }, + render: renderMultiDiffEditorIncrementalUpdate(), + }), + MultiDiffEditorIncrementalResolved: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: true, durationMs: 2000 }, + render: renderMultiDiffEditorIncrementalUpdate(), + }), + MultiDiffEditorIncrementalResolvedRealtime: defineComponentFixture({ + labels: { kind: 'animated' }, + virtualTime: { enabled: false }, + render: renderMultiDiffEditorIncrementalUpdate(), + }), + MultiDiffEditorDocumentSwapBefore: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: true, durationMs: 100 }, + render: renderMultiDiffEditorDocumentSwap(), + }), + MultiDiffEditorDocumentSwapAfter: defineComponentFixture({ + labels: { kind: 'screenshot' }, + virtualTime: { enabled: true, durationMs: 2000 }, + render: renderMultiDiffEditorDocumentSwap(), + }), + MultiDiffEditorDocumentSwapRealtime: defineComponentFixture({ + labels: { kind: 'animated' }, + virtualTime: { enabled: false }, + render: renderMultiDiffEditorDocumentSwap(), + }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts index de6a4becb52..13a7511ba5a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -433,63 +433,6 @@ interface IRenderEditorOptions { readonly editorDisplayMode?: 'preview' | 'raw'; } -async function waitForAnimationFrames(count: number): Promise { - for (let i = 0; i < count; i++) { - await new Promise(resolve => mainWindow.requestAnimationFrame(() => resolve())); - } -} - -function getVisibleEditorSignature(container: HTMLElement): string { - const sectionCounts = [...container.querySelectorAll('.section-list-item')].map(item => item.textContent?.replace(/\s+/g, ' ').trim() ?? '').join('|'); - const visibleContent = [...container.querySelectorAll('.prompts-content-container, .mcp-content-container, .plugin-content-container')] - .find(node => node instanceof HTMLElement && node.style.display !== 'none'); - const visibleRows = visibleContent - ? [...visibleContent.querySelectorAll('.monaco-list-row')].map(row => row.textContent?.replace(/\s+/g, ' ').trim() ?? '').join('|') - : ''; - - return `${sectionCounts}@@${visibleRows}`; -} - -async function waitForEditorToSettle(container: HTMLElement): Promise { - let previousSignature = ''; - let stableIterations = 0; - - await new Promise(resolve => setTimeout(resolve, 150)); - - for (let i = 0; i < 20; i++) { - await waitForAnimationFrames(2); - await new Promise(resolve => setTimeout(resolve, 25)); - - const signature = getVisibleEditorSignature(container); - if (signature && signature === previousSignature) { - stableIterations++; - if (stableIterations >= 2) { - return; - } - } else { - stableIterations = 0; - previousSignature = signature; - } - } -} - -async function waitForVisibleScrollbarsToFade(container: HTMLElement): Promise { - const deadline = Date.now() + 4000; - - while (Date.now() < deadline) { - const hasVisibleScrollbar = [...container.querySelectorAll('.scrollbar.vertical')].some(scrollbar => { - const style = mainWindow.getComputedStyle(scrollbar); - return scrollbar.classList.contains('visible') && style.opacity !== '0'; - }); - - if (!hasVisibleScrollbar) { - return; - } - - await new Promise(resolve => setTimeout(resolve, 100)); - } -} - function renderFixtureMarkdown(markdown: string): HTMLElement { const container = DOM.$('div.fixture-rendered-markdown'); const lines = markdown.split(/\r?\n/); @@ -770,13 +713,8 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor editor.selectSectionById(options.selectedSection); } - await waitForEditorToSettle(ctx.container); - if (options.scrollToBottom) { editor.revealLastItem(); - await waitForAnimationFrames(2); - await new Promise(resolve => setTimeout(resolve, 2400)); - await waitForVisibleScrollbarsToFade(ctx.container); } if (options.openFirstItem) { @@ -791,15 +729,10 @@ async function renderEditor(ctx: ComponentFixtureContext, options: IRenderEditor rowToOpen.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })); rowToOpen.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, button: 0 })); rowToOpen.dispatchEvent(new MouseEvent('click', { bubbles: true, button: 0 })); - // Allow any async setInput to settle. - await waitForAnimationFrames(2); - await new Promise(resolve => setTimeout(resolve, 250)); if (options.editorDisplayMode === 'raw') { const modeButton = ctx.container.querySelector('.editor-mode-button') as HTMLButtonElement | undefined; modeButton?.click(); - await waitForAnimationFrames(2); - await new Promise(resolve => setTimeout(resolve, 100)); } } }