Fixes component fixture errors (#314164)

* Fixes component fixture errors

* refactor: replace empty mock with MockChatModeService in chat fixture services
This commit is contained in:
Henning Dieterichs
2026-05-04 22:43:25 +02:00
committed by GitHub
parent e4e0fa64ea
commit 83b3a75bba
7 changed files with 717 additions and 235 deletions

View File

@@ -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

View File

@@ -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);

View File

@@ -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<string, IMenuItem[]>();
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<string>(); }
resetHiddenStates() { }
}
export interface IChatFixtureServicesOptions {
/** Observable backing IChatArtifactsService.getArtifacts().artifactGroups. */
readonly artifactGroups?: IObservable<readonly IArtifactSourceGroup[]>;
/** 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<IDecorationsService>() { override onDidChangeDecorations = Event.None; }());
reg.defineInstance(ITextFileService, new class extends mock<ITextFileService>() { override readonly untitled = new class extends mock<ITextFileService['untitled']>() { override readonly onDidChangeLabel = Event.None; }(); }());
reg.defineInstance(IFileService, new class extends mock<IFileService>() { override onDidFilesChange = Event.None; override onDidRunOperation = Event.None; override hasProvider() { return false; } }());
reg.defineInstance(IEditorService, new class extends mock<IEditorService>() { override onDidActiveEditorChange = Event.None; }());
reg.defineInstance(IExtensionService, new class extends mock<IExtensionService>() { override readonly onDidChangeExtensions = Event.None; }());
reg.defineInstance(IPathService, new class extends mock<IPathService>() { }());
reg.defineInstance(IWorkbenchAssignmentService, new class extends mock<IWorkbenchAssignmentService>() { override async getCurrentExperiments() { return []; } override async getTreatment() { return undefined; } override onDidRefetchAssignments = Event.None; }());
reg.defineInstance(IWorkspaceContextService, new class extends mock<IWorkspaceContextService>() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }());
reg.defineInstance(IWorkbenchLayoutService, new class extends mock<IWorkbenchLayoutService>() { override onDidChangePartVisibility = Event.None; override onDidChangeWindowMaximized = Event.None; override isVisible() { return true; } }());
reg.defineInstance(IViewDescriptorService, new class extends mock<IViewDescriptorService>() { override onDidChangeLocation = Event.None; }());
reg.defineInstance(INotebookDocumentService, new class extends mock<INotebookDocumentService>() { }());
reg.defineInstance(ISCMService, new class extends mock<ISCMService>() {
override readonly onDidAddRepository = Event.None;
override readonly onDidRemoveRepository = Event.None;
override readonly repositories = [];
override readonly repositoryCount = 0;
}());
reg.defineInstance(IFileDialogService, new class extends mock<IFileDialogService>() { }());
reg.defineInstance(IProductService, new class extends mock<IProductService>() { }());
reg.defineInstance(IUpdateService, new class extends mock<IUpdateService>() { override onStateChange = Event.None; override get state() { return { type: StateType.Uninitialized as const }; } }());
reg.defineInstance(IUriIdentityService, new class extends mock<IUriIdentityService>() { }());
reg.defineInstance(IActionWidgetService, new class extends mock<IActionWidgetService>() { override show() { } override hide() { } override get isVisible() { return false; } }());
reg.defineInstance(ISharedWebContentExtractorService, new class extends mock<ISharedWebContentExtractorService>() { }());
reg.defineInstance(IAccessibleViewService, new class extends mock<IAccessibleViewService>() { 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<IChatAgentNameService>() {
override getAgentNameRestriction() { return true; }
}());
reg.define(IChatService, MockChatService);
reg.defineInstance(IChatWidgetService, new class extends mock<IChatWidgetService>() {
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<IChatAccessibilityService>() {
override acceptRequest() { }
override disposeRequest() { }
override acceptResponse() { }
override acceptElicitation() { }
}());
reg.defineInstance(IWorkbenchEnvironmentService, new class extends mock<IWorkbenchEnvironmentService>() {
override readonly isExtensionDevelopment = false;
override readonly isBuilt = true;
override readonly isSessionsWindow = false;
}());
reg.defineInstance(IChatSessionsService, new class extends mock<IChatSessionsService>() { 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<IChatEntitlementService>() { }());
reg.defineInstance(IChatModeService, new MockChatModeService());
reg.defineInstance(ILanguageModelsService, new class extends mock<ILanguageModelsService>() { override onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } }());
reg.defineInstance(ILanguageModelToolsService, new class extends mock<ILanguageModelToolsService>() { override onDidChangeTools = Event.None; override onDidPrepareToolCallBecomeUnresponsive = Event.None; override getTools() { return []; } }());
reg.defineInstance(IChatContextService, new class extends mock<IChatContextService>() { }());
reg.defineInstance(IChatContextPickService, new class extends mock<IChatContextPickService>() { }());
reg.defineInstance(IChatAttachmentWidgetRegistry, new class extends mock<IChatAttachmentWidgetRegistry>() { }());
reg.defineInstance(IChatAttachmentResolveService, new class extends mock<IChatAttachmentResolveService>() { }());
reg.defineInstance(IChatWidgetHistoryService, new class extends mock<IChatWidgetHistoryService>() { override getHistory() { return []; } override readonly onDidChangeHistory = Event.None; }());
reg.defineInstance(IChatImageCarouselService, new class extends mock<IChatImageCarouselService>() { }());
reg.defineInstance(IChatMarkdownAnchorService, new class extends mock<IChatMarkdownAnchorService>() { override register() { return { dispose() { } }; } }());
reg.defineInstance(IChatInputNotificationService, new class extends mock<IChatInputNotificationService>() {
override readonly onDidChange = Event.None;
override getActiveNotification() { return undefined; }
}());
reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { override readonly model = new class extends mock<IAgentSessionsService['model']>() { override readonly onDidChangeSessions = Event.None; }(); }());
const artifactGroups = options.artifactGroups ?? observableValue<readonly IArtifactSourceGroup[]>('artifactGroups', []);
reg.defineInstance(IChatArtifactsService, new class extends mock<IChatArtifactsService>() {
override getArtifacts(): IChatArtifacts {
return new class extends mock<IChatArtifacts>() {
override readonly artifactGroups = artifactGroups;
override setAgentArtifacts() { }
override clearAgentArtifacts() { }
override clearSubagentArtifacts() { }
override migrate() { }
}();
}
}());
const todos = [...(options.todos ?? [])];
reg.defineInstance(IChatTodoListService, new class extends mock<IChatTodoListService>() {
override readonly onDidUpdateTodos = Event.None;
override getTodos() { return [...todos]; }
override setTodos() { }
override migrateTodos() { }
}());
}

View File

@@ -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<string, IMenuItem[]>();
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<string>(); }
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<IDecorationsService>() { override onDidChangeDecorations = Event.None; }());
reg.defineInstance(ITextFileService, new class extends mock<ITextFileService>() { override readonly untitled = new class extends mock<ITextFileService['untitled']>() { override readonly onDidChangeLabel = Event.None; }(); }());
reg.defineInstance(ILanguageModelsService, new class extends mock<ILanguageModelsService>() { override onDidChangeLanguageModels = Event.None; override getLanguageModelIds() { return []; } }());
reg.defineInstance(IFileService, new class extends mock<IFileService>() { override onDidFilesChange = Event.None; override onDidRunOperation = Event.None; }());
reg.defineInstance(IEditorService, new class extends mock<IEditorService>() { override onDidActiveEditorChange = Event.None; }());
reg.defineInstance(IChatAgentService, new class extends mock<IChatAgentService>() { override onDidChangeAgents = Event.None; override getAgents() { return []; } override getActivatedAgents() { return []; } }());
reg.defineInstance(ISharedWebContentExtractorService, new class extends mock<ISharedWebContentExtractorService>() { }());
reg.defineInstance(IWorkbenchAssignmentService, new class extends mock<IWorkbenchAssignmentService>() { override async getCurrentExperiments() { return []; } override async getTreatment() { return undefined; } override onDidRefetchAssignments = Event.None; }());
reg.defineInstance(IChatEntitlementService, new class extends mock<IChatEntitlementService>() { }());
reg.defineInstance(IChatModeService, new MockChatModeService());
reg.defineInstance(ILanguageModelToolsService, new class extends mock<ILanguageModelToolsService>() { override onDidChangeTools = Event.None; override getTools() { return []; } }());
reg.defineInstance(IChatService, new class extends mock<IChatService>() { override onDidSubmitRequest = Event.None; }());
reg.defineInstance(IChatSessionsService, new class extends mock<IChatSessionsService>() { 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<IChatContextService>() { }());
reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { override readonly model = new class extends mock<IAgentSessionsService['model']>() { override readonly onDidChangeSessions = Event.None; }(); }());
reg.defineInstance(IWorkspaceContextService, new class extends mock<IWorkspaceContextService>() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }());
reg.defineInstance(IWorkbenchLayoutService, new class extends mock<IWorkbenchLayoutService>() { override onDidChangePartVisibility = Event.None; override onDidChangeWindowMaximized = Event.None; override isVisible() { return true; } }());
reg.defineInstance(IViewDescriptorService, new class extends mock<IViewDescriptorService>() { override onDidChangeLocation = Event.None; }());
reg.defineInstance(IChatAttachmentWidgetRegistry, new class extends mock<IChatAttachmentWidgetRegistry>() { }());
reg.defineInstance(IChatAttachmentResolveService, new class extends mock<IChatAttachmentResolveService>() { }());
reg.defineInstance(IExtensionService, new class extends mock<IExtensionService>() { override readonly onDidChangeExtensions = Event.None; }());
reg.defineInstance(IPathService, new class extends mock<IPathService>() { }());
reg.defineInstance(IChatWidgetHistoryService, new class extends mock<IChatWidgetHistoryService>() { override getHistory() { return []; } override readonly onDidChangeHistory = Event.None; }());
reg.defineInstance(IChatContextPickService, new class extends mock<IChatContextPickService>() { }());
reg.defineInstance(IListService, new ListService());
reg.defineInstance(INotebookDocumentService, new class extends mock<INotebookDocumentService>() { }());
reg.defineInstance(ISCMService, new class extends mock<ISCMService>() {
override readonly onDidAddRepository = Event.None;
override readonly onDidRemoveRepository = Event.None;
override readonly repositories = [];
override readonly repositoryCount = 0;
}());
reg.defineInstance(IActionWidgetService, new class extends mock<IActionWidgetService>() { override show() { } override hide() { } override get isVisible() { return false; } }());
reg.defineInstance(IFileDialogService, new class extends mock<IFileDialogService>() { }());
reg.defineInstance(IProductService, new class extends mock<IProductService>() { }());
reg.defineInstance(IChatImageCarouselService, new class extends mock<IChatImageCarouselService>() { }());
reg.defineInstance(IUpdateService, new class extends mock<IUpdateService>() { override onStateChange = Event.None; override get state() { return { type: StateType.Uninitialized as const }; } }());
reg.defineInstance(IUriIdentityService, new class extends mock<IUriIdentityService>() { }());
reg.defineInstance(IChatArtifactsService, new class extends mock<IChatArtifactsService>() {
override getArtifacts(): IChatArtifacts {
return new class extends mock<IChatArtifacts>() {
override readonly artifactGroups = artifactsObs;
override setAgentArtifacts() { }
override clearAgentArtifacts() { }
override clearSubagentArtifacts() { }
override migrate() { }
}();
}
}());
reg.defineInstance(IChatTodoListService, new class extends mock<IChatTodoListService>() {
override readonly onDidUpdateTodos = Event.None;
override getTodos() { return [...todos]; }
override setTodos() { }
override migrateTodos() { }
}());
registerChatFixtureServices(reg, { artifactGroups: artifactsObs, todos });
},
});

View File

@@ -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<IFixtureMessage>;
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<void> {
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<IChatWidgetService>() {
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<IChatWidget>() {
override readonly onDidChangeViewModel = new Emitter<never>().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 }) }),
});

View File

@@ -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<void> = () => toDisposable(() => { });
constructor(private readonly _delayMs: number) { }
async computeDiff(original: ITextModel, modified: ITextModel, options: IDocumentDiffProviderOptions, _cancellationToken: CancellationToken): Promise<IDocumentDiff> {
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<IDocumentDiffItem>[] = [
RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: original1, modified: modified1 }, { dispose() { } }),
RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: original2, modified: modified2 }, { dispose() { } }),
RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: original3, modified: modified3 }, { dispose() { } }),
];
const model: IMultiDiffEditorModel = {
documents: ValueWithChangeEvent.const(documents),
return {
doc1: RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: original1, modified: modified1 }, { dispose() { } }),
doc2: RefCounted.createOfNonDisposable<IDocumentDiffItem>({ original: original2, modified: modified2 }, { dispose() { } }),
doc3: RefCounted.createOfNonDisposable<IDocumentDiffItem>({ 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<readonly RefCounted<IDocumentDiffItem>[]>([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<IDocumentDiffItem>({ 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<readonly RefCounted<IDocumentDiffItem>[]>([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(),
}),
});

View File

@@ -433,63 +433,6 @@ interface IRenderEditorOptions {
readonly editorDisplayMode?: 'preview' | 'raw';
}
async function waitForAnimationFrames(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await new Promise<void>(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<void> {
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<void> {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const hasVisibleScrollbar = [...container.querySelectorAll<HTMLElement>('.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));
}
}
}