/*--------------------------------------------------------------------------------------------- * 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 'vs/base/browser/dom'; import { IKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar, IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar'; import { Button } from 'vs/base/browser/ui/button/button'; import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; import { IIdentityProvider, IKeyboardNavigationLabelProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list/list'; import { DefaultKeyboardNavigationDelegate, IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget'; import { ITreeContextMenuEvent, ITreeFilter, ITreeNode, ITreeRenderer, ITreeSorter, TreeFilterResult, TreeVisibility } from 'vs/base/browser/ui/tree/tree'; import { Action, ActionRunner, IAction, Separator } from 'vs/base/common/actions'; import { RunOnceScheduler, disposableTimeout } from 'vs/base/common/async'; import { Color, RGBA } from 'vs/base/common/color'; import { Emitter, Event } from 'vs/base/common/event'; import { FuzzyScore } from 'vs/base/common/filters'; import { KeyCode } from 'vs/base/common/keyCodes'; import { Disposable, IDisposable, MutableDisposable, dispose } from 'vs/base/common/lifecycle'; import { fuzzyContains } from 'vs/base/common/strings'; import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import 'vs/css!./media/testing'; import { MarkdownRenderer } from 'vs/editor/contrib/markdownRenderer/browser/markdownRenderer'; import { localize } from 'vs/nls'; import { DropdownWithPrimaryActionViewItem } from 'vs/platform/actions/browser/dropdownWithPrimaryActionViewItem'; import { MenuEntryActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; import { IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { UnmanagedProgress } from 'vs/platform/progress/common/progress'; import { IStorageService, StorageScope, StorageTarget, WillSaveStateReason } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { defaultButtonStyles } from 'vs/platform/theme/browser/defaultStyles'; import { foreground } from 'vs/platform/theme/common/colorRegistry'; import { IThemeService, registerThemingParticipant } from 'vs/platform/theme/common/themeService'; import { ThemeIcon } from 'vs/base/common/themables'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; import { IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet'; import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput'; import { IViewDescriptorService } from 'vs/workbench/common/views'; import { HierarchicalByLocationProjection } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation'; import { ByNameTestItemElement, HierarchicalByNameProjection } from 'vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByName'; import { ITestTreeProjection, TestExplorerTreeElement, TestItemTreeElement, TestTreeErrorMessage } from 'vs/workbench/contrib/testing/browser/explorerProjections/index'; import { getTestItemContextOverlay } from 'vs/workbench/contrib/testing/browser/explorerProjections/testItemContextOverlay'; import { TestingObjectTree } from 'vs/workbench/contrib/testing/browser/explorerProjections/testingObjectTree'; import { ISerializedTestTreeCollapseState } from 'vs/workbench/contrib/testing/browser/explorerProjections/testingViewState'; import * as icons from 'vs/workbench/contrib/testing/browser/icons'; import { TestingExplorerFilter } from 'vs/workbench/contrib/testing/browser/testingExplorerFilter'; import { CountSummary, ITestingProgressUiService } from 'vs/workbench/contrib/testing/browser/testingProgressUiService'; import { TestingConfigKeys, TestingCountBadge, getTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration'; import { TestCommandId, TestExplorerViewMode, TestExplorerViewSorting, Testing, labelForTestInState } from 'vs/workbench/contrib/testing/common/constants'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; import { ITestExplorerFilterState, TestExplorerFilterState, TestFilterTerm } from 'vs/workbench/contrib/testing/common/testExplorerFilterState'; import { TestId } from 'vs/workbench/contrib/testing/common/testId'; import { ITestProfileService, canUseProfileWithTest } from 'vs/workbench/contrib/testing/common/testProfileService'; import { TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { IMainThreadTestCollection, ITestService, testCollectionIsEmpty } from 'vs/workbench/contrib/testing/common/testService'; import { ITestRunProfile, InternalTestItem, TestItemExpandState, TestResultState, TestRunProfileBitset } from 'vs/workbench/contrib/testing/common/testTypes'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener'; import { cmpPriority, isFailedState, isStateWithResult } from 'vs/workbench/contrib/testing/common/testingStates'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { ITestingContinuousRunService } from 'vs/workbench/contrib/testing/common/testingContinuousRunService'; import { IActivityService, NumberBadge } from 'vs/workbench/services/activity/common/activity'; const enum LastFocusState { Input, Tree, } export class TestingExplorerView extends ViewPane { public viewModel!: TestingExplorerViewModel; private filterActionBar = this._register(new MutableDisposable()); private container!: HTMLElement; private treeHeader!: HTMLElement; private countSummary: CountSummary | undefined; private discoveryProgress = this._register(new MutableDisposable()); private readonly filter = this._register(new MutableDisposable()); private readonly badgeDisposable = this._register(new MutableDisposable()); private readonly filterFocusListener = this._register(new MutableDisposable()); private readonly dimensions = { width: 0, height: 0 }; private lastFocusState = LastFocusState.Input; public get focusedTreeElements() { return this.viewModel.tree.getFocus().filter(isDefined); } constructor( options: IViewletViewOptions, @IActivityService private readonly activityService: IActivityService, @IContextMenuService contextMenuService: IContextMenuService, @IKeybindingService keybindingService: IKeybindingService, @IConfigurationService configurationService: IConfigurationService, @IInstantiationService instantiationService: IInstantiationService, @IViewDescriptorService viewDescriptorService: IViewDescriptorService, @IContextKeyService contextKeyService: IContextKeyService, @IOpenerService openerService: IOpenerService, @IThemeService themeService: IThemeService, @ITestService private readonly testService: ITestService, @ITelemetryService telemetryService: ITelemetryService, @ITestingProgressUiService private readonly testProgressService: ITestingProgressUiService, @ITestProfileService private readonly testProfileService: ITestProfileService, @ICommandService private readonly commandService: ICommandService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); const relayout = this._register(new RunOnceScheduler(() => this.layoutBody(), 1)); this._register(this.onDidChangeViewWelcomeState(() => { if (!this.shouldShowWelcome()) { relayout.schedule(); } })); this._register(testService.collection.onBusyProvidersChange(busy => { this.updateDiscoveryProgress(busy); })); this._register(testProfileService.onDidChange(() => this.updateActions())); const onDidChangeTestingCountBadge = Event.filter(configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('testing.countBadge')); this._register(onDidChangeTestingCountBadge(this.renderActivityCount, this)); } public override shouldShowWelcome() { return this.viewModel?.welcomeExperience === WelcomeExperience.ForWorkspace ?? true; } public override focus() { super.focus(); if (this.lastFocusState === LastFocusState.Tree) { this.viewModel.tree.domFocus(); } else { this.filter.value?.focus(); } } /** * Gets include/exclude items in the tree, based either on visible tests * or a use selection. */ public getTreeIncludeExclude(profile?: ITestRunProfile, filterToType: 'visible' | 'selected' = 'visible') { const projection = this.viewModel.projection.value; if (!projection) { return { include: [], exclude: [] }; } if (projection instanceof ByNameTestItemElement) { return { include: [...this.testService.collection.rootItems], exclude: [], }; } // To calculate includes and excludes, we include the first children that // have a majority of their items included too, and then apply exclusions. const include = new Set(); const exclude: InternalTestItem[] = []; const attempt = (element: TestExplorerTreeElement, alreadyIncluded: boolean) => { // sanity check hasElement since updates are debounced and they may exist // but not be rendered yet if (!(element instanceof TestItemTreeElement) || !this.viewModel.tree.hasElement(element)) { return; } // If the current node is not visible or runnable in the current profile, it's excluded const inTree = this.viewModel.tree.getNode(element); if (!inTree.visible) { if (alreadyIncluded) { exclude.push(element.test); } return; } // If it's not already included but most of its children are, then add it // if it can be run under the current profile (when specified) if ( // If it's not already included... !alreadyIncluded // And it can be run using the current profile (if any) && (!profile || canUseProfileWithTest(profile, element.test)) // And either it's a leaf node or most children are included, the include it. && (inTree.children.length === 0 || inTree.visibleChildrenCount * 2 >= inTree.children.length) // And not if we're only showing a single of its children, since it // probably fans out later. (Worse case we'll directly include its single child) && inTree.visibleChildrenCount !== 1 ) { include.add(element.test); alreadyIncluded = true; } // Recurse ✨ for (const child of element.children) { attempt(child, alreadyIncluded); } }; if (filterToType === 'selected') { const sel = this.viewModel.tree.getSelection().filter(isDefined); if (sel.length) { L: for (const node of sel) { if (node instanceof TestItemTreeElement) { // avoid adding an item if its parent is already included for (let i: TestItemTreeElement | null = node; i; i = i.parent) { if (include.has(i.test)) { continue L; } } include.add(node.test); node.children.forEach(c => attempt(c, true)); } } return { include: [...include], exclude }; } } for (const root of this.testService.collection.rootItems) { const element = projection.getElementByTestId(root.item.extId); if (!element) { continue; } if (profile && !canUseProfileWithTest(profile, root)) { continue; } // single controllers won't have visible root ID nodes, handle that case specially if (!this.viewModel.tree.hasElement(element)) { const visibleChildren = [...element.children].reduce((acc, c) => this.viewModel.tree.hasElement(c) && this.viewModel.tree.getNode(c).visible ? acc + 1 : acc, 0); // note we intentionally check children > 0 here, unlike above, since // we don't want to bother dispatching to controllers who have no discovered tests if (element.children.size > 0 && visibleChildren * 2 >= element.children.size) { include.add(element.test); element.children.forEach(c => attempt(c, true)); } else { element.children.forEach(c => attempt(c, false)); } } else { attempt(element, false); } } return { include: [...include], exclude }; } /** * @override */ protected override renderBody(container: HTMLElement): void { super.renderBody(container); this.container = dom.append(container, dom.$('.test-explorer')); this.treeHeader = dom.append(this.container, dom.$('.test-explorer-header')); this.filterActionBar.value = this.createFilterActionBar(); const messagesContainer = dom.append(this.treeHeader, dom.$('.test-explorer-messages')); this._register(this.testProgressService.onTextChange(text => { const hadText = !!messagesContainer.innerText; const hasText = !!text; messagesContainer.innerText = text; if (hadText !== hasText) { this.layoutBody(); } })); this._register(this.testProgressService.onCountChange((text: CountSummary) => { this.countSummary = text; this.renderActivityCount(); })); const listContainer = dom.append(this.container, dom.$('.test-explorer-tree')); this.viewModel = this.instantiationService.createInstance(TestingExplorerViewModel, listContainer, this.onDidChangeBodyVisibility); this._register(this.viewModel.tree.onDidFocus(() => this.lastFocusState = LastFocusState.Tree)); this._register(this.viewModel.onChangeWelcomeVisibility(() => this._onDidChangeViewWelcomeState.fire())); this._register(this.viewModel); this._onDidChangeViewWelcomeState.fire(); } /** @override */ public override getActionViewItem(action: IAction): IActionViewItem | undefined { switch (action.id) { case TestCommandId.FilterAction: this.filter.value = this.instantiationService.createInstance(TestingExplorerFilter, action); this.filterFocusListener.value = this.filter.value.onDidFocus(() => this.lastFocusState = LastFocusState.Input); return this.filter.value; case TestCommandId.RunSelectedAction: return this.getRunGroupDropdown(TestRunProfileBitset.Run, action); case TestCommandId.DebugSelectedAction: return this.getRunGroupDropdown(TestRunProfileBitset.Debug, action); default: return super.getActionViewItem(action); } } /** @inheritdoc */ private getTestConfigGroupActions(group: TestRunProfileBitset) { const profileActions: IAction[] = []; let participatingGroups = 0; let hasConfigurable = false; const defaults = this.testProfileService.getGroupDefaultProfiles(group); for (const { profiles, controller } of this.testProfileService.all()) { let hasAdded = false; for (const profile of profiles) { if (profile.group !== group) { continue; } if (!hasAdded) { hasAdded = true; participatingGroups++; profileActions.push(new Action(`${controller.id}.$root`, controller.label.value, undefined, false)); } hasConfigurable = hasConfigurable || profile.hasConfigurationHandler; profileActions.push(new Action( `${controller.id}.${profile.profileId}`, defaults.includes(profile) ? localize('defaultTestProfile', '{0} (Default)', profile.label) : profile.label, undefined, undefined, () => { const { include, exclude } = this.getTreeIncludeExclude(profile); this.testService.runResolvedTests({ exclude: exclude.map(e => e.item.extId), targets: [{ profileGroup: profile.group, profileId: profile.profileId, controllerId: profile.controllerId, testIds: include.map(i => i.item.extId), }] }); }, )); } } // If there's only one group, don't add a heading for it in the dropdown. if (participatingGroups === 1) { profileActions.shift(); } const postActions: IAction[] = []; if (profileActions.length > 1) { postActions.push(new Action( 'selectDefaultTestConfigurations', localize('selectDefaultConfigs', 'Select Default Profile'), undefined, undefined, () => this.commandService.executeCommand(TestCommandId.SelectDefaultTestProfiles, group), )); } if (hasConfigurable) { postActions.push(new Action( 'configureTestProfiles', localize('configureTestProfiles', 'Configure Test Profiles'), undefined, undefined, () => this.commandService.executeCommand(TestCommandId.ConfigureTestProfilesAction, group), )); } return Separator.join(profileActions, postActions); } /** * @override */ public override saveState() { this.filter.value?.saveState(); super.saveState(); } private getRunGroupDropdown(group: TestRunProfileBitset, defaultAction: IAction) { const dropdownActions = this.getTestConfigGroupActions(group); if (dropdownActions.length < 2) { return super.getActionViewItem(defaultAction); } const primaryAction = this.instantiationService.createInstance(MenuItemAction, { id: defaultAction.id, title: defaultAction.label, icon: group === TestRunProfileBitset.Run ? icons.testingRunAllIcon : icons.testingDebugAllIcon, }, undefined, undefined, undefined); const dropdownAction = new Action('selectRunConfig', 'Select Configuration...', 'codicon-chevron-down', true); return this.instantiationService.createInstance( DropdownWithPrimaryActionViewItem, primaryAction, dropdownAction, dropdownActions, '', this.contextMenuService, {} ); } private createFilterActionBar() { const bar = new ActionBar(this.treeHeader, { actionViewItemProvider: action => this.getActionViewItem(action), triggerKeys: { keyDown: false, keys: [] }, }); bar.push(new Action(TestCommandId.FilterAction)); bar.getContainer().classList.add('testing-filter-action-bar'); return bar; } private updateDiscoveryProgress(busy: number) { if (!busy && this.discoveryProgress) { this.discoveryProgress.clear(); } else if (busy && !this.discoveryProgress.value) { this.discoveryProgress.value = this.instantiationService.createInstance(UnmanagedProgress, { location: this.getProgressLocation() }); } } private renderActivityCount() { const countBadgeType = this.configurationService.getValue(TestingConfigKeys.CountBadge); if (!this.countSummary || countBadgeType === TestingCountBadge.Off || this.countSummary[countBadgeType] === 0) { this.badgeDisposable.value = undefined; } else { const badge = new NumberBadge(this.countSummary[countBadgeType], num => this.getLocalizedBadgeString(countBadgeType, num)); this.badgeDisposable.value = this.activityService.showViewActivity(Testing.ExplorerViewId, { badge }); } } private getLocalizedBadgeString(countBadgeType: TestingCountBadge, count: number): string { switch (countBadgeType) { case TestingCountBadge.Passed: return localize('testingCountBadgePassed', '{0} passed tests', count); case TestingCountBadge.Skipped: return localize('testingCountBadgeSkipped', '{0} skipped tests', count); default: return localize('testingCountBadgeFailed', '{0} failed tests', count); } } /** * @override */ protected override layoutBody(height = this.dimensions.height, width = this.dimensions.width): void { super.layoutBody(height, width); this.dimensions.height = height; this.dimensions.width = width; this.container.style.height = `${height}px`; this.viewModel.layout(height - this.treeHeader.clientHeight, width); this.filter.value?.layout(width); } } const enum WelcomeExperience { None, ForWorkspace, ForDocument, } class TestingExplorerViewModel extends Disposable { public tree: TestingObjectTree; private filter: TestsFilter; public projection = this._register(new MutableDisposable()); private readonly revealTimeout = new MutableDisposable(); private readonly _viewMode = TestingContextKeys.viewMode.bindTo(this.contextKeyService); private readonly _viewSorting = TestingContextKeys.viewSorting.bindTo(this.contextKeyService); private readonly welcomeVisibilityEmitter = new Emitter(); private readonly actionRunner = new TestExplorerActionRunner(() => this.tree.getSelection().filter(isDefined)); private readonly lastViewState = new StoredValue({ key: 'testing.treeState', scope: StorageScope.WORKSPACE, target: StorageTarget.MACHINE, }, this.storageService); private readonly noTestForDocumentWidget: NoTestsForDocumentWidget; /** * Whether there's a reveal request which has not yet been delivered. This * can happen if the user asks to reveal before the test tree is loaded. * We check to see if the reveal request is present on each tree update, * and do it then if so. */ private hasPendingReveal = false; /** * Fires when the visibility of the placeholder state changes. */ public readonly onChangeWelcomeVisibility = this.welcomeVisibilityEmitter.event; /** * Gets whether the welcome should be visible. */ public welcomeExperience = WelcomeExperience.None; public get viewMode() { return this._viewMode.get() ?? TestExplorerViewMode.Tree; } public set viewMode(newMode: TestExplorerViewMode) { if (newMode === this._viewMode.get()) { return; } this._viewMode.set(newMode); this.updatePreferredProjection(); this.storageService.store('testing.viewMode', newMode, StorageScope.WORKSPACE, StorageTarget.MACHINE); } public get viewSorting() { return this._viewSorting.get() ?? TestExplorerViewSorting.ByStatus; } public set viewSorting(newSorting: TestExplorerViewSorting) { if (newSorting === this._viewSorting.get()) { return; } this._viewSorting.set(newSorting); this.tree.resort(null); this.storageService.store('testing.viewSorting', newSorting, StorageScope.WORKSPACE, StorageTarget.MACHINE); } constructor( listContainer: HTMLElement, onDidChangeVisibility: Event, @IConfigurationService configurationService: IConfigurationService, @IEditorService editorService: IEditorService, @IMenuService private readonly menuService: IMenuService, @IContextMenuService private readonly contextMenuService: IContextMenuService, @ITestService private readonly testService: ITestService, @ITestExplorerFilterState private readonly filterState: TestExplorerFilterState, @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @ITestResultService private readonly testResults: ITestResultService, @ITestingPeekOpener private readonly peekOpener: ITestingPeekOpener, @ITestProfileService private readonly testProfileService: ITestProfileService, @ITestingContinuousRunService private readonly crService: ITestingContinuousRunService, ) { super(); this.hasPendingReveal = !!filterState.reveal.value; this.noTestForDocumentWidget = this._register(instantiationService.createInstance(NoTestsForDocumentWidget, listContainer)); this._viewMode.set(this.storageService.get('testing.viewMode', StorageScope.WORKSPACE, TestExplorerViewMode.Tree) as TestExplorerViewMode); this._viewSorting.set(this.storageService.get('testing.viewSorting', StorageScope.WORKSPACE, TestExplorerViewSorting.ByLocation) as TestExplorerViewSorting); this.reevaluateWelcomeState(); this.filter = this.instantiationService.createInstance(TestsFilter, testService.collection); this.tree = instantiationService.createInstance( TestingObjectTree, 'Test Explorer List', listContainer, new ListDelegate(), [ instantiationService.createInstance(TestItemRenderer, this.actionRunner), instantiationService.createInstance(ErrorRenderer), ], { identityProvider: instantiationService.createInstance(IdentityProvider), hideTwistiesOfChildlessElements: false, sorter: instantiationService.createInstance(TreeSorter, this), keyboardNavigationLabelProvider: instantiationService.createInstance(TreeKeyboardNavigationLabelProvider), accessibilityProvider: instantiationService.createInstance(ListAccessibilityProvider), filter: this.filter, findWidgetEnabled: false }) as TestingObjectTree; // saves the collapse state so that if items are removed or refreshed, they // retain the same state (#170169) const collapseStateSaver = this._register(new RunOnceScheduler(() => { // reuse the last view state to avoid making a bunch of object garbage: const state = this.tree.getOptimizedViewState(this.lastViewState.get({})); const projection = this.projection.value; if (projection) { projection.lastState = state; } }, 3000)); this._register(this.tree.onDidChangeCollapseState(evt => { if (evt.node.element instanceof TestItemTreeElement) { if (!evt.node.collapsed) { this.projection.value?.expandElement(evt.node.element, evt.deep ? Infinity : 0); } collapseStateSaver.schedule(); } })); this._register(this.crService.onDidChange(testId => { if (testId) { // a continuous run test will sort to the top: const elem = this.projection.value?.getElementByTestId(testId); this.tree.resort(elem?.parent && this.tree.hasElement(elem.parent) ? elem.parent : null, false); } })); this._register(onDidChangeVisibility(visible => { if (visible) { this.ensureProjection(); } })); this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); this._register(Event.any( filterState.text.onDidChange, filterState.fuzzy.onDidChange, testService.excluded.onTestExclusionsChanged, )(this.tree.refilter, this.tree)); this._register(this.tree); this._register(this.onChangeWelcomeVisibility(e => { this.noTestForDocumentWidget.setVisible(e === WelcomeExperience.ForDocument); })); this._register(dom.addStandardDisposableListener(this.tree.getHTMLElement(), 'keydown', evt => { if (evt.equals(KeyCode.Enter)) { this.handleExecuteKeypress(evt); } else if (DefaultKeyboardNavigationDelegate.mightProducePrintableCharacter(evt)) { filterState.text.value = evt.browserEvent.key; filterState.focusInput(); } })); this._register(filterState.reveal.onDidChange(id => this.revealById(id, undefined, false))); this._register(onDidChangeVisibility(visible => { if (visible) { filterState.focusInput(); } })); this._register(this.tree.onDidChangeSelection(evt => { if (evt.browserEvent instanceof MouseEvent && (evt.browserEvent.altKey || evt.browserEvent.shiftKey)) { return; // don't focus when alt-clicking to multi select } const selected = evt.elements[0]; if (selected && evt.browserEvent && selected instanceof TestItemTreeElement && selected.children.size === 0 && selected.test.expand === TestItemExpandState.NotExpandable) { this.tryPeekError(selected); } })); let followRunningTests = getTestingConfiguration(configurationService, TestingConfigKeys.FollowRunningTest); this._register(configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(TestingConfigKeys.FollowRunningTest)) { followRunningTests = getTestingConfiguration(configurationService, TestingConfigKeys.FollowRunningTest); } })); let alwaysRevealTestAfterStateChange = getTestingConfiguration(configurationService, TestingConfigKeys.AlwaysRevealTestOnStateChange); this._register(configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(TestingConfigKeys.AlwaysRevealTestOnStateChange)) { alwaysRevealTestAfterStateChange = getTestingConfiguration(configurationService, TestingConfigKeys.AlwaysRevealTestOnStateChange); } })); this._register(testResults.onTestChanged(evt => { if (!followRunningTests) { return; } if (evt.reason !== TestResultItemChangeReason.OwnStateChange) { return; } if (this.tree.selectionSize > 1) { return; // don't change a multi-selection #180950 } // follow running tests, or tests whose state changed. Tests that // complete very fast may not enter the running state at all. if (evt.item.ownComputedState !== TestResultState.Running && !(evt.previousState === TestResultState.Queued && isStateWithResult(evt.item.ownComputedState))) { return; } this.revealById(evt.item.item.extId, alwaysRevealTestAfterStateChange, false); })); this._register(testResults.onResultsChanged(() => { this.tree.resort(null); })); this._register(this.testProfileService.onDidChange(() => { this.tree.rerender(); })); const onEditorChange = () => { if (editorService.activeEditor instanceof DiffEditorInput) { this.filter.filterToDocumentUri(editorService.activeEditor.primary.resource); } else { this.filter.filterToDocumentUri(editorService.activeEditor?.resource); } if (this.filterState.isFilteringFor(TestFilterTerm.CurrentDoc)) { this.tree.refilter(); } }; this._register(editorService.onDidActiveEditorChange(onEditorChange)); this._register(this.storageService.onWillSaveState(({ reason, }) => { if (reason === WillSaveStateReason.SHUTDOWN) { this.lastViewState.store(this.tree.getOptimizedViewState()); } })); onEditorChange(); } /** * Re-layout the tree. */ public layout(height?: number, width?: number): void { this.tree.layout(height, width); } /** * Tries to reveal by extension ID. Queues the request if the extension * ID is not currently available. */ private revealById(id: string | undefined, expand = true, focus = true) { if (!id) { this.hasPendingReveal = false; return; } const projection = this.ensureProjection(); // If the item itself is visible in the tree, show it. Otherwise, expand // its closest parent. let expandToLevel = 0; const idPath = [...TestId.fromString(id).idsFromRoot()]; for (let i = idPath.length - 1; i >= expandToLevel; i--) { const element = projection.getElementByTestId(idPath[i].toString()); // Skip all elements that aren't in the tree. if (!element || !this.tree.hasElement(element)) { continue; } // If this 'if' is true, we're at the closest-visible parent to the node // we want to expand. Expand that, and then start the loop again because // we might already have children for it. if (i < idPath.length - 1) { if (expand) { this.tree.expand(element); expandToLevel = i + 1; // avoid an infinite loop if the test does not exist i = idPath.length - 1; // restart the loop since new children may now be visible continue; } } // Otherwise, we've arrived! // If the node or any of its children are excluded, flip on the 'show // excluded tests' checkbox automatically. If we didn't expand, then set // target focus target to the first collapsed element. let focusTarget = element; for (let n: TestItemTreeElement | null = element; n instanceof TestItemTreeElement; n = n.parent) { if (n.test && this.testService.excluded.contains(n.test)) { this.filterState.toggleFilteringFor(TestFilterTerm.Hidden, true); break; } if (!expand && (this.tree.hasElement(n) && this.tree.isCollapsed(n))) { focusTarget = n; } } this.filterState.reveal.value = undefined; this.hasPendingReveal = false; if (focus) { this.tree.domFocus(); } if (this.tree.getRelativeTop(focusTarget) === null) { this.tree.reveal(focusTarget, 0.5); } this.revealTimeout.value = disposableTimeout(() => { this.tree.setFocus([focusTarget]); this.tree.setSelection([focusTarget]); }, 1); return; } // If here, we've expanded all parents we can. Waiting on data to come // in to possibly show the revealed test. this.hasPendingReveal = true; } /** * Collapse all items in the tree. */ public async collapseAll() { this.tree.collapseAll(); } /** * Tries to peek the first test error, if the item is in a failed state. */ private tryPeekError(item: TestItemTreeElement) { const lookup = item.test && this.testResults.getStateById(item.test.item.extId); return lookup && lookup[1].tasks.some(s => isFailedState(s.state)) ? this.peekOpener.tryPeekFirstError(lookup[0], lookup[1], { preserveFocus: true }) : false; } private onContextMenu(evt: ITreeContextMenuEvent) { const element = evt.element; if (!(element instanceof TestItemTreeElement)) { return; } const { actions } = getActionableElementActions(this.contextKeyService, this.menuService, this.testService, this.crService, this.testProfileService, element); this.contextMenuService.showContextMenu({ getAnchor: () => evt.anchor, getActions: () => actions.secondary, getActionsContext: () => element, actionRunner: this.actionRunner, }); } private handleExecuteKeypress(evt: IKeyboardEvent) { const focused = this.tree.getFocus(); const selected = this.tree.getSelection(); let targeted: (TestExplorerTreeElement | null)[]; if (focused.length === 1 && selected.includes(focused[0])) { evt.browserEvent?.preventDefault(); targeted = selected; } else { targeted = focused; } const toRun = targeted .filter((e): e is TestItemTreeElement => e instanceof TestItemTreeElement); if (toRun.length) { this.testService.runTests({ group: TestRunProfileBitset.Run, tests: toRun.map(t => t.test), }); } } private reevaluateWelcomeState() { const shouldShowWelcome = this.testService.collection.busyProviders === 0 && testCollectionIsEmpty(this.testService.collection); const welcomeExperience = shouldShowWelcome ? (this.filterState.isFilteringFor(TestFilterTerm.CurrentDoc) ? WelcomeExperience.ForDocument : WelcomeExperience.ForWorkspace) : WelcomeExperience.None; if (welcomeExperience !== this.welcomeExperience) { this.welcomeExperience = welcomeExperience; this.welcomeVisibilityEmitter.fire(welcomeExperience); } } private ensureProjection() { return this.projection.value ?? this.updatePreferredProjection(); } private updatePreferredProjection() { this.projection.clear(); const lastState = this.lastViewState.get({}); if (this._viewMode.get() === TestExplorerViewMode.List) { this.projection.value = this.instantiationService.createInstance(HierarchicalByNameProjection, lastState); } else { this.projection.value = this.instantiationService.createInstance(HierarchicalByLocationProjection, lastState); } const scheduler = new RunOnceScheduler(() => this.applyProjectionChanges(), 200); this.projection.value.onUpdate(() => { if (!scheduler.isScheduled()) { scheduler.schedule(); } }); this.applyProjectionChanges(); return this.projection.value; } private applyProjectionChanges() { this.reevaluateWelcomeState(); this.projection.value?.applyTo(this.tree); this.tree.refilter(); if (this.hasPendingReveal) { this.revealById(this.filterState.reveal.value); } } /** * Gets the selected tests from the tree. */ public getSelectedTests() { return this.tree.getSelection(); } } const enum FilterResult { Exclude, Inherit, Include, } const hasNodeInOrParentOfUri = (collection: IMainThreadTestCollection, ident: IUriIdentityService, testUri: URI, fromNode?: string) => { const queue: Iterable[] = [fromNode ? [fromNode] : collection.rootIds]; while (queue.length) { for (const id of queue.pop()!) { const node = collection.getNodeById(id); if (!node) { continue; } if (!node.item.uri || !ident.extUri.isEqualOrParent(testUri, node.item.uri)) { continue; } // Only show nodes that can be expanded (and might have a child with // a range) or ones that have a physical location. if (node.item.range || node.expand === TestItemExpandState.Expandable) { return true; } queue.push(node.children); } } return false; }; class TestsFilter implements ITreeFilter { private documentUri: URI | undefined; constructor( private readonly collection: IMainThreadTestCollection, @ITestExplorerFilterState private readonly state: ITestExplorerFilterState, @ITestService private readonly testService: ITestService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, ) { } /** * @inheritdoc */ public filter(element: TestItemTreeElement): TreeFilterResult { if (element instanceof TestTreeErrorMessage) { return TreeVisibility.Visible; } if ( element.test && !this.state.isFilteringFor(TestFilterTerm.Hidden) && this.testService.excluded.contains(element.test) ) { return TreeVisibility.Hidden; } switch (Math.min(this.testFilterText(element), this.testLocation(element), this.testState(element), this.testTags(element))) { case FilterResult.Exclude: return TreeVisibility.Hidden; case FilterResult.Include: return TreeVisibility.Visible; default: return TreeVisibility.Recurse; } } public filterToDocumentUri(uri: URI | undefined) { this.documentUri = uri; } private testTags(element: TestItemTreeElement): FilterResult { if (!this.state.includeTags.size && !this.state.excludeTags.size) { return FilterResult.Include; } return (this.state.includeTags.size ? element.test.item.tags.some(t => this.state.includeTags.has(t)) : true) && element.test.item.tags.every(t => !this.state.excludeTags.has(t)) ? FilterResult.Include : FilterResult.Inherit; } private testState(element: TestItemTreeElement): FilterResult { if (this.state.isFilteringFor(TestFilterTerm.Failed)) { return isFailedState(element.state) ? FilterResult.Include : FilterResult.Inherit; } if (this.state.isFilteringFor(TestFilterTerm.Executed)) { return element.state !== TestResultState.Unset ? FilterResult.Include : FilterResult.Inherit; } return FilterResult.Include; } private testLocation(element: TestItemTreeElement): FilterResult { if (!this.documentUri) { return FilterResult.Include; } if (!this.state.isFilteringFor(TestFilterTerm.CurrentDoc) || !(element instanceof TestItemTreeElement)) { return FilterResult.Include; } if (hasNodeInOrParentOfUri(this.collection, this.uriIdentityService, this.documentUri, element.test.item.extId)) { return FilterResult.Include; } return FilterResult.Inherit; } private testFilterText(element: TestItemTreeElement) { if (this.state.globList.length === 0) { return FilterResult.Include; } const fuzzy = this.state.fuzzy.value; for (let e: TestItemTreeElement | null = element; e; e = e.parent) { // start as included if the first glob is a negation let included = this.state.globList[0].include === false ? FilterResult.Include : FilterResult.Inherit; const data = e.label.toLowerCase(); for (const { include, text } of this.state.globList) { if (fuzzy ? fuzzyContains(data, text) : data.includes(text)) { included = include ? FilterResult.Include : FilterResult.Exclude; } } if (included !== FilterResult.Inherit) { return included; } } return FilterResult.Inherit; } } class TreeSorter implements ITreeSorter { constructor( private readonly viewModel: TestingExplorerViewModel, @ITestingContinuousRunService private readonly crService: ITestingContinuousRunService, ) { } public compare(a: TestExplorerTreeElement, b: TestExplorerTreeElement): number { if (a instanceof TestTreeErrorMessage || b instanceof TestTreeErrorMessage) { return (a instanceof TestTreeErrorMessage ? -1 : 0) + (b instanceof TestTreeErrorMessage ? 1 : 0); } const crDelta = +this.crService.isSpecificallyEnabledFor(b.test.item.extId) - +this.crService.isSpecificallyEnabledFor(a.test.item.extId); if (crDelta !== 0) { return crDelta; } const durationDelta = (b.duration || 0) - (a.duration || 0); if (this.viewModel.viewSorting === TestExplorerViewSorting.ByDuration && durationDelta !== 0) { return durationDelta; } const stateDelta = cmpPriority(a.state, b.state); if (this.viewModel.viewSorting === TestExplorerViewSorting.ByStatus && stateDelta !== 0) { return stateDelta; } let inSameLocation = false; if (a instanceof TestItemTreeElement && b instanceof TestItemTreeElement && a.test.item.uri && b.test.item.uri && a.test.item.uri.toString() === b.test.item.uri.toString() && a.test.item.range && b.test.item.range) { inSameLocation = true; const delta = a.test.item.range.startLineNumber - b.test.item.range.startLineNumber; if (delta !== 0) { return delta; } } // If tests are in the same location and there's no preferred sortText, // keep the extension's insertion order (#163449). return inSameLocation && !a.sortText && !b.sortText ? 0 : (a.sortText || a.label).localeCompare(b.sortText || b.label); } } class NoTestsForDocumentWidget extends Disposable { private readonly el: HTMLElement; constructor( container: HTMLElement, @ITestExplorerFilterState filterState: ITestExplorerFilterState ) { super(); const el = this.el = dom.append(container, dom.$('.testing-no-test-placeholder')); const emptyParagraph = dom.append(el, dom.$('p')); emptyParagraph.innerText = localize('testingNoTest', 'No tests were found in this file.'); const buttonLabel = localize('testingFindExtension', 'Show Workspace Tests'); const button = this._register(new Button(el, { title: buttonLabel, ...defaultButtonStyles })); button.label = buttonLabel; this._register(button.onDidClick(() => filterState.toggleFilteringFor(TestFilterTerm.CurrentDoc, false))); } public setVisible(isVisible: boolean) { this.el.classList.toggle('visible', isVisible); } } class TestExplorerActionRunner extends ActionRunner { constructor(private getSelectedTests: () => ReadonlyArray) { super(); } protected override async runAction(action: IAction, context: TestExplorerTreeElement): Promise { if (!(action instanceof MenuItemAction)) { return super.runAction(action, context); } const selection = this.getSelectedTests(); const contextIsSelected = selection.some(s => s === context); const actualContext = contextIsSelected ? selection : [context]; const actionable = actualContext.filter((t): t is TestItemTreeElement => t instanceof TestItemTreeElement); await action.run(...actionable); } } const getLabelForTestTreeElement = (element: TestItemTreeElement) => { let label = labelForTestInState(element.description || element.label, element.state); if (element instanceof TestItemTreeElement) { if (element.duration !== undefined) { label = localize({ key: 'testing.treeElementLabelDuration', comment: ['{0} is the original label in testing.treeElementLabel, {1} is a duration'], }, '{0}, in {1}', label, formatDuration(element.duration)); } if (element.retired) { label = localize({ key: 'testing.treeElementLabelOutdated', comment: ['{0} is the original label in testing.treeElementLabel'], }, '{0}, outdated result', label); } } return label; }; class ListAccessibilityProvider implements IListAccessibilityProvider { getWidgetAriaLabel(): string { return localize('testExplorer', "Test Explorer"); } getAriaLabel(element: TestExplorerTreeElement): string { return element instanceof TestTreeErrorMessage ? element.description : getLabelForTestTreeElement(element); } } class TreeKeyboardNavigationLabelProvider implements IKeyboardNavigationLabelProvider { getKeyboardNavigationLabel(element: TestExplorerTreeElement) { return element instanceof TestTreeErrorMessage ? element.message : element.label; } } class ListDelegate implements IListVirtualDelegate { getHeight(_element: TestExplorerTreeElement) { return 22; } getTemplateId(element: TestExplorerTreeElement) { if (element instanceof TestTreeErrorMessage) { return ErrorRenderer.ID; } return TestItemRenderer.ID; } } class IdentityProvider implements IIdentityProvider { public getId(element: TestExplorerTreeElement) { return element.treeId; } } interface IErrorTemplateData { label: HTMLElement; } class ErrorRenderer implements ITreeRenderer { static readonly ID = 'error'; private readonly renderer: MarkdownRenderer; constructor(@IInstantiationService instantionService: IInstantiationService) { this.renderer = instantionService.createInstance(MarkdownRenderer, {}); } get templateId(): string { return ErrorRenderer.ID; } renderTemplate(container: HTMLElement): IErrorTemplateData { const label = dom.append(container, dom.$('.error')); return { label }; } renderElement({ element }: ITreeNode, _: number, data: IErrorTemplateData): void { if (typeof element.message === 'string') { data.label.innerText = element.message; } else { const result = this.renderer.render(element.message, { inline: true }); data.label.appendChild(result.element); } data.label.title = element.description; } disposeTemplate(): void { // noop } } interface IActionableElementTemplateData { current?: TestItemTreeElement; label: HTMLElement; icon: HTMLElement; wrapper: HTMLElement; actionBar: ActionBar; elementDisposable: IDisposable[]; templateDisposable: IDisposable[]; } class TestItemRenderer extends Disposable implements ITreeRenderer { public static readonly ID = 'testItem'; constructor( private readonly actionRunner: TestExplorerActionRunner, @IMenuService private readonly menuService: IMenuService, @ITestService protected readonly testService: ITestService, @ITestProfileService protected readonly profiles: ITestProfileService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITestingContinuousRunService private readonly crService: ITestingContinuousRunService, ) { super(); } /** * @inheritdoc */ public readonly templateId = TestItemRenderer.ID; /** * @inheritdoc */ public renderTemplate(container: HTMLElement): IActionableElementTemplateData { const wrapper = dom.append(container, dom.$('.test-item')); const icon = dom.append(wrapper, dom.$('.computed-state')); const label = dom.append(wrapper, dom.$('.label')); dom.append(wrapper, dom.$(ThemeIcon.asCSSSelector(icons.testingHiddenIcon))); const actionBar = new ActionBar(wrapper, { actionRunner: this.actionRunner, actionViewItemProvider: action => action instanceof MenuItemAction ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, undefined) : undefined }); const crListener = this.crService.onDidChange(changed => { if (templateData.current && (!changed || changed === templateData.current.test.item.extId)) { this.fillActionBar(templateData.current, templateData); } }); const templateData: IActionableElementTemplateData = { wrapper, label, actionBar, icon, elementDisposable: [], templateDisposable: [actionBar, crListener] }; return templateData; } /** * @inheritdoc */ disposeTemplate(templateData: IActionableElementTemplateData): void { dispose(templateData.templateDisposable); templateData.templateDisposable = []; } /** * @inheritdoc */ disposeElement(_element: ITreeNode, _: number, templateData: IActionableElementTemplateData): void { dispose(templateData.elementDisposable); templateData.elementDisposable = []; } private fillActionBar(element: TestItemTreeElement, data: IActionableElementTemplateData) { const { actions, contextOverlay } = getActionableElementActions(this.contextKeyService, this.menuService, this.testService, this.crService, this.profiles, element); data.actionBar.domNode.classList.toggle('testing-is-continuous-run', !!contextOverlay.getContextKeyValue(TestingContextKeys.isContinuousModeOn.key)); data.actionBar.clear(); data.actionBar.context = element; data.actionBar.push(actions.primary, { icon: true, label: false }); } /** * @inheritdoc */ public renderElement(node: ITreeNode, _depth: number, data: IActionableElementTemplateData): void { data.current = node.element; this.fillActionBar(node.element, data); const testHidden = this.testService.excluded.contains(node.element.test); data.wrapper.classList.toggle('test-is-hidden', testHidden); const icon = icons.testingStatesToIcons.get( node.element.test.expand === TestItemExpandState.BusyExpanding || node.element.test.item.busy ? TestResultState.Running : node.element.state); data.icon.className = 'computed-state ' + (icon ? ThemeIcon.asClassName(icon) : ''); if (node.element.retired) { data.icon.className += ' retired'; } data.label.title = getLabelForTestTreeElement(node.element); if (node.element.label.trim()) { dom.reset(data.label, ...renderLabelWithIcons(node.element.label)); } else { data.label.textContent = String.fromCharCode(0xA0); //   } let description = node.element.description; if (node.element.duration !== undefined) { description = description ? `${description}: ${formatDuration(node.element.duration)}` : formatDuration(node.element.duration); } if (description) { dom.append(data.label, dom.$('span.test-label-description', {}, description)); } } } const formatDuration = (ms: number) => { if (ms < 10) { return `${ms.toFixed(1)}ms`; } if (ms < 1_000) { return `${ms.toFixed(0)}ms`; } return `${(ms / 1000).toFixed(1)}s`; }; const getActionableElementActions = ( contextKeyService: IContextKeyService, menuService: IMenuService, testService: ITestService, crService: ITestingContinuousRunService, profiles: ITestProfileService, element: TestItemTreeElement, ) => { const test = element instanceof TestItemTreeElement ? element.test : undefined; const contextKeys: [string, unknown][] = getTestItemContextOverlay(test, test ? profiles.capabilitiesForTest(test) : 0); contextKeys.push(['view', Testing.ExplorerViewId]); if (test) { const ctrl = testService.getTestController(test.controllerId); const supportsCr = !!ctrl && profiles.getControllerProfiles(ctrl.id).some(p => p.supportsContinuousRun); contextKeys.push([ TestingContextKeys.canRefreshTests.key, !!ctrl?.canRefresh.value && TestId.isRoot(test.item.extId), ], [ TestingContextKeys.testItemIsHidden.key, testService.excluded.contains(test) ], [ TestingContextKeys.isContinuousModeOn.key, supportsCr && crService.isSpecificallyEnabledFor(test.item.extId) ], [ TestingContextKeys.isParentRunningContinuously.key, supportsCr && crService.isEnabledForAParentOf(test.item.extId) ], [ TestingContextKeys.supportsContinuousRun.key, supportsCr, ], [ TestingContextKeys.controllerId.key, test.controllerId, ], [ TestingContextKeys.testItemExtId.key, test.item.extId, ]); } const contextOverlay = contextKeyService.createOverlay(contextKeys); const menu = menuService.createMenu(MenuId.TestItem, contextOverlay); try { const primary: IAction[] = []; const secondary: IAction[] = []; const result = { primary, secondary }; createAndFillInActionBarActions(menu, { shouldForwardArgs: true, }, result, 'inline'); return { actions: result, contextOverlay }; } finally { menu.dispose(); } }; registerThemingParticipant((theme, collector) => { if (theme.type === 'dark') { const foregroundColor = theme.getColor(foreground); if (foregroundColor) { const fgWithOpacity = new Color(new RGBA(foregroundColor.rgba.r, foregroundColor.rgba.g, foregroundColor.rgba.b, 0.65)); collector.addRule(`.test-explorer .test-explorer-messages { color: ${fgWithOpacity}; }`); } } });