From 46defb1587562004e912485c4840711ef3d7fd3f Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 24 Aug 2026 15:07:42 +0800 Subject: [PATCH 1/6] fix: avoid repeated test discovery --- src/commands/testExplorerCommands.ts | 6 +- src/controller/testController.ts | 57 ++++++++- .../suite/testController.loadChildren.test.ts | 113 ++++++++++++++++++ 3 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 test/suite/testController.loadChildren.test.ts diff --git a/src/commands/testExplorerCommands.ts b/src/commands/testExplorerCommands.ts index ecd47c47..242d38a4 100644 --- a/src/commands/testExplorerCommands.ts +++ b/src/commands/testExplorerCommands.ts @@ -45,7 +45,7 @@ export async function refreshExplorer(): Promise { // Force re-resolution of all existing project roots const loadPromises: Promise[] = []; testController?.items.forEach((root: TestItem) => { - loadPromises.push(loadChildren(root)); + loadPromises.push(loadChildren(root, undefined, true)); }); await Promise.all(loadPromises); @@ -90,13 +90,13 @@ export async function refreshProject(classpathUri: Uri): Promise { if (matchedProject) { // Re-resolve only the matched project's children - await loadChildren(matchedProject); + await loadChildren(matchedProject, undefined, true); } else if (childProjectMatched) { // The classpath URI is an ancestor containing test projects – refresh all children const loadPromises: Promise[] = []; testController?.items.forEach((root: TestItem) => { if (root.uri && ensureTrailingSeparator(root.uri.toString()).startsWith(uriString)) { - loadPromises.push(loadChildren(root)); + loadPromises.push(loadChildren(root, undefined, true)); } }); await Promise.all(loadPromises); diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 7b734398..9777b2a3 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -27,6 +27,7 @@ import { parsePartsFromTestId } from '../utils/testItemUtils'; export let testController: TestController | undefined; export const watchers: Disposable[] = []; export const runnableTag: TestTag = new TestTag('runnable'); +const pendingTestItemResolutions: WeakMap> = new WeakMap(); export function createTestController(): void { testController?.dispose(); @@ -51,7 +52,7 @@ export function creatTestProfile(name: string, kind: TestRunProfileKind): void { testController?.createRunProfile(name, kind, runHandler, false, runnableTag); } -export const loadChildren: (item: TestItem, token?: CancellationToken) => any = instrumentOperation('java.test.explorer.loadChildren', async (_operationId: string, item: TestItem, token?: CancellationToken) => { +export const loadChildren: (item: TestItem, token?: CancellationToken, force?: boolean) => Promise = instrumentOperation('java.test.explorer.loadChildren', async (_operationId: string, item: TestItem, token?: CancellationToken, force: boolean = false) => { if (!item) { await loadJavaProjects(); return; @@ -61,8 +62,45 @@ export const loadChildren: (item: TestItem, token?: CancellationToken) => any = if (!data) { return; } + + if (!force && !item.canResolveChildren) { + return; + } + + const pendingResolution: Promise | undefined = pendingTestItemResolutions.get(item); + if (pendingResolution) { + await pendingResolution; + if (!force && !item.canResolveChildren) { + return; + } + } + + if (force) { + invalidateTestItemResolution(item); + } + + if (token?.isCancellationRequested) { + return; + } + + const resolution: Promise = resolveTestItemChildren(item, data, token); + pendingTestItemResolutions.set(item, resolution); + try { + await resolution; + } finally { + if (pendingTestItemResolutions.get(item) === resolution) { + pendingTestItemResolutions.delete(item); + } + } +}); + +async function resolveTestItemChildren(item: TestItem, data: ITestItemData, + token?: CancellationToken): Promise { if (data.testLevel === TestLevel.Project) { const packageAndTypes: IJavaTestItem[] = await findTestPackagesAndTypes(data.jdtHandler, token); + if (token?.isCancellationRequested) { + return; + } synchronizeItemsRecursively(item, packageAndTypes); } else if (data.testLevel === TestLevel.Package) { // unreachable code @@ -72,9 +110,24 @@ export const loadChildren: (item: TestItem, token?: CancellationToken) => any = return; } const testMethods: IJavaTestItem[] = await findDirectTestChildrenForClass(data.jdtHandler, token); + if (token?.isCancellationRequested) { + return; + } synchronizeItemsRecursively(item, testMethods); } -}); + + item.canResolveChildren = false; +} + +function invalidateTestItemResolution(item: TestItem): void { + const testLevel: TestLevel | undefined = dataCache.get(item)?.testLevel; + if (testLevel !== undefined && testLevel <= TestLevel.Class) { + item.canResolveChildren = true; + } + item.children.forEach((child: TestItem) => { + invalidateTestItemResolution(child); + }); +} async function startWatchingWorkspace(): Promise { if (!workspace.workspaceFolders) { diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts new file mode 100644 index 00000000..02883ca8 --- /dev/null +++ b/test/suite/testController.loadChildren.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from 'assert'; +import * as sinon from 'sinon'; +import { CancellationTokenSource, TestController, TestItem, tests } from 'vscode'; +import { loadChildren } from '../../src/controller/testController'; +import { dataCache } from '../../src/controller/testItemDataCache'; +import * as controllerUtils from '../../src/controller/utils'; +import { TestKind, TestLevel } from '../../src/java-test-runner.api'; +import { IJavaTestItem } from '../../src/types'; +import { setupTestEnv } from './utils'; + +function createTestItem(testController: TestController, id: string, testLevel: TestLevel): TestItem { + const item: TestItem = testController.createTestItem(id, id); + item.canResolveChildren = true; + dataCache.set(item, { + jdtHandler: `${id}-handler`, + fullName: id, + projectName: 'project', + testLevel, + testKind: TestKind.JUnit5, + }); + return item; +} + +suite('testController - loadChildren', () => { + + let testController: TestController; + + suiteSetup(async function () { + await setupTestEnv(); + }); + + setup(() => { + testController = tests.createTestController('loadChildrenTestController', 'loadChildrenTestController'); + }); + + teardown(() => { + sinon.restore(); + testController.dispose(); + }); + + test('should reuse resolved project children until a forced refresh', async () => { + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + const findTestsStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([]); + + await loadChildren(project); + await loadChildren(project); + + assert.ok(findTestsStub.calledOnce); + assert.strictEqual(project.canResolveChildren, false); + + await loadChildren(project, undefined, true); + + assert.ok(findTestsStub.calledTwice); + assert.strictEqual(project.canResolveChildren, false); + }); + + test('should share an in-progress project resolution', async () => { + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + let completeSearch!: (items: IJavaTestItem[]) => void; + const searchResult: Promise = new Promise((resolve) => { + completeSearch = resolve; + }); + const findTestsStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes').returns(searchResult); + + const firstResolution: Promise = loadChildren(project); + const secondResolution: Promise = loadChildren(project); + completeSearch([]); + await Promise.all([firstResolution, secondResolution]); + + assert.ok(findTestsStub.calledOnce); + assert.strictEqual(project.canResolveChildren, false); + }); + + test('should reuse resolved class children', async () => { + const testClass: TestItem = createTestItem(testController, 'testClass', TestLevel.Class); + const findMethodsStub = sinon.stub(controllerUtils, 'findDirectTestChildrenForClass').resolves([]); + + await loadChildren(testClass); + await loadChildren(testClass); + + assert.ok(findMethodsStub.calledOnce); + assert.strictEqual(testClass.canResolveChildren, false); + }); + + test('should invalidate descendant resolutions during a forced refresh', async () => { + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + const testClass: TestItem = createTestItem(testController, 'testClass', TestLevel.Class); + testClass.canResolveChildren = false; + project.children.add(testClass); + sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([]); + + await loadChildren(project, undefined, true); + + assert.strictEqual(project.canResolveChildren, false); + assert.strictEqual(testClass.canResolveChildren, true); + }); + + test('should leave a cancelled item unresolved', async () => { + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + const source: CancellationTokenSource = new CancellationTokenSource(); + source.cancel(); + const findTestsStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([]); + + await loadChildren(project, source.token); + + assert.ok(findTestsStub.notCalled); + assert.strictEqual(project.canResolveChildren, true); + source.dispose(); + }); +}); From 6f60727292fd50063e411c7249277da9400f80f2 Mon Sep 17 00:00:00 2001 From: wenyutang-ms Date: Mon, 24 Aug 2026 16:33:35 +0800 Subject: [PATCH 2/6] fix: keep test discovery cache consistent --- src/controller/testController.ts | 15 ++++-- src/controller/testItemDataCache.ts | 10 ++++ src/controller/utils.ts | 47 +++++++++++++---- ...trollerUtils.updateItemForDocument.test.ts | 52 +++++++++++++++++++ .../suite/testController.loadChildren.test.ts | 35 +++++++++++-- 5 files changed, 142 insertions(+), 17 deletions(-) create mode 100644 test/suite/controllerUtils.updateItemForDocument.test.ts diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 9777b2a3..a56173a1 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -16,7 +16,7 @@ import { JUnitLaunchProtocol } from '../constants'; import { IJavaTestItem } from '../types'; import { loadRunConfig } from '../utils/configUtils'; import { resolveLaunchConfigurationForRunner } from '../utils/launchUtils'; -import { dataCache, ITestItemData } from './testItemDataCache'; +import { dataCache, getResolutionVersion, invalidateResolutionVersion, ITestItemData } from './testItemDataCache'; import { createTestItem, findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, resolvePath, synchronizeItemsRecursively, updateItemForDocumentWithDebounce } from './utils'; import { JavaTestCoverageProvider } from '../provider/JavaTestCoverageProvider'; import { testRunnerService } from './testRunnerService'; @@ -83,7 +83,8 @@ export const loadChildren: (item: TestItem, token?: CancellationToken, force?: b return; } - const resolution: Promise = resolveTestItemChildren(item, data, token); + const resolutionVersion: number = getResolutionVersion(item); + const resolution: Promise = resolveTestItemChildren(item, data, resolutionVersion, token); pendingTestItemResolutions.set(item, resolution); try { await resolution; @@ -94,11 +95,11 @@ export const loadChildren: (item: TestItem, token?: CancellationToken, force?: b } }); -async function resolveTestItemChildren(item: TestItem, data: ITestItemData, +async function resolveTestItemChildren(item: TestItem, data: ITestItemData, resolutionVersion: number, token?: CancellationToken): Promise { if (data.testLevel === TestLevel.Project) { const packageAndTypes: IJavaTestItem[] = await findTestPackagesAndTypes(data.jdtHandler, token); - if (token?.isCancellationRequested) { + if (token?.isCancellationRequested || resolutionVersion !== getResolutionVersion(item)) { return; } synchronizeItemsRecursively(item, packageAndTypes); @@ -110,16 +111,20 @@ async function resolveTestItemChildren(item: TestItem, data: ITestItemData, return; } const testMethods: IJavaTestItem[] = await findDirectTestChildrenForClass(data.jdtHandler, token); - if (token?.isCancellationRequested) { + if (token?.isCancellationRequested || resolutionVersion !== getResolutionVersion(item)) { return; } synchronizeItemsRecursively(item, testMethods); } + if (resolutionVersion !== getResolutionVersion(item)) { + return; + } item.canResolveChildren = false; } function invalidateTestItemResolution(item: TestItem): void { + invalidateResolutionVersion(item); const testLevel: TestLevel | undefined = dataCache.get(item)?.testLevel; if (testLevel !== undefined && testLevel <= TestLevel.Class) { item.canResolveChildren = true; diff --git a/src/controller/testItemDataCache.ts b/src/controller/testItemDataCache.ts index 49845a65..570e07c5 100644 --- a/src/controller/testItemDataCache.ts +++ b/src/controller/testItemDataCache.ts @@ -27,6 +27,16 @@ class TestItemDataCache { export const dataCache: TestItemDataCache = new TestItemDataCache(); +const resolutionVersions: WeakMap = new WeakMap(); + +export function getResolutionVersion(item: TestItem): number { + return resolutionVersions.get(item) ?? 0; +} + +export function invalidateResolutionVersion(item: TestItem): void { + resolutionVersions.set(item, getResolutionVersion(item) + 1); +} + export interface ITestItemData { jdtHandler: string; fullName: string; diff --git a/src/controller/utils.ts b/src/controller/utils.ts index 535bbbf0..c62efef3 100644 --- a/src/controller/utils.ts +++ b/src/controller/utils.ts @@ -12,7 +12,7 @@ import { IJavaTestItem, ProjectType } from '../types'; import { executeJavaLanguageServerCommand } from '../utils/commandUtils'; import { getRequestDelay, lruCache, MovingAverage } from './debouncing'; import { runnableTag, testController } from './testController'; -import { dataCache } from './testItemDataCache'; +import { dataCache, invalidateResolutionVersion } from './testItemDataCache'; import { TestKind, TestLevel } from '../java-test-runner.api'; /** @@ -217,15 +217,11 @@ export async function updateItemForDocument(uri: Uri, testTypes?: IJavaTestItem[ return []; } + const expectedTypeIds: Set = new Set(testTypes.map((testType: IJavaTestItem) => testType.id)); + removeOutdatedTestItemsForDocument(belongingPackage, uri, expectedTypeIds); + const tests: TestItem[] = []; - if (testTypes.length === 0) { - // Remove the children with the same uri when no test items is found - belongingPackage.children.forEach((typeItem: TestItem) => { - if (path.relative(typeItem.uri?.fsPath || '', uri.fsPath) === '') { - belongingPackage!.children.delete(typeItem.id); - } - }); - } else { + if (testTypes.length > 0) { for (const testType of testTypes) { // here we do not directly call synchronizeItemsRecursively() because testTypes here are just part of the // children of the belonging package, we don't want to delete other children unexpectedly. @@ -238,6 +234,7 @@ export async function updateItemForDocument(uri: Uri, testTypes?: IJavaTestItem[ } tests.push(testTypeItem); synchronizeItemsRecursively(testTypeItem, testType.children); + testTypeItem.canResolveChildren = false; } } @@ -248,6 +245,38 @@ export async function updateItemForDocument(uri: Uri, testTypes?: IJavaTestItem[ return tests; } +export function removeOutdatedTestItemsForDocument(belongingPackage: TestItem, uri: Uri, + expectedTypeIds: Set): void { + const belongingProject: TestItem | undefined = belongingPackage.parent; + if (!belongingProject) { + return; + } + + belongingProject.children.forEach((packageItem: TestItem) => { + packageItem.children.forEach((typeItem: TestItem) => { + if (path.relative(typeItem.uri?.fsPath || '', uri.fsPath) !== '') { + return; + } + + invalidateResolutionRecursively(typeItem); + if (!expectedTypeIds.has(typeItem.id)) { + packageItem.children.delete(typeItem.id); + } + }); + + if (packageItem !== belongingPackage && packageItem.children.size === 0) { + belongingProject.children.delete(packageItem.id); + } + }); +} + +function invalidateResolutionRecursively(item: TestItem): void { + invalidateResolutionVersion(item); + item.children.forEach((child: TestItem) => { + invalidateResolutionRecursively(child); + }); +} + /** * Give a test item for a type, find its belonging package item according to its id. */ diff --git a/test/suite/controllerUtils.updateItemForDocument.test.ts b/test/suite/controllerUtils.updateItemForDocument.test.ts new file mode 100644 index 00000000..e75ef20e --- /dev/null +++ b/test/suite/controllerUtils.updateItemForDocument.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +import * as assert from 'assert'; +import { TestController, TestItem, tests, Uri } from 'vscode'; +import { removeOutdatedTestItemsForDocument } from '../../src/controller/utils'; +import { getResolutionVersion } from '../../src/controller/testItemDataCache'; + +function createTestItem(testController: TestController, id: string, parent?: TestItem, uri?: Uri): TestItem { + const item: TestItem = testController.createTestItem(id, id, uri); + parent?.children.add(item); + return item; +} + +suite('controllerUtils - updateItemForDocument', () => { + + let testController: TestController; + + setup(() => { + testController = tests.createTestController( + 'updateItemForDocumentTestController', 'updateItemForDocumentTestController'); + }); + + teardown(() => { + testController.dispose(); + }); + + test('should remove an outdated class from the same document', async () => { + const uri: Uri = Uri.file('/mock/test/RenamedTest.java'); + const project: TestItem = createTestItem(testController, 'document-update-project'); + testController.items.add(project); + const oldPackage: TestItem = createTestItem( + testController, 'document-update-project@old.package', project); + const newPackage: TestItem = createTestItem( + testController, 'document-update-project@new.package', project); + const oldClass: TestItem = createTestItem( + testController, 'document-update-project@old.package.OldTest', oldPackage, uri); + const nestedClass: TestItem = createTestItem( + testController, 'document-update-project@old.package.OldTest$NestedTest', oldClass, uri); + assert.strictEqual(oldClass.uri?.toString(), uri.toString()); + const oldClassVersion: number = getResolutionVersion(oldClass); + const nestedClassVersion: number = getResolutionVersion(nestedClass); + + removeOutdatedTestItemsForDocument( + newPackage, uri, new Set(['document-update-project@new.package.NewTest'])); + + assert.strictEqual(project.children.get(oldPackage.id), undefined); + assert.ok(project.children.get(newPackage.id)); + assert.strictEqual(getResolutionVersion(oldClass), oldClassVersion + 1); + assert.strictEqual(getResolutionVersion(nestedClass), nestedClassVersion + 1); + }); +}); diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index 02883ca8..2a19c2df 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -88,14 +88,43 @@ suite('testController - loadChildren', () => { test('should invalidate descendant resolutions during a forced refresh', async () => { const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); const testClass: TestItem = createTestItem(testController, 'testClass', TestLevel.Class); - testClass.canResolveChildren = false; project.children.add(testClass); - sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([]); - + let completeMethodSearch!: (items: IJavaTestItem[]) => void; + sinon.stub(controllerUtils, 'findDirectTestChildrenForClass').returns(new Promise((resolve) => { + completeMethodSearch = resolve; + })); + sinon.stub(controllerUtils, 'findTestPackagesAndTypes').resolves([{ + children: [], + uri: undefined, + range: undefined, + jdtHandler: 'testClass-handler', + fullName: 'testClass', + label: 'testClass', + id: 'testClass', + projectName: 'project', + testKind: TestKind.JUnit5, + testLevel: TestLevel.Class, + }]); + + const staleClassResolution: Promise = loadChildren(testClass); await loadChildren(project, undefined, true); + completeMethodSearch([{ + children: [], + uri: undefined, + range: undefined, + jdtHandler: 'staleMethod-handler', + fullName: 'staleMethod', + label: 'staleMethod', + id: 'staleMethod', + projectName: 'project', + testKind: TestKind.JUnit5, + testLevel: TestLevel.Method, + }]); + await staleClassResolution; assert.strictEqual(project.canResolveChildren, false); assert.strictEqual(testClass.canResolveChildren, true); + assert.strictEqual(testClass.children.get('staleMethod'), undefined); }); test('should leave a cancelled item unresolved', async () => { From 050967d0ebc0bc59e7eded8c4d3ff28cded1b83d Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Mon, 24 Aug 2026 17:15:22 +0800 Subject: [PATCH 3/6] fix: address test discovery race conditions --- src/controller/testController.ts | 22 +++++++--- src/controller/utils.ts | 16 ++++++- ...trollerUtils.updateItemForDocument.test.ts | 43 +++++++++++++++---- .../suite/testController.loadChildren.test.ts | 39 ++++++++++++++++- 4 files changed, 102 insertions(+), 18 deletions(-) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index a56173a1..be89e68a 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -63,13 +63,23 @@ export const loadChildren: (item: TestItem, token?: CancellationToken, force?: b return; } + if (token?.isCancellationRequested) { + return; + } + if (!force && !item.canResolveChildren) { return; } - const pendingResolution: Promise | undefined = pendingTestItemResolutions.get(item); - if (pendingResolution) { + let pendingResolution: Promise | undefined; + while ((pendingResolution = pendingTestItemResolutions.get(item))) { await pendingResolution; + if (pendingTestItemResolutions.get(item) === pendingResolution) { + pendingTestItemResolutions.delete(item); + } + if (token?.isCancellationRequested) { + return; + } if (!force && !item.canResolveChildren) { return; } @@ -79,10 +89,6 @@ export const loadChildren: (item: TestItem, token?: CancellationToken, force?: b invalidateTestItemResolution(item); } - if (token?.isCancellationRequested) { - return; - } - const resolutionVersion: number = getResolutionVersion(item); const resolution: Promise = resolveTestItemChildren(item, data, resolutionVersion, token); pendingTestItemResolutions.set(item, resolution); @@ -124,8 +130,10 @@ async function resolveTestItemChildren(item: TestItem, data: ITestItemData, reso } function invalidateTestItemResolution(item: TestItem): void { - invalidateResolutionVersion(item); const testLevel: TestLevel | undefined = dataCache.get(item)?.testLevel; + if (testLevel === TestLevel.Project || testLevel === TestLevel.Class) { + invalidateResolutionVersion(item); + } if (testLevel !== undefined && testLevel <= TestLevel.Class) { item.canResolveChildren = true; } diff --git a/src/controller/utils.ts b/src/controller/utils.ts index c62efef3..212f51f4 100644 --- a/src/controller/utils.ts +++ b/src/controller/utils.ts @@ -114,6 +114,15 @@ export function synchronizeItemsRecursively(parent: TestItem, childrenData: IJav } } +export function markTestClassesResolvedRecursively(item: TestItem): void { + if (dataCache.get(item)?.testLevel === TestLevel.Class) { + item.canResolveChildren = false; + } + item.children.forEach((child: TestItem) => { + markTestClassesResolvedRecursively(child); + }); +} + export function updateOrCreateTestItem(parent: TestItem, childData: IJavaTestItem): TestItem { let childItem: TestItem | undefined = parent.children.get(childData.id); if (childItem) { @@ -234,7 +243,7 @@ export async function updateItemForDocument(uri: Uri, testTypes?: IJavaTestItem[ } tests.push(testTypeItem); synchronizeItemsRecursively(testTypeItem, testType.children); - testTypeItem.canResolveChildren = false; + markTestClassesResolvedRecursively(testTypeItem); } } @@ -252,6 +261,7 @@ export function removeOutdatedTestItemsForDocument(belongingPackage: TestItem, u return; } + invalidateResolutionVersion(belongingProject); belongingProject.children.forEach((packageItem: TestItem) => { packageItem.children.forEach((typeItem: TestItem) => { if (path.relative(typeItem.uri?.fsPath || '', uri.fsPath) !== '') { @@ -271,7 +281,9 @@ export function removeOutdatedTestItemsForDocument(belongingPackage: TestItem, u } function invalidateResolutionRecursively(item: TestItem): void { - invalidateResolutionVersion(item); + if (dataCache.get(item)?.testLevel === TestLevel.Class) { + invalidateResolutionVersion(item); + } item.children.forEach((child: TestItem) => { invalidateResolutionRecursively(child); }); diff --git a/test/suite/controllerUtils.updateItemForDocument.test.ts b/test/suite/controllerUtils.updateItemForDocument.test.ts index e75ef20e..96c0af10 100644 --- a/test/suite/controllerUtils.updateItemForDocument.test.ts +++ b/test/suite/controllerUtils.updateItemForDocument.test.ts @@ -3,12 +3,21 @@ import * as assert from 'assert'; import { TestController, TestItem, tests, Uri } from 'vscode'; -import { removeOutdatedTestItemsForDocument } from '../../src/controller/utils'; -import { getResolutionVersion } from '../../src/controller/testItemDataCache'; +import { markTestClassesResolvedRecursively, removeOutdatedTestItemsForDocument } from '../../src/controller/utils'; +import { dataCache, getResolutionVersion } from '../../src/controller/testItemDataCache'; +import { TestKind, TestLevel } from '../../src/java-test-runner.api'; -function createTestItem(testController: TestController, id: string, parent?: TestItem, uri?: Uri): TestItem { +function createTestItem(testController: TestController, id: string, testLevel: TestLevel, + parent?: TestItem, uri?: Uri): TestItem { const item: TestItem = testController.createTestItem(id, id, uri); parent?.children.add(item); + dataCache.set(item, { + jdtHandler: `${id}-handler`, + fullName: id, + projectName: 'project', + testLevel, + testKind: TestKind.JUnit5, + }); return item; } @@ -27,17 +36,20 @@ suite('controllerUtils - updateItemForDocument', () => { test('should remove an outdated class from the same document', async () => { const uri: Uri = Uri.file('/mock/test/RenamedTest.java'); - const project: TestItem = createTestItem(testController, 'document-update-project'); + const project: TestItem = createTestItem( + testController, 'document-update-project', TestLevel.Project); testController.items.add(project); const oldPackage: TestItem = createTestItem( - testController, 'document-update-project@old.package', project); + testController, 'document-update-project@old.package', TestLevel.Package, project); const newPackage: TestItem = createTestItem( - testController, 'document-update-project@new.package', project); + testController, 'document-update-project@new.package', TestLevel.Package, project); const oldClass: TestItem = createTestItem( - testController, 'document-update-project@old.package.OldTest', oldPackage, uri); + testController, 'document-update-project@old.package.OldTest', TestLevel.Class, oldPackage, uri); const nestedClass: TestItem = createTestItem( - testController, 'document-update-project@old.package.OldTest$NestedTest', oldClass, uri); + testController, 'document-update-project@old.package.OldTest$NestedTest', + TestLevel.Class, oldClass, uri); assert.strictEqual(oldClass.uri?.toString(), uri.toString()); + const projectVersion: number = getResolutionVersion(project); const oldClassVersion: number = getResolutionVersion(oldClass); const nestedClassVersion: number = getResolutionVersion(nestedClass); @@ -46,7 +58,22 @@ suite('controllerUtils - updateItemForDocument', () => { assert.strictEqual(project.children.get(oldPackage.id), undefined); assert.ok(project.children.get(newPackage.id)); + assert.strictEqual(getResolutionVersion(project), projectVersion + 1); assert.strictEqual(getResolutionVersion(oldClass), oldClassVersion + 1); assert.strictEqual(getResolutionVersion(nestedClass), nestedClassVersion + 1); }); + + test('should mark top-level and nested classes as resolved', () => { + const testClass: TestItem = createTestItem( + testController, 'document-update-project@test.TestClass', TestLevel.Class); + const nestedClass: TestItem = createTestItem( + testController, 'document-update-project@test.TestClass$NestedTest', TestLevel.Class, testClass); + testClass.canResolveChildren = true; + nestedClass.canResolveChildren = true; + + markTestClassesResolvedRecursively(testClass); + + assert.strictEqual(testClass.canResolveChildren, false); + assert.strictEqual(nestedClass.canResolveChildren, false); + }); }); diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index 2a19c2df..43ba7b4d 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; import { CancellationTokenSource, TestController, TestItem, tests } from 'vscode'; import { loadChildren } from '../../src/controller/testController'; -import { dataCache } from '../../src/controller/testItemDataCache'; +import { dataCache, invalidateResolutionVersion } from '../../src/controller/testItemDataCache'; import * as controllerUtils from '../../src/controller/utils'; import { TestKind, TestLevel } from '../../src/java-test-runner.api'; import { IJavaTestItem } from '../../src/types'; @@ -74,6 +74,43 @@ suite('testController - loadChildren', () => { assert.strictEqual(project.canResolveChildren, false); }); + test('should share a retry after an in-progress resolution is invalidated', async () => { + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + let completeInitialSearch!: (items: IJavaTestItem[]) => void; + let completeRetrySearch!: (items: IJavaTestItem[]) => void; + let signalRetryStarted!: () => void; + const initialSearch: Promise = new Promise((resolve) => { + completeInitialSearch = resolve; + }); + const retrySearch: Promise = new Promise((resolve) => { + completeRetrySearch = resolve; + }); + const retryStarted: Promise = new Promise((resolve) => { + signalRetryStarted = resolve; + }); + const findTestsStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes'); + findTestsStub.onFirstCall().returns(initialSearch); + findTestsStub.onSecondCall().callsFake(() => { + signalRetryStarted(); + return retrySearch; + }); + + const initialResolution: Promise = loadChildren(project); + const firstWaiter: Promise = loadChildren(project); + const secondWaiter: Promise = loadChildren(project); + invalidateResolutionVersion(project); + completeInitialSearch([]); + await retryStarted; + + assert.strictEqual(findTestsStub.callCount, 2); + + completeRetrySearch([]); + await Promise.all([initialResolution, firstWaiter, secondWaiter]); + + assert.strictEqual(findTestsStub.callCount, 2); + assert.strictEqual(project.canResolveChildren, false); + }); + test('should reuse resolved class children', async () => { const testClass: TestItem = createTestItem(testController, 'testClass', TestLevel.Class); const findMethodsStub = sinon.stub(controllerUtils, 'findDirectTestChildrenForClass').resolves([]); From d9dce9a0e092755ae9af4b306709a7d223369066 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Tue, 25 Aug 2026 10:58:05 +0800 Subject: [PATCH 4/6] fix: coalesce forced test discovery --- src/controller/testController.ts | 45 ++++++++------- .../suite/testController.loadChildren.test.ts | 56 +++++++++++++++++++ 2 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index be89e68a..3880d6f1 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -67,36 +67,39 @@ export const loadChildren: (item: TestItem, token?: CancellationToken, force?: b return; } - if (!force && !item.canResolveChildren) { + if (force) { + invalidateTestItemResolution(item); + } else if (!item.canResolveChildren) { return; } - let pendingResolution: Promise | undefined; - while ((pendingResolution = pendingTestItemResolutions.get(item))) { - await pendingResolution; - if (pendingTestItemResolutions.get(item) === pendingResolution) { - pendingTestItemResolutions.delete(item); - } + while (item.canResolveChildren) { if (token?.isCancellationRequested) { return; } - if (!force && !item.canResolveChildren) { - return; + + const pendingResolution: Promise | undefined = pendingTestItemResolutions.get(item); + if (pendingResolution) { + await pendingResolution; + if (pendingTestItemResolutions.get(item) === pendingResolution) { + pendingTestItemResolutions.delete(item); + } + continue; } - } - if (force) { - invalidateTestItemResolution(item); - } + const resolutionVersion: number = getResolutionVersion(item); + const resolution: Promise = resolveTestItemChildren(item, data, resolutionVersion, token); + pendingTestItemResolutions.set(item, resolution); + try { + await resolution; + } finally { + if (pendingTestItemResolutions.get(item) === resolution) { + pendingTestItemResolutions.delete(item); + } + } - const resolutionVersion: number = getResolutionVersion(item); - const resolution: Promise = resolveTestItemChildren(item, data, resolutionVersion, token); - pendingTestItemResolutions.set(item, resolution); - try { - await resolution; - } finally { - if (pendingTestItemResolutions.get(item) === resolution) { - pendingTestItemResolutions.delete(item); + if (token?.isCancellationRequested || resolutionVersion === getResolutionVersion(item)) { + return; } } }); diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index 43ba7b4d..c45fc844 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -111,6 +111,62 @@ suite('testController - loadChildren', () => { assert.strictEqual(project.canResolveChildren, false); }); + test('should retry its own resolution after invalidation', async () => { + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + let completeInitialSearch!: (items: IJavaTestItem[]) => void; + const initialSearch: Promise = new Promise((resolve) => { + completeInitialSearch = resolve; + }); + const findTestsStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes'); + findTestsStub.onFirstCall().returns(initialSearch); + findTestsStub.onSecondCall().resolves([]); + + const resolution: Promise = loadChildren(project); + invalidateResolutionVersion(project); + completeInitialSearch([]); + await resolution; + + assert.strictEqual(findTestsStub.callCount, 2); + assert.strictEqual(project.canResolveChildren, false); + }); + + test('should coalesce concurrent forced refreshes into the latest resolution', async () => { + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + project.canResolveChildren = false; + let completeInitialSearch!: (items: IJavaTestItem[]) => void; + let completeLatestSearch!: (items: IJavaTestItem[]) => void; + let signalLatestSearchStarted!: () => void; + const initialSearch: Promise = new Promise((resolve) => { + completeInitialSearch = resolve; + }); + const latestSearch: Promise = new Promise((resolve) => { + completeLatestSearch = resolve; + }); + const latestSearchStarted: Promise = new Promise((resolve) => { + signalLatestSearchStarted = resolve; + }); + const findTestsStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes'); + findTestsStub.onFirstCall().returns(initialSearch); + findTestsStub.onSecondCall().callsFake(() => { + signalLatestSearchStarted(); + return latestSearch; + }); + + const firstRefresh: Promise = loadChildren(project, undefined, true); + const secondRefresh: Promise = loadChildren(project, undefined, true); + const thirdRefresh: Promise = loadChildren(project, undefined, true); + completeInitialSearch([]); + await latestSearchStarted; + + assert.strictEqual(findTestsStub.callCount, 2); + + completeLatestSearch([]); + await Promise.all([firstRefresh, secondRefresh, thirdRefresh]); + + assert.strictEqual(findTestsStub.callCount, 2); + assert.strictEqual(project.canResolveChildren, false); + }); + test('should reuse resolved class children', async () => { const testClass: TestItem = createTestItem(testController, 'testClass', TestLevel.Class); const findMethodsStub = sinon.stub(controllerUtils, 'findDirectTestChildrenForClass').resolves([]); From 8030a0fd7137578c40d10c200d61b91d28872328 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Tue, 25 Aug 2026 11:23:49 +0800 Subject: [PATCH 5/6] fix: keep resolution metadata current --- src/controller/testController.ts | 7 ++- src/controller/utils.ts | 51 +++++++++++-------- src/types.ts | 2 +- ...trollerUtils.updateItemForDocument.test.ts | 35 ++++++++++++- .../suite/testController.loadChildren.test.ts | 50 ++++++++++++++++++ 5 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 3880d6f1..aa069a6b 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -58,8 +58,7 @@ export const loadChildren: (item: TestItem, token?: CancellationToken, force?: b return; } - const data: ITestItemData | undefined = dataCache.get(item); - if (!data) { + if (!dataCache.get(item)) { return; } @@ -87,6 +86,10 @@ export const loadChildren: (item: TestItem, token?: CancellationToken, force?: b continue; } + const data: ITestItemData | undefined = dataCache.get(item); + if (!data) { + return; + } const resolutionVersion: number = getResolutionVersion(item); const resolution: Promise = resolveTestItemChildren(item, data, resolutionVersion, token); pendingTestItemResolutions.set(item, resolution); diff --git a/src/controller/utils.ts b/src/controller/utils.ts index 212f51f4..45107336 100644 --- a/src/controller/utils.ts +++ b/src/controller/utils.ts @@ -90,27 +90,31 @@ export async function getProjectType(item: IJavaTestItem): Promise * - If an existing child is not contained in the childrenData parameter, it will be deleted * - If a child does not exist, create it, otherwise, update it as well as its metadata. */ -export function synchronizeItemsRecursively(parent: TestItem, childrenData: IJavaTestItem[] | undefined): void { - if (childrenData) { - // remove the out-of-date children - parent.children.forEach((child: TestItem) => { - if (dataCache.get(child)?.testLevel === TestLevel.Invocation) { - // only remove the invocation items before a new test session starts - return; - } - const existingItem: IJavaTestItem | undefined = childrenData.find((data: IJavaTestItem) => data.id === child.id); - if (!existingItem) { - parent.children.delete(child.id); - } - }); - // update/create children - for (const child of childrenData) { - const childItem: TestItem = updateOrCreateTestItem(parent, child); - if (child.testLevel <= TestLevel.Class) { - childItem.canResolveChildren = true; - } - synchronizeItemsRecursively(childItem, child.children); +export function synchronizeItemsRecursively(parent: TestItem, childrenData: IJavaTestItem[] | undefined, + childrenAreComplete: boolean = false): void { + if (!childrenData && !childrenAreComplete) { + return; + } + + const children: IJavaTestItem[] = childrenData ?? []; + // remove the out-of-date children + parent.children.forEach((child: TestItem) => { + if (dataCache.get(child)?.testLevel === TestLevel.Invocation) { + // only remove the invocation items before a new test session starts + return; + } + const existingItem: IJavaTestItem | undefined = children.find((data: IJavaTestItem) => data.id === child.id); + if (!existingItem) { + parent.children.delete(child.id); } + }); + // update/create children + for (const child of children) { + const childItem: TestItem = updateOrCreateTestItem(parent, child); + if (child.testLevel <= TestLevel.Class) { + childItem.canResolveChildren = true; + } + synchronizeItemsRecursively(childItem, child.children, childrenAreComplete); } } @@ -134,6 +138,7 @@ export function updateOrCreateTestItem(parent: TestItem, childData: IJavaTestIte } function updateTestItem(testItem: TestItem, metaInfo: IJavaTestItem): void { + const previousJdtHandler: string | undefined = dataCache.get(testItem)?.jdtHandler; testItem.range = asRange(metaInfo.range); testItem.label = metaInfo.label; dataCache.set(testItem, { @@ -143,6 +148,10 @@ function updateTestItem(testItem: TestItem, metaInfo: IJavaTestItem): void { testLevel: metaInfo.testLevel, testKind: metaInfo.testKind, }); + if (previousJdtHandler !== undefined && previousJdtHandler !== metaInfo.jdtHandler && + (metaInfo.testLevel === TestLevel.Project || metaInfo.testLevel === TestLevel.Class)) { + invalidateResolutionVersion(testItem); + } } /** @@ -242,7 +251,7 @@ export async function updateItemForDocument(uri: Uri, testTypes?: IJavaTestItem[ updateTestItem(testTypeItem, testType); } tests.push(testTypeItem); - synchronizeItemsRecursively(testTypeItem, testType.children); + synchronizeItemsRecursively(testTypeItem, testType.children, true); markTestClassesResolvedRecursively(testTypeItem); } } diff --git a/src/types.ts b/src/types.ts index fc3a0ae0..d614c8d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,7 +5,7 @@ import { Range } from 'vscode'; import { TestKind, TestLevel } from './java-test-runner.api'; export interface IJavaTestItem { - children: IJavaTestItem[]; + children?: IJavaTestItem[]; uri: string | undefined; range: Range | undefined; jdtHandler: string; diff --git a/test/suite/controllerUtils.updateItemForDocument.test.ts b/test/suite/controllerUtils.updateItemForDocument.test.ts index 96c0af10..410211fe 100644 --- a/test/suite/controllerUtils.updateItemForDocument.test.ts +++ b/test/suite/controllerUtils.updateItemForDocument.test.ts @@ -3,9 +3,10 @@ import * as assert from 'assert'; import { TestController, TestItem, tests, Uri } from 'vscode'; -import { markTestClassesResolvedRecursively, removeOutdatedTestItemsForDocument } from '../../src/controller/utils'; +import { markTestClassesResolvedRecursively, removeOutdatedTestItemsForDocument, synchronizeItemsRecursively } from '../../src/controller/utils'; import { dataCache, getResolutionVersion } from '../../src/controller/testItemDataCache'; import { TestKind, TestLevel } from '../../src/java-test-runner.api'; +import { IJavaTestItem } from '../../src/types'; function createTestItem(testController: TestController, id: string, testLevel: TestLevel, parent?: TestItem, uri?: Uri): TestItem { @@ -76,4 +77,36 @@ suite('controllerUtils - updateItemForDocument', () => { assert.strictEqual(testClass.canResolveChildren, false); assert.strictEqual(nestedClass.canResolveChildren, false); }); + + test('should clear missing children from a complete file snapshot', () => { + const testClass: TestItem = createTestItem( + testController, 'document-update-project@test.TestClass', TestLevel.Class); + const staleMethod: TestItem = createTestItem( + testController, 'document-update-project@test.TestClass#staleMethod', TestLevel.Method, testClass); + const nestedClass: TestItem = createTestItem( + testController, 'document-update-project@test.TestClass$NestedTest', TestLevel.Class, testClass); + const staleNestedMethod: TestItem = createTestItem( + testController, 'document-update-project@test.TestClass$NestedTest#staleMethod', + TestLevel.Method, nestedClass); + const nestedClassData: IJavaTestItem = { + uri: undefined, + range: undefined, + jdtHandler: 'updated-nested-handler', + fullName: 'test.TestClass$NestedTest', + label: 'NestedTest', + id: nestedClass.id, + projectName: 'project', + testKind: TestKind.JUnit5, + testLevel: TestLevel.Class, + }; + + synchronizeItemsRecursively(testClass, [nestedClassData], true); + markTestClassesResolvedRecursively(testClass); + + assert.strictEqual(testClass.children.get(staleMethod.id), undefined); + assert.ok(testClass.children.get(nestedClass.id)); + assert.strictEqual(nestedClass.children.get(staleNestedMethod.id), undefined); + assert.strictEqual(testClass.canResolveChildren, false); + assert.strictEqual(nestedClass.canResolveChildren, false); + }); }); diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index c45fc844..cc700ca2 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -167,6 +167,56 @@ suite('testController - loadChildren', () => { assert.strictEqual(project.canResolveChildren, false); }); + test('should re-read class metadata when its handler changes during a retry', async () => { + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + const testClass: TestItem = createTestItem(testController, 'testClass', TestLevel.Class); + project.children.add(testClass); + let completeInitialSearch!: (items: IJavaTestItem[]) => void; + let completeRetrySearch!: (items: IJavaTestItem[]) => void; + let signalRetryStarted!: () => void; + const initialSearch: Promise = new Promise((resolve) => { + completeInitialSearch = resolve; + }); + const retrySearch: Promise = new Promise((resolve) => { + completeRetrySearch = resolve; + }); + const retryStarted: Promise = new Promise((resolve) => { + signalRetryStarted = resolve; + }); + const findMethodsStub = sinon.stub(controllerUtils, 'findDirectTestChildrenForClass'); + findMethodsStub.onFirstCall().returns(initialSearch); + findMethodsStub.onSecondCall().callsFake(() => { + signalRetryStarted(); + return retrySearch; + }); + findMethodsStub.onThirdCall().resolves([]); + + const resolution: Promise = loadChildren(testClass); + invalidateResolutionVersion(testClass); + completeInitialSearch([]); + await retryStarted; + + controllerUtils.synchronizeItemsRecursively(project, [{ + children: [], + uri: undefined, + range: undefined, + jdtHandler: 'updated-handler', + fullName: 'testClass', + label: 'testClass', + id: 'testClass', + projectName: 'project', + testKind: TestKind.JUnit5, + testLevel: TestLevel.Class, + }]); + completeRetrySearch([]); + await resolution; + + assert.deepStrictEqual( + findMethodsStub.getCalls().map((call: sinon.SinonSpyCall) => call.args[0]), + ['testClass-handler', 'testClass-handler', 'updated-handler']); + assert.strictEqual(testClass.canResolveChildren, false); + }); + test('should reuse resolved class children', async () => { const testClass: TestItem = createTestItem(testController, 'testClass', TestLevel.Class); const findMethodsStub = sinon.stub(controllerUtils, 'findDirectTestChildrenForClass').resolves([]); From 0620aebd26669d8f7a4083769cea3495a15419b8 Mon Sep 17 00:00:00 2001 From: wenytang-ms Date: Tue, 25 Aug 2026 17:09:47 +0800 Subject: [PATCH 6/6] fix: invalidate discovery after file deletion --- src/controller/testController.ts | 8 +-- .../suite/testController.loadChildren.test.ts | 66 ++++++++++++++++++- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/controller/testController.ts b/src/controller/testController.ts index aa069a6b..de3b3c71 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -17,7 +17,7 @@ import { IJavaTestItem } from '../types'; import { loadRunConfig } from '../utils/configUtils'; import { resolveLaunchConfigurationForRunner } from '../utils/launchUtils'; import { dataCache, getResolutionVersion, invalidateResolutionVersion, ITestItemData } from './testItemDataCache'; -import { createTestItem, findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, resolvePath, synchronizeItemsRecursively, updateItemForDocumentWithDebounce } from './utils'; +import { createTestItem, findDirectTestChildrenForClass, findTestPackagesAndTypes, findTestTypesAndMethods, loadJavaProjects, removeOutdatedTestItemsForDocument, resolvePath, synchronizeItemsRecursively, updateItemForDocumentWithDebounce } from './utils'; import { JavaTestCoverageProvider } from '../provider/JavaTestCoverageProvider'; import { testRunnerService } from './testRunnerService'; import { IRunTestContext, TestRunner, TestFinishEvent, TestItemStatusChangeEvent, TestKind, TestLevel, TestResultState, TestIdParts } from '../java-test-runner.api'; @@ -199,11 +199,7 @@ async function startWatchingWorkspace(): Promise { return; } - belongingPackage.children.forEach((item: TestItem) => { - if (item.uri?.toString() === uri.toString()) { - belongingPackage.children.delete(item.id); - } - }); + removeOutdatedTestItemsForDocument(belongingPackage, uri, new Set()); if (belongingPackage.children.size === 0) { belongingProject.children.delete(belongingPackage.id); diff --git a/test/suite/testController.loadChildren.test.ts b/test/suite/testController.loadChildren.test.ts index cc700ca2..cdf54e51 100644 --- a/test/suite/testController.loadChildren.test.ts +++ b/test/suite/testController.loadChildren.test.ts @@ -3,7 +3,7 @@ import * as assert from 'assert'; import * as sinon from 'sinon'; -import { CancellationTokenSource, TestController, TestItem, tests } from 'vscode'; +import { CancellationTokenSource, TestController, TestItem, tests, Uri } from 'vscode'; import { loadChildren } from '../../src/controller/testController'; import { dataCache, invalidateResolutionVersion } from '../../src/controller/testItemDataCache'; import * as controllerUtils from '../../src/controller/utils'; @@ -11,9 +11,11 @@ import { TestKind, TestLevel } from '../../src/java-test-runner.api'; import { IJavaTestItem } from '../../src/types'; import { setupTestEnv } from './utils'; -function createTestItem(testController: TestController, id: string, testLevel: TestLevel): TestItem { - const item: TestItem = testController.createTestItem(id, id); +function createTestItem(testController: TestController, id: string, testLevel: TestLevel, + parent?: TestItem, uri?: Uri): TestItem { + const item: TestItem = testController.createTestItem(id, id, uri); item.canResolveChildren = true; + parent?.children.add(item); dataCache.set(item, { jdtHandler: `${id}-handler`, fullName: id, @@ -57,6 +59,64 @@ suite('testController - loadChildren', () => { assert.strictEqual(project.canResolveChildren, false); }); + test('should discard stale project discovery after a file is deleted', async () => { + const uri: Uri = Uri.file('/mock/test/DeletedTest.java'); + const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); + testController.items.add(project); + const testPackage: TestItem = createTestItem( + testController, 'project@test', TestLevel.Package, project); + const deletedClass: TestItem = createTestItem( + testController, 'project@test.DeletedTest', TestLevel.Class, testPackage, uri); + let completeStaleSearch!: (items: IJavaTestItem[]) => void; + let signalStaleSearchStarted!: () => void; + const staleSearch: Promise = new Promise((resolve) => { + completeStaleSearch = resolve; + }); + const staleSearchStarted: Promise = new Promise((resolve) => { + signalStaleSearchStarted = resolve; + }); + const findTestsStub = sinon.stub(controllerUtils, 'findTestPackagesAndTypes'); + findTestsStub.onFirstCall().callsFake(() => { + signalStaleSearchStarted(); + return staleSearch; + }); + findTestsStub.onSecondCall().resolves([]); + + const resolution: Promise = loadChildren(project); + await staleSearchStarted; + controllerUtils.removeOutdatedTestItemsForDocument(testPackage, uri, new Set()); + if (testPackage.children.size === 0) { + project.children.delete(testPackage.id); + } + completeStaleSearch([{ + children: [{ + uri: uri.toString(), + range: undefined, + jdtHandler: 'stale-class-handler', + fullName: 'test.DeletedTest', + label: 'DeletedTest', + id: deletedClass.id, + projectName: 'project', + testKind: TestKind.JUnit5, + testLevel: TestLevel.Class, + }], + uri: undefined, + range: undefined, + jdtHandler: 'stale-package-handler', + fullName: 'test', + label: 'test', + id: testPackage.id, + projectName: 'project', + testKind: TestKind.JUnit5, + testLevel: TestLevel.Package, + }]); + await resolution; + + assert.strictEqual(findTestsStub.callCount, 2); + assert.strictEqual(project.children.get(testPackage.id), undefined); + assert.strictEqual(project.canResolveChildren, false); + }); + test('should share an in-progress project resolution', async () => { const project: TestItem = createTestItem(testController, 'project', TestLevel.Project); let completeSearch!: (items: IJavaTestItem[]) => void;