Skip to content

Commit 0098190

Browse files
committed
fix(vscode): refresh the Test Explorer after a language server restart
- test items and run buttons no longer vanish until a window reload after a restart - a folder's tests are re-discovered when its language server restarts - run profiles are rebuilt once per restart instead of on every state change - a test run no longer hangs when a debug session fails to start - cancelling a run stops only that run, and works while it is still starting - a fixed error clears its decoration; stale diagnostics are cleared on an invalid environment - per-folder refresh, so one folder no longer interferes with another Fixes #618
1 parent 3e1be81 commit 0098190

1 file changed

Lines changed: 100 additions & 64 deletions

File tree

vscode-client/extension/testcontrollermanager.ts

Lines changed: 100 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ import { escapeRobotGlobPatterns, filterAsync, Mutex, truncateAndReplaceNewlines
88
import { CONFIG_SECTION } from "./config";
99
import { Range, Diagnostic, DiagnosticSeverity } from "vscode-languageclient/node";
1010

11+
const ROOT_TEST_ITEM_KEY = "__root__";
12+
13+
function rootSnapshotKey(folder: vscode.WorkspaceFolder): string {
14+
return `${ROOT_TEST_ITEM_KEY}:${folder.uri.toString()}`;
15+
}
16+
1117
function arraysEqual<T>(a: T[] | undefined, b: T[] | undefined): boolean {
1218
if (a === b) return true;
1319
if (a === undefined || b === undefined) return false;
@@ -186,7 +192,7 @@ export class TestControllerManager {
186192
private readonly updateEditorsMutex = new Mutex();
187193
private readonly debugSessions = new Set<vscode.DebugSession>();
188194
private readonly didChangedTimer = new Map<string, DidChangeEntry>();
189-
private refreshWorkspaceChangeTimer: DidChangeEntry | undefined;
195+
private readonly refreshWorkspaceChangeTimer = new Map<string, DidChangeEntry>();
190196
private diagnosticCollection = vscode.languages.createDiagnosticCollection("robotCode discovery");
191197
private activeStepDecorationType: vscode.TextEditorDecorationType;
192198
showEditorRunDecorations = false;
@@ -197,9 +203,8 @@ export class TestControllerManager {
197203
private readonly testItemByUri = new Map<string, vscode.TestItem>();
198204
// Cache of the last set tag IDs per TestItem ID. Used for tag diffing in addOrUpdateTestItem.
199205
private readonly lastSetTags = new Map<string, string[]>();
200-
// Deep snapshot of the last successfully processed children per TestItem ID
201-
// (key "__root__" for the workspace top level). Used in refreshItem for the result
202-
// compare — if the new children are structurally identical, no UI update is triggered.
206+
// Snapshot of the last processed children per TestItem id (plus a per-folder
207+
// "__root__:<uri>" key) for refreshItem's result compare.
203208
private readonly lastKnownChildren = new Map<string, RobotTestItem[] | undefined>();
204209
// CTS for the config-change refresh path. Cancelled on the next config change so an
205210
// in-flight refresh terminates before the next refreshWorkspace queues behind it on
@@ -260,22 +265,20 @@ export class TestControllerManager {
260265
this.activeStepDecorationType,
261266
this.languageClientsManager.onClientStateChanged(async (event) => {
262267
const folder = vscode.workspace.getWorkspaceFolder(event.uri);
263-
if (folder) {
264-
this.invalidateProfilesCache(folder);
265-
}
266268
switch (event.state) {
267269
case ClientState.Running: {
268-
// refresh()'s single-inflight handles cancellation of any predecessor.
269-
await this.refresh();
270+
await this.refreshWorkspace(folder);
270271
break;
271272
}
272-
case ClientState.Stopped: {
273-
if (folder) this.removeWorkspaceFolderItems(folder);
274-
273+
case ClientState.Refreshed: {
274+
if (folder && !this.languageClientsManager.clients.has(folder.uri.toString())) {
275+
await this.refreshWorkspace(folder);
276+
}
277+
if (folder) this.invalidateProfilesCache(folder);
278+
await this.updateRunProfiles();
275279
break;
276280
}
277281
}
278-
await this.updateRunProfiles();
279282
}),
280283
vscode.workspace.onDidChangeConfiguration(async (event) => {
281284
let testExplorerChanged = false;
@@ -687,16 +690,16 @@ export class TestControllerManager {
687690
this.testController.items.delete(item.id);
688691
}
689692
}
693+
694+
this.lastKnownChildren.delete(rootSnapshotKey(folder));
690695
}
691696

692697
dispose(): void {
693698
this.didChangedTimer.forEach((entry) => entry.cancel());
694699
this.didChangedTimer.clear();
695700

696-
if (this.refreshWorkspaceChangeTimer) {
697-
this.refreshWorkspaceChangeTimer.cancel();
698-
this.refreshWorkspaceChangeTimer = undefined;
699-
}
701+
this.refreshWorkspaceChangeTimer.forEach((entry) => entry.cancel());
702+
this.refreshWorkspaceChangeTimer.clear();
700703

701704
this.robotItemIndex.clear();
702705
this.testItemByUri.clear();
@@ -902,6 +905,11 @@ export class TestControllerManager {
902905
token?: vscode.CancellationToken,
903906
): Promise<RobotCodeDiscoverResult> {
904907
if (!(await this.languageClientsManager.isValidRobotEnvironmentInFolder(folder))) {
908+
if (prune) {
909+
this.diagnosticCollection.forEach((uri, _diagnostics, collection) => {
910+
if (vscode.workspace.getWorkspaceFolder(uri) === folder) collection.delete(uri);
911+
});
912+
}
905913
return {};
906914
}
907915

@@ -1013,7 +1021,9 @@ export class TestControllerManager {
10131021
token,
10141022
);
10151023

1016-
this.lastDiscoverResults.set(folder, result);
1024+
if (result?.items !== undefined) {
1025+
this.lastDiscoverResults.set(folder, result);
1026+
}
10171027
// Index the freshly discovered subtree for O(1) findRobotItem lookups.
10181028
this.indexRobotTree(result?.items);
10191029

@@ -1197,44 +1207,55 @@ export class TestControllerManager {
11971207
item.busy = false;
11981208
}
11991209
} else {
1200-
const rootKey = "__root__";
1210+
const keptIds = new Set<string>();
1211+
const liveFolderKeys = new Set<string>();
12011212

1202-
// Phase 1: collect the new root children (re-discover if needed) without UI calls.
1203-
const newRootChildren: RobotTestItem[] = [];
12041213
for (const folder of vscode.workspace.workspaceFolders ?? []) {
12051214
if (token?.isCancellationRequested) return;
12061215
if (!this.isTestExplorerEnabledForWorkspace(folder)) continue;
12071216

1217+
const folderKey = rootSnapshotKey(folder);
1218+
liveFolderKeys.add(folderKey);
1219+
12081220
if (this.robotTestItems.get(folder) === undefined || !this.robotTestItems.get(folder)?.valid) {
12091221
const items = await this.getTestsFromWorkspaceFolder(folder, token);
1210-
if (items === undefined) continue;
1222+
if (items === undefined) {
1223+
this.lastKnownChildren.delete(folderKey);
1224+
continue;
1225+
}
12111226
this.robotTestItems.set(folder, new WorkspaceFolderEntry(true, items));
12121227
}
1213-
const tests = this.robotTestItems.get(folder)?.items;
1214-
if (tests) {
1215-
for (const t of tests) newRootChildren.push(t);
1228+
1229+
const folderChildren = this.robotTestItems.get(folder)?.items;
1230+
if (folderChildren === undefined) {
1231+
this.lastKnownChildren.delete(folderKey);
1232+
continue;
12161233
}
1217-
}
12181234

1219-
// Phase 2: result compare against the last snapshot — if identical, no UI update.
1220-
const lastKnown = this.lastKnownChildren.get(rootKey);
1221-
if (robotItemListsEqual(lastKnown, newRootChildren)) return;
1235+
if (robotItemListsEqual(this.lastKnownChildren.get(folderKey), folderChildren)) {
1236+
for (const t of folderChildren) keptIds.add(t.id);
1237+
continue;
1238+
}
12221239

1223-
// Phase 3: actual UI updates.
1224-
const addedIds = new Set<string>();
1225-
for (const test of newRootChildren) {
1226-
if (token?.isCancellationRequested) return;
1227-
addedIds.add(test.id);
1228-
const newItem = this.addOrUpdateTestItem(undefined, test);
1229-
// Recursion path from the workspace refresh → skip the per-document discover.
1230-
await this.refreshItem(newItem, token, true);
1231-
if (newItem.canResolveChildren && newItem.children.size === 0 && newItem.error === undefined) {
1232-
addedIds.delete(newItem.id);
1240+
for (const test of folderChildren) {
1241+
if (token?.isCancellationRequested) return;
1242+
const newItem = this.addOrUpdateTestItem(undefined, test);
1243+
// Recursion path from the workspace refresh → skip the per-document discover.
1244+
await this.refreshItem(newItem, token, true);
1245+
if (!(newItem.canResolveChildren && newItem.children.size === 0 && newItem.error === undefined)) {
1246+
keptIds.add(test.id);
1247+
}
12331248
}
1249+
this.lastKnownChildren.set(folderKey, this.snapshotChildren(folderChildren));
12341250
}
12351251

1236-
this.removeNotAddedTestItems(undefined, addedIds);
1237-
this.lastKnownChildren.set(rootKey, this.snapshotChildren(newRootChildren));
1252+
for (const key of [...this.lastKnownChildren.keys()]) {
1253+
if (key.startsWith(`${ROOT_TEST_ITEM_KEY}:`) && !liveFolderKeys.has(key)) {
1254+
this.lastKnownChildren.delete(key);
1255+
}
1256+
}
1257+
1258+
this.removeNotAddedTestItems(undefined, keptIds);
12381259
}
12391260
}
12401261

@@ -1293,7 +1314,7 @@ export class TestControllerManager {
12931314
testItem.description = newDescription;
12941315
}
12951316

1296-
if (robotTestItem.error !== undefined && testItem.error !== robotTestItem.error) {
1317+
if (testItem.error !== robotTestItem.error) {
12971318
testItem.error = robotTestItem.error;
12981319
}
12991320

@@ -1337,6 +1358,7 @@ export class TestControllerManager {
13371358
}
13381359
this.lastSetTags.delete(i);
13391360
this.robotItemIndex.delete(i);
1361+
this.lastKnownChildren.delete(i);
13401362
});
13411363

13421364
return itemsToRemove.size > 0;
@@ -1420,27 +1442,27 @@ export class TestControllerManager {
14201442

14211443
if (this.didChangedTimer.has(uri.toString())) return;
14221444

1423-
if (this.refreshWorkspaceChangeTimer) {
1424-
this.refreshWorkspaceChangeTimer.cancel();
1425-
this.refreshWorkspaceChangeTimer = undefined;
1426-
}
1445+
const workspaceKey = workspace.uri.toString();
1446+
this.refreshWorkspaceChangeTimer.get(workspaceKey)?.cancel();
14271447

14281448
const cancelationTokenSource = new vscode.CancellationTokenSource();
14291449

1430-
this.refreshWorkspaceChangeTimer = new DidChangeEntry(
1431-
setTimeout(() => {
1432-
this.refreshWorkspace(workspace, cancelationTokenSource.token).then(
1433-
() => undefined,
1434-
() => undefined,
1435-
);
1436-
}, TestControllerManager.DEBOUNCE_MS),
1437-
cancelationTokenSource,
1450+
this.refreshWorkspaceChangeTimer.set(
1451+
workspaceKey,
1452+
new DidChangeEntry(
1453+
setTimeout(() => {
1454+
this.refreshWorkspaceChangeTimer.delete(workspaceKey);
1455+
this.refreshWorkspace(workspace, cancelationTokenSource.token).then(
1456+
() => undefined,
1457+
() => undefined,
1458+
);
1459+
}, TestControllerManager.DEBOUNCE_MS),
1460+
cancelationTokenSource,
1461+
),
14381462
);
14391463
} else {
1440-
if (this.refreshWorkspaceChangeTimer) {
1441-
this.refreshWorkspaceChangeTimer.cancel();
1442-
this.refreshWorkspaceChangeTimer = undefined;
1443-
}
1464+
this.refreshWorkspaceChangeTimer.forEach((entry) => entry.cancel());
1465+
this.refreshWorkspaceChangeTimer.clear();
14441466

14451467
// refresh()'s single-inflight handles cancellation of any predecessor.
14461468
this.refresh().then(
@@ -1524,17 +1546,23 @@ export class TestControllerManager {
15241546
const testRun = this.testController.createTestRun(request, undefined);
15251547
let run_started = false;
15261548

1527-
token.onCancellationRequested(async (_) => {
1528-
for (const e of this.testRunInfos.keys()) {
1529-
for (const session of this.debugSessions) {
1530-
if (session.configuration.runId === e) {
1531-
await vscode.debug.stopDebugging(session);
1532-
}
1549+
const myRunIds = new Set<string>();
1550+
let cancelled = false;
1551+
const stopMyRunSessions = async () => {
1552+
for (const session of this.debugSessions) {
1553+
if (myRunIds.has(session.configuration.runId)) {
1554+
await vscode.debug.stopDebugging(session);
15331555
}
15341556
}
1557+
};
1558+
1559+
token.onCancellationRequested(async (_) => {
1560+
cancelled = true;
1561+
await stopMyRunSessions();
15351562
});
15361563

15371564
for (const [folder, testItems] of included) {
1565+
if (cancelled) break;
15381566
if (
15391567
request.profile !== undefined &&
15401568
request.profile.tag !== undefined &&
@@ -1543,6 +1571,7 @@ export class TestControllerManager {
15431571
continue;
15441572

15451573
const runId = TestControllerManager.nextRunId();
1574+
myRunIds.add(runId);
15461575
this.testRunInfos.set(runId, new TestRunInfo(testRun));
15471576

15481577
const options: vscode.DebugSessionOptions = {
@@ -1573,6 +1602,7 @@ export class TestControllerManager {
15731602
profiles,
15741603
testConfiguration,
15751604
);
1605+
if (!started) this.testRunInfos.delete(runId);
15761606
run_started = run_started || started;
15771607
} else {
15781608
const includedInWs = testItems
@@ -1643,8 +1673,14 @@ export class TestControllerManager {
16431673
testConfiguration,
16441674
);
16451675

1676+
if (!started) this.testRunInfos.delete(runId);
16461677
run_started = run_started || started;
16471678
}
1679+
1680+
if (cancelled) {
1681+
await stopMyRunSessions();
1682+
break;
1683+
}
16481684
}
16491685

16501686
if (!run_started) {

0 commit comments

Comments
 (0)