1273 lines
45 KiB
TypeScript
1273 lines
45 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* 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 { IIdentityProvider, IKeyboardNavigationLabelProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
|
|
import { DefaultKeyboardNavigationDelegate, IListAccessibilityProvider } from 'vs/base/browser/ui/list/listWidget';
|
|
import { ObjectTree } from 'vs/base/browser/ui/tree/objectTree';
|
|
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 { disposableTimeout, RunOnceScheduler } from 'vs/base/common/async';
|
|
import { Color, RGBA } from 'vs/base/common/color';
|
|
import { Emitter, Event } from 'vs/base/common/event';
|
|
import * as extpath from 'vs/base/common/extpath';
|
|
import { FuzzyScore } from 'vs/base/common/filters';
|
|
import { KeyCode } from 'vs/base/common/keyCodes';
|
|
import { Disposable, dispose, IDisposable, MutableDisposable } 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/browser/core/markdownRenderer';
|
|
import { localize } from 'vs/nls';
|
|
import { DropdownWithPrimaryActionViewItem } from 'vs/platform/actions/browser/dropdownWithPrimaryActionViewItem';
|
|
import { createAndFillInActionBarActions, MenuEntryActionViewItem } 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 { FileKind } from 'vs/platform/files/common/files';
|
|
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
|
|
import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding';
|
|
import { WorkbenchObjectTree } from 'vs/platform/list/browser/listService';
|
|
import { IOpenerService } from 'vs/platform/opener/common/opener';
|
|
import { UnmanagedProgress } from 'vs/platform/progress/common/progress';
|
|
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
|
|
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
|
|
import { foreground } from 'vs/platform/theme/common/colorRegistry';
|
|
import { attachButtonStyler } from 'vs/platform/theme/common/styler';
|
|
import { IThemeService, registerThemingParticipant, ThemeIcon } from 'vs/platform/theme/common/themeService';
|
|
import { IResourceLabel, IResourceLabelOptions, IResourceLabelProps, ResourceLabels } from 'vs/workbench/browser/labels';
|
|
import { ViewPane } from 'vs/workbench/browser/parts/views/viewPane';
|
|
import { IViewletViewOptions } from 'vs/workbench/browser/parts/views/viewsViewlet';
|
|
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 * as icons from 'vs/workbench/contrib/testing/browser/icons';
|
|
import { TestingExplorerFilter } from 'vs/workbench/contrib/testing/browser/testingExplorerFilter';
|
|
import { ITestingProgressUiService } from 'vs/workbench/contrib/testing/browser/testingProgressUiService';
|
|
import { getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration';
|
|
import { labelForTestInState, TestExplorerViewMode, TestExplorerViewSorting, Testing, testStateNames } from 'vs/workbench/contrib/testing/common/constants';
|
|
import { InternalTestItem, ITestRunProfile, TestItemExpandState, TestResultState, TestRunProfileBitset } from 'vs/workbench/contrib/testing/common/testCollection';
|
|
import { ITestExplorerFilterState, TestExplorerFilterState, TestFilterTerm } from 'vs/workbench/contrib/testing/common/testExplorerFilterState';
|
|
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
|
|
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 { canUseProfileWithTest, ITestProfileService } 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 { IEditorService } from 'vs/workbench/services/editor/common/editorService';
|
|
import { ConfigureTestProfilesAction, DebugSelectedAction, RunSelectedAction, SelectDefaultTestProfiles } from './testExplorerActions';
|
|
|
|
export class TestingExplorerView extends ViewPane {
|
|
public viewModel!: TestingExplorerViewModel;
|
|
private filterActionBar = this._register(new MutableDisposable());
|
|
private container!: HTMLElement;
|
|
private treeHeader!: HTMLElement;
|
|
private discoveryProgress = this._register(new MutableDisposable<UnmanagedProgress>());
|
|
private filter?: TestingExplorerFilter;
|
|
private readonly dimensions = { width: 0, height: 0 };
|
|
|
|
constructor(
|
|
options: IViewletViewOptions,
|
|
@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()));
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
public override shouldShowWelcome() {
|
|
return this.viewModel?.welcomeExperience === WelcomeExperience.ForWorkspace ?? true;
|
|
}
|
|
|
|
public getSelectedOrVisibleItems(profile?: ITestRunProfile) {
|
|
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: InternalTestItem[] = [];
|
|
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.push(element.test);
|
|
alreadyIncluded = true;
|
|
}
|
|
|
|
// Recurse ✨
|
|
for (const child of element.children) {
|
|
attempt(child, alreadyIncluded);
|
|
}
|
|
};
|
|
|
|
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.push(element.test);
|
|
element.children.forEach(c => attempt(c, true));
|
|
} else {
|
|
element.children.forEach(c => attempt(c, false));
|
|
}
|
|
} else {
|
|
attempt(element, false);
|
|
}
|
|
}
|
|
|
|
return { 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();
|
|
}
|
|
}));
|
|
|
|
const listContainer = dom.append(this.container, dom.$('.test-explorer-tree'));
|
|
this.viewModel = this.instantiationService.createInstance(TestingExplorerViewModel, listContainer, this.onDidChangeBodyVisibility);
|
|
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 Testing.FilterActionId:
|
|
return this.filter = this.instantiationService.createInstance(TestingExplorerFilter, action);
|
|
case RunSelectedAction.ID:
|
|
return this.getRunGroupDropdown(TestRunProfileBitset.Run, action);
|
|
case DebugSelectedAction.ID:
|
|
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.getSelectedOrVisibleItems(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();
|
|
}
|
|
|
|
let postActions: IAction[] = [];
|
|
if (profileActions.length > 1) {
|
|
postActions.push(new Action(
|
|
'selectDefaultTestConfigurations',
|
|
localize('selectDefaultConfigs', 'Select Default Profile'),
|
|
undefined,
|
|
undefined,
|
|
() => this.commandService.executeCommand<ITestRunProfile>(SelectDefaultTestProfiles.ID, group),
|
|
));
|
|
}
|
|
|
|
if (hasConfigurable) {
|
|
postActions.push(new Action(
|
|
'configureTestProfiles',
|
|
localize('configureTestProfiles', 'Configure Test Profiles'),
|
|
undefined,
|
|
undefined,
|
|
() => this.commandService.executeCommand<ITestRunProfile>(ConfigureTestProfilesAction.ID, group),
|
|
));
|
|
}
|
|
|
|
return Separator.join(profileActions, postActions);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
public override saveState() {
|
|
this.filter?.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);
|
|
|
|
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(Testing.FilterActionId));
|
|
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() });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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?.layout(width);
|
|
}
|
|
}
|
|
|
|
const enum WelcomeExperience {
|
|
None,
|
|
ForWorkspace,
|
|
ForDocument,
|
|
}
|
|
|
|
export class TestingExplorerViewModel extends Disposable {
|
|
public tree: ObjectTree<TestExplorerTreeElement, FuzzyScore>;
|
|
private filter: TestsFilter;
|
|
public projection = this._register(new MutableDisposable<ITestTreeProjection>());
|
|
|
|
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<WelcomeExperience>();
|
|
private readonly actionRunner = new TestExplorerActionRunner(() => this.tree.getSelection().filter(isDefined));
|
|
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.USER);
|
|
}
|
|
|
|
|
|
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.USER);
|
|
}
|
|
|
|
constructor(
|
|
listContainer: HTMLElement,
|
|
onDidChangeVisibility: Event<boolean>,
|
|
@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,
|
|
) {
|
|
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);
|
|
|
|
const labels = this._register(instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: onDidChangeVisibility }));
|
|
|
|
this.reevaluateWelcomeState();
|
|
this.filter = this.instantiationService.createInstance(TestsFilter, testService.collection);
|
|
this.tree = instantiationService.createInstance(
|
|
WorkbenchObjectTree,
|
|
'Test Explorer List',
|
|
listContainer,
|
|
new ListDelegate(),
|
|
[
|
|
instantiationService.createInstance(TestItemRenderer, labels, this.actionRunner),
|
|
instantiationService.createInstance(ErrorRenderer),
|
|
],
|
|
{
|
|
simpleKeyboardNavigation: true,
|
|
identityProvider: instantiationService.createInstance(IdentityProvider),
|
|
hideTwistiesOfChildlessElements: false,
|
|
sorter: instantiationService.createInstance(TreeSorter, this),
|
|
keyboardNavigationLabelProvider: instantiationService.createInstance(TreeKeyboardNavigationLabelProvider),
|
|
accessibilityProvider: instantiationService.createInstance(ListAccessibilityProvider),
|
|
filter: this.filter,
|
|
}) as WorkbenchObjectTree<TestExplorerTreeElement, FuzzyScore>;
|
|
|
|
this._register(this.tree.onDidChangeCollapseState(evt => {
|
|
if (evt.node.element instanceof TestItemTreeElement) {
|
|
this.projection.value?.expandElement(evt.node.element, evt.deep ? Infinity : 0);
|
|
}
|
|
}));
|
|
|
|
this._register(onDidChangeVisibility(visible => {
|
|
if (visible) {
|
|
this.ensureProjection();
|
|
}
|
|
}));
|
|
|
|
this._register(this.tree.onContextMenu(e => this.onContextMenu(e)));
|
|
|
|
this._register(Event.any(
|
|
filterState.text.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(() => {
|
|
followRunningTests = getTestingConfiguration(configurationService, TestingConfigKeys.FollowRunningTest);
|
|
}));
|
|
|
|
this._register(testResults.onTestChanged(evt => {
|
|
if (!followRunningTests) {
|
|
return;
|
|
}
|
|
|
|
if (evt.reason !== TestResultItemChangeReason.OwnStateChange) {
|
|
return;
|
|
}
|
|
|
|
// 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.previous === TestResultState.Queued && isStateWithResult(evt.item.ownComputedState))) {
|
|
return;
|
|
}
|
|
|
|
this.revealById(evt.item.item.extId, false, false);
|
|
}));
|
|
|
|
this._register(testResults.onResultsChanged(evt => {
|
|
this.tree.resort(null);
|
|
|
|
if (followRunningTests && 'completed' in evt) {
|
|
const selected = this.tree.getSelection()[0];
|
|
if (selected) {
|
|
this.tree.reveal(selected, 0.5);
|
|
}
|
|
}
|
|
}));
|
|
|
|
this._register(this.testProfileService.onDidChange(() => {
|
|
this.tree.rerender();
|
|
}));
|
|
|
|
const onEditorChange = () => {
|
|
this.filter.filterToDocumentUri(editorService.activeEditor?.resource);
|
|
if (this.filterState.isFilteringFor(TestFilterTerm.CurrentDoc)) {
|
|
this.tree.refilter();
|
|
}
|
|
};
|
|
|
|
this._register(editorService.onDidActiveEditorChange(onEditorChange));
|
|
|
|
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.
|
|
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;
|
|
}
|
|
}
|
|
|
|
this.filterState.reveal.value = undefined;
|
|
this.hasPendingReveal = false;
|
|
if (focus) {
|
|
this.tree.domFocus();
|
|
}
|
|
|
|
this.revealTimeout.value = disposableTimeout(() => {
|
|
// Don't scroll to the item if it's already visible
|
|
if (this.tree.getRelativeTop(element) === null) {
|
|
this.tree.reveal(element, 0.5);
|
|
}
|
|
|
|
this.tree.setFocus([element]);
|
|
this.tree.setSelection([element]);
|
|
}, 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<TestExplorerTreeElement | null>) {
|
|
const element = evt.element;
|
|
if (!(element instanceof TestItemTreeElement)) {
|
|
return;
|
|
}
|
|
|
|
const actions = getActionableElementActions(this.contextKeyService, this.menuService, this.testService, this.testProfileService, element);
|
|
this.contextMenuService.showContextMenu({
|
|
getAnchor: () => evt.anchor,
|
|
getActions: () => [
|
|
...actions.value.primary,
|
|
new Separator(),
|
|
...actions.value.secondary,
|
|
],
|
|
getActionsContext: () => element,
|
|
onHide: () => actions.dispose(),
|
|
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();
|
|
|
|
if (this._viewMode.get() === TestExplorerViewMode.List) {
|
|
this.projection.value = this.instantiationService.createInstance(HierarchicalByNameProjection);
|
|
} else {
|
|
this.projection.value = this.instantiationService.createInstance(HierarchicalByLocationProjection);
|
|
}
|
|
|
|
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);
|
|
|
|
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, testUri: URI, fromNode?: string) => {
|
|
const fsPath = testUri.fsPath;
|
|
|
|
const queue: Iterable<string>[] = [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 || !extpath.isEqualOrParent(fsPath, node.item.uri.fsPath)) {
|
|
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<TestExplorerTreeElement> {
|
|
private documentUri: URI | undefined;
|
|
|
|
constructor(
|
|
private readonly collection: IMainThreadTestCollection,
|
|
@ITestExplorerFilterState private readonly state: ITestExplorerFilterState,
|
|
@ITestService private readonly testService: ITestService,
|
|
) { }
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
public filter(element: TestItemTreeElement): TreeFilterResult<void> {
|
|
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.documentUri, element.test.item.extId)) {
|
|
return FilterResult.Include;
|
|
}
|
|
|
|
return FilterResult.Inherit;
|
|
}
|
|
|
|
private testFilterText(element: TestItemTreeElement) {
|
|
if (this.state.globList.length === 0) {
|
|
return FilterResult.Include;
|
|
}
|
|
|
|
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 (fuzzyContains(data, text)) {
|
|
included = include ? FilterResult.Include : FilterResult.Exclude;
|
|
}
|
|
}
|
|
|
|
if (included !== FilterResult.Inherit) {
|
|
return included;
|
|
}
|
|
}
|
|
|
|
return FilterResult.Inherit;
|
|
}
|
|
}
|
|
|
|
class TreeSorter implements ITreeSorter<TestExplorerTreeElement> {
|
|
constructor(private readonly viewModel: TestingExplorerViewModel) { }
|
|
|
|
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 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;
|
|
}
|
|
|
|
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) {
|
|
const delta = a.test.item.range.startLineNumber - b.test.item.range.startLineNumber;
|
|
if (delta !== 0) {
|
|
return delta;
|
|
}
|
|
}
|
|
|
|
return a.label.localeCompare(b.label);
|
|
}
|
|
}
|
|
|
|
class NoTestsForDocumentWidget extends Disposable {
|
|
private readonly el: HTMLElement;
|
|
constructor(
|
|
container: HTMLElement,
|
|
@ITestExplorerFilterState filterState: ITestExplorerFilterState,
|
|
@IThemeService themeService: IThemeService,
|
|
) {
|
|
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 }));
|
|
button.label = buttonLabel;
|
|
this._register(attachButtonStyler(button, themeService));
|
|
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<TestExplorerTreeElement>) {
|
|
super();
|
|
}
|
|
|
|
override async runAction(action: IAction, context: TestExplorerTreeElement): Promise<any> {
|
|
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.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, testStateNames[element.state]);
|
|
}
|
|
}
|
|
|
|
return label;
|
|
};
|
|
|
|
class ListAccessibilityProvider implements IListAccessibilityProvider<TestExplorerTreeElement> {
|
|
getWidgetAriaLabel(): string {
|
|
return localize('testExplorer', "Test Explorer");
|
|
}
|
|
|
|
getAriaLabel(element: TestExplorerTreeElement): string {
|
|
return element instanceof TestTreeErrorMessage
|
|
? element.description
|
|
: getLabelForTestTreeElement(element);
|
|
}
|
|
}
|
|
|
|
class TreeKeyboardNavigationLabelProvider implements IKeyboardNavigationLabelProvider<TestExplorerTreeElement> {
|
|
getKeyboardNavigationLabel(element: TestExplorerTreeElement) {
|
|
return element instanceof TestTreeErrorMessage ? element.message : element.label;
|
|
}
|
|
}
|
|
|
|
class ListDelegate implements IListVirtualDelegate<TestExplorerTreeElement> {
|
|
getHeight(_element: TestExplorerTreeElement) {
|
|
return 22;
|
|
}
|
|
|
|
getTemplateId(element: TestExplorerTreeElement) {
|
|
if (element instanceof TestTreeErrorMessage) {
|
|
return ErrorRenderer.ID;
|
|
}
|
|
|
|
return TestItemRenderer.ID;
|
|
}
|
|
}
|
|
|
|
class IdentityProvider implements IIdentityProvider<TestExplorerTreeElement> {
|
|
public getId(element: TestExplorerTreeElement) {
|
|
return element.treeId;
|
|
}
|
|
}
|
|
|
|
interface IErrorTemplateData {
|
|
label: HTMLElement;
|
|
}
|
|
|
|
class ErrorRenderer implements ITreeRenderer<TestTreeErrorMessage, FuzzyScore, IErrorTemplateData> {
|
|
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<TestTreeErrorMessage, FuzzyScore>, _: 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 {
|
|
label: IResourceLabel;
|
|
icon: HTMLElement;
|
|
wrapper: HTMLElement;
|
|
actionBar: ActionBar;
|
|
elementDisposable: IDisposable[];
|
|
templateDisposable: IDisposable[];
|
|
}
|
|
|
|
abstract class ActionableItemTemplateData<T extends TestItemTreeElement> extends Disposable
|
|
implements ITreeRenderer<T, FuzzyScore, IActionableElementTemplateData> {
|
|
constructor(
|
|
protected readonly labels: ResourceLabels,
|
|
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,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
abstract get templateId(): string;
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
public renderTemplate(container: HTMLElement): IActionableElementTemplateData {
|
|
const wrapper = dom.append(container, dom.$('.test-item'));
|
|
|
|
const icon = dom.append(wrapper, dom.$('.computed-state'));
|
|
const name = dom.append(wrapper, dom.$('.name'));
|
|
const label = this.labels.create(name, { supportHighlights: true });
|
|
|
|
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
|
|
});
|
|
|
|
return { wrapper, label, actionBar, icon, elementDisposable: [], templateDisposable: [label, actionBar] };
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
public renderElement({ element }: ITreeNode<T, FuzzyScore>, _: number, data: IActionableElementTemplateData): void {
|
|
this.fillActionBar(element, data);
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
disposeTemplate(templateData: IActionableElementTemplateData): void {
|
|
dispose(templateData.templateDisposable);
|
|
templateData.templateDisposable = [];
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
disposeElement(_element: ITreeNode<T, FuzzyScore>, _: number, templateData: IActionableElementTemplateData): void {
|
|
dispose(templateData.elementDisposable);
|
|
templateData.elementDisposable = [];
|
|
}
|
|
|
|
private fillActionBar(element: T, data: IActionableElementTemplateData) {
|
|
const actions = getActionableElementActions(this.contextKeyService, this.menuService, this.testService, this.profiles, element);
|
|
data.elementDisposable.push(actions);
|
|
data.actionBar.clear();
|
|
data.actionBar.context = element;
|
|
data.actionBar.push(actions.value.primary, { icon: true, label: false });
|
|
}
|
|
}
|
|
|
|
class TestItemRenderer extends ActionableItemTemplateData<TestItemTreeElement> {
|
|
public static readonly ID = 'testItem';
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
get templateId(): string {
|
|
return TestItemRenderer.ID;
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
public override renderElement(node: ITreeNode<TestItemTreeElement, FuzzyScore>, depth: number, data: IActionableElementTemplateData): void {
|
|
super.renderElement(node, depth, data);
|
|
|
|
const label: IResourceLabelProps = { name: node.element.label };
|
|
const options: IResourceLabelOptions = {};
|
|
data.label.setResource(label, options);
|
|
|
|
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';
|
|
}
|
|
|
|
label.resource = node.element.test.item.uri;
|
|
options.title = getLabelForTestTreeElement(node.element);
|
|
options.fileKind = FileKind.FILE;
|
|
label.description = node.element.description || undefined;
|
|
|
|
if (node.element.duration !== undefined) {
|
|
label.description = label.description
|
|
? `${label.description}: ${formatDuration(node.element.duration)}`
|
|
: formatDuration(node.element.duration);
|
|
}
|
|
|
|
data.label.setResource(label, options);
|
|
}
|
|
}
|
|
|
|
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,
|
|
profiles: ITestProfileService,
|
|
element: TestItemTreeElement,
|
|
) => {
|
|
const test = element instanceof TestItemTreeElement ? element.test : undefined;
|
|
const contextOverlay = contextKeyService.createOverlay([
|
|
['view', Testing.ExplorerViewId],
|
|
[TestingContextKeys.testItemIsHidden.key, !!test && testService.excluded.contains(test)],
|
|
...getTestItemContextOverlay(test, test ? profiles.capabilitiesForTest(test) : 0),
|
|
]);
|
|
const menu = menuService.createMenu(MenuId.TestItem, contextOverlay);
|
|
|
|
try {
|
|
const primary: IAction[] = [];
|
|
const secondary: IAction[] = [];
|
|
const result = { primary, secondary };
|
|
const actionsDisposable = createAndFillInActionBarActions(menu, {
|
|
shouldForwardArgs: true,
|
|
}, result, 'inline');
|
|
|
|
return { value: result, dispose: () => actionsDisposable.dispose };
|
|
} 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}; }`);
|
|
}
|
|
}
|
|
});
|