testing: improve test children API

This changeset results from the discussion in and fixes #126987.
Migration for these changes should take about 15-20 minutes.

- `createTestItem` no longer takes a parent. Instead, it creates a free-
  floating test item, which can be added as a child of a parent.
- The `TestItem.children` is now a `TestItemCollection`, a set-like
  interface that also allows replacing items (intelligently diffing
	them internally) wholesale. This removes the need for the "generation
	counter" used in samples previously.
- There is no longer a `root` on the test controller, but instead an
  `items` property which is the same `TestItemCollection`
- The `tests` in the `TestRunRequest` has been replaced with an `include`
  property. If undefined, the extension should run all tests. (Since
	there is no longer a root to reference).

Here's some example migrations:

- 3fad3d66c1
- 3aff746316
This commit is contained in:
Connor Peet
2021-07-14 18:08:04 -07:00
parent 039582c0dd
commit 581ff12c39
18 changed files with 662 additions and 367 deletions

View File

@@ -3,26 +3,50 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Emitter } from 'vs/base/common/event';
import { TestItemImpl } from 'vs/workbench/api/common/extHostTypes';
import * as vscode from 'vscode';
export const enum ExtHostTestItemEventType {
NewChild,
Disposed,
export const enum ExtHostTestItemEventOp {
Upsert,
RemoveChild,
Invalidated,
SetProp,
Bulk,
}
export interface ITestItemUpsertChild {
op: ExtHostTestItemEventOp.Upsert;
item: TestItemImpl;
}
export interface ITestItemRemoveChild {
op: ExtHostTestItemEventOp.RemoveChild;
id: string;
}
export interface ITestItemInvalidated {
op: ExtHostTestItemEventOp.Invalidated;
}
export interface ITestItemSetProp {
op: ExtHostTestItemEventOp.SetProp;
key: keyof vscode.TestItem;
value: any;
}
export interface ITestItemBulkReplace {
op: ExtHostTestItemEventOp.Bulk;
ops: (ITestItemUpsertChild | ITestItemRemoveChild)[];
}
export type ExtHostTestItemEvent =
| [evt: ExtHostTestItemEventType.NewChild, item: TestItemImpl]
| [evt: ExtHostTestItemEventType.Disposed]
| [evt: ExtHostTestItemEventType.Invalidated]
| [evt: ExtHostTestItemEventType.SetProp, key: keyof vscode.TestItem, value: any];
| ITestItemUpsertChild
| ITestItemRemoveChild
| ITestItemInvalidated
| ITestItemSetProp
| ITestItemBulkReplace;
export interface IExtHostTestItemApi {
children: Map<string, TestItemImpl>;
bus: Emitter<ExtHostTestItemEvent>;
parent?: TestItemImpl;
listener?: (evt: ExtHostTestItemEvent) => void;
}
const eventPrivateApis = new WeakMap<TestItemImpl, IExtHostTestItemApi>();
@@ -35,9 +59,216 @@ const eventPrivateApis = new WeakMap<TestItemImpl, IExtHostTestItemApi>();
export const getPrivateApiFor = (impl: TestItemImpl) => {
let api = eventPrivateApis.get(impl);
if (!api) {
api = { children: new Map(), bus: new Emitter() };
api = {};
eventPrivateApis.set(impl, api);
}
return api;
};
const testItemPropAccessor = <K extends keyof vscode.TestItem>(
api: IExtHostTestItemApi,
key: K,
defaultValue: vscode.TestItem[K],
equals: (a: vscode.TestItem[K], b: vscode.TestItem[K]) => boolean
) => {
let value = defaultValue;
return {
enumerable: true,
configurable: false,
get() {
return value;
},
set(newValue: vscode.TestItem[K]) {
if (!equals(value, newValue)) {
value = newValue;
api.listener?.({ op: ExtHostTestItemEventOp.SetProp, key, value: newValue });
}
},
};
};
type WritableProps = Pick<vscode.TestItem, 'range' | 'label' | 'description' | 'canResolveChildren' | 'busy' | 'error'>;
const strictEqualComparator = <T>(a: T, b: T) => a === b;
const propComparators: { [K in keyof Required<WritableProps>]: (a: vscode.TestItem[K], b: vscode.TestItem[K]) => boolean } = {
range: (a, b) => {
if (a === b) { return true; }
if (!a || !b) { return false; }
return a.isEqual(b);
},
label: strictEqualComparator,
description: strictEqualComparator,
busy: strictEqualComparator,
error: strictEqualComparator,
canResolveChildren: strictEqualComparator
};
const writablePropKeys = Object.keys(propComparators) as (keyof Required<WritableProps>)[];
const makePropDescriptors = (api: IExtHostTestItemApi, label: string): { [K in keyof Required<WritableProps>]: PropertyDescriptor } => ({
range: testItemPropAccessor(api, 'range', undefined, propComparators.range),
label: testItemPropAccessor(api, 'label', label, propComparators.label),
description: testItemPropAccessor(api, 'description', undefined, propComparators.description),
canResolveChildren: testItemPropAccessor(api, 'canResolveChildren', false, propComparators.canResolveChildren),
busy: testItemPropAccessor(api, 'busy', false, propComparators.busy),
error: testItemPropAccessor(api, 'error', undefined, propComparators.error),
});
/**
* Returns a partial test item containing the writable properties in B that
* are different from A.
*/
export const diffTestItems = (a: vscode.TestItem, b: vscode.TestItem) => {
const output = new Map<keyof WritableProps, unknown>();
for (const key of writablePropKeys) {
const cmp = propComparators[key] as (a: unknown, b: unknown) => boolean;
if (!cmp(a[key], b[key])) {
output.set(key, b[key]);
}
}
return output;
};
export class DuplicateTestItemError extends Error {
constructor(id: string) {
super(`Attempted to insert a duplicate test item ID ${id}`);
}
}
export class InvalidTestItemError extends Error {
constructor(id: string) {
super(`TestItem with ID "${id}" is invalid. Make sure to create it from the createTestItem method.`);
}
}
export const createTestItemCollection = (owningItem: TestItemImpl):
vscode.TestItemCollection & { toJSON(): readonly vscode.TestItem[] } => {
const api = getPrivateApiFor(owningItem);
let all: readonly TestItemImpl[] | undefined;
let mapped = new Map<string, TestItemImpl>();
return {
/** @inheritdoc */
get all() {
if (!all) {
all = Object.freeze([...mapped.values()]);
}
return all;
},
/** @inheritdoc */
set all(items: readonly vscode.TestItem[]) {
const newMapped = new Map<string, TestItemImpl>();
const toDelete = new Set(mapped.keys());
const bulk: ITestItemBulkReplace = { op: ExtHostTestItemEventOp.Bulk, ops: [] };
for (const item of items) {
if (!(item instanceof TestItemImpl)) {
throw new InvalidTestItemError(item.id);
}
if (newMapped.has(item.id)) {
throw new DuplicateTestItemError(item.id);
}
newMapped.set(item.id, item);
toDelete.delete(item.id);
bulk.ops.push({ op: ExtHostTestItemEventOp.Upsert, item });
}
for (const id of toDelete.keys()) {
bulk.ops.push({ op: ExtHostTestItemEventOp.RemoveChild, id });
}
api.listener?.(bulk);
// important mutations come after firing, so if an error happens no
// changes will be "saved":
mapped = newMapped;
all = undefined;
},
/** @inheritdoc */
add(item: vscode.TestItem) {
if (!(item instanceof TestItemImpl)) {
throw new InvalidTestItemError(item.id);
}
mapped.set(item.id, item);
all = undefined;
api.listener?.({ op: ExtHostTestItemEventOp.Upsert, item });
},
/** @inheritdoc */
remove(id: string) {
if (mapped.delete(id)) {
all = undefined;
api.listener?.({ op: ExtHostTestItemEventOp.RemoveChild, id });
}
},
/** @inheritdoc */
get(itemId: string) {
return mapped.get(itemId);
},
/** JSON serialization function. */
toJSON() {
return this.all;
},
};
};
export class TestItemImpl implements vscode.TestItem {
public readonly id!: string;
public readonly uri!: vscode.Uri | undefined;
public readonly children!: vscode.TestItemCollection;
public readonly parent!: TestItemImpl | undefined;
public range!: vscode.Range | undefined;
public description!: string | undefined;
public label!: string;
public error!: string | vscode.MarkdownString;
public busy!: boolean;
public canResolveChildren!: boolean;
/**
* Note that data is deprecated and here for back-compat only
*/
constructor(id: string, label: string, uri: vscode.Uri | undefined) {
const api = getPrivateApiFor(this);
Object.defineProperties(this, {
id: {
value: id,
enumerable: true,
writable: false,
},
uri: {
value: uri,
enumerable: true,
writable: false,
},
parent: {
enumerable: false,
get() { return api.parent; },
},
children: {
value: createTestItemCollection(this),
enumerable: true,
writable: false,
},
...makePropDescriptors(api, label),
});
}
/** @deprecated back compat */
public invalidateResults() {
getPrivateApiFor(this).listener?.({ op: ExtHostTestItemEventOp.Invalidated });
}
}