mirror of
https://github.com/microsoft/vscode.git
synced 2025-12-24 20:26:08 +00:00
added notebook output renderer tests
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
import { RGBA, Color } from './color';
|
||||
import { ansiColorIdentifiers } from './colorMap';
|
||||
import { ttPolicy } from './htmlHelper';
|
||||
import { linkify } from './linkify';
|
||||
|
||||
|
||||
@@ -379,11 +380,6 @@ export function handleANSIOutput(text: string, trustHtml: boolean): HTMLSpanElem
|
||||
}
|
||||
}
|
||||
|
||||
const ttPolicy = window.trustedTypes?.createPolicy('notebookRenderer', {
|
||||
createHTML: value => value,
|
||||
createScript: value => value,
|
||||
});
|
||||
|
||||
function appendStylizedStringToContainer(
|
||||
root: HTMLElement,
|
||||
stringContent: string,
|
||||
|
||||
10
extensions/notebook-renderers/src/htmlHelper.ts
Normal file
10
extensions/notebook-renderers/src/htmlHelper.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export const ttPolicy = (typeof window !== 'undefined') ?
|
||||
window.trustedTypes?.createPolicy('notebookRenderer', {
|
||||
createHTML: value => value,
|
||||
createScript: value => value,
|
||||
}) : undefined;
|
||||
@@ -5,34 +5,8 @@
|
||||
|
||||
import type { ActivationFunction, OutputItem, RendererContext } from 'vscode-notebook-renderer';
|
||||
import { createOutputContent, scrollableClass } from './textHelper';
|
||||
|
||||
interface IDisposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface HtmlRenderingHook {
|
||||
/**
|
||||
* Invoked after the output item has been rendered but before it has been appended to the document.
|
||||
*
|
||||
* @return A new `HTMLElement` or `undefined` to continue using the provided element.
|
||||
*/
|
||||
postRender(outputItem: OutputItem, element: HTMLElement, signal: AbortSignal): HTMLElement | undefined | Promise<HTMLElement | undefined>;
|
||||
}
|
||||
|
||||
interface JavaScriptRenderingHook {
|
||||
/**
|
||||
* Invoked before the script is evaluated.
|
||||
*
|
||||
* @return A new string of JavaScript or `undefined` to continue using the provided string.
|
||||
*/
|
||||
preEvaluate(outputItem: OutputItem, element: HTMLElement, script: string, signal: AbortSignal): string | undefined | Promise<string | undefined>;
|
||||
}
|
||||
|
||||
interface RenderOptions {
|
||||
readonly lineLimit: number;
|
||||
readonly outputScrolling: boolean;
|
||||
readonly outputWordWrap: boolean;
|
||||
}
|
||||
import { HtmlRenderingHook, IDisposable, IRichRenderContext, JavaScriptRenderingHook, RenderOptions } from './rendererTypes';
|
||||
import { ttPolicy } from './htmlHelper';
|
||||
|
||||
function clearContainer(container: HTMLElement) {
|
||||
while (container.firstChild) {
|
||||
@@ -67,11 +41,6 @@ function renderImage(outputInfo: OutputItem, element: HTMLElement): IDisposable
|
||||
return disposable;
|
||||
}
|
||||
|
||||
const ttPolicy = window.trustedTypes?.createPolicy('notebookRenderer', {
|
||||
createHTML: value => value,
|
||||
createScript: value => value,
|
||||
});
|
||||
|
||||
const preservedScriptAttributes: (keyof HTMLScriptElement)[] = [
|
||||
'type', 'src', 'nonce', 'noModule', 'async',
|
||||
];
|
||||
@@ -138,8 +107,6 @@ interface Event<T> {
|
||||
(listener: (e: T) => any, thisArgs?: any, disposables?: IDisposable[]): IDisposable;
|
||||
}
|
||||
|
||||
type IRichRenderContext = RendererContext<void> & { readonly settings: RenderOptions; readonly onDidChangeSettings: Event<RenderOptions> };
|
||||
|
||||
function createDisposableStore(): { push(...disposables: IDisposable[]): void; dispose(): void } {
|
||||
const localDisposables: IDisposable[] = [];
|
||||
const disposable = {
|
||||
@@ -177,15 +144,13 @@ function renderError(
|
||||
|
||||
if (err.stack) {
|
||||
outputElement.classList.add('traceback');
|
||||
if (ctx.settings.outputWordWrap) {
|
||||
outputElement.classList.add('wordWrap');
|
||||
}
|
||||
disposableStore.push(ctx.onDidChangeSettings(e => {
|
||||
outputElement.classList.toggle('wordWrap', e.outputWordWrap);
|
||||
}));
|
||||
|
||||
const outputScrolling = ctx.settings.outputScrolling;
|
||||
const content = createOutputContent(outputInfo.id, [err.stack ?? ''], ctx.settings.lineLimit, outputScrolling, true);
|
||||
content.classList.toggle('word-wrap', ctx.settings.outputWordWrap);
|
||||
disposableStore.push(ctx.onDidChangeSettings(e => {
|
||||
content.classList.toggle('word-wrap', e.outputWordWrap);
|
||||
}));
|
||||
content.classList.toggle('scrollable', outputScrolling);
|
||||
outputElement.classList.toggle('remove-padding', outputScrolling);
|
||||
outputElement.appendChild(content);
|
||||
@@ -281,9 +246,9 @@ function renderStream(outputInfo: OutputItem, outputElement: HTMLElement, error:
|
||||
contentParent.appendChild(content);
|
||||
contentParent.classList.toggle('scrollable', outputScrolling);
|
||||
|
||||
contentParent.classList.toggle('wordWrap', ctx.settings.outputWordWrap);
|
||||
contentParent.classList.toggle('word-wrap', ctx.settings.outputWordWrap);
|
||||
disposableStore.push(ctx.onDidChangeSettings(e => {
|
||||
contentParent.classList.toggle('wordWrap', e.outputWordWrap);
|
||||
contentParent.classList.toggle('word-wrap', e.outputWordWrap);
|
||||
}));
|
||||
|
||||
|
||||
@@ -305,7 +270,7 @@ function renderText(outputInfo: OutputItem, outputElement: HTMLElement, ctx: IRi
|
||||
const content = createOutputContent(outputInfo.id, [text], ctx.settings.lineLimit, ctx.settings.outputScrolling, false);
|
||||
content.classList.add('output-plaintext');
|
||||
if (ctx.settings.outputWordWrap) {
|
||||
content.classList.add('wordWrap');
|
||||
content.classList.add('word-wrap');
|
||||
}
|
||||
|
||||
const outputScrolling = ctx.settings.outputScrolling;
|
||||
@@ -347,7 +312,7 @@ export const activate: ActivationFunction<void> = (ctx) => {
|
||||
white-space: pre;
|
||||
}
|
||||
/* When wordwrap turned on, force it to pre-wrap */
|
||||
#container div.output_container .wordWrap span {
|
||||
#container div.output_container .word-wrap span {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#container div.output .scrollable {
|
||||
|
||||
@@ -11,7 +11,7 @@ const WIN_RELATIVE_PATH = /(?:(?:\~|\.)(?:(?:\\|\/)[\w\.-]*)+)/;
|
||||
const WIN_PATH = new RegExp(`(${WIN_ABSOLUTE_PATH.source}|${WIN_RELATIVE_PATH.source})`);
|
||||
const POSIX_PATH = /((?:\~|\.)?(?:\/[\w\.-]*)+)/;
|
||||
const LINE_COLUMN = /(?:\:([\d]+))?(?:\:([\d]+))?/;
|
||||
const isWindows = navigator.userAgent.indexOf('Windows') >= 0;
|
||||
const isWindows = (typeof navigator !== 'undefined') ? navigator.userAgent && navigator.userAgent.indexOf('Windows') >= 0 : false;
|
||||
const PATH_LINK_REGEX = new RegExp(`${isWindows ? WIN_PATH.source : POSIX_PATH.source}${LINE_COLUMN.source}`, 'g');
|
||||
|
||||
const MAX_LENGTH = 2000;
|
||||
|
||||
37
extensions/notebook-renderers/src/rendererTypes.ts
Normal file
37
extensions/notebook-renderers/src/rendererTypes.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { OutputItem, RendererContext } from 'vscode-notebook-renderer';
|
||||
import { Event } from 'vscode';
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface HtmlRenderingHook {
|
||||
/**
|
||||
* Invoked after the output item has been rendered but before it has been appended to the document.
|
||||
*
|
||||
* @return A new `HTMLElement` or `undefined` to continue using the provided element.
|
||||
*/
|
||||
postRender(outputItem: OutputItem, element: HTMLElement, signal: AbortSignal): HTMLElement | undefined | Promise<HTMLElement | undefined>;
|
||||
}
|
||||
|
||||
export interface JavaScriptRenderingHook {
|
||||
/**
|
||||
* Invoked before the script is evaluated.
|
||||
*
|
||||
* @return A new string of JavaScript or `undefined` to continue using the provided string.
|
||||
*/
|
||||
preEvaluate(outputItem: OutputItem, element: HTMLElement, script: string, signal: AbortSignal): string | undefined | Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
readonly lineLimit: number;
|
||||
readonly outputScrolling: boolean;
|
||||
readonly outputWordWrap: boolean;
|
||||
}
|
||||
|
||||
export type IRichRenderContext = RendererContext<void> & { readonly settings: RenderOptions; readonly onDidChangeSettings: Event<RenderOptions> };
|
||||
40
extensions/notebook-renderers/src/test/index.ts
Normal file
40
extensions/notebook-renderers/src/test/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as testRunner from '../../../../test/integration/electron/testrunner';
|
||||
|
||||
const options: import('mocha').MochaOptions = {
|
||||
ui: 'tdd',
|
||||
color: true,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
// These integration tests is being run in multiple environments (electron, web, remote)
|
||||
// so we need to set the suite name based on the environment as the suite name is used
|
||||
// for the test results file name
|
||||
let suite = '';
|
||||
if (process.env.VSCODE_BROWSER) {
|
||||
suite = `${process.env.VSCODE_BROWSER} Browser Integration notebook output renderer Tests`;
|
||||
} else if (process.env.REMOTE_VSCODE) {
|
||||
suite = 'Remote Integration notebook output renderer Tests';
|
||||
} else {
|
||||
suite = 'Integration notebook output renderer Tests';
|
||||
}
|
||||
|
||||
if (process.env.BUILD_ARTIFACTSTAGINGDIRECTORY) {
|
||||
options.reporter = 'mocha-multi-reporters';
|
||||
options.reporterOptions = {
|
||||
reporterEnabled: 'spec, mocha-junit-reporter',
|
||||
mochaJunitReporterReporterOptions: {
|
||||
testsuitesTitle: `${suite} ${process.platform}`,
|
||||
mochaFile: path.join(process.env.BUILD_ARTIFACTSTAGINGDIRECTORY, `test-results/${process.platform}-${process.arch}-${suite.toLowerCase().replace(/[^\w]/g, '-')}-results.xml`)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
testRunner.configure(options);
|
||||
|
||||
export = testRunner;
|
||||
229
extensions/notebook-renderers/src/test/notebookRenderer.test.ts
Normal file
229
extensions/notebook-renderers/src/test/notebookRenderer.test.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as assert from 'assert';
|
||||
import { activate } from '..';
|
||||
import { OutputItem, RendererApi } from 'vscode-notebook-renderer';
|
||||
import { IRichRenderContext, RenderOptions } from '../rendererTypes';
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM();
|
||||
global.document = dom.window.document;
|
||||
|
||||
suite('Notebook builtin output renderer', () => {
|
||||
|
||||
const error = {
|
||||
name: "NameError",
|
||||
message: "name 'x' is not defined",
|
||||
stack: "\u001b[1;31m---------------------------------------------------------------------------\u001b[0m" +
|
||||
"\n\u001b[1;31mNameError\u001b[0m Traceback (most recent call last)" +
|
||||
"\nCell \u001b[1;32mIn[3], line 1\u001b[0m" +
|
||||
"\n\u001b[1;32m----> 1\u001b[0m \u001b[39mprint\u001b[39m(x)" +
|
||||
"\n\n\u001b[1;31mNameError\u001b[0m: name 'x' is not defined"
|
||||
};
|
||||
|
||||
const errorMimeType = 'application/vnd.code.notebook.error';
|
||||
|
||||
const stdoutMimeType = 'application/vnd.code.notebook.stdout';
|
||||
const stderrMimeType = 'application/vnd.code.notebook.stderr';
|
||||
|
||||
const textLikeMimeTypes = [
|
||||
stdoutMimeType,
|
||||
stderrMimeType,
|
||||
'text/plain'
|
||||
];
|
||||
|
||||
type optionalRenderOptions = { [k in keyof RenderOptions]?: RenderOptions[k] };
|
||||
|
||||
function createContext(settings?: optionalRenderOptions): IRichRenderContext {
|
||||
return {
|
||||
setState(_value: void) { },
|
||||
getState() { return undefined; },
|
||||
async getRenderer(_id): Promise<RendererApi | undefined> { return undefined; },
|
||||
settings: {
|
||||
outputWordWrap: true,
|
||||
outputScrolling: true,
|
||||
lineLimit: 30,
|
||||
...settings
|
||||
} as RenderOptions,
|
||||
onDidChangeSettings<T>(_listener: (e: T) => any, _thisArgs?: any, _disposables?: any) {
|
||||
return {
|
||||
dispose(): void { }
|
||||
};
|
||||
},
|
||||
workspace: {
|
||||
isTrusted: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createElement(elementType: 'div' | 'span', classes: string[]) {
|
||||
const el = global.document.createElement(elementType);
|
||||
classes.forEach((c) => el.classList.add(c));
|
||||
return el;
|
||||
}
|
||||
|
||||
// Helper to generate HTML similar to what is passed to the renderer
|
||||
// <div class="cell_container" >
|
||||
// <div class="output_container" >
|
||||
// <div class="output" >
|
||||
class OutputHtml {
|
||||
private readonly cell = createElement('div', ['cell_container']);
|
||||
private readonly firstOutput: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
const outputContainer = createElement('div', ['output_container']);
|
||||
const outputElement = createElement('div', ['output']);
|
||||
|
||||
this.cell.appendChild(outputContainer);
|
||||
outputContainer.appendChild(outputElement);
|
||||
|
||||
this.firstOutput = outputElement;
|
||||
}
|
||||
|
||||
public getFirstOuputElement() {
|
||||
return this.firstOutput;
|
||||
}
|
||||
|
||||
public appendOutputElement() {
|
||||
const outputElement = createElement('div', ['output']);
|
||||
const outputContainer = createElement('div', ['output_container']);
|
||||
this.cell.appendChild(outputContainer);
|
||||
outputContainer.appendChild(outputElement);
|
||||
|
||||
return outputElement;
|
||||
}
|
||||
}
|
||||
|
||||
function createOutputItem(text: string, mime: string, id: string = '123'): OutputItem {
|
||||
return {
|
||||
id: id,
|
||||
mime: mime,
|
||||
text() {
|
||||
return text;
|
||||
},
|
||||
blob() {
|
||||
return [] as any;
|
||||
},
|
||||
json() {
|
||||
return '{ }';
|
||||
},
|
||||
data() {
|
||||
return [] as any;
|
||||
},
|
||||
metadata: {}
|
||||
};
|
||||
}
|
||||
|
||||
textLikeMimeTypes.forEach((mimeType) => {
|
||||
test(`Render with wordwrap and scrolling for mimetype ${mimeType}`, async () => {
|
||||
const context = createContext({ outputWordWrap: true, outputScrolling: true });
|
||||
const renderer = await activate(context);
|
||||
assert.ok(renderer, 'Renderer not created');
|
||||
|
||||
const outputElement = new OutputHtml().getFirstOuputElement();
|
||||
const outputItem = createOutputItem('content', mimeType);
|
||||
renderer!.renderOutputItem(outputItem, outputElement);
|
||||
|
||||
const inserted = outputElement.firstChild as HTMLElement;
|
||||
assert.ok(inserted, `nothing appended to output element: ${outputElement.innerHTML}`);
|
||||
assert.ok(outputElement.classList.contains('remove-padding'), `Padding should be removed for scrollable outputs ${outputElement.classList}`);
|
||||
assert.ok(inserted.classList.contains('word-wrap') && inserted.classList.contains('scrollable'),
|
||||
`output content classList should contain word-wrap and scrollable ${inserted.classList}`);
|
||||
assert.ok(inserted.innerHTML.indexOf('>content</') > -1, `Content was not added to output element: ${outputElement.innerHTML}`);
|
||||
});
|
||||
|
||||
test(`Render without wordwrap or scrolling for mimetype ${mimeType}`, async () => {
|
||||
const context = createContext({ outputWordWrap: false, outputScrolling: false });
|
||||
const renderer = await activate(context);
|
||||
assert.ok(renderer, 'Renderer not created');
|
||||
|
||||
const outputElement = new OutputHtml().getFirstOuputElement();
|
||||
const outputItem = createOutputItem('content', mimeType);
|
||||
renderer!.renderOutputItem(outputItem, outputElement);
|
||||
|
||||
const inserted = outputElement.firstChild as HTMLElement;
|
||||
assert.ok(inserted, `nothing appended to output element: ${outputElement.innerHTML}`);
|
||||
assert.ok(!outputElement.classList.contains('remove-padding'), `Padding should not be removed for non-scrollable outputs: ${outputElement.classList}`);
|
||||
assert.ok(!inserted.classList.contains('word-wrap') && !inserted.classList.contains('scrollable'),
|
||||
`output content classList should not contain word-wrap and scrollable ${inserted.classList}`);
|
||||
assert.ok(inserted.innerHTML.indexOf('>content</') > -1, `Content was not added to output element: ${outputElement.innerHTML}`);
|
||||
});
|
||||
|
||||
test(`Replace content in element for mimetype ${mimeType}`, async () => {
|
||||
const context = createContext();
|
||||
const renderer = await activate(context);
|
||||
assert.ok(renderer, 'Renderer not created');
|
||||
|
||||
const outputElement = new OutputHtml().getFirstOuputElement();
|
||||
const outputItem = createOutputItem('content', 'text/plain');
|
||||
renderer!.renderOutputItem(outputItem, outputElement);
|
||||
const outputItem2 = createOutputItem('replaced content', 'text/plain');
|
||||
renderer!.renderOutputItem(outputItem2, outputElement);
|
||||
|
||||
const inserted = outputElement.firstChild as HTMLElement;
|
||||
assert.ok(inserted.innerHTML.indexOf('>content</') === -1, `Old content was not removed to output element: ${outputElement.innerHTML}`);
|
||||
assert.ok(inserted.innerHTML.indexOf('>replaced content</') !== -1, `Content was not added to output element: ${outputElement.innerHTML}`);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
test(`Render with wordwrap and scrolling for error output`, async () => {
|
||||
const context = createContext({ outputWordWrap: true, outputScrolling: true });
|
||||
const renderer = await activate(context);
|
||||
assert.ok(renderer, 'Renderer not created');
|
||||
|
||||
const outputElement = new OutputHtml().getFirstOuputElement();
|
||||
const outputItem = createOutputItem(JSON.stringify(error), errorMimeType);
|
||||
renderer!.renderOutputItem(outputItem, outputElement);
|
||||
|
||||
const inserted = outputElement.firstChild as HTMLElement;
|
||||
assert.ok(inserted, `nothing appended to output element: ${outputElement.innerHTML}`);
|
||||
assert.ok(outputElement.classList.contains('remove-padding'), 'Padding should be removed for scrollable outputs');
|
||||
assert.ok(inserted.classList.contains('word-wrap') && inserted.classList.contains('scrollable'),
|
||||
`output content classList should contain word-wrap and scrollable ${inserted.classList}`);
|
||||
assert.ok(inserted.innerHTML.indexOf('>: name \'x\' is not defined</') > -1, `Content was not added to output element:\n ${outputElement.innerHTML}`);
|
||||
});
|
||||
|
||||
test(`Replace content in element for error output`, async () => {
|
||||
const context = createContext();
|
||||
const renderer = await activate(context);
|
||||
assert.ok(renderer, 'Renderer not created');
|
||||
|
||||
const outputElement = new OutputHtml().getFirstOuputElement();
|
||||
const outputItem = createOutputItem(JSON.stringify(error), errorMimeType);
|
||||
await renderer!.renderOutputItem(outputItem, outputElement);
|
||||
const error2: typeof error = { ...error, message: 'new message', stack: 'replaced content' };
|
||||
const outputItem2 = createOutputItem(JSON.stringify(error2), errorMimeType);
|
||||
await renderer!.renderOutputItem(outputItem2, outputElement);
|
||||
|
||||
const inserted = outputElement.firstChild as HTMLElement;
|
||||
assert.ok(inserted.innerHTML.indexOf('>: name \'x\' is not defined</') === -1, `Content was not removed from output element:\n ${outputElement.innerHTML}`);
|
||||
assert.ok(inserted.innerHTML.indexOf('>replaced content</') !== -1, `Content was not added to output element:\n ${outputElement.innerHTML}`);
|
||||
});
|
||||
|
||||
test(`Multiple adjacent = streaming outputs should be consolidated one element`, async () => {
|
||||
const context = createContext();
|
||||
const renderer = await activate(context);
|
||||
assert.ok(renderer, 'Renderer not created');
|
||||
|
||||
const outputHtml = new OutputHtml();
|
||||
const outputElement = outputHtml.getFirstOuputElement();
|
||||
const outputItem1 = createOutputItem('first stream content', stdoutMimeType, '1');
|
||||
const outputItem2 = createOutputItem('second stream content', stdoutMimeType, '2');
|
||||
const outputItem3 = createOutputItem('third stream content', stderrMimeType, '3');
|
||||
renderer!.renderOutputItem(outputItem1, outputElement);
|
||||
renderer!.renderOutputItem(outputItem2, outputHtml.appendOutputElement());
|
||||
renderer!.renderOutputItem(outputItem3, outputHtml.appendOutputElement());
|
||||
|
||||
|
||||
const inserted = outputElement.firstChild as HTMLElement;
|
||||
assert.ok(inserted, `nothing appended to output element: ${outputElement.innerHTML}`);
|
||||
assert.ok(inserted.innerHTML.indexOf('>first stream content</') > -1, `Content was not added to output element: ${outputElement.innerHTML}`);
|
||||
assert.ok(inserted.innerHTML.indexOf('>second stream content</') > -1, `Content was not added to output element: ${outputElement.innerHTML}`);
|
||||
assert.ok(inserted.innerHTML.indexOf('>third stream content</') > -1, `Content was not added to output element: ${outputElement.innerHTML}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user