Skip to content

Commit 76c45af

Browse files
committed
test: harden package integration retries
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: edcaaa35-9a42-4351-98fa-55bcf8f1968e
1 parent 26250c5 commit 76c45af

5 files changed

Lines changed: 89 additions & 10 deletions

File tree

src/test/common/testUtils.unit.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,28 @@ suite('Test Utilities', () => {
8080
(error: unknown) => error === conditionError,
8181
);
8282
});
83+
84+
test('should retry and preserve the last condition error at timeout', async () => {
85+
const conditionError = new Error('Package refresh failed');
86+
let attempts = 0;
87+
88+
await assert.rejects(
89+
() =>
90+
waitForCondition(
91+
() => {
92+
attempts++;
93+
return Promise.reject(conditionError);
94+
},
95+
50,
96+
'Should preserve the refresh error',
97+
10,
98+
true,
99+
true,
100+
),
101+
(error: unknown) => error === conditionError,
102+
);
103+
assert.ok(attempts > 1, 'The rejected condition should be retried before timing out');
104+
});
83105
});
84106

85107
suite('retryUntilSuccess', () => {

src/test/integration/environmentFixture.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,19 @@ export async function createEnvironmentFixture(
7575
let projectSettingAdded = false;
7676
let environmentCreated = false;
7777
let environment: PythonEnvironment | undefined;
78+
let apiRemovalSettled = false;
7879
let disposePromise: Promise<void> | undefined;
7980
let markerWritten = false;
8081
let projectRootCreated = false;
82+
const getApiRemoval = createSingleFlightOperation(() => {
83+
if (!environment) {
84+
return Promise.resolve();
85+
}
86+
apiRemovalSettled = false;
87+
return api.removeEnvironment(environment, { runHeadless: true }).finally(() => {
88+
apiRemovalSettled = true;
89+
});
90+
});
8191

8292
const cleanup = async (): Promise<void> => {
8393
const cleanupErrors: Error[] = [];
@@ -92,15 +102,10 @@ export async function createEnvironmentFixture(
92102
cleanupErrors.push(toError(error));
93103
}
94104
if (ownershipVerified && environment) {
95-
let apiRemovalSettled = false;
96-
const apiRemoval = api
97-
.removeEnvironment(environment, { runHeadless: true })
98-
.finally(() => {
99-
apiRemovalSettled = true;
100-
});
105+
const apiRemovalPromise = getApiRemoval();
101106
try {
102107
await withTimeout(
103-
apiRemoval,
108+
apiRemovalPromise,
104109
COMMAND_TIMEOUT_MS,
105110
`${request.name} API environment removal timed out`,
106111
);
@@ -109,7 +114,7 @@ export async function createEnvironmentFixture(
109114
if (!apiRemovalSettled) {
110115
try {
111116
await withTimeout(
112-
apiRemoval,
117+
apiRemovalPromise,
113118
API_REMOVAL_SETTLE_TIMEOUT_MS,
114119
`${request.name} API environment removal did not settle after timing out`,
115120
);
@@ -537,6 +542,17 @@ async function withTimeout<T>(operation: Promise<T>, timeoutMs: number, message:
537542
}
538543
}
539544

545+
/**
546+
* Returns a function that starts an asynchronous operation at most once and shares its promise.
547+
*/
548+
export function createSingleFlightOperation<T>(operation: () => Promise<T>): () => Promise<T> {
549+
let promise: Promise<T> | undefined;
550+
return () => {
551+
promise ??= operation();
552+
return promise;
553+
};
554+
}
555+
540556
function sanitizeName(value: string): string {
541557
return value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
542558
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
import assert from 'assert';
5+
import { createSingleFlightOperation } from './environmentFixture';
6+
7+
suite('Environment fixture helpers', () => {
8+
test('shares a delayed operation across retries', async () => {
9+
let calls = 0;
10+
let resolveOperation: (() => void) | undefined;
11+
const getOperation = createSingleFlightOperation(
12+
() =>
13+
new Promise<void>((resolve) => {
14+
calls++;
15+
resolveOperation = resolve;
16+
}),
17+
);
18+
19+
const first = getOperation();
20+
const retry = getOperation();
21+
22+
assert.strictEqual(retry, first);
23+
assert.strictEqual(calls, 1);
24+
assert.ok(resolveOperation);
25+
resolveOperation();
26+
await Promise.all([first, retry]);
27+
assert.strictEqual(getOperation(), first);
28+
assert.strictEqual(calls, 1);
29+
});
30+
});

src/test/integration/packageManager.integration.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,8 @@ for (const profile of profiles) {
154154
30_000,
155155
'Package not installed',
156156
1_000,
157-
false,
157+
true,
158+
true,
158159
);
159160

160161
const directPackageNames = await vscode.commands.executeCommand<string[] | undefined>(
@@ -175,7 +176,8 @@ for (const profile of profiles) {
175176
30_000,
176177
'Package not uninstalled',
177178
1_000,
178-
false,
179+
true,
180+
true,
179181
);
180182
}
181183
});

src/test/testUtils.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export function sleep(ms: number): Promise<void> {
2929
* @param errorMessage - Error message if condition is not met
3030
* @param pollIntervalMs - How often to check condition (default: 100ms)
3131
* @param retryOnError - Whether rejected conditions should be retried (default: true)
32+
* @param rejectWithLastError - Whether a timeout after rejected conditions should preserve the last error
3233
*
3334
* @example
3435
* // Wait for extension to activate
@@ -52,13 +53,16 @@ export async function waitForCondition(
5253
errorMessage: string | (() => string) = 'Condition not met within timeout',
5354
pollIntervalMs: number = 100,
5455
retryOnError: boolean = true,
56+
rejectWithLastError: boolean = false,
5557
): Promise<void> {
5658
return new Promise<void>((resolve, reject) => {
5759
const startTime = Date.now();
60+
let lastError: unknown;
5861

5962
const checkCondition = async () => {
6063
try {
6164
const result = await condition();
65+
lastError = undefined;
6266
if (result) {
6367
resolve();
6468
return;
@@ -68,9 +72,14 @@ export async function waitForCondition(
6872
reject(error);
6973
return;
7074
}
75+
lastError = error;
7176
}
7277

7378
if (Date.now() - startTime >= timeoutMs) {
79+
if (rejectWithLastError && lastError !== undefined) {
80+
reject(lastError);
81+
return;
82+
}
7483
const msg = typeof errorMessage === 'function' ? errorMessage() : errorMessage;
7584
reject(new Error(`${msg} (waited ${timeoutMs}ms)`));
7685
return;

0 commit comments

Comments
 (0)