testing: add method to publish extension-provided results

This commit is contained in:
Connor Peet 2021-02-18 17:29:39 -08:00
parent 52ebf0c7f8
commit 5a0bf3751a
No known key found for this signature in database
GPG Key ID: CF8FD2EA0DBC61BD
11 changed files with 263 additions and 61 deletions

View File

@ -94,19 +94,19 @@ export namespace Iterable {
/**
* Returns an iterable slice of the array, with the same semantics as `array.slice()`.
*/
export function* slice<T>(iterable: ReadonlyArray<T>, from: number, to = iterable.length): Iterable<T> {
export function* slice<T>(arr: ReadonlyArray<T>, from: number, to = arr.length): Iterable<T> {
if (from < 0) {
from += iterable.length;
from += arr.length;
}
if (to < 0) {
to += iterable.length;
} else if (to > iterable.length) {
to = iterable.length;
to += arr.length;
} else if (to > arr.length) {
to = arr.length;
}
for (; from < to; from++) {
yield iterable[from];
yield arr[from];
}
}
@ -135,4 +135,25 @@ export namespace Iterable {
return [consumed, { [Symbol.iterator]() { return iterator; } }];
}
/**
* Returns whether the iterables are the same length and all items are
* equal using the comparator function.
*/
export function equals<T>(a: Iterable<T>, b: Iterable<T>, comparator = (at: T, bt: T) => at === bt) {
const ai = a[Symbol.iterator]();
const bi = b[Symbol.iterator]();
while (true) {
const an = ai.next();
const bn = bi.next();
if (an.done !== bn.done) {
return false;
} else if (an.done) {
return true;
} else if (!comparator(an.value, bn.value)) {
return false;
}
}
}
}

View File

@ -25,4 +25,11 @@ suite('Iterable', function () {
assert.equal(Iterable.first(customIterable), 'one'); // fresh
});
test('equals', () => {
assert.strictEqual(Iterable.equals([1, 2], [1, 2]), true);
assert.strictEqual(Iterable.equals([1, 2], [1]), false);
assert.strictEqual(Iterable.equals([1], [1, 2]), false);
assert.strictEqual(Iterable.equals([2, 1], [1, 2]), false);
});
});

View File

@ -2227,6 +2227,22 @@ declare module 'vscode' {
*/
export function createDocumentTestObserver(document: TextDocument): TestObserver;
/**
* Inserts custom test results into the VS Code UI. The results are
* inserted and sorted based off the `completedAt` timestamp. If the
* results are being read from a file, for example, the `completedAt`
* time should generally be the modified time of the file if not more
* specific time is available.
*
* This will no-op if the inserted results are deeply equal to an
* existing result.
*
* @param results test results
* @param persist whether the test results should be saved by VS Code
* and persisted across reloads. Defaults to true.
*/
export function publishTestResult(results: TestResults, persist?: boolean): void;
/**
* List of test results stored by VS Code, sorted in descnding
* order by their `completedAt` time.
@ -2553,7 +2569,7 @@ declare module 'vscode' {
* may be out of date. If the test still exists in the workspace, consumers
* can use its `id` to correlate the result instance with the living test.
*
* @todo coverage and other info may eventually live here
* @todo coverage and other info may eventually be provided here
*/
export interface TestResults {
/**
@ -2573,6 +2589,12 @@ declare module 'vscode' {
* provided in {@link TestResult} interfaces.
*/
export interface TestItemWithResults extends TestItem {
/**
* ID of the test result, this is required in order to correlate the result
* with the live test item.
*/
readonly id: string;
/**
* Current result of the test.
*/

View File

@ -9,8 +9,8 @@ import { isDefined } from 'vs/base/common/types';
import { URI, UriComponents } from 'vs/base/common/uri';
import { Range } from 'vs/editor/common/core/range';
import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers';
import { getTestSubscriptionKey, ITestState, RunTestsRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { ITestResultService, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResultService';
import { getTestSubscriptionKey, ISerializedTestResults, ITestState, RunTestsRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { HydratedTestResult, ITestResultService, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResultService';
import { ITestService } from 'vs/workbench/contrib/testing/common/testService';
import { ExtHostContext, ExtHostTestingResource, ExtHostTestingShape, IExtHostContext, MainContext, MainThreadTestingShape } from '../common/extHost.protocol';
@ -48,11 +48,10 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh
}
this._register(resultService.onResultsChanged(evt => {
if ('completed' in evt) {
const serialized = evt.completed.toJSON();
if (serialized) {
this.proxy.$publishTestResults([serialized]);
}
const results = 'completed' in evt ? evt.completed : ('inserted' in evt ? evt.inserted : undefined);
const serialized = results?.toJSON();
if (serialized) {
this.proxy.$publishTestResults([serialized]);
}
}));
@ -63,6 +62,13 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh
}
}
/**
* @inheritdoc
*/
$publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void {
this.resultService.push(new HydratedTestResult(results, persist));
}
/**
* @inheritdoc
*/

View File

@ -339,6 +339,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
checkProposedApiEnabled(extension);
return extHostTesting.runTests(provider);
},
publishTestResult(results, persist = true) {
checkProposedApiEnabled(extension);
return extHostTesting.publishExtensionProvidedResults(results, persist);
},
get onDidChangeTestResults() {
checkProposedApiEnabled(extension);
return extHostTesting.onResultsChanged;

View File

@ -1864,6 +1864,7 @@ export interface MainThreadTestingShape {
$publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void;
$updateTestStateInRun(runId: string, testId: string, state: ITestState): void;
$runTests(req: RunTestsRequest, token: CancellationToken): Promise<string>;
$publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void;
$retireTest(extId: string): void;
}

View File

@ -17,11 +17,11 @@ import { ExtHostTestingResource, ExtHostTestingShape, MainContext, MainThreadTes
import { ExtHostDocumentData } from 'vs/workbench/api/common/extHostDocumentData';
import { IExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors';
import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService';
import { TestItem, TestState } from 'vs/workbench/api/common/extHostTypeConverters';
import { TestItem, TestResults, TestState } from 'vs/workbench/api/common/extHostTypeConverters';
import { Disposable } from 'vs/workbench/api/common/extHostTypes';
import { IExtHostWorkspace } from 'vs/workbench/api/common/extHostWorkspace';
import { OwnedTestCollection, SingleUseTestCollection } from 'vs/workbench/contrib/testing/common/ownedTestCollection';
import { AbstractIncrementalTestCollection, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, RunTestForProviderRequest, SerializedTestResultItem, TestDiffOpType, TestIdWithProvider, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { AbstractIncrementalTestCollection, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, RunTestForProviderRequest, TestDiffOpType, TestIdWithProvider, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import type * as vscode from 'vscode';
const getTestSubscriptionKey = (resource: ExtHostTestingResource, uri: URI) => `${resource}:${uri.toString()}`;
@ -101,6 +101,12 @@ export class ExtHostTesting implements ExtHostTestingShape {
}, token);
}
/**
* Implements vscode.test.publishTestResults
*/
public publishExtensionProvidedResults(results: vscode.TestResults, persist: boolean): void {
this.proxy.$publishExtensionProvidedResults(TestResults.from(generateUuid(), results), persist);
}
/**
* Updates test results shown to extensions.
@ -109,7 +115,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
public $publishTestResults(results: ISerializedTestResults[]): void {
this.results = Object.freeze(
results
.map(r => deepFreeze(convertTestResults(r)))
.map(r => deepFreeze(TestResults.to(r)))
.concat(this.results)
.sort((a, b) => b.completedAt - a.completedAt)
.slice(0, 32),
@ -355,29 +361,6 @@ export class ExtHostTesting implements ExtHostTestingShape {
}
}
const convertTestResultItem = (item: SerializedTestResultItem, byInternalId: Map<string, SerializedTestResultItem>): vscode.TestItemWithResults => ({
...TestItem.toShallow(item.item),
result: TestState.to(item.state),
children: item.children
.map(c => byInternalId.get(c))
.filter(isDefined)
.map(c => convertTestResultItem(c, byInternalId)),
});
const convertTestResults = (r: ISerializedTestResults): vscode.TestResults => {
const roots: SerializedTestResultItem[] = [];
const byInternalId = new Map<string, SerializedTestResultItem>();
for (const item of r.items) {
byInternalId.set(item.id, item);
if (item.direct) {
roots.push(item);
}
}
return { completedAt: r.completedAt, results: roots.map(r => convertTestResultItem(r, byInternalId)) };
};
/*
* A class which wraps a vscode.TestItem that provides the ability to filter a TestItem's children
* to only the children that are located in a certain vscode.Uri.

View File

@ -22,7 +22,7 @@ import * as extHostProtocol from 'vs/workbench/api/common/extHost.protocol';
import { MarkerSeverity, IRelatedInformation, IMarkerData, MarkerTag } from 'vs/platform/markers/common/markers';
import { ACTIVE_GROUP, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors';
import { isString, isNumber } from 'vs/base/common/types';
import { isString, isNumber, isDefined } from 'vs/base/common/types';
import * as marked from 'vs/base/common/marked/marked';
import { parse } from 'vs/base/common/marshalling';
import { cloneAndChange } from 'vs/base/common/objects';
@ -32,7 +32,7 @@ import { RenderLineNumbersType } from 'vs/editor/common/config/editorOptions';
import { CommandsConverter } from 'vs/workbench/api/common/extHostCommands';
import { ExtHostNotebookController } from 'vs/workbench/api/common/extHostNotebook';
import * as notebooks from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { ITestItem, ITestState } from 'vs/workbench/contrib/testing/common/testCollection';
import { ISerializedTestResults, ITestItem, ITestState, SerializedTestResultItem } from 'vs/workbench/contrib/testing/common/testCollection';
export interface PositionLike {
line: number;
@ -1622,7 +1622,6 @@ export namespace TestState {
}
}
export namespace TestItem {
export function from(item: vscode.TestItem, parentExtId?: string): ITestItem {
return {
@ -1649,3 +1648,67 @@ export namespace TestItem {
};
}
}
export namespace TestResults {
export function from(id: string, results: vscode.TestResults): ISerializedTestResults {
const serialized: ISerializedTestResults = {
completedAt: results.completedAt,
id,
items: [],
};
const queue: [parent: SerializedTestResultItem | null, children: Iterable<vscode.TestItemWithResults>][] = [
[null, results.results],
];
let counter = 0;
while (queue.length) {
const [parent, children] = queue.pop()!;
for (const item of children) {
const serializedItem: SerializedTestResultItem = {
children: item.children?.map(c => c.id) ?? [],
computedState: item.result.state,
id: `${id}-${counter++}`,
item: TestItem.from(item, parent?.item.extId),
state: TestState.from(item.result),
retired: undefined,
parent: parent?.id ?? null,
providerId: '',
direct: !parent,
};
serialized.items.push(serializedItem);
if (item.children) {
queue.push([serializedItem, item.children]);
}
}
}
return serialized;
}
const convertTestResultItem = (item: SerializedTestResultItem, byInternalId: Map<string, SerializedTestResultItem>): vscode.TestItemWithResults => ({
...TestItem.toShallow(item.item),
result: TestState.to(item.state),
children: item.children
.map(c => byInternalId.get(c))
.filter(isDefined)
.map(c => convertTestResultItem(c, byInternalId)),
});
export function to(serialized: ISerializedTestResults): vscode.TestResults {
const roots: SerializedTestResultItem[] = [];
const byInternalId = new Map<string, SerializedTestResultItem>();
for (const item of serialized.items) {
byInternalId.set(item.id, item);
if (item.direct) {
roots.push(item);
}
}
return {
completedAt: serialized.completedAt,
results: roots.map(r => convertTestResultItem(r, byInternalId)),
};
}
}

View File

@ -90,7 +90,7 @@ export interface TestResultItem extends IncrementalTestCollectionItem {
/** True if the test is outdated */
retired: boolean;
/** True if the test was directly requested by the run (is not a child or parent) */
direct?: true;
direct?: boolean;
}
export type SerializedTestResultItem = Omit<TestResultItem, 'children' | 'retired'> & { children: string[], retired: undefined };

View File

@ -3,8 +3,11 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { findFirstInSorted } from 'vs/base/common/arrays';
import { Emitter, Event } from 'vs/base/common/event';
import { Iterable } from 'vs/base/common/iterator';
import { Lazy } from 'vs/base/common/lazy';
import { equals } from 'vs/base/common/objects';
import { isDefined } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
@ -409,7 +412,7 @@ export class LiveTestResult implements ITestResult {
/**
* Test results hydrated from a previously-serialized test run.
*/
class HydratedTestResult implements ITestResult {
export class HydratedTestResult implements ITestResult {
/**
* @inheritdoc
*/
@ -434,7 +437,7 @@ class HydratedTestResult implements ITestResult {
private readonly byExtId = new Map<string, TestResultItem>();
constructor(private readonly serialized: ISerializedTestResults) {
constructor(private readonly serialized: ISerializedTestResults, private readonly persist = true) {
this.id = serialized.id;
this.completedAt = serialized.completedAt;
@ -467,14 +470,15 @@ class HydratedTestResult implements ITestResult {
/**
* @inheritdoc
*/
public toJSON(): ISerializedTestResults {
return this.serialized;
public toJSON(): ISerializedTestResults | undefined {
return this.persist ? this.serialized : undefined;
}
}
export type ResultChangeEvent =
| { completed: LiveTestResult }
| { started: LiveTestResult }
| { inserted: ITestResult }
| { removed: ITestResult[] };
export interface ITestResultService {
@ -502,7 +506,7 @@ export interface ITestResultService {
/**
* Adds a new test result to the collection.
*/
push(result: LiveTestResult): LiveTestResult;
push<T extends ITestResult>(result: T): T;
/**
* Looks up a set of test results by ID.
@ -519,6 +523,14 @@ export const ITestResultService = createDecorator<ITestResultService>('testResul
const RETAIN_LAST_RESULTS = 64;
/**
* Returns if the tests in the results are exactly equal. Check the counts
* first as a cheap check before starting to iterate.
*/
const resultsEqual = (a: ITestResult, b: ITestResult) =>
a.completedAt === b.completedAt && equals(a.counts, b.counts) && Iterable.equals(a.tests, b.tests,
(at, bt) => equals(at.state, bt.state) && equals(at.item, bt.item));
export class TestResultService implements ITestResultService {
declare _serviceBrand: undefined;
private changeResultEmitter = new Emitter<ResultChangeEvent>();
@ -579,17 +591,47 @@ export class TestResultService implements ITestResultService {
/**
* @inheritdoc
*/
public push(result: LiveTestResult): LiveTestResult {
this.results.unshift(result);
public push<T extends ITestResult>(result: T): T {
if (result.completedAt === undefined) {
this.results.unshift(result);
} else {
const index = findFirstInSorted(this.results, r => r.completedAt !== undefined && r.completedAt <= result.completedAt!);
const prev = this.results[index];
if (prev && resultsEqual(result, prev)) {
return result;
}
this.results.splice(index, 0, result);
this.persist();
}
if (this.results.length > RETAIN_LAST_RESULTS) {
this.results.pop();
}
result.onComplete(() => this.onComplete(result));
result.onChange(this.testChangeEmitter.fire, this.testChangeEmitter);
this.isRunning.set(true);
this.changeResultEmitter.fire({ started: result });
result.setAllToState(queuedState, () => true);
if (result instanceof LiveTestResult) {
result.onComplete(() => this.onComplete(result));
result.onChange(this.testChangeEmitter.fire, this.testChangeEmitter);
this.isRunning.set(true);
this.changeResultEmitter.fire({ started: result });
result.setAllToState(queuedState, () => true);
} else {
this.changeResultEmitter.fire({ inserted: result });
// If this is not a new result, go through each of its tests. For each
// test for which the new result is the most recently inserted, fir
// a change event so that UI updates.
for (const item of result.tests) {
for (const otherResult of this.results) {
if (otherResult === result) {
this.testChangeEmitter.fire({ item, result, reason: TestResultItemChangeReason.ComputedStateChange });
break;
} else if (otherResult.getStateByExtId(item.item.extId) !== undefined) {
break;
}
}
}
}
return result;
}
@ -615,19 +657,26 @@ export class TestResultService implements ITestResultService {
}
this.results = keep;
this.serializedResults.store(this.results.map(r => r.toJSON()).filter(isDefined));
this.persist();
this.changeResultEmitter.fire({ removed });
}
private onComplete(result: LiveTestResult) {
// move the complete test run down behind any still-running ones
this.resort();
this.isRunning.set(this.results.length > 0 && this.results[0].completedAt === undefined);
this.serializedResults.store(this.results.map(r => r.toJSON()).filter(isDefined));
this.updateIsRunning();
this.persist();
this.changeResultEmitter.fire({ completed: result });
}
private resort() {
this.results.sort((a, b) => (b.completedAt ?? Number.MAX_SAFE_INTEGER) - (a.completedAt ?? Number.MAX_SAFE_INTEGER));
}
private updateIsRunning() {
this.isRunning.set(this.results.length > 0 && this.results[0].completedAt === undefined);
}
private persist() {
this.serializedResults.store(this.results.map(r => r.toJSON()).filter(isDefined));
}
}

View File

@ -6,7 +6,7 @@
import * as assert from 'assert';
import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService';
import { InternalTestItem } from 'vs/workbench/contrib/testing/common/testCollection';
import { LiveTestResult, makeEmptyCounts, TestResultItemChange, TestResultItemChangeReason, TestResultService } from 'vs/workbench/contrib/testing/common/testResultService';
import { HydratedTestResult, LiveTestResult, makeEmptyCounts, TestResultItemChange, TestResultItemChangeReason, TestResultService } from 'vs/workbench/contrib/testing/common/testResultService';
import { ReExportedTestRunState as TestRunState } from 'vs/workbench/contrib/testing/common/testStubs';
import { getInitializedMainTestCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection';
import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices';
@ -190,5 +190,51 @@ suite('Workbench - Test Results Service', () => {
r.markComplete();
assert.deepStrictEqual(results.results, [r, r2]);
});
const makeHydrated = (completedAt = 42, state = TestRunState.Passed) => new HydratedTestResult({
completedAt,
id: 'some-id',
items: [{
...getInitializedMainTestCollection().getNodeById('2')!,
state: { state: TestRunState.Passed, duration: 0, messages: [] },
computedState: TestRunState.Passed,
retired: undefined,
children: [],
}]
});
test('pushes hydrated results', () => {
results.push(r);
const hydrated = makeHydrated();
results.push(hydrated);
assert.deepStrictEqual(results.results, [r, hydrated]);
});
test('deduplicates identical results', () => {
results.push(r);
const hydrated1 = makeHydrated();
results.push(hydrated1);
const hydrated2 = makeHydrated();
results.push(hydrated2);
assert.deepStrictEqual(results.results, [r, hydrated1]);
});
test('does not deduplicate if different completedAt', () => {
results.push(r);
const hydrated1 = makeHydrated();
results.push(hydrated1);
const hydrated2 = makeHydrated(30);
results.push(hydrated2);
assert.deepStrictEqual(results.results, [r, hydrated1, hydrated2]);
});
test('does not deduplicate if different tests', () => {
results.push(r);
const hydrated1 = makeHydrated();
results.push(hydrated1);
const hydrated2 = makeHydrated(undefined, TestRunState.Failed);
results.push(hydrated2);
assert.deepStrictEqual(results.results, [r, hydrated1, hydrated2]);
});
});
});