413 lines
11 KiB
TypeScript
413 lines
11 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 vscode from 'vscode';
|
||
|
import * as nls from 'vscode-nls';
|
||
|
|
||
|
const localize = nls.loadMessageBundle();
|
||
|
|
||
|
interface IDisposable {
|
||
|
dispose(): void;
|
||
|
}
|
||
|
|
||
|
const enum Constants {
|
||
|
ConfigSection = 'testing',
|
||
|
EnableCodeLensConfig = 'enableCodeLens',
|
||
|
EnableDiagnosticsConfig = 'enableProblemDiagnostics',
|
||
|
}
|
||
|
|
||
|
export function activate(context: vscode.ExtensionContext) {
|
||
|
const diagnostics = vscode.languages.createDiagnosticCollection();
|
||
|
const services = new TestingEditorServices(diagnostics);
|
||
|
context.subscriptions.push(
|
||
|
services,
|
||
|
diagnostics,
|
||
|
vscode.languages.registerCodeLensProvider({ scheme: 'file' }, services),
|
||
|
);
|
||
|
}
|
||
|
|
||
|
class TestingConfig implements IDisposable {
|
||
|
private section = vscode.workspace.getConfiguration(Constants.ConfigSection);
|
||
|
private readonly changeEmitter = new vscode.EventEmitter<void>();
|
||
|
private readonly listener = vscode.workspace.onDidChangeConfiguration(evt => {
|
||
|
if (evt.affectsConfiguration(Constants.ConfigSection)) {
|
||
|
this.section = vscode.workspace.getConfiguration(Constants.ConfigSection);
|
||
|
this.changeEmitter.fire();
|
||
|
}
|
||
|
});
|
||
|
|
||
|
public readonly onChange = this.changeEmitter.event;
|
||
|
|
||
|
public get codeLens() {
|
||
|
return this.section.get(Constants.EnableCodeLensConfig, true);
|
||
|
}
|
||
|
|
||
|
public get diagnostics() {
|
||
|
return this.section.get(Constants.EnableDiagnosticsConfig, false);
|
||
|
}
|
||
|
|
||
|
public get isEnabled() {
|
||
|
return this.codeLens || this.diagnostics;
|
||
|
}
|
||
|
|
||
|
public dispose() {
|
||
|
this.listener.dispose();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export class TestingEditorServices implements IDisposable, vscode.CodeLensProvider {
|
||
|
private readonly codeLensChangeEmitter = new vscode.EventEmitter<void>();
|
||
|
private readonly documents = new Map<string, DocumentTestObserver>();
|
||
|
private readonly config = new TestingConfig();
|
||
|
private disposables: IDisposable[];
|
||
|
private wasEnabled = this.config.isEnabled;
|
||
|
|
||
|
/**
|
||
|
* @inheritdoc
|
||
|
*/
|
||
|
public readonly onDidChangeCodeLenses = this.codeLensChangeEmitter.event;
|
||
|
|
||
|
constructor(private readonly diagnostics: vscode.DiagnosticCollection) {
|
||
|
this.disposables = [
|
||
|
new vscode.Disposable(() => this.expireAll()),
|
||
|
|
||
|
this.config,
|
||
|
|
||
|
vscode.window.onDidChangeVisibleTextEditors((editors) => {
|
||
|
if (!this.config.isEnabled) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const expiredEditors = new Set(this.documents.keys());
|
||
|
for (const editor of editors) {
|
||
|
const key = editor.document.uri.toString();
|
||
|
this.ensure(key, editor.document);
|
||
|
expiredEditors.delete(key);
|
||
|
}
|
||
|
|
||
|
for (const expired of expiredEditors) {
|
||
|
this.expire(expired);
|
||
|
}
|
||
|
}),
|
||
|
|
||
|
vscode.workspace.onDidCloseTextDocument((document) => {
|
||
|
this.expire(document.uri.toString());
|
||
|
}),
|
||
|
|
||
|
this.config.onChange(() => {
|
||
|
if (!this.wasEnabled || this.config.isEnabled) {
|
||
|
this.attachToAllVisible();
|
||
|
} else if (this.wasEnabled || !this.config.isEnabled) {
|
||
|
this.expireAll();
|
||
|
}
|
||
|
|
||
|
this.wasEnabled = this.config.isEnabled;
|
||
|
this.codeLensChangeEmitter.fire();
|
||
|
}),
|
||
|
];
|
||
|
|
||
|
if (this.config.isEnabled) {
|
||
|
this.attachToAllVisible();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @inheritdoc
|
||
|
*/
|
||
|
public provideCodeLenses(document: vscode.TextDocument) {
|
||
|
if (!this.config.codeLens) {
|
||
|
return [];
|
||
|
}
|
||
|
|
||
|
return this.documents.get(document.uri.toString())?.provideCodeLenses() ?? [];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Attach to all currently visible editors.
|
||
|
*/
|
||
|
private attachToAllVisible() {
|
||
|
for (const editor of vscode.window.visibleTextEditors) {
|
||
|
this.ensure(editor.document.uri.toString(), editor.document);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Unattaches to all tests.
|
||
|
*/
|
||
|
private expireAll() {
|
||
|
for (const observer of this.documents.values()) {
|
||
|
observer.dispose();
|
||
|
}
|
||
|
|
||
|
this.documents.clear();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Subscribes to tests for the document URI.
|
||
|
*/
|
||
|
private ensure(key: string, document: vscode.TextDocument) {
|
||
|
const state = this.documents.get(key);
|
||
|
if (!state) {
|
||
|
const observer = new DocumentTestObserver(document, this.diagnostics, this.config);
|
||
|
this.documents.set(key, observer);
|
||
|
observer.onDidChangeCodeLenses(() => this.config.codeLens && this.codeLensChangeEmitter.fire());
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Expires and removes the watcher for the document.
|
||
|
*/
|
||
|
private expire(key: string) {
|
||
|
const observer = this.documents.get(key);
|
||
|
if (!observer) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
observer.dispose();
|
||
|
this.documents.delete(key);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @override
|
||
|
*/
|
||
|
public dispose() {
|
||
|
this.disposables.forEach((d) => d.dispose());
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class DocumentTestObserver implements IDisposable {
|
||
|
private readonly codeLensChangeEmitter = new vscode.EventEmitter<void>();
|
||
|
private readonly observer = vscode.test.createDocumentTestObserver(this.document);
|
||
|
private readonly disposables: IDisposable[];
|
||
|
public readonly onDidChangeCodeLenses = this.codeLensChangeEmitter.event;
|
||
|
private didHaveDiagnostics = this.config.diagnostics;
|
||
|
|
||
|
constructor(
|
||
|
private readonly document: vscode.TextDocument,
|
||
|
private readonly diagnostics: vscode.DiagnosticCollection,
|
||
|
private readonly config: TestingConfig,
|
||
|
) {
|
||
|
this.disposables = [
|
||
|
this.observer,
|
||
|
this.codeLensChangeEmitter,
|
||
|
|
||
|
config.onChange(() => {
|
||
|
if (this.didHaveDiagnostics && !config.diagnostics) {
|
||
|
this.diagnostics.set(document.uri, []);
|
||
|
} else if (!this.didHaveDiagnostics && config.diagnostics) {
|
||
|
this.updateDiagnostics();
|
||
|
}
|
||
|
|
||
|
this.didHaveDiagnostics = config.diagnostics;
|
||
|
}),
|
||
|
|
||
|
this.observer.onDidChangeTest(() => {
|
||
|
this.updateDiagnostics();
|
||
|
this.codeLensChangeEmitter.fire();
|
||
|
}),
|
||
|
];
|
||
|
|
||
|
}
|
||
|
|
||
|
private updateDiagnostics() {
|
||
|
if (!this.config.diagnostics) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const uriString = this.document.uri.toString();
|
||
|
const diagnostics: vscode.Diagnostic[] = [];
|
||
|
for (const test of iterateOverTests(this.observer.tests)) {
|
||
|
for (const message of test.state.messages) {
|
||
|
if (message.location?.uri.toString() === uriString) {
|
||
|
diagnostics.push({
|
||
|
range: message.location.range,
|
||
|
message: message.message.toString(),
|
||
|
severity: testToDiagnosticSeverity(message.severity),
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
this.diagnostics.set(this.document.uri, diagnostics);
|
||
|
}
|
||
|
|
||
|
public provideCodeLenses(): vscode.CodeLens[] {
|
||
|
const lenses: vscode.CodeLens[] = [];
|
||
|
|
||
|
for (const test of iterateOverTests(this.observer.tests)) {
|
||
|
const { debuggable = false, runnable = true } = test;
|
||
|
if (!test.location || !(debuggable || runnable)) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
const summary = summarize(test);
|
||
|
|
||
|
lenses.push({
|
||
|
isResolved: true,
|
||
|
range: test.location.range,
|
||
|
command: {
|
||
|
title: `$(${testStateToIcon[summary.computedState]}) ${getLabelFor(test, summary)}`,
|
||
|
command: 'vscode.runTests',
|
||
|
arguments: [[test]],
|
||
|
tooltip: localize('tooltip.debug', 'Debug {0}', test.label),
|
||
|
},
|
||
|
});
|
||
|
|
||
|
if (debuggable) {
|
||
|
lenses.push({
|
||
|
isResolved: true,
|
||
|
range: test.location.range,
|
||
|
command: {
|
||
|
title: localize('action.debug', 'Debug'),
|
||
|
command: 'vscode.debugTests',
|
||
|
arguments: [[test]],
|
||
|
tooltip: localize('tooltip.debug', 'Debug {0}', test.label),
|
||
|
},
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return lenses;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @override
|
||
|
*/
|
||
|
public dispose() {
|
||
|
this.diagnostics.set(this.document.uri, []);
|
||
|
this.disposables.forEach(d => d.dispose());
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function getLabelFor(test: vscode.TestItem, summary: ITestSummary) {
|
||
|
if (summary.duration !== undefined) {
|
||
|
return localize(
|
||
|
'tooltip.runStateWithDuration',
|
||
|
'{0}/{1} Tests Passed in {2}',
|
||
|
summary.passed,
|
||
|
summary.passed + summary.failed,
|
||
|
formatDuration(summary.duration),
|
||
|
);
|
||
|
}
|
||
|
|
||
|
if (summary.passed > 0 || summary.failed > 0) {
|
||
|
return localize('tooltip.runState', '{0}/{1} Tests Passed', summary.passed, summary.failed);
|
||
|
}
|
||
|
|
||
|
if (test.state.runState === vscode.TestRunState.Passed) {
|
||
|
return test.state.duration !== undefined
|
||
|
? localize('state.passedWithDuration', 'Passed in {0}', formatDuration(test.state.duration))
|
||
|
: localize('state.passed', 'Passed');
|
||
|
}
|
||
|
|
||
|
if (isFailedState(test.state.runState)) {
|
||
|
return localize('state.failed', 'Failed');
|
||
|
}
|
||
|
|
||
|
return localize('action.run', 'Run Tests');
|
||
|
}
|
||
|
|
||
|
function formatDuration(duration: number) {
|
||
|
if (duration < 1_000) {
|
||
|
return `${Math.round(duration)}ms`;
|
||
|
}
|
||
|
|
||
|
if (duration < 100_000) {
|
||
|
return `${(duration / 1000).toPrecision(3)}s`;
|
||
|
}
|
||
|
|
||
|
return `${(duration / 1000 / 60).toPrecision(3)}m`;
|
||
|
}
|
||
|
|
||
|
const statePriority: { [K in vscode.TestRunState]: number } = {
|
||
|
[vscode.TestRunState.Running]: 6,
|
||
|
[vscode.TestRunState.Queued]: 5,
|
||
|
[vscode.TestRunState.Errored]: 4,
|
||
|
[vscode.TestRunState.Failed]: 3,
|
||
|
[vscode.TestRunState.Passed]: 2,
|
||
|
[vscode.TestRunState.Skipped]: 1,
|
||
|
[vscode.TestRunState.Unset]: 0,
|
||
|
};
|
||
|
|
||
|
const maxPriority = (a: vscode.TestRunState, b: vscode.TestRunState) =>
|
||
|
statePriority[a] > statePriority[b] ? a : b;
|
||
|
|
||
|
const isFailedState = (s: vscode.TestRunState) =>
|
||
|
s === vscode.TestRunState.Failed || s === vscode.TestRunState.Errored;
|
||
|
|
||
|
interface ITestSummary {
|
||
|
passed: number;
|
||
|
failed: number;
|
||
|
duration: number | undefined;
|
||
|
computedState: vscode.TestRunState;
|
||
|
}
|
||
|
|
||
|
function summarize(test: vscode.TestItem) {
|
||
|
let passed = 0;
|
||
|
let failed = 0;
|
||
|
let duration: number | undefined;
|
||
|
let computedState = test.state.runState;
|
||
|
|
||
|
const queue = test.children ? [test.children] : [];
|
||
|
while (queue.length) {
|
||
|
for (const test of queue.pop()!) {
|
||
|
computedState = maxPriority(computedState, test.state.runState);
|
||
|
if (test.state.runState === vscode.TestRunState.Passed) {
|
||
|
passed++;
|
||
|
if (test.state.duration !== undefined) {
|
||
|
duration = test.state.duration + (duration ?? 0);
|
||
|
}
|
||
|
} else if (isFailedState(test.state.runState)) {
|
||
|
failed++;
|
||
|
if (test.state.duration !== undefined) {
|
||
|
duration = test.state.duration + (duration ?? 0);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (test.children) {
|
||
|
queue.push(test.children);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return { passed, failed, duration, computedState };
|
||
|
}
|
||
|
|
||
|
function* iterateOverTests(tests: ReadonlyArray<vscode.TestItem>) {
|
||
|
const queue = [tests];
|
||
|
while (queue.length) {
|
||
|
for (const test of queue.pop()!) {
|
||
|
yield test;
|
||
|
if (test.children) {
|
||
|
queue.push(test.children);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const testStateToIcon: { [K in vscode.TestRunState]: string } = {
|
||
|
[vscode.TestRunState.Errored]: 'testing-error-icon',
|
||
|
[vscode.TestRunState.Failed]: 'testing-failed-icon',
|
||
|
[vscode.TestRunState.Passed]: 'testing-passed-icon',
|
||
|
[vscode.TestRunState.Queued]: 'testing-queued-icon',
|
||
|
[vscode.TestRunState.Skipped]: 'testing-skipped-icon',
|
||
|
[vscode.TestRunState.Unset]: 'beaker',
|
||
|
[vscode.TestRunState.Running]: 'loading~spin',
|
||
|
};
|
||
|
|
||
|
const testToDiagnosticSeverity = (severity: vscode.TestMessageSeverity | undefined) => {
|
||
|
switch (severity) {
|
||
|
case vscode.TestMessageSeverity.Hint:
|
||
|
return vscode.DiagnosticSeverity.Hint;
|
||
|
case vscode.TestMessageSeverity.Information:
|
||
|
return vscode.DiagnosticSeverity.Information;
|
||
|
case vscode.TestMessageSeverity.Warning:
|
||
|
return vscode.DiagnosticSeverity.Warning;
|
||
|
case vscode.TestMessageSeverity.Error:
|
||
|
default:
|
||
|
return vscode.DiagnosticSeverity.Error;
|
||
|
}
|
||
|
};
|