Switch to built-in VS Code test UI unconditionally
This commit is contained in:
Родитель
1b737678ba
Коммит
1dc6111fba
|
@ -21,13 +21,6 @@ To see what has changed in the last few versions of the extension, see the [Chan
|
|||
|
||||
This project will track new feature development in CodeQL and, whenever appropriate, bring that functionality to the Visual Studio Code experience.
|
||||
|
||||
## Dependencies
|
||||
|
||||
This extension depends on the following two extensions for required functionality. They will be installed automatically when you install VS Code CodeQL.
|
||||
|
||||
- [Test Adapter Converter](https://marketplace.visualstudio.com/items?itemName=ms-vscode.test-adapter-converter)
|
||||
- [Test Explorer UI](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer)
|
||||
|
||||
## Contributing
|
||||
|
||||
This project welcomes contributions. See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to build, install, and contribute.
|
||||
|
|
|
@ -17,8 +17,6 @@ For information about other configurations, see the separate [CodeQL help](https
|
|||
### Quick start: Installing and configuring the extension
|
||||
|
||||
1. [Install the extension](#installing-the-extension).
|
||||
*Note: vscode-codeql installs the following dependencies for required functionality: [Test Adapter Converter](https://marketplace.visualstudio.com/items?itemName=ms-vscode.test-adapter-converter), [Test Explorer UI](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer).*
|
||||
|
||||
1. [Check access to the CodeQL CLI](#checking-access-to-the-codeql-cli).
|
||||
1. [Clone the CodeQL starter workspace](#cloning-the-codeql-starter-workspace).
|
||||
|
||||
|
|
|
@ -40,8 +40,6 @@
|
|||
"vscode-extension-telemetry": "^0.1.6",
|
||||
"vscode-jsonrpc": "^8.0.2",
|
||||
"vscode-languageclient": "^8.0.2",
|
||||
"vscode-test-adapter-api": "^1.7.0",
|
||||
"vscode-test-adapter-util": "^0.7.0",
|
||||
"yauzl": "^2.10.0",
|
||||
"zip-a-folder": "^3.1.3"
|
||||
},
|
||||
|
@ -31984,31 +31982,6 @@
|
|||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.3.tgz",
|
||||
"integrity": "sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA=="
|
||||
},
|
||||
"node_modules/vscode-test-adapter-api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-test-adapter-api/-/vscode-test-adapter-api-1.9.0.tgz",
|
||||
"integrity": "sha512-lltjehUP0J9H3R/HBctjlqeUCwn2t9Lbhj2Y500ib+j5Y4H3hw+hVTzuSsfw16LtxY37knlU39QIlasa7svzOQ==",
|
||||
"engines": {
|
||||
"vscode": "^1.23.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-test-adapter-util": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-test-adapter-util/-/vscode-test-adapter-util-0.7.1.tgz",
|
||||
"integrity": "sha512-OZZvLDDNhayVVISyTmgUntOhMzl6j9/wVGfNqI2zuR5bQIziTQlDs9W29dFXDTGXZOxazS6uiHkrr86BKDzYUA==",
|
||||
"dependencies": {
|
||||
"tslib": "^1.11.1",
|
||||
"vscode-test-adapter-api": "^1.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"vscode": "^1.24.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-test-adapter-util/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",
|
||||
|
|
|
@ -21,7 +21,6 @@
|
|||
"Programming Languages"
|
||||
],
|
||||
"extensionDependencies": [
|
||||
"hbenl.vscode-test-explorer",
|
||||
"vscode.git"
|
||||
],
|
||||
"capabilities": {
|
||||
|
@ -1938,8 +1937,6 @@
|
|||
"vscode-extension-telemetry": "^0.1.6",
|
||||
"vscode-jsonrpc": "^8.0.2",
|
||||
"vscode-languageclient": "^8.0.2",
|
||||
"vscode-test-adapter-api": "^1.7.0",
|
||||
"vscode-test-adapter-util": "^0.7.0",
|
||||
"yauzl": "^2.10.0",
|
||||
"zip-a-folder": "^3.1.3"
|
||||
},
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
import type { CommandManager } from "../packages/commands";
|
||||
import type { Uri, Range, TextDocumentShowOptions } from "vscode";
|
||||
import type { Uri, Range, TextDocumentShowOptions, TestItem } from "vscode";
|
||||
import type { AstItem } from "../language-support";
|
||||
import type { DbTreeViewItem } from "../databases/ui/db-tree-view-item";
|
||||
import type { DatabaseItem } from "../databases/local-databases";
|
||||
import type { QueryHistoryInfo } from "../query-history/query-history-info";
|
||||
import type { TestTreeNode } from "../query-testing/test-tree-node";
|
||||
import type {
|
||||
VariantAnalysis,
|
||||
VariantAnalysisScannedRepository,
|
||||
|
@ -334,11 +333,9 @@ export type SummaryLanguageSupportCommands = {
|
|||
};
|
||||
|
||||
export type TestUICommands = {
|
||||
"codeQLTests.showOutputDifferences": (node: TestTreeNode) => Promise<void>;
|
||||
"codeQLTests.acceptOutput": (node: TestTreeNode) => Promise<void>;
|
||||
"codeQLTests.acceptOutputContextTestItem": (
|
||||
node: TestTreeNode,
|
||||
) => Promise<void>;
|
||||
"codeQLTests.showOutputDifferences": (node: TestItem) => Promise<void>;
|
||||
"codeQLTests.acceptOutput": (node: TestItem) => Promise<void>;
|
||||
"codeQLTests.acceptOutputContextTestItem": (node: TestItem) => Promise<void>;
|
||||
};
|
||||
|
||||
export type MockGitHubApiServerCommands = {
|
||||
|
|
|
@ -15,8 +15,6 @@ import { arch, homedir, platform } from "os";
|
|||
import { ensureDir } from "fs-extra";
|
||||
import { join } from "path";
|
||||
import { dirSync } from "tmp-promise";
|
||||
import type { TestHub } from "vscode-test-adapter-api";
|
||||
import { testExplorerExtensionId } from "vscode-test-adapter-api";
|
||||
import { lt, parse } from "semver";
|
||||
import { watch } from "chokidar";
|
||||
import {
|
||||
|
@ -28,7 +26,6 @@ import {
|
|||
CliConfigListener,
|
||||
DistributionConfigListener,
|
||||
GitHubDatabaseConfigListener,
|
||||
isCanary,
|
||||
joinOrderWarningThreshold,
|
||||
QueryHistoryConfigListener,
|
||||
QueryServerConfigListener,
|
||||
|
@ -90,8 +87,6 @@ import {
|
|||
} from "./common/logging/vscode";
|
||||
import { QueryHistoryManager } from "./query-history/query-history-manager";
|
||||
import type { CompletedLocalQueryInfo } from "./query-results";
|
||||
import { QLTestAdapterFactory } from "./query-testing/test-adapter";
|
||||
import { TestUIService } from "./query-testing/test-ui";
|
||||
import { CompareView } from "./compare/compare-view";
|
||||
import {
|
||||
initializeTelemetry,
|
||||
|
@ -130,7 +125,6 @@ import { DebuggerUI } from "./debugger/debugger-ui";
|
|||
import { ModelEditorModule } from "./model-editor/model-editor-module";
|
||||
import { TestManager } from "./query-testing/test-manager";
|
||||
import { TestRunner } from "./query-testing/test-runner";
|
||||
import type { TestManagerBase } from "./query-testing/test-manager-base";
|
||||
import { QueryRunner, QueryServerClient } from "./query-server";
|
||||
import { QueriesModule } from "./queries-panel/queries-module";
|
||||
import { OpenReferencedFileCodeLensProvider } from "./local-queries/open-referenced-file-code-lens-provider";
|
||||
|
@ -977,27 +971,8 @@ async function activateWithInstalledDistribution(
|
|||
const testRunner = new TestRunner(dbm, cliServer);
|
||||
ctx.subscriptions.push(testRunner);
|
||||
|
||||
let testManager: TestManagerBase | undefined = undefined;
|
||||
if (isCanary()) {
|
||||
testManager = new TestManager(app, testRunner, cliServer);
|
||||
ctx.subscriptions.push(testManager);
|
||||
} else {
|
||||
const testExplorerExtension = extensions.getExtension<TestHub>(
|
||||
testExplorerExtensionId,
|
||||
);
|
||||
if (testExplorerExtension) {
|
||||
const testHub = testExplorerExtension.exports;
|
||||
const testAdapterFactory = new QLTestAdapterFactory(
|
||||
testHub,
|
||||
testRunner,
|
||||
cliServer,
|
||||
);
|
||||
ctx.subscriptions.push(testAdapterFactory);
|
||||
|
||||
testManager = new TestUIService(app, testHub);
|
||||
ctx.subscriptions.push(testManager);
|
||||
}
|
||||
}
|
||||
const testManager = new TestManager(app, testRunner, cliServer);
|
||||
ctx.subscriptions.push(testManager);
|
||||
|
||||
const testUiCommands = testManager?.getCommands() ?? {};
|
||||
|
||||
|
|
|
@ -1,26 +1,4 @@
|
|||
import { extname } from "path";
|
||||
import type { Event, WorkspaceFolder } from "vscode";
|
||||
import { CancellationTokenSource, EventEmitter } from "vscode";
|
||||
import type {
|
||||
TestAdapter,
|
||||
TestEvent,
|
||||
TestHub,
|
||||
TestInfo,
|
||||
TestLoadFinishedEvent,
|
||||
TestLoadStartedEvent,
|
||||
TestRunFinishedEvent,
|
||||
TestRunStartedEvent,
|
||||
TestSuiteEvent,
|
||||
TestSuiteInfo,
|
||||
} from "vscode-test-adapter-api";
|
||||
import { TestAdapterRegistrar } from "vscode-test-adapter-util";
|
||||
import { QLTestDiscovery } from "./qltest-discovery";
|
||||
import { DisposableObject } from "../common/disposable-object";
|
||||
import type { CodeQLCliServer, TestCompleted } from "../codeql-cli/cli";
|
||||
import { testLogger } from "../common/logging/vscode";
|
||||
import type { TestRunner } from "./test-runner";
|
||||
import type { FileTreeNode } from "../common/file-tree-nodes";
|
||||
import { FileTreeDirectory, FileTreeLeaf } from "../common/file-tree-nodes";
|
||||
|
||||
/**
|
||||
* Get the full path of the `.expected` file for the specified QL test.
|
||||
|
@ -47,28 +25,6 @@ function getTestOutputFile(testPath: string, extension: string): string {
|
|||
return changeExtension(testPath, extension);
|
||||
}
|
||||
|
||||
/**
|
||||
* A factory service that creates `QLTestAdapter` objects for workspace folders on demand.
|
||||
*/
|
||||
export class QLTestAdapterFactory extends DisposableObject {
|
||||
constructor(
|
||||
testHub: TestHub,
|
||||
testRunner: TestRunner,
|
||||
cliServer: CodeQLCliServer,
|
||||
) {
|
||||
super();
|
||||
|
||||
// this will register a QLTestAdapter for each WorkspaceFolder
|
||||
this.push(
|
||||
new TestAdapterRegistrar(
|
||||
testHub,
|
||||
(workspaceFolder) =>
|
||||
new QLTestAdapter(workspaceFolder, testRunner, cliServer),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the file extension of the specified path.
|
||||
* @param p The original file path.
|
||||
|
@ -77,197 +33,3 @@ export class QLTestAdapterFactory extends DisposableObject {
|
|||
function changeExtension(p: string, ext: string): string {
|
||||
return p.slice(0, -extname(p).length) + ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test adapter for QL tests.
|
||||
*/
|
||||
export class QLTestAdapter extends DisposableObject implements TestAdapter {
|
||||
private readonly qlTestDiscovery: QLTestDiscovery;
|
||||
private readonly _tests = this.push(
|
||||
new EventEmitter<TestLoadStartedEvent | TestLoadFinishedEvent>(),
|
||||
);
|
||||
private readonly _testStates = this.push(
|
||||
new EventEmitter<
|
||||
TestRunStartedEvent | TestRunFinishedEvent | TestSuiteEvent | TestEvent
|
||||
>(),
|
||||
);
|
||||
private readonly _autorun = this.push(new EventEmitter<void>());
|
||||
private runningTask?: CancellationTokenSource = undefined;
|
||||
|
||||
constructor(
|
||||
public readonly workspaceFolder: WorkspaceFolder,
|
||||
private readonly testRunner: TestRunner,
|
||||
cliServer: CodeQLCliServer,
|
||||
) {
|
||||
super();
|
||||
|
||||
this.qlTestDiscovery = this.push(
|
||||
new QLTestDiscovery(workspaceFolder, cliServer),
|
||||
);
|
||||
void this.qlTestDiscovery.refresh();
|
||||
|
||||
this.push(this.qlTestDiscovery.onDidChangeTests(this.discoverTests, this));
|
||||
}
|
||||
|
||||
public get tests(): Event<TestLoadStartedEvent | TestLoadFinishedEvent> {
|
||||
return this._tests.event;
|
||||
}
|
||||
|
||||
public get testStates(): Event<
|
||||
TestRunStartedEvent | TestRunFinishedEvent | TestSuiteEvent | TestEvent
|
||||
> {
|
||||
return this._testStates.event;
|
||||
}
|
||||
|
||||
public get autorun(): Event<void> | undefined {
|
||||
return this._autorun.event;
|
||||
}
|
||||
|
||||
private static createTestOrSuiteInfos(
|
||||
testNodes: readonly FileTreeNode[],
|
||||
): Array<TestSuiteInfo | TestInfo> {
|
||||
return testNodes.map((childNode) => {
|
||||
return QLTestAdapter.createTestOrSuiteInfo(childNode);
|
||||
});
|
||||
}
|
||||
|
||||
private static createTestOrSuiteInfo(
|
||||
testNode: FileTreeNode,
|
||||
): TestSuiteInfo | TestInfo {
|
||||
if (testNode instanceof FileTreeLeaf) {
|
||||
return QLTestAdapter.createTestInfo(testNode);
|
||||
} else if (testNode instanceof FileTreeDirectory) {
|
||||
return QLTestAdapter.createTestSuiteInfo(testNode, testNode.name);
|
||||
} else {
|
||||
throw new Error("Unexpected test type.");
|
||||
}
|
||||
}
|
||||
|
||||
private static createTestInfo(testFile: FileTreeLeaf): TestInfo {
|
||||
return {
|
||||
type: "test",
|
||||
id: testFile.path,
|
||||
label: testFile.name,
|
||||
tooltip: testFile.path,
|
||||
file: testFile.path,
|
||||
};
|
||||
}
|
||||
|
||||
private static createTestSuiteInfo(
|
||||
testDirectory: FileTreeDirectory,
|
||||
label: string,
|
||||
): TestSuiteInfo {
|
||||
return {
|
||||
type: "suite",
|
||||
id: testDirectory.path,
|
||||
label,
|
||||
children: QLTestAdapter.createTestOrSuiteInfos(testDirectory.children),
|
||||
tooltip: testDirectory.path,
|
||||
};
|
||||
}
|
||||
|
||||
public async load(): Promise<void> {
|
||||
this.discoverTests();
|
||||
}
|
||||
|
||||
private discoverTests(): void {
|
||||
this._tests.fire({ type: "started" } as TestLoadStartedEvent);
|
||||
|
||||
const testDirectory = this.qlTestDiscovery.testDirectory;
|
||||
let testSuite: TestSuiteInfo | undefined;
|
||||
if (testDirectory?.children.length) {
|
||||
const children = QLTestAdapter.createTestOrSuiteInfos(
|
||||
testDirectory.children,
|
||||
);
|
||||
testSuite = {
|
||||
type: "suite",
|
||||
label: "CodeQL",
|
||||
id: testDirectory.path,
|
||||
children,
|
||||
};
|
||||
}
|
||||
this._tests.fire({
|
||||
type: "finished",
|
||||
suite: testSuite,
|
||||
} as TestLoadFinishedEvent);
|
||||
}
|
||||
|
||||
public async run(tests: string[]): Promise<void> {
|
||||
if (this.runningTask !== undefined) {
|
||||
throw new Error("Tests already running.");
|
||||
}
|
||||
|
||||
testLogger.outputChannel.clear();
|
||||
testLogger.outputChannel.show(true);
|
||||
|
||||
this.runningTask = this.track(new CancellationTokenSource());
|
||||
const token = this.runningTask.token;
|
||||
|
||||
this._testStates.fire({
|
||||
type: "started",
|
||||
tests,
|
||||
} as TestRunStartedEvent);
|
||||
|
||||
await this.testRunner.run(tests, testLogger, token, (event) =>
|
||||
this.processTestEvent(event),
|
||||
);
|
||||
|
||||
this._testStates.fire({ type: "finished" } as TestRunFinishedEvent);
|
||||
this.clearTask();
|
||||
}
|
||||
|
||||
private clearTask(): void {
|
||||
if (this.runningTask !== undefined) {
|
||||
const runningTask = this.runningTask;
|
||||
this.runningTask = undefined;
|
||||
this.disposeAndStopTracking(runningTask);
|
||||
}
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
if (this.runningTask !== undefined) {
|
||||
void testLogger.log("Cancelling test run...");
|
||||
this.runningTask.cancel();
|
||||
this.clearTask();
|
||||
}
|
||||
}
|
||||
|
||||
private async processTestEvent(event: TestCompleted): Promise<void> {
|
||||
const state = event.pass
|
||||
? "passed"
|
||||
: event.messages?.length
|
||||
? "errored"
|
||||
: "failed";
|
||||
let message: string | undefined;
|
||||
if (event.failureDescription || event.diff?.length) {
|
||||
message =
|
||||
event.failureStage === "RESULT"
|
||||
? [
|
||||
"",
|
||||
`${state}: ${event.test}`,
|
||||
event.failureDescription || event.diff?.join("\n"),
|
||||
"",
|
||||
].join("\n")
|
||||
: [
|
||||
"",
|
||||
`${event.failureStage?.toLowerCase() ?? "unknown stage"} error: ${
|
||||
event.test
|
||||
}`,
|
||||
event.failureDescription ||
|
||||
`${event.messages[0].severity}: ${event.messages[0].message}`,
|
||||
"",
|
||||
].join("\n");
|
||||
void testLogger.log(message);
|
||||
}
|
||||
this._testStates.fire({
|
||||
type: "test",
|
||||
state,
|
||||
test: event.test,
|
||||
message,
|
||||
decorations: event.messages?.map((msg) => ({
|
||||
line: msg.position.line,
|
||||
message: msg.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,9 +6,8 @@ import type { TestItem, TextDocumentShowOptions } from "vscode";
|
|||
import { Uri, window } from "vscode";
|
||||
import { basename } from "path";
|
||||
import type { App } from "../common/app";
|
||||
import type { TestTreeNode } from "./test-tree-node";
|
||||
|
||||
type TestNode = TestTreeNode | TestItem;
|
||||
type TestNode = TestItem;
|
||||
|
||||
/**
|
||||
* Base class for both the legacy and new test services. Implements commands that are common to
|
||||
|
|
|
@ -1,9 +0,0 @@
|
|||
import type { TestSuiteInfo, TestInfo } from "vscode-test-adapter-api";
|
||||
|
||||
/**
|
||||
* Tree view node for a test, suite, or collection. This object is passed as the argument to the
|
||||
* command handler of a context menu item for a tree view item.
|
||||
*/
|
||||
export interface TestTreeNode {
|
||||
readonly info: TestSuiteInfo | TestInfo;
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
import type {
|
||||
TestHub,
|
||||
TestController,
|
||||
TestAdapter,
|
||||
TestRunStartedEvent,
|
||||
TestRunFinishedEvent,
|
||||
TestEvent,
|
||||
TestSuiteEvent,
|
||||
} from "vscode-test-adapter-api";
|
||||
import type { TestTreeNode } from "./test-tree-node";
|
||||
import { DisposableObject } from "../common/disposable-object";
|
||||
import { QLTestAdapter } from "./test-adapter";
|
||||
import type { App } from "../common/app";
|
||||
import { TestManagerBase } from "./test-manager-base";
|
||||
|
||||
type VSCodeTestEvent =
|
||||
| TestRunStartedEvent
|
||||
| TestRunFinishedEvent
|
||||
| TestSuiteEvent
|
||||
| TestEvent;
|
||||
|
||||
/**
|
||||
* Test event listener. Currently unused, but left in to keep the plumbing hooked up for future use.
|
||||
*/
|
||||
class QLTestListener extends DisposableObject {
|
||||
constructor(adapter: TestAdapter) {
|
||||
super();
|
||||
|
||||
this.push(adapter.testStates(this.onTestStatesEvent, this));
|
||||
}
|
||||
|
||||
private onTestStatesEvent(_e: VSCodeTestEvent): void {
|
||||
/**/
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service that implements all UI and commands for QL tests.
|
||||
*/
|
||||
export class TestUIService extends TestManagerBase implements TestController {
|
||||
private readonly listeners: Map<TestAdapter, QLTestListener> = new Map();
|
||||
|
||||
public constructor(
|
||||
app: App,
|
||||
private readonly testHub: TestHub,
|
||||
) {
|
||||
super(app);
|
||||
|
||||
testHub.registerTestController(this);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.testHub.unregisterTestController(this);
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public registerTestAdapter(adapter: TestAdapter): void {
|
||||
this.listeners.set(adapter, new QLTestListener(adapter));
|
||||
}
|
||||
|
||||
public unregisterTestAdapter(adapter: TestAdapter): void {
|
||||
if (adapter instanceof QLTestAdapter) {
|
||||
this.listeners.delete(adapter);
|
||||
}
|
||||
}
|
||||
|
||||
protected getTestPath(node: TestTreeNode): string {
|
||||
return node.info.id;
|
||||
}
|
||||
}
|
|
@ -50,20 +50,10 @@ export default class JestRunnerInstalledExtensions extends VSCodeTestRunner {
|
|||
const [cli, ...args] =
|
||||
resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath);
|
||||
|
||||
spawnSync(
|
||||
cli,
|
||||
[
|
||||
...args,
|
||||
"--install-extension",
|
||||
"hbenl.vscode-test-explorer",
|
||||
"--install-extension",
|
||||
"ms-vscode.test-adapter-converter",
|
||||
],
|
||||
{
|
||||
encoding: "utf-8",
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
spawnSync(cli, args, {
|
||||
encoding: "utf-8",
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
installedOnVsCodeVersions.add(versionKey);
|
||||
}
|
||||
|
|
Загрузка…
Ссылка в новой задаче