Skip to content

Commit e2a9cec

Browse files
authored
allow large scale testing (#21269)
allows new testing rewrite to handle 500+ tests and load and run these tests. High limit tested was 10,000 tests.
1 parent f2f5fe2 commit e2a9cec

File tree

9 files changed

+217
-22
lines changed

9 files changed

+217
-22
lines changed

.vscode/launch.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,7 @@
2222
// Enable this to log telemetry to the output during debugging
2323
"XVSC_PYTHON_LOG_TELEMETRY": "1",
2424
// Enable this to log debugger output. Directory must exist ahead of time
25-
"XDEBUGPY_LOG_DIR": "${workspaceRoot}/tmp/Debug_Output_Ex",
26-
"ENABLE_PYTHON_TESTING_REWRITE": "1"
25+
"XDEBUGPY_LOG_DIR": "${workspaceRoot}/tmp/Debug_Output_Ex"
2726
}
2827
},
2928
{

pythonFiles/unittestadapter/discovery.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88
import sys
99
import traceback
1010
import unittest
11-
from typing import List, Literal, Optional, Tuple, Union
11+
from typing import List, Optional, Tuple, Union
12+
13+
script_dir = pathlib.Path(__file__).parent.parent
14+
sys.path.append(os.fspath(script_dir))
15+
sys.path.append(os.fspath(script_dir / "lib" / "python"))
16+
17+
from typing_extensions import Literal
1218

1319
# Add the path to pythonFiles to sys.path to find testing_tools.socket_manager.
1420
PYTHON_FILES = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

pythonFiles/unittestadapter/utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@
66
import inspect
77
import os
88
import pathlib
9+
import sys
910
import unittest
10-
from typing import List, Tuple, TypedDict, Union
11+
from typing import List, Tuple, Union
12+
13+
script_dir = pathlib.Path(__file__).parent.parent
14+
sys.path.append(os.fspath(script_dir))
15+
sys.path.append(os.fspath(script_dir / "lib" / "python"))
16+
17+
from typing_extensions import TypedDict
1118

1219
# Types
1320

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
import io
4+
import json
5+
import os
6+
import pathlib
7+
import socket
8+
import sys
9+
from typing import List
10+
11+
import pytest
12+
13+
CONTENT_LENGTH: str = "Content-Length:"
14+
15+
16+
def process_rpc_json(data: str) -> List[str]:
17+
"""Process the JSON data which comes from the server which runs the pytest discovery."""
18+
str_stream: io.StringIO = io.StringIO(data)
19+
20+
length: int = 0
21+
22+
while True:
23+
line: str = str_stream.readline()
24+
if CONTENT_LENGTH.lower() in line.lower():
25+
length = int(line[len(CONTENT_LENGTH) :])
26+
break
27+
28+
if not line or line.isspace():
29+
raise ValueError("Header does not contain Content-Length")
30+
31+
while True:
32+
line: str = str_stream.readline()
33+
if not line or line.isspace():
34+
break
35+
36+
raw_json: str = str_stream.read(length)
37+
return json.loads(raw_json)
38+
39+
40+
# This script handles running pytest via pytest.main(). It is called via run in the
41+
# pytest execution adapter and gets the test_ids to run via stdin and the rest of the
42+
# args through sys.argv. It then runs pytest.main() with the args and test_ids.
43+
44+
if __name__ == "__main__":
45+
# Add the root directory to the path so that we can import the plugin.
46+
directory_path = pathlib.Path(__file__).parent.parent
47+
sys.path.append(os.fspath(directory_path))
48+
# Get the rest of the args to run with pytest.
49+
args = sys.argv[1:]
50+
run_test_ids_port = os.environ.get("RUN_TEST_IDS_PORT")
51+
run_test_ids_port_int = (
52+
int(run_test_ids_port) if run_test_ids_port is not None else 0
53+
)
54+
test_ids_from_buffer = []
55+
try:
56+
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
57+
client_socket.connect(("localhost", run_test_ids_port_int))
58+
print(f"CLIENT: Server listening on port {run_test_ids_port_int}...")
59+
buffer = b""
60+
61+
while True:
62+
# Receive the data from the client
63+
data = client_socket.recv(1024 * 1024)
64+
if not data:
65+
break
66+
67+
# Append the received data to the buffer
68+
buffer += data
69+
70+
try:
71+
# Try to parse the buffer as JSON
72+
test_ids_from_buffer = process_rpc_json(buffer.decode("utf-8"))
73+
# Clear the buffer as complete JSON object is received
74+
buffer = b""
75+
76+
# Process the JSON data
77+
print(f"Received JSON data: {test_ids_from_buffer}")
78+
break
79+
except json.JSONDecodeError:
80+
# JSON decoding error, the complete JSON object is not yet received
81+
continue
82+
except socket.error as e:
83+
print(f"Error: Could not connect to runTestIdsPort: {e}")
84+
print("Error: Could not connect to runTestIdsPort")
85+
try:
86+
if test_ids_from_buffer:
87+
arg_array = ["-p", "vscode_pytest"] + args + test_ids_from_buffer
88+
pytest.main(arg_array)
89+
except json.JSONDecodeError:
90+
print("Error: Could not parse test ids from stdin")

src/client/common/process/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export type SpawnOptions = ChildProcessSpawnOptions & {
2525
throwOnStdErr?: boolean;
2626
extraVariables?: NodeJS.ProcessEnv;
2727
outputChannel?: OutputChannel;
28+
stdinStr?: string;
2829
};
2930

3031
export type ShellOptions = ExecOptions & { throwOnStdErr?: boolean };

src/client/testing/testController/common/server.ts

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,23 +26,35 @@ export class PythonTestServer implements ITestServer, Disposable {
2626

2727
constructor(private executionFactory: IPythonExecutionFactory, private debugLauncher: ITestDebugLauncher) {
2828
this.server = net.createServer((socket: net.Socket) => {
29+
let buffer: Buffer = Buffer.alloc(0); // Buffer to accumulate received data
2930
socket.on('data', (data: Buffer) => {
3031
try {
3132
let rawData: string = data.toString();
32-
33-
while (rawData.length > 0) {
34-
const rpcHeaders = jsonRPCHeaders(rawData);
33+
buffer = Buffer.concat([buffer, data]);
34+
while (buffer.length > 0) {
35+
const rpcHeaders = jsonRPCHeaders(buffer.toString());
3536
const uuid = rpcHeaders.headers.get(JSONRPC_UUID_HEADER);
37+
const totalContentLength = rpcHeaders.headers.get('Content-Length');
38+
if (!uuid) {
39+
traceLog('On data received: Error occurred because payload UUID is undefined');
40+
this._onDataReceived.fire({ uuid: '', data: '' });
41+
return;
42+
}
43+
if (!this.uuids.includes(uuid)) {
44+
traceLog('On data received: Error occurred because the payload UUID is not recognized');
45+
this._onDataReceived.fire({ uuid: '', data: '' });
46+
return;
47+
}
3648
rawData = rpcHeaders.remainingRawData;
37-
if (uuid && this.uuids.includes(uuid)) {
38-
const rpcContent = jsonRPCContent(rpcHeaders.headers, rawData);
39-
rawData = rpcContent.remainingRawData;
40-
this._onDataReceived.fire({ uuid, data: rpcContent.extractedJSON });
49+
const rpcContent = jsonRPCContent(rpcHeaders.headers, rawData);
50+
const extractedData = rpcContent.extractedJSON;
51+
if (extractedData.length === Number(totalContentLength)) {
52+
// do not send until we have the full content
53+
this._onDataReceived.fire({ uuid, data: extractedData });
4154
this.uuids = this.uuids.filter((u) => u !== uuid);
55+
buffer = Buffer.alloc(0);
4256
} else {
43-
traceLog(`Error processing test server request: uuid not found`);
44-
this._onDataReceived.fire({ uuid: '', data: '' });
45-
return;
57+
break;
4658
}
4759
}
4860
} catch (ex) {

src/client/testing/testController/common/utils.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
3+
import * as net from 'net';
4+
import { traceLog } from '../../../logging';
35

46
export function fixLogLines(content: string): string {
57
const lines = content.split(/\r?\n/g);
@@ -50,3 +52,38 @@ export function jsonRPCContent(headers: Map<string, string>, rawData: string): I
5052
remainingRawData,
5153
};
5254
}
55+
export const startServer = (testIds: string): Promise<number> =>
56+
new Promise((resolve, reject) => {
57+
const server = net.createServer((socket: net.Socket) => {
58+
// Convert the test_ids array to JSON
59+
const testData = JSON.stringify(testIds);
60+
61+
// Create the headers
62+
const headers = [`Content-Length: ${Buffer.byteLength(testData)}`, 'Content-Type: application/json'];
63+
64+
// Create the payload by concatenating the headers and the test data
65+
const payload = `${headers.join('\r\n')}\r\n\r\n${testData}`;
66+
67+
// Send the payload to the socket
68+
socket.write(payload);
69+
70+
// Handle socket events
71+
socket.on('data', (data) => {
72+
traceLog('Received data:', data.toString());
73+
});
74+
75+
socket.on('end', () => {
76+
traceLog('Client disconnected');
77+
});
78+
});
79+
80+
server.listen(0, () => {
81+
const { port } = server.address() as net.AddressInfo;
82+
traceLog(`Server listening on port ${port}`);
83+
resolve(port);
84+
});
85+
86+
server.on('error', (error: Error) => {
87+
reject(error);
88+
});
89+
});

src/client/testing/testController/pytest/pytestExecutionAdapter.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33

44
import { Uri } from 'vscode';
55
import * as path from 'path';
6+
import * as net from 'net';
67
import { IConfigurationService, ITestOutputChannel } from '../../../common/types';
78
import { createDeferred, Deferred } from '../../../common/utils/async';
8-
import { traceVerbose } from '../../../logging';
9+
import { traceLog, traceVerbose } from '../../../logging';
910
import { DataReceivedEvent, ExecutionTestPayload, ITestExecutionAdapter, ITestServer } from '../common/types';
1011
import {
1112
ExecutionFactoryCreateWithEnvironmentOptions,
@@ -90,6 +91,7 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
9091
TEST_PORT: this.testServer.getPort().toString(),
9192
},
9293
outputChannel: this.outputChannel,
94+
stdinStr: testIds.toString(),
9395
};
9496

9597
// Create the Python environment in which to execute the command.
@@ -114,7 +116,48 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
114116
if (debugBool && !testArgs.some((a) => a.startsWith('--capture') || a === '-s')) {
115117
testArgs.push('--capture', 'no');
116118
}
117-
const pluginArgs = ['-p', 'vscode_pytest', '-v'].concat(testArgs).concat(testIds);
119+
const pluginArgs = ['-p', 'vscode_pytest'].concat(testArgs).concat(testIds);
120+
const scriptPath = path.join(fullPluginPath, 'vscode_pytest', 'run_pytest_script.py');
121+
const runArgs = [scriptPath, ...testArgs];
122+
123+
const testData = JSON.stringify(testIds);
124+
const headers = [`Content-Length: ${Buffer.byteLength(testData)}`, 'Content-Type: application/json'];
125+
const payload = `${headers.join('\r\n')}\r\n\r\n${testData}`;
126+
127+
const startServer = (): Promise<number> =>
128+
new Promise((resolve, reject) => {
129+
const server = net.createServer((socket: net.Socket) => {
130+
socket.on('end', () => {
131+
traceLog('Client disconnected');
132+
});
133+
});
134+
135+
server.listen(0, () => {
136+
const { port } = server.address() as net.AddressInfo;
137+
traceLog(`Server listening on port ${port}`);
138+
resolve(port);
139+
});
140+
141+
server.on('error', (error: Error) => {
142+
reject(error);
143+
});
144+
server.on('connection', (socket: net.Socket) => {
145+
socket.write(payload);
146+
traceLog('payload sent', payload);
147+
});
148+
});
149+
150+
// Start the server and wait until it is listening
151+
await startServer()
152+
.then((assignedPort) => {
153+
traceLog(`Server started and listening on port ${assignedPort}`);
154+
if (spawnOptions.extraVariables)
155+
spawnOptions.extraVariables.RUN_TEST_IDS_PORT = assignedPort.toString();
156+
})
157+
.catch((error) => {
158+
console.error('Error starting server:', error);
159+
});
160+
118161
if (debugBool) {
119162
const pytestPort = this.testServer.getPort().toString();
120163
const pytestUUID = uuid.toString();
@@ -129,9 +172,10 @@ export class PytestTestExecutionAdapter implements ITestExecutionAdapter {
129172
console.debug(`Running debug test with arguments: ${pluginArgs.join(' ')}\r\n`);
130173
await debugLauncher!.launchDebugger(launchOptions);
131174
} else {
132-
const runArgs = ['-m', 'pytest'].concat(pluginArgs);
133-
console.debug(`Running test with arguments: ${runArgs.join(' ')}\r\n`);
134-
execService?.exec(runArgs, spawnOptions);
175+
await execService?.exec(runArgs, spawnOptions).catch((ex) => {
176+
console.debug(`Error while running tests: ${testIds}\r\n${ex}\r\n\r\n`);
177+
return Promise.reject(ex);
178+
});
135179
}
136180
} catch (ex) {
137181
console.debug(`Error while running tests: ${testIds}\r\n${ex}\r\n\r\n`);

src/client/testing/testController/workspaceTestAdapter.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -348,12 +348,11 @@ export class WorkspaceTestAdapter {
348348
const testingErrorConst =
349349
this.testProvider === 'pytest' ? Testing.errorPytestDiscovery : Testing.errorUnittestDiscovery;
350350
const { errors } = rawTestData;
351-
traceError(testingErrorConst, '\r\n', errors!.join('\r\n\r\n'));
352-
351+
traceError(testingErrorConst, '\r\n', errors?.join('\r\n\r\n'));
353352
let errorNode = testController.items.get(`DiscoveryError:${workspacePath}`);
354353
const message = util.format(
355354
`${testingErrorConst} ${Testing.seePythonOutput}\r\n`,
356-
errors!.join('\r\n\r\n'),
355+
errors?.join('\r\n\r\n'),
357356
);
358357

359358
if (errorNode === undefined) {

0 commit comments

Comments
 (0)