mirror of
https://github.com/Microsoft/vscode
synced 2024-10-05 19:02:54 +00:00
testing: implement proposed active profiles api (#197664)
testing: implement proposed active profiles api For https://github.com/microsoft/vscode/issues/193160
This commit is contained in:
parent
273862296b
commit
2749de3808
|
@ -52,6 +52,7 @@
|
|||
"treeViewReveal",
|
||||
"workspaceTrust",
|
||||
"telemetry",
|
||||
"testingActiveProfile",
|
||||
"windowActivity",
|
||||
"interactiveUserActions"
|
||||
],
|
||||
|
|
|
@ -540,8 +540,8 @@ export namespace Event {
|
|||
export interface IChainableSythensis<T> {
|
||||
map<O>(fn: (i: T) => O): IChainableSythensis<O>;
|
||||
forEach(fn: (i: T) => void): IChainableSythensis<T>;
|
||||
filter<R extends T>(fn: (e: T) => e is R): IChainableSythensis<R>;
|
||||
filter(fn: (e: T) => boolean): IChainableSythensis<T>;
|
||||
filter<R>(fn: (e: T | R) => e is R): IChainableSythensis<R>;
|
||||
reduce<R>(merge: (last: R, event: T) => R, initial: R): IChainableSythensis<R>;
|
||||
reduce<R>(merge: (last: R | undefined, event: T) => R): IChainableSythensis<R>;
|
||||
latch(equals?: (a: T, b: T) => boolean): IChainableSythensis<T>;
|
||||
|
|
|
@ -5,12 +5,13 @@
|
|||
|
||||
import { VSBuffer } from 'vs/base/common/buffer';
|
||||
import { CancellationToken } from 'vs/base/common/cancellation';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { revive } from 'vs/base/common/marshalling';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { Range } from 'vs/editor/common/core/range';
|
||||
import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue';
|
||||
import { ExtensionRunTestsRequest, IFileCoverage, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes';
|
||||
import { ExtensionRunTestsRequest, IFileCoverage, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestRunProfileBitset, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes';
|
||||
import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
|
||||
import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService';
|
||||
import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult';
|
||||
|
@ -45,6 +46,21 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh
|
|||
this.proxy.$cancelExtensionTestRun(runId);
|
||||
}));
|
||||
|
||||
this._register(Event.debounce(testProfiles.onDidChange, (_last, e) => e)(() => {
|
||||
const defaults = new Set([
|
||||
...testProfiles.getGroupDefaultProfiles(TestRunProfileBitset.Run),
|
||||
...testProfiles.getGroupDefaultProfiles(TestRunProfileBitset.Debug),
|
||||
...testProfiles.getGroupDefaultProfiles(TestRunProfileBitset.Coverage),
|
||||
]);
|
||||
|
||||
const obj: Record</* controller id */string, /* profile id */ number[]> = {};
|
||||
for (const { controller, profiles } of this.testProfiles.all()) {
|
||||
obj[controller.id] = profiles.filter(p => defaults.has(p)).map(p => p.profileId);
|
||||
}
|
||||
|
||||
this.proxy.$setActiveRunProfiles(obj);
|
||||
}));
|
||||
|
||||
this._register(resultService.onResultsChanged(evt => {
|
||||
const results = 'completed' in evt ? evt.completed : ('inserted' in evt ? evt.inserted : undefined);
|
||||
const serialized = results?.toJSONWithMessages();
|
||||
|
|
|
@ -2638,6 +2638,8 @@ export interface ExtHostTestingShape {
|
|||
$refreshTests(controllerId: string, token: CancellationToken): Promise<void>;
|
||||
/** Ensures any pending test diffs are flushed */
|
||||
$syncTests(): Promise<void>;
|
||||
/** Sets the active test run profiles */
|
||||
$setActiveRunProfiles(profiles: Record</* controller id */string, /* profile id */ number[]>): void;
|
||||
}
|
||||
|
||||
export interface ExtHostLocalizationShape {
|
||||
|
|
|
@ -29,6 +29,7 @@ import { TestCommandId } from 'vs/workbench/contrib/testing/common/constants';
|
|||
import { TestId, TestIdPathParts, TestPosition } from 'vs/workbench/contrib/testing/common/testId';
|
||||
import { InvalidTestItemError } from 'vs/workbench/contrib/testing/common/testItemCollection';
|
||||
import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes';
|
||||
import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions';
|
||||
import type * as vscode from 'vscode';
|
||||
|
||||
interface ControllerInfo {
|
||||
|
@ -36,14 +37,18 @@ interface ControllerInfo {
|
|||
profiles: Map<number, vscode.TestRunProfile>;
|
||||
collection: ExtHostTestItemCollection;
|
||||
extension: Readonly<IRelaxedExtensionDescription>;
|
||||
activeProfiles: Set<number>;
|
||||
}
|
||||
|
||||
export class ExtHostTesting implements ExtHostTestingShape {
|
||||
private readonly resultsChangedEmitter = new Emitter<void>();
|
||||
private readonly controllers = new Map</* controller ID */ string, ControllerInfo>();
|
||||
type ActiveProfileChangeEvent = Map</* controllerId */ string, Map< /* profileId */number, boolean>>;
|
||||
|
||||
export class ExtHostTesting extends Disposable implements ExtHostTestingShape {
|
||||
private readonly resultsChangedEmitter = this._register(new Emitter<void>());
|
||||
protected readonly controllers = new Map</* controller ID */ string, ControllerInfo>();
|
||||
private readonly proxy: MainThreadTestingShape;
|
||||
private readonly runTracker: TestRunCoordinator;
|
||||
private readonly observer: TestObservers;
|
||||
private readonly activeProfilesChangedEmitter = this._register(new Emitter<ActiveProfileChangeEvent>());
|
||||
|
||||
public onResultsChanged = this.resultsChangedEmitter.event;
|
||||
public results: ReadonlyArray<vscode.TestRunResult> = [];
|
||||
|
@ -53,6 +58,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
|
|||
commands: ExtHostCommands,
|
||||
private readonly editors: ExtHostDocumentsAndEditors,
|
||||
) {
|
||||
super();
|
||||
this.proxy = rpc.getProxy(MainContext.MainThreadTesting);
|
||||
this.observer = new TestObservers(this.proxy);
|
||||
this.runTracker = new TestRunCoordinator(this.proxy);
|
||||
|
@ -110,6 +116,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
|
|||
collection.root.label = label;
|
||||
|
||||
const profiles = new Map<number, vscode.TestRunProfile>();
|
||||
const activeProfiles = new Set<number>();
|
||||
const proxy = this.proxy;
|
||||
|
||||
const controller: vscode.TestController = {
|
||||
|
@ -140,7 +147,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
|
|||
profileId++;
|
||||
}
|
||||
|
||||
return new TestRunProfileImpl(this.proxy, profiles, controllerId, profileId, label, group, runHandler, isDefault, tag, supportsContinuousRun);
|
||||
return new TestRunProfileImpl(this.proxy, profiles, extension, activeProfiles, this.activeProfilesChangedEmitter.event, controllerId, profileId, label, group, runHandler, isDefault, tag, supportsContinuousRun);
|
||||
},
|
||||
createTestItem(id, label, uri) {
|
||||
return new TestItemImpl(controllerId, id, label, uri);
|
||||
|
@ -170,7 +177,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
|
|||
proxy.$registerTestController(controllerId, label, !!refreshHandler);
|
||||
disposable.add(toDisposable(() => proxy.$unregisterTestController(controllerId)));
|
||||
|
||||
const info: ControllerInfo = { controller, collection, profiles: profiles, extension };
|
||||
const info: ControllerInfo = { controller, collection, profiles, extension, activeProfiles };
|
||||
this.controllers.set(controllerId, info);
|
||||
disposable.add(toDisposable(() => this.controllers.delete(controllerId)));
|
||||
|
||||
|
@ -245,6 +252,33 @@ export class ExtHostTesting implements ExtHostTestingShape {
|
|||
this.controllers.get(controllerId)?.profiles.get(profileId)?.configureHandler?.();
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
$setActiveRunProfiles(profiles: Record</* controller id */string, /* profile id */ number[]>): void {
|
||||
const evt: ActiveProfileChangeEvent = new Map();
|
||||
for (const [controllerId, profileIds] of Object.entries(profiles)) {
|
||||
const ctrl = this.controllers.get(controllerId);
|
||||
if (!ctrl) {
|
||||
continue;
|
||||
}
|
||||
const changes = new Map<number, boolean>();
|
||||
const added = profileIds.filter(id => !ctrl.activeProfiles.has(id));
|
||||
const removed = [...ctrl.activeProfiles].filter(id => !profileIds.includes(id));
|
||||
for (const id of added) {
|
||||
changes.set(id, true);
|
||||
ctrl.activeProfiles.add(id);
|
||||
}
|
||||
for (const id of removed) {
|
||||
changes.set(id, false);
|
||||
ctrl.activeProfiles.delete(id);
|
||||
}
|
||||
if (changes.size) {
|
||||
evt.set(controllerId, changes);
|
||||
}
|
||||
}
|
||||
|
||||
this.activeProfilesChangedEmitter.fire(evt);
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async $refreshTests(controllerId: string, token: CancellationToken) {
|
||||
await this.controllers.get(controllerId)?.controller.refreshHandler?.(token);
|
||||
|
@ -1005,6 +1039,9 @@ class TestObservers {
|
|||
|
||||
export class TestRunProfileImpl implements vscode.TestRunProfile {
|
||||
readonly #proxy: MainThreadTestingShape;
|
||||
readonly #extension: IRelaxedExtensionDescription;
|
||||
readonly #activeProfiles: ReadonlySet<number>;
|
||||
readonly #onDidChangeActiveProfiles: Event<ActiveProfileChangeEvent>;
|
||||
#profiles?: Map<number, vscode.TestRunProfile>;
|
||||
private _configureHandler?: (() => void);
|
||||
|
||||
|
@ -1065,9 +1102,25 @@ export class TestRunProfileImpl implements vscode.TestRunProfile {
|
|||
}
|
||||
}
|
||||
|
||||
public get isSelected() {
|
||||
checkProposedApiEnabled(this.#extension, 'testingActiveProfile');
|
||||
return this.#activeProfiles.has(this.profileId);
|
||||
}
|
||||
|
||||
public get onDidChangeSelected() {
|
||||
checkProposedApiEnabled(this.#extension, 'testingActiveProfile');
|
||||
return Event.chain(this.#onDidChangeActiveProfiles, $ => $
|
||||
.map(ev => ev.get(this.controllerId)?.get(this.profileId))
|
||||
.filter(isDefined)
|
||||
);
|
||||
}
|
||||
|
||||
constructor(
|
||||
proxy: MainThreadTestingShape,
|
||||
profiles: Map<number, vscode.TestRunProfile>,
|
||||
extension: IRelaxedExtensionDescription,
|
||||
activeProfiles: ReadonlySet<number>,
|
||||
onDidChangeActiveProfiles: Event<ActiveProfileChangeEvent>,
|
||||
public readonly controllerId: string,
|
||||
public readonly profileId: number,
|
||||
private _label: string,
|
||||
|
@ -1079,6 +1132,9 @@ export class TestRunProfileImpl implements vscode.TestRunProfile {
|
|||
) {
|
||||
this.#proxy = proxy;
|
||||
this.#profiles = profiles;
|
||||
this.#extension = extension;
|
||||
this.#activeProfiles = activeProfiles;
|
||||
this.#onDidChangeActiveProfiles = onDidChangeActiveProfiles;
|
||||
profiles.set(profileId, this);
|
||||
|
||||
const groupBitset = profileGroupToBitset[kind];
|
||||
|
|
|
@ -7,21 +7,27 @@ import * as assert from 'assert';
|
|||
import * as sinon from 'sinon';
|
||||
import { VSBuffer } from 'vs/base/common/buffer';
|
||||
import { CancellationTokenSource } from 'vs/base/common/cancellation';
|
||||
import { Event } from 'vs/base/common/event';
|
||||
import { Iterable } from 'vs/base/common/iterator';
|
||||
import { URI } from 'vs/base/common/uri';
|
||||
import { mockObject, MockObject } from 'vs/base/test/common/mock';
|
||||
import { mock, mockObject, MockObject } from 'vs/base/test/common/mock';
|
||||
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
|
||||
import * as editorRange from 'vs/editor/common/core/range';
|
||||
import { IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions';
|
||||
import { ExtensionIdentifier, IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions';
|
||||
import { NullLogService } from 'vs/platform/log/common/log';
|
||||
import { MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol';
|
||||
import { ExtHostCommands } from 'vs/workbench/api/common/extHostCommands';
|
||||
import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors';
|
||||
import { TestRunCoordinator, TestRunDto, TestRunProfileImpl } from 'vs/workbench/api/common/extHostTesting';
|
||||
import { IExtHostTelemetry } from 'vs/workbench/api/common/extHostTelemetry';
|
||||
import { ExtHostTesting, TestRunCoordinator, TestRunDto, TestRunProfileImpl } from 'vs/workbench/api/common/extHostTesting';
|
||||
import { ExtHostTestItemCollection, TestItemImpl } from 'vs/workbench/api/common/extHostTestItem';
|
||||
import * as convert from 'vs/workbench/api/common/extHostTypeConverters';
|
||||
import { Location, Position, Range, TestMessage, TestResultState, TestRunProfileKind, TestRunRequest as TestRunRequestImpl, TestTag } from 'vs/workbench/api/common/extHostTypes';
|
||||
import { AnyCallRPCProtocol } from 'vs/workbench/api/test/common/testRPCProtocol';
|
||||
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
|
||||
import { TestDiffOpType, TestItemExpandState, TestMessageType, TestsDiff } from 'vs/workbench/contrib/testing/common/testTypes';
|
||||
import type { TestItem, TestRunRequest } from 'vscode';
|
||||
import { nullExtensionDescription } from 'vs/workbench/services/extensions/common/extensions';
|
||||
import type { TestController, TestItem, TestRunProfile, TestRunRequest } from 'vscode';
|
||||
|
||||
const simplify = (item: TestItem) => ({
|
||||
id: item.id,
|
||||
|
@ -604,7 +610,7 @@ suite('ExtHost Testing', () => {
|
|||
cts = new CancellationTokenSource();
|
||||
c = new TestRunCoordinator(proxy);
|
||||
|
||||
configuration = new TestRunProfileImpl(mockObject<MainThreadTestingShape>()(), new Map(), 'ctrlId', 42, 'Do Run', TestRunProfileKind.Run, () => { }, false);
|
||||
configuration = new TestRunProfileImpl(mockObject<MainThreadTestingShape>()(), new Map(), nullExtensionDescription, new Set(), Event.None, 'ctrlId', 42, 'Do Run', TestRunProfileKind.Run, () => { }, false);
|
||||
|
||||
await single.expand(single.root.id, Infinity);
|
||||
single.collectDiff();
|
||||
|
@ -867,4 +873,82 @@ suite('ExtHost Testing', () => {
|
|||
task1.end();
|
||||
});
|
||||
});
|
||||
|
||||
suite('service', () => {
|
||||
let ctrl: TestExtHostTesting;
|
||||
|
||||
class TestExtHostTesting extends ExtHostTesting {
|
||||
public getProfileInternalId(ctrl: TestController, profile: TestRunProfile) {
|
||||
for (const [id, p] of this.controllers.get(ctrl.id)!.profiles) {
|
||||
if (profile === p) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('profile not found');
|
||||
}
|
||||
}
|
||||
|
||||
setup(() => {
|
||||
const rpcProtocol = AnyCallRPCProtocol();
|
||||
ctrl = ds.add(new TestExtHostTesting(
|
||||
rpcProtocol,
|
||||
new ExtHostCommands(rpcProtocol, new NullLogService(), new class extends mock<IExtHostTelemetry>() {
|
||||
override onExtensionError(): boolean {
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
new ExtHostDocumentsAndEditors(rpcProtocol, new NullLogService()),
|
||||
));
|
||||
});
|
||||
|
||||
test('exposes active profiles correctly', async () => {
|
||||
const extA = { ...nullExtensionDescription, identifier: new ExtensionIdentifier('ext.a'), enabledApiProposals: ['testingActiveProfile'] };
|
||||
const extB = { ...nullExtensionDescription, identifier: new ExtensionIdentifier('ext.b'), enabledApiProposals: ['testingActiveProfile'] };
|
||||
|
||||
const ctrlA = ds.add(ctrl.createTestController(extA, 'a', 'ctrla'));
|
||||
const profAA = ds.add(ctrlA.createRunProfile('aa', TestRunProfileKind.Run, () => { }));
|
||||
const profAB = ds.add(ctrlA.createRunProfile('ab', TestRunProfileKind.Run, () => { }));
|
||||
|
||||
const ctrlB = ds.add(ctrl.createTestController(extB, 'b', 'ctrlb'));
|
||||
const profBA = ds.add(ctrlB.createRunProfile('ba', TestRunProfileKind.Run, () => { }));
|
||||
const profBB = ds.add(ctrlB.createRunProfile('bb', TestRunProfileKind.Run, () => { }));
|
||||
const neverCalled = sinon.stub();
|
||||
|
||||
// empty default state:
|
||||
assert.deepStrictEqual(profAA.isSelected, false);
|
||||
assert.deepStrictEqual(profBA.isSelected, false);
|
||||
assert.deepStrictEqual(profBB.isSelected, false);
|
||||
|
||||
// fires a change event:
|
||||
const changeA = Event.toPromise(profAA.onDidChangeSelected as Event<boolean>);
|
||||
const changeBA = Event.toPromise(profBA.onDidChangeSelected as Event<boolean>);
|
||||
const changeBB = Event.toPromise(profBB.onDidChangeSelected as Event<boolean>);
|
||||
|
||||
ds.add(profAB.onDidChangeSelected(neverCalled));
|
||||
assert.strictEqual(neverCalled.called, false);
|
||||
|
||||
ctrl.$setActiveRunProfiles({
|
||||
a: [ctrl.getProfileInternalId(ctrlA, profAA)],
|
||||
b: [ctrl.getProfileInternalId(ctrlB, profBA), ctrl.getProfileInternalId(ctrlB, profBB)]
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(await changeA, true);
|
||||
assert.deepStrictEqual(await changeBA, true);
|
||||
assert.deepStrictEqual(await changeBB, true);
|
||||
|
||||
// updates internal state:
|
||||
assert.deepStrictEqual(profAA.isSelected, true);
|
||||
assert.deepStrictEqual(profBA.isSelected, true);
|
||||
assert.deepStrictEqual(profBB.isSelected, true);
|
||||
assert.deepStrictEqual(profAB.isSelected, false);
|
||||
|
||||
// no-ops if equal
|
||||
ds.add(profAA.onDidChangeSelected(neverCalled));
|
||||
ctrl.$setActiveRunProfiles({
|
||||
a: [ctrl.getProfileInternalId(ctrlA, profAA)],
|
||||
});
|
||||
assert.strictEqual(neverCalled.called, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -28,6 +28,18 @@ export function SingleProxyRPCProtocol(thing: any): IExtHostContext & IExtHostRp
|
|||
};
|
||||
}
|
||||
|
||||
/** Makes a fake {@link SingleProxyRPCProtocol} on which any method can be called */
|
||||
export function AnyCallRPCProtocol<T>(useCalls?: { [K in keyof T]: T[K] }) {
|
||||
return SingleProxyRPCProtocol(new Proxy({}, {
|
||||
get(_target, prop: string) {
|
||||
if (useCalls && prop in useCalls) {
|
||||
return (useCalls as any)[prop];
|
||||
}
|
||||
return () => Promise.resolve(undefined);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export class TestRPCProtocol implements IExtHostContext, IExtHostRpcService {
|
||||
|
||||
public _serviceBrand: undefined;
|
||||
|
|
|
@ -97,6 +97,7 @@ export const allApiProposals = Object.freeze({
|
|||
terminalSelection: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalSelection.d.ts',
|
||||
testCoverage: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testCoverage.d.ts',
|
||||
testObserver: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testObserver.d.ts',
|
||||
testingActiveProfile: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testingActiveProfile.d.ts',
|
||||
textSearchProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textSearchProvider.d.ts',
|
||||
timeline: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.timeline.d.ts',
|
||||
tokenInformation: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tokenInformation.d.ts',
|
||||
|
|
21
src/vscode-dts/vscode.proposed.testingActiveProfile.d.ts
vendored
Normal file
21
src/vscode-dts/vscode.proposed.testingActiveProfile.d.ts
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// https://github.com/microsoft/vscode/issues/193160 @connor4312
|
||||
|
||||
declare module 'vscode' {
|
||||
export interface TestRunProfile {
|
||||
/**
|
||||
* Whether this profile is currently selected as a default by the user
|
||||
*/
|
||||
readonly isSelected: boolean;
|
||||
|
||||
/**
|
||||
* Fired when a user has changed whether this is a selected profile. The
|
||||
* event contains the new value of {@link isSelected}
|
||||
*/
|
||||
onDidChangeSelected: Event<boolean>;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue