src/vs/workbench/contrib/testing/common/testItemCollection.ts

750 LOC · 255 covered · 495 uncovered · 29 ranges · 154 concepts · 1 introducers · 103 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- extHostTypes.ts ×270
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { Barrier, isThenable, RunOnceScheduler } from '../../../../base/common/async.js';
7 > import { Emitter } from '../../../../base/common/event.js';
8 > import { Disposable } from '../../../../base/common/lifecycle.js';
9 > import { assertNever } from '../../../../base/common/assert.js';
10 > import { applyTestItemUpdate, ITestItem, ITestTag, namespaceTestTag, TestDiffOpType, TestItemExpandState, TestsDiff, TestsDiffOp } from './testTypes.js';
11 > import { TestId } from './testId.js';
12 > import { URI } from '../../../../base/common/uri.js';
13 >
14 > /**
15 > * @private
16 > */
17 > interface CollectionItem<T> {
18 > readonly fullId: TestId;
19 > actual: T;
20 > expand: TestItemExpandState;
21 > /**
22 > * Number of levels of items below this one that are expanded. May be infinite.
23 > */
24 > expandLevels?: number;
25 > resolveBarrier?: Barrier;
26 > }
27 >
28 > export const enum TestItemEventOp {
29 > Upsert,
30 > SetTags,
31 > UpdateCanResolveChildren,
32 > RemoveChild,
33 > SetProp,
34 > Bulk,
35 > DocumentSynced,
36 > }
37 >
38 > export interface ITestItemUpsertChild {
39 > op: TestItemEventOp.Upsert;
40 > item: ITestItemLike;
41 > }
42 >
43 > export interface ITestItemUpdateCanResolveChildren {
44 > op: TestItemEventOp.UpdateCanResolveChildren;
45 > state: boolean;
46 > }
47 >
48 > export interface ITestItemSetTags {
49 > op: TestItemEventOp.SetTags;
50 > new: ITestTag[];
51 > old: ITestTag[];
52 > }
53 >
54 > export interface ITestItemRemoveChild {
55 > op: TestItemEventOp.RemoveChild;
56 > id: string;
57 > }
58 >
59 > export interface ITestItemSetProp {
60 > op: TestItemEventOp.SetProp;
61 > update: Partial<ITestItem>;
62 > }
63 > export interface ITestItemBulkReplace {
64 > op: TestItemEventOp.Bulk;
65 > ops: (ITestItemUpsertChild | ITestItemRemoveChild)[];
66 > }
67 >
68 > export interface ITestItemDocumentSynced {
69 > op: TestItemEventOp.DocumentSynced;
70 > }
71 >
72 > export type ExtHostTestItemEvent =
73 > | ITestItemSetTags
74 > | ITestItemUpsertChild
75 > | ITestItemRemoveChild
76 > | ITestItemUpdateCanResolveChildren
77 > | ITestItemSetProp
78 > | ITestItemBulkReplace
79 > | ITestItemDocumentSynced;
80 >
81 > export interface ITestItemApi<T> {
82 > controllerId: string;
83 > parent?: T;
84 > listener?: (evt: ExtHostTestItemEvent) => void;
85 > }
86 >
87 > export interface ITestItemCollectionOptions<T> {
88 > /** Controller ID to use to prefix these test items. */
89 > controllerId: string;
90 >
91 > /** Gets the document version at the given URI, if it's open */
92 > getDocumentVersion(uri: URI | undefined): number | undefined;
93 >
94 > /** Gets API for the given test item, used to listen for events and set parents. */
95 > getApiFor(item: T): ITestItemApi<T>;
96 >
97 > /** Converts the full test item to the common interface. */
98 > toITestItem(item: T): ITestItem;
99 >
100 > /** Gets children for the item. */
101 > getChildren(item: T): ITestChildrenLike<T>;
102 >
103 > /** Root to use for the new test collection. */
104 > root: T;
105 > }
106 >
107 > const strictEqualComparator = <T>(a: T, b: T) => a === b;
108 > const diffableProps: { [K in keyof ITestItem]?: (a: ITestItem[K], b: ITestItem[K]) => boolean } = {
109 > range: (a, b) => {
110 if (a === b) { return true; }
111 if (!a || !b) { return false; }
112 return a.equalsRange(b);
113 },
114 > busy: strictEqualComparator, extHostTypes.ts ×270
115 > label: strictEqualComparator,
116 > description: strictEqualComparator,
117 > error: strictEqualComparator,
118 > sortText: strictEqualComparator,
119 > tags: (a, b) => {
120 if (a.length !== b.length) {
121 return false;
122 }
123
124 if (a.some(t1 => !b.includes(t1))) {
125 return false;
126 }
127
128 return true;
129 },
131 >
132 > const diffableEntries = Object.entries(diffableProps) as readonly [keyof ITestItem, (a: unknown, b: unknown) => boolean][];
133 >
134 > const diffTestItems = (a: ITestItem, b: ITestItem) => {
135 let output: Record<string, unknown> | undefined;
136 for (const [key, cmp] of diffableEntries) {
137 if (!cmp(a[key], b[key])) {
138 if (output) {
139 output[key] = b[key];
140 } else {
141 output = { [key]: b[key] };
142 }
143 }
144 }
145
146 return output as Partial<ITestItem> | undefined;
147 };
149 > export interface ITestChildrenLike<T> extends Iterable<[string, T]> {
150 > get(id: string): T | undefined;
151 > delete(id: string): void;
152 > }
153 >
154 > export interface ITestItemLike {
155 > id: string;
156 > tags: readonly ITestTag[];
157 > uri?: URI;
158 > canResolveChildren: boolean;
159 > }
160 >
161 > /**
162 > * Maintains a collection of test items for a single controller.
163 > */
164 > export class TestItemCollection<T extends ITestItemLike> extends Disposable {
165 > private readonly debounceSendDiff = this._register(new RunOnceScheduler(() => this.flushDiff(), 200));
166 > private readonly diffOpEmitter = this._register(new Emitter<TestsDiff>());
167 > private _resolveHandler?: (item: T | undefined) => Promise<void> | void;
168 >
169 > public get root() {
170 > return this.options.root;
171 > }
172 >
173 > public readonly tree = new Map</* full test id */string, CollectionItem<T>>();
174 > private readonly tags = new Map<string, { label?: string; refCount: number }>();
175 >
176 > protected diff: TestsDiff = [];
177 >
178 > constructor(private readonly options: ITestItemCollectionOptions<T>) {
179 super();
180 this.root.canResolveChildren = true;
181 this.upsertItem(this.root, undefined);
182 }
184 > /**
185 > * Handler used for expanding test items.
186 > */
187 > public set resolveHandler(handler: undefined | ((item: T | undefined) => void)) {
188 this._resolveHandler = handler;
189 for (const test of this.tree.values()) {
190 this.updateExpandability(test);
191 }
192 }
194 > public get resolveHandler() {
195 return this._resolveHandler;
196 }
198 > /**
199 > * Fires when an operation happens that should result in a diff.
200 > */
201 > public readonly onDidGenerateDiff = this.diffOpEmitter.event;
202 >
203 > /**
204 > * Gets a diff of all changes that have been made, and clears the diff queue.
205 > */
206 > public collectDiff() {
207 const diff = this.diff;
208 this.diff = [];
209 return diff;
210 }
212 > /**
213 > * Pushes a new diff entry onto the collected diff list.
214 > */
215 > public pushDiff(diff: TestsDiffOp) {
216 switch (diff.op) {
217 case TestDiffOpType.DocumentSynced: {
218 for (const existing of this.diff) {
219 if (existing.op === TestDiffOpType.DocumentSynced && existing.uri === diff.uri) {
220 existing.docv = diff.docv;
221 return;
222 }
223 }
224
225 break;
226 }
227 case TestDiffOpType.Update: {
228 // Try to merge updates, since they're invoked per-property
229 const last = this.diff[this.diff.length - 1];
230 if (last) {
231 if (last.op === TestDiffOpType.Update && last.item.extId === diff.item.extId) {
232 applyTestItemUpdate(last.item, diff.item);
233 return;
234 }
235
236 if (last.op === TestDiffOpType.Add && last.item.item.extId === diff.item.extId) {
237 applyTestItemUpdate(last.item, diff.item);
238 return;
239 }
240 }
241 break;
242 }
243 }
244
245 this.diff.push(diff);
246
247 if (!this.debounceSendDiff.isScheduled()) {
248 this.debounceSendDiff.schedule();
249 }
250 }
252 > /**
253 > * Expands the test and the given number of `levels` of children. If levels
254 > * is < 0, then all children will be expanded. If it's 0, then only this
255 > * item will be expanded.
256 > */
257 > public expand(testId: string, levels: number): Promise<void> | void {
258 const internal = this.tree.get(testId);
259 if (!internal) {
260 return;
261 }
262
263 if (internal.expandLevels === undefined || levels > internal.expandLevels) {
264 internal.expandLevels = levels;
265 }
266
267 // try to avoid awaiting things if the provider returns synchronously in
268 // order to keep everything in a single diff and DOM update.
269 if (internal.expand === TestItemExpandState.Expandable) {
270 const r = this.resolveChildren(internal);
271 return !r.isOpen()
272 ? r.wait().then(() => this.expandChildren(internal, levels - 1))
273 : this.expandChildren(internal, levels - 1);
274 } else if (internal.expand === TestItemExpandState.Expanded) {
275 return internal.resolveBarrier?.isOpen() === false
276 ? internal.resolveBarrier.wait().then(() => this.expandChildren(internal, levels - 1))
277 : this.expandChildren(internal, levels - 1);
278 }
279 }
281 > public override dispose() {
282 for (const item of this.tree.values()) {
283 this.options.getApiFor(item.actual).listener = undefined;
284 }
285
286 this.tree.clear();
287 this.diff = [];
288 super.dispose();
289 }
291 > private onTestItemEvent(internal: CollectionItem<T>, evt: ExtHostTestItemEvent) {
292 switch (evt.op) {
293 case TestItemEventOp.RemoveChild:
294 this.removeItem(TestId.joinToString(internal.fullId, evt.id));
295 break;
296
297 case TestItemEventOp.Upsert:
298 this.upsertItem(evt.item as T, internal);
299 break;
300
301 case TestItemEventOp.Bulk:
302 for (const op of evt.ops) {
303 this.onTestItemEvent(internal, op);
304 }
305 break;
306
307 case TestItemEventOp.SetTags:
308 this.diffTagRefs(evt.new, evt.old, internal.fullId.toString());
309 break;
310
311 case TestItemEventOp.UpdateCanResolveChildren:
312 this.updateExpandability(internal);
313 break;
314
315 case TestItemEventOp.SetProp:
316 this.pushDiff({
317 op: TestDiffOpType.Update,
318 item: {
319 extId: internal.fullId.toString(),
320 item: evt.update,
321 }
322 });
323 break;
324
325 case TestItemEventOp.DocumentSynced:
326 this.documentSynced(internal.actual.uri);
327 break;
328
329 default:
330 assertNever(evt);
331 }
332 }
334 > private documentSynced(uri: URI | undefined) {
335 if (uri) {
336 this.pushDiff({
337 op: TestDiffOpType.DocumentSynced,
338 uri,
339 docv: this.options.getDocumentVersion(uri)
340 });
341 }
342 }
344 > private upsertItem(actual: T, parent: CollectionItem<T> | undefined): void {
345 const fullId = TestId.fromExtHostTestItem(actual, this.root.id, parent?.actual);
346
347 // If this test item exists elsewhere in the tree already (exists at an
348 // old ID with an existing parent), remove that old item.
349 const privateApi = this.options.getApiFor(actual);
350 if (privateApi.parent && privateApi.parent !== parent?.actual) {
351 this.options.getChildren(privateApi.parent).delete(actual.id);
352 }
353
354 let internal = this.tree.get(fullId.toString());
355 // Case 1: a brand new item
356 if (!internal) {
357 internal = {
358 fullId,
359 actual,
360 expandLevels: parent?.expandLevels /* intentionally undefined or 0 */ ? parent.expandLevels - 1 : undefined,
361 expand: TestItemExpandState.NotExpandable, // updated by `connectItemAndChildren`
362 };
363
364 actual.tags.forEach(this.incrementTagRefs, this);
365 this.tree.set(internal.fullId.toString(), internal);
366 this.setItemParent(actual, parent);
367 this.pushDiff({
368 op: TestDiffOpType.Add,
369 item: {
370 controllerId: this.options.controllerId,
371 expand: internal.expand,
372 item: this.options.toITestItem(actual),
373 },
374 });
375
376 this.connectItemAndChildren(actual, internal, parent);
377 return;
378 }
379
380 // Case 2: re-insertion of an existing item, no-op
381 if (internal.actual === actual) {
382 this.connectItem(actual, internal, parent); // re-connect in case the parent changed
383 return; // no-op
384 }
385
386 // Case 3: upsert of an existing item by ID, with a new instance
387 if (internal.actual.uri?.toString() !== actual.uri?.toString()) {
388 // If the item has a new URI, re-insert it; we don't support updating
389 // URIs on existing test items.
390 this.removeItem(fullId.toString());
391 return this.upsertItem(actual, parent);
392 }
393 const oldChildren = this.options.getChildren(internal.actual);
394 const oldActual = internal.actual;
395 const update = diffTestItems(this.options.toITestItem(oldActual), this.options.toITestItem(actual));
396 this.options.getApiFor(oldActual).listener = undefined;
397
398 internal.actual = actual;
399 internal.resolveBarrier = undefined;
400 internal.expand = TestItemExpandState.NotExpandable; // updated by `connectItemAndChildren`
401
402 if (update) {
403 // tags are handled in a special way
404 if (update.hasOwnProperty('tags')) {
405 this.diffTagRefs(actual.tags, oldActual.tags, fullId.toString());
406 delete update.tags;
407 }
408 this.onTestItemEvent(internal, { op: TestItemEventOp.SetProp, update });
409 }
410
411 this.connectItemAndChildren(actual, internal, parent);
412
413 // Remove any orphaned children.
414 for (const [_, child] of oldChildren) {
415 if (!this.options.getChildren(actual).get(child.id)) {
416 this.removeItem(TestId.joinToString(fullId, child.id));
417 }
418 }
419
420 // Re-expand the element if it was previous expanded (#207574)
421 const expandLevels = internal.expandLevels;
422 if (expandLevels !== undefined) {
423 // Wait until a microtask to allow the extension to finish setting up
424 // properties of the element and children before we ask it to expand.
425 queueMicrotask(() => {
426 if (internal.expand === TestItemExpandState.Expandable) {
427 internal.expandLevels = undefined;
428 this.expand(fullId.toString(), expandLevels);
429 }
430 });
431 }
432
433 // Mark ranges in the document as synced (#161320)
434 this.documentSynced(internal.actual.uri);
435 }
437 > private diffTagRefs(newTags: readonly ITestTag[], oldTags: readonly ITestTag[], extId: string) {
438 const toDelete = new Set(oldTags.map(t => t.id));
439 for (const tag of newTags) {
440 if (!toDelete.delete(tag.id)) {
441 this.incrementTagRefs(tag);
442 }
443 }
444
445 this.pushDiff({
446 op: TestDiffOpType.Update,
447 item: { extId, item: { tags: newTags.map(v => namespaceTestTag(this.options.controllerId, v.id)) } }
448 });
449
450 toDelete.forEach(this.decrementTagRefs, this);
451 }
453 > private incrementTagRefs(tag: ITestTag) {
454 const existing = this.tags.get(tag.id);
455 if (existing) {
456 existing.refCount++;
457 } else {
458 this.tags.set(tag.id, { refCount: 1 });
459 this.pushDiff({
460 op: TestDiffOpType.AddTag, tag: {
461 id: namespaceTestTag(this.options.controllerId, tag.id),
462 }
463 });
464 }
465 }
467 > private decrementTagRefs(tagId: string) {
468 const existing = this.tags.get(tagId);
469 if (existing && !--existing.refCount) {
470 this.tags.delete(tagId);
471 this.pushDiff({ op: TestDiffOpType.RemoveTag, id: namespaceTestTag(this.options.controllerId, tagId) });
472 }
473 }
475 > private setItemParent(actual: T, parent: CollectionItem<T> | undefined) {
476 this.options.getApiFor(actual).parent = parent && parent.actual !== this.root ? parent.actual : undefined;
477 }
479 > private connectItem(actual: T, internal: CollectionItem<T>, parent: CollectionItem<T> | undefined) {
480 this.setItemParent(actual, parent);
481 const api = this.options.getApiFor(actual);
482 api.parent = parent?.actual;
483 api.listener = evt => this.onTestItemEvent(internal, evt);
484 this.updateExpandability(internal);
485 }
487 > private connectItemAndChildren(actual: T, internal: CollectionItem<T>, parent: CollectionItem<T> | undefined) {
488 this.connectItem(actual, internal, parent);
489
490 // Discover any existing children that might have already been added
491 for (const [_, child] of this.options.getChildren(actual)) {
492 this.upsertItem(child, internal);
493 }
494 }
496 > /**
497 > * Updates the `expand` state of the item. Should be called whenever the
498 > * resolved state of the item changes. Can automatically expand the item
499 > * if requested by a consumer.
500 > */
501 > private updateExpandability(internal: CollectionItem<T>) {
502 let newState: TestItemExpandState;
503 if (!this._resolveHandler) {
504 newState = TestItemExpandState.NotExpandable;
505 } else if (internal.resolveBarrier) {
506 newState = internal.resolveBarrier.isOpen()
507 ? TestItemExpandState.Expanded
508 : TestItemExpandState.BusyExpanding;
509 } else {
510 newState = internal.actual.canResolveChildren
511 ? TestItemExpandState.Expandable
512 : TestItemExpandState.NotExpandable;
513 }
514
515 if (newState === internal.expand) {
516 return;
517 }
518
519 internal.expand = newState;
520 this.pushDiff({ op: TestDiffOpType.Update, item: { extId: internal.fullId.toString(), expand: newState } });
521
522 if (newState === TestItemExpandState.Expandable && internal.expandLevels !== undefined) {
523 this.resolveChildren(internal);
524 }
525 }
527 > /**
528 > * Expands all children of the item, "levels" deep. If levels is 0, only
529 > * the children will be expanded. If it's 1, the children and their children
530 > * will be expanded. If it's <0, it's a no-op.
531 > */
532 > private expandChildren(internal: CollectionItem<T>, levels: number): Promise<void> | void {
533 if (levels < 0) {
534 return;
535 }
536
537 const expandRequests: Promise<void>[] = [];
538 for (const [_, child] of this.options.getChildren(internal.actual)) {
539 const promise = this.expand(TestId.joinToString(internal.fullId, child.id), levels);
540 if (isThenable(promise)) {
541 expandRequests.push(promise);
542 }
543 }
544
545 if (expandRequests.length) {
546 return Promise.all(expandRequests).then(() => { });
547 }
548 }
550 > /**
551 > * Calls `discoverChildren` on the item, refreshing all its tests.
552 > */
553 > private resolveChildren(internal: CollectionItem<T>) {
554 if (internal.resolveBarrier) {
555 return internal.resolveBarrier;
556 }
557
558 if (!this._resolveHandler) {
559 const b = new Barrier();
560 b.open();
561 return b;
562 }
563
564 internal.expand = TestItemExpandState.BusyExpanding;
565 this.pushExpandStateUpdate(internal);
566
567 const barrier = internal.resolveBarrier = new Barrier();
568 const applyError = (err: Error) => {
569 console.error(`Unhandled error in resolveHandler of test controller "${this.options.controllerId}"`, err);
570 };
571
572 let r: Thenable<void> | undefined | void;
573 try {
574 r = this._resolveHandler(internal.actual === this.root ? undefined : internal.actual);
575 } catch (err) {
576 applyError(err);
577 }
578
579 if (isThenable(r)) {
580 r.catch(applyError).then(() => {
581 barrier.open();
582 this.updateExpandability(internal);
583 });
584 } else {
585 barrier.open();
586 this.updateExpandability(internal);
587 }
588
589 return internal.resolveBarrier;
590 }
592 > private pushExpandStateUpdate(internal: CollectionItem<T>) {
593 this.pushDiff({ op: TestDiffOpType.Update, item: { extId: internal.fullId.toString(), expand: internal.expand } });
594 }
596 > private removeItem(childId: string) {
597 const childItem = this.tree.get(childId);
598 if (!childItem) {
599 throw new Error('attempting to remove non-existent child');
600 }
601
602 this.pushDiff({ op: TestDiffOpType.Remove, itemId: childId });
603
604 const queue: (CollectionItem<T> | undefined)[] = [childItem];
605 while (queue.length) {
606 const item = queue.pop();
607 if (!item) {
608 continue;
609 }
610
611 this.options.getApiFor(item.actual).listener = undefined;
612
613 for (const tag of item.actual.tags) {
614 this.decrementTagRefs(tag.id);
615 }
616
617 this.tree.delete(item.fullId.toString());
618 for (const [_, child] of this.options.getChildren(item.actual)) {
619 queue.push(this.tree.get(TestId.joinToString(item.fullId, child.id)));
620 }
621 }
622 }
624 > /**
625 > * Immediately emits any pending diffs on the collection.
626 > */
627 > public flushDiff() {
628 const diff = this.collectDiff();
629 if (diff.length) {
630 this.diffOpEmitter.fire(diff);
631 }
632 }
634 >
635 > /** Implementation of vscode.TestItemCollection */
636 > export interface ITestItemChildren<T extends ITestItemLike> extends Iterable<[string, T]> {
637 > readonly size: number;
638 > replace(items: readonly T[]): void;
639 > forEach(callback: (item: T, collection: this) => unknown, thisArg?: unknown): void;
640 > add(item: T): void;
641 > delete(itemId: string): void;
642 > get(itemId: string): T | undefined;
643 >
644 > toJSON(): readonly T[];
645 > }
646 >
647 > export class DuplicateTestItemError extends Error {
648 > constructor(id: string) {
649 super(`Attempted to insert a duplicate test item ID ${id}`);
650 }
652 >
653 > export class InvalidTestItemError extends Error {
654 > constructor(id: string) {
655 super(`TestItem with ID "${id}" is invalid. Make sure to create it from the createTestItem method.`);
656 }
658 >
659 > export class MixedTestItemController extends Error {
660 > constructor(id: string, ctrlA: string, ctrlB: string) {
661 super(`TestItem with ID "${id}" is from controller "${ctrlA}" and cannot be added as a child of an item from controller "${ctrlB}".`);
662 }
664 >
665 > export const createTestItemChildren = <T extends ITestItemLike>(api: ITestItemApi<T>, getApi: (item: T) => ITestItemApi<T>, checkCtor: Function): ITestItemChildren<T> => {
666 let mapped = new Map<string, T>();
667
668 return {
669 /** @inheritdoc */
670 get size() {
671 return mapped.size;
672 },
673
674 /** @inheritdoc */
675 forEach(callback: (item: T, collection: ITestItemChildren<T>) => unknown, thisArg?: unknown) {
676 for (const item of mapped.values()) {
677 callback.call(thisArg, item, this);
678 }
679 },
680
681 /** @inheritdoc */
682 [Symbol.iterator](): IterableIterator<[string, T]> {
683 return mapped.entries();
684 },
685
686 /** @inheritdoc */
687 replace(items: Iterable<T>) {
688 const newMapped = new Map<string, T>();
689 const toDelete = new Set(mapped.keys());
690 const bulk: ITestItemBulkReplace = { op: TestItemEventOp.Bulk, ops: [] };
691
692 for (const item of items) {
693 if (!(item instanceof checkCtor)) {
694 throw new InvalidTestItemError((item as ITestItemLike).id);
695 }
696
697 const itemController = getApi(item).controllerId;
698 if (itemController !== api.controllerId) {
699 throw new MixedTestItemController(item.id, itemController, api.controllerId);
700 }
701
702 if (newMapped.has(item.id)) {
703 throw new DuplicateTestItemError(item.id);
704 }
705
706 newMapped.set(item.id, item);
707 toDelete.delete(item.id);
708 bulk.ops.push({ op: TestItemEventOp.Upsert, item });
709 }
710
711 for (const id of toDelete.keys()) {
712 bulk.ops.push({ op: TestItemEventOp.RemoveChild, id });
713 }
714
715 api.listener?.(bulk);
716
717 // important mutations come after firing, so if an error happens no
718 // changes will be "saved":
719 mapped = newMapped;
720 },
721
722
723 /** @inheritdoc */
724 add(item: T) {
725 if (!(item instanceof checkCtor)) {
726 throw new InvalidTestItemError((item as ITestItemLike).id);
727 }
728
729 mapped.set(item.id, item);
730 api.listener?.({ op: TestItemEventOp.Upsert, item });
731 },
732
733 /** @inheritdoc */
734 delete(id: string) {
735 if (mapped.delete(id)) {
736 api.listener?.({ op: TestItemEventOp.RemoveChild, id });
737 }
738 },
739
740 /** @inheritdoc */
741 get(itemId: string) {
742 return mapped.get(itemId);
743 },
744
745 /** JSON serialization function. */
746 toJSON() {
747 return Array.from(mapped.values());
748 },
749 };
750 };