mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
test(inspector): add some tests (#5461)
This commit is contained in:
parent
1f3449c7da
commit
0c7da44465
@ -325,7 +325,7 @@ async function openPage(context: BrowserContext, url: string | undefined): Promi
|
||||
}
|
||||
|
||||
async function open(options: Options, url: string | undefined, language: string) {
|
||||
const { context, launchOptions, contextOptions } = await launchContext(options, false);
|
||||
const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWCLI_HEADLESS_FOR_TEST);
|
||||
await context._enableRecorder({
|
||||
language,
|
||||
launchOptions,
|
||||
@ -339,7 +339,7 @@ async function open(options: Options, url: string | undefined, language: string)
|
||||
}
|
||||
|
||||
async function codegen(options: Options, url: string | undefined, language: string, outputFile?: string) {
|
||||
const { context, launchOptions, contextOptions } = await launchContext(options, false);
|
||||
const { context, launchOptions, contextOptions } = await launchContext(options, !!process.env.PWCLI_HEADLESS_FOR_TEST);
|
||||
if (process.env.PWTRACE)
|
||||
contextOptions._traceDir = path.join(process.cwd(), '.trace');
|
||||
await context._enableRecorder({
|
||||
|
||||
@ -47,6 +47,7 @@ export type BrowserOptions = PlaywrightOptions & {
|
||||
protocolLogger: types.ProtocolLogger,
|
||||
browserLogsCollector: RecentLogsCollector,
|
||||
slowMo?: number;
|
||||
wsEndpoint?: string; // Only there when connected over web socket.
|
||||
};
|
||||
|
||||
export abstract class Browser extends SdkObject {
|
||||
|
||||
@ -20,7 +20,7 @@ import path from 'path';
|
||||
import * as util from 'util';
|
||||
import { BrowserContext, normalizeProxySettings, validateBrowserContextOptions } from './browserContext';
|
||||
import * as registry from '../utils/registry';
|
||||
import { ConnectionTransport } from './transport';
|
||||
import { ConnectionTransport, WebSocketTransport } from './transport';
|
||||
import { BrowserOptions, Browser, BrowserProcess, PlaywrightOptions } from './browser';
|
||||
import { launchProcess, Env, envArrayToObject } from './processLauncher';
|
||||
import { PipeTransport } from './pipeTransport';
|
||||
@ -112,6 +112,7 @@ export abstract class BrowserType extends SdkObject {
|
||||
proxy: options.proxy,
|
||||
protocolLogger,
|
||||
browserLogsCollector,
|
||||
wsEndpoint: options.useWebSocket ? (transport as WebSocketTransport).wsEndpoint : undefined,
|
||||
};
|
||||
if (persistent)
|
||||
validateBrowserContextOptions(persistent, browserOptions);
|
||||
@ -180,6 +181,8 @@ export abstract class BrowserType extends SdkObject {
|
||||
await validateHostRequirements(this._registry, this._name);
|
||||
}
|
||||
|
||||
let wsEndpointCallback: ((wsEndpoint: string) => void) | undefined;
|
||||
const wsEndpoint = options.useWebSocket ? new Promise<string>(f => wsEndpointCallback = f) : undefined;
|
||||
// Note: it is important to define these variables before launchProcess, so that we don't get
|
||||
// "Cannot access 'browserServer' before initialization" if something went wrong.
|
||||
let transport: ConnectionTransport | undefined = undefined;
|
||||
@ -192,6 +195,11 @@ export abstract class BrowserType extends SdkObject {
|
||||
handleSIGTERM,
|
||||
handleSIGHUP,
|
||||
log: (message: string) => {
|
||||
if (wsEndpointCallback) {
|
||||
const match = message.match(/DevTools listening on (.*)/);
|
||||
if (match)
|
||||
wsEndpointCallback(match[1]);
|
||||
}
|
||||
progress.log(message);
|
||||
browserLogsCollector.log(message);
|
||||
},
|
||||
@ -217,9 +225,12 @@ export abstract class BrowserType extends SdkObject {
|
||||
kill
|
||||
};
|
||||
progress.cleanupWhenAborted(() => browserProcess && closeOrKill(browserProcess, progress.timeUntilDeadline()));
|
||||
|
||||
const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream];
|
||||
transport = new PipeTransport(stdio[3], stdio[4]);
|
||||
if (options.useWebSocket) {
|
||||
transport = await WebSocketTransport.connect(progress, await wsEndpoint!);
|
||||
} else {
|
||||
const stdio = launchedProcess.stdio as unknown as [NodeJS.ReadableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.WritableStream, NodeJS.ReadableStream];
|
||||
transport = new PipeTransport(stdio[3], stdio[4]);
|
||||
}
|
||||
return { browserProcess, downloadsPath, transport };
|
||||
}
|
||||
|
||||
@ -242,7 +253,10 @@ function copyTestHooks(from: object, to: object) {
|
||||
}
|
||||
|
||||
function validateLaunchOptions<Options extends types.LaunchOptions>(options: Options): Options {
|
||||
const { devtools = false, headless = !isDebugMode() && !devtools } = options;
|
||||
const { devtools = false } = options;
|
||||
let { headless = !devtools } = options;
|
||||
if (isDebugMode())
|
||||
headless = false;
|
||||
return { ...options, devtools, headless };
|
||||
}
|
||||
|
||||
|
||||
@ -119,7 +119,10 @@ export class Chromium extends BrowserType {
|
||||
throw new Error('Arguments can not specify page to be opened');
|
||||
const chromeArguments = [...DEFAULT_ARGS];
|
||||
chromeArguments.push(`--user-data-dir=${userDataDir}`);
|
||||
chromeArguments.push('--remote-debugging-pipe');
|
||||
if (options.useWebSocket)
|
||||
chromeArguments.push('--remote-debugging-port=0');
|
||||
else
|
||||
chromeArguments.push('--remote-debugging-pipe');
|
||||
if (options.devtools)
|
||||
chromeArguments.push('--auto-open-devtools-for-tabs');
|
||||
if (options.headless) {
|
||||
|
||||
@ -21,16 +21,11 @@ import { CallMetadata, InstrumentationListener, SdkObject } from '../instrumenta
|
||||
import { isDebugMode, isUnderTest } from '../../utils/utils';
|
||||
|
||||
export class InspectorController implements InstrumentationListener {
|
||||
private _recorders = new Map<BrowserContext, Promise<RecorderSupplement>>();
|
||||
private _waitOperations = new Map<string, CallMetadata>();
|
||||
|
||||
async onContextCreated(context: BrowserContext): Promise<void> {
|
||||
if (isDebugMode())
|
||||
this._recorders.set(context, RecorderSupplement.getOrCreate(context));
|
||||
}
|
||||
|
||||
async onContextDidDestroy(context: BrowserContext): Promise<void> {
|
||||
this._recorders.delete(context);
|
||||
RecorderSupplement.getOrCreate(context);
|
||||
}
|
||||
|
||||
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||
@ -61,10 +56,10 @@ export class InspectorController implements InstrumentationListener {
|
||||
// Force create recorder on pause.
|
||||
if (!context._browser.options.headful && !isUnderTest())
|
||||
return;
|
||||
this._recorders.set(context, RecorderSupplement.getOrCreate(context));
|
||||
RecorderSupplement.getOrCreate(context);
|
||||
}
|
||||
|
||||
const recorder = await this._recorders.get(context);
|
||||
const recorder = await RecorderSupplement.getNoCreate(context);
|
||||
await recorder?.onBeforeCall(sdkObject, metadata);
|
||||
}
|
||||
|
||||
@ -87,14 +82,14 @@ export class InspectorController implements InstrumentationListener {
|
||||
}
|
||||
}
|
||||
|
||||
const recorder = await this._recorders.get(sdkObject.attribution.context);
|
||||
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
|
||||
await recorder?.onAfterCall(metadata);
|
||||
}
|
||||
|
||||
async onBeforeInputAction(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||
if (!sdkObject.attribution.context)
|
||||
return;
|
||||
const recorder = await this._recorders.get(sdkObject.attribution.context);
|
||||
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
|
||||
await recorder?.onBeforeInputAction(metadata);
|
||||
}
|
||||
|
||||
@ -102,7 +97,7 @@ export class InspectorController implements InstrumentationListener {
|
||||
debugLogger.log(logName as any, message);
|
||||
if (!sdkObject.attribution.context)
|
||||
return;
|
||||
const recorder = await this._recorders.get(sdkObject.attribution.context);
|
||||
const recorder = await RecorderSupplement.getNoCreate(sdkObject.attribution.context);
|
||||
await recorder?.updateCallLog([metadata]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@ import { internalCallMetadata } from '../../instrumentation';
|
||||
import type { CallLog, EventData, Mode, Source } from './recorderTypes';
|
||||
import { BrowserContext } from '../../browserContext';
|
||||
import { isUnderTest } from '../../../utils/utils';
|
||||
import { RecentLogsCollector } from '../../../utils/debugLogger';
|
||||
|
||||
const readFileAsync = util.promisify(fs.readFile);
|
||||
|
||||
@ -104,34 +103,20 @@ export class RecorderApp extends EventEmitter {
|
||||
sdkLanguage: inspectedContext._options.sdkLanguage,
|
||||
args,
|
||||
noDefaultViewport: true,
|
||||
headless: isUnderTest() && !inspectedContext._browser.options.headful
|
||||
headless: !!process.env.PWCLI_HEADLESS_FOR_TEST || (isUnderTest() && !inspectedContext._browser.options.headful),
|
||||
useWebSocket: isUnderTest()
|
||||
});
|
||||
const wsEndpoint = isUnderTest() ? await this._parseWsEndpoint(context._browser.options.browserLogsCollector) : undefined;
|
||||
const controller = new ProgressController(internalCallMetadata(), context._browser);
|
||||
await controller.run(async progress => {
|
||||
await context._browser._defaultContext!._loadDefaultContextAsIs(progress);
|
||||
});
|
||||
|
||||
const [page] = context.pages();
|
||||
const result = new RecorderApp(page, wsEndpoint);
|
||||
const result = new RecorderApp(page, context._browser.options.wsEndpoint);
|
||||
await result._init();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static async _parseWsEndpoint(recentLogs: RecentLogsCollector): Promise<string> {
|
||||
let callback: ((log: string) => void) | undefined;
|
||||
const result = new Promise<string>(f => callback = f);
|
||||
const check = (log: string) => {
|
||||
const match = log.match(/DevTools listening on (.*)/);
|
||||
if (match)
|
||||
callback!(match[1]);
|
||||
};
|
||||
for (const log of recentLogs.recentLogs())
|
||||
check(log);
|
||||
recentLogs.on('log', check);
|
||||
return result;
|
||||
}
|
||||
|
||||
async setMode(mode: 'none' | 'recording' | 'inspecting'): Promise<void> {
|
||||
await this._page.mainFrame()._evaluateExpression(((mode: Mode) => {
|
||||
window.playwrightSetMode(mode);
|
||||
|
||||
@ -67,6 +67,10 @@ export class RecorderSupplement {
|
||||
return recorderPromise;
|
||||
}
|
||||
|
||||
static getNoCreate(context: BrowserContext): Promise<RecorderSupplement> | undefined {
|
||||
return (context as any)[symbol] as Promise<RecorderSupplement> | undefined;
|
||||
}
|
||||
|
||||
constructor(context: BrowserContext, params: channels.BrowserContextRecorderSupplementEnableParams) {
|
||||
this._context = context;
|
||||
this._params = params;
|
||||
@ -325,6 +329,8 @@ export class RecorderSupplement {
|
||||
}
|
||||
|
||||
async onBeforeCall(sdkObject: SdkObject, metadata: CallMetadata): Promise<void> {
|
||||
if (this._mode === 'recording')
|
||||
return;
|
||||
this._currentCallsMetadata.set(metadata, sdkObject);
|
||||
this._updateUserSources();
|
||||
this.updateCallLog([metadata]);
|
||||
@ -333,6 +339,8 @@ export class RecorderSupplement {
|
||||
}
|
||||
|
||||
async onAfterCall(metadata: CallMetadata): Promise<void> {
|
||||
if (this._mode === 'recording')
|
||||
return;
|
||||
if (!metadata.error)
|
||||
this._currentCallsMetadata.delete(metadata);
|
||||
this._pausedCallsMetadata.delete(metadata);
|
||||
@ -372,16 +380,20 @@ export class RecorderSupplement {
|
||||
}
|
||||
|
||||
async onBeforeInputAction(metadata: CallMetadata): Promise<void> {
|
||||
if (this._mode === 'recording')
|
||||
return;
|
||||
if (this._pauseOnNextStatement)
|
||||
await this.pause(metadata);
|
||||
}
|
||||
|
||||
async updateCallLog(metadatas: CallMetadata[]): Promise<void> {
|
||||
if (this._mode === 'recording')
|
||||
return;
|
||||
const logs: CallLog[] = [];
|
||||
for (const metadata of metadatas) {
|
||||
if (!metadata.method)
|
||||
continue;
|
||||
const title = metadata.stack?.[0]?.function || metadata.method;
|
||||
const title = metadata.method;
|
||||
let status: 'done' | 'in-progress' | 'paused' | 'error' = 'done';
|
||||
if (this._currentCallsMetadata.has(metadata))
|
||||
status = 'in-progress';
|
||||
|
||||
@ -50,6 +50,7 @@ export class WebSocketTransport implements ConnectionTransport {
|
||||
|
||||
onmessage?: (message: ProtocolResponse) => void;
|
||||
onclose?: () => void;
|
||||
readonly wsEndpoint: string;
|
||||
|
||||
static async connect(progress: Progress, url: string): Promise<WebSocketTransport> {
|
||||
progress.log(`<ws connecting> ${url}`);
|
||||
@ -75,6 +76,7 @@ export class WebSocketTransport implements ConnectionTransport {
|
||||
}
|
||||
|
||||
constructor(progress: Progress, url: string) {
|
||||
this.wsEndpoint = url;
|
||||
this._ws = new WebSocket(url, [], {
|
||||
perMessageDeflate: false,
|
||||
maxPayload: 256 * 1024 * 1024, // 256Mb,
|
||||
|
||||
@ -264,7 +264,8 @@ type LaunchOptionsBase = {
|
||||
proxy?: ProxySettings,
|
||||
downloadsPath?: string,
|
||||
chromiumSandbox?: boolean,
|
||||
slowMo?: number;
|
||||
slowMo?: number,
|
||||
useWebSocket?: boolean,
|
||||
};
|
||||
export type LaunchOptions = LaunchOptionsBase & {
|
||||
firefoxUserPrefs?: { [key: string]: string | number | boolean },
|
||||
|
||||
@ -16,7 +16,6 @@
|
||||
|
||||
import debug from 'debug';
|
||||
import fs from 'fs';
|
||||
import { EventEmitter } from 'events';
|
||||
|
||||
const debugLoggerColorMap = {
|
||||
'api': 45, // cyan
|
||||
@ -64,11 +63,10 @@ class DebugLogger {
|
||||
export const debugLogger = new DebugLogger();
|
||||
|
||||
const kLogCount = 50;
|
||||
export class RecentLogsCollector extends EventEmitter {
|
||||
export class RecentLogsCollector {
|
||||
private _logs: string[] = [];
|
||||
|
||||
log(message: string) {
|
||||
this.emit('log', message);
|
||||
this._logs.push(message);
|
||||
if (this._logs.length === kLogCount * 2)
|
||||
this._logs.splice(0, kLogCount);
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
import { folio } from './remoteServer.fixture';
|
||||
const { it, expect, describe } = folio;
|
||||
|
||||
describe('lauch server', (suite, { mode }) => {
|
||||
describe('launch server', (suite, { mode }) => {
|
||||
suite.skip(mode !== 'default');
|
||||
}, () => {
|
||||
it('should work', async ({browserType, browserOptions}) => {
|
||||
|
||||
@ -19,9 +19,9 @@ import * as http from 'http';
|
||||
|
||||
const { it, describe, expect } = folio;
|
||||
|
||||
describe('cli codegen', (suite, { mode, browserName, headful }) => {
|
||||
suite.fixme(browserName === 'firefox' && headful, 'Focus is off');
|
||||
describe('cli codegen', (suite, { browserName, headful, mode }) => {
|
||||
suite.skip(mode !== 'default');
|
||||
suite.fixme(browserName === 'firefox' && headful, 'Focus is off');
|
||||
}, () => {
|
||||
it('should click', async ({ page, recorder }) => {
|
||||
await recorder.setContentAndWait(`<button onclick="console.log('click')">Submit</button>`);
|
||||
|
||||
@ -20,8 +20,7 @@ import * as url from 'url';
|
||||
|
||||
const { it, describe, expect } = folio;
|
||||
|
||||
describe('cli codegen', (suite, { mode, browserName, headful }) => {
|
||||
// suite.fixme(browserName === 'firefox' && headful, 'Focus is off');
|
||||
describe('cli codegen', (suite, { mode }) => {
|
||||
suite.skip(mode !== 'default');
|
||||
}, () => {
|
||||
it('should contain open page', async ({ recorder }) => {
|
||||
|
||||
@ -146,10 +146,10 @@ class Recorder {
|
||||
}
|
||||
}
|
||||
|
||||
fixtures.runCLI.init(async ({ browserName }, runTest) => {
|
||||
fixtures.runCLI.init(async ({ browserName, headful }, runTest) => {
|
||||
let cli: CLIMock;
|
||||
const cliFactory = (args: string[]) => {
|
||||
cli = new CLIMock(browserName, args);
|
||||
cli = new CLIMock(browserName, !headful, args);
|
||||
return cli;
|
||||
};
|
||||
await runTest(cliFactory);
|
||||
@ -163,7 +163,7 @@ class CLIMock {
|
||||
private waitForCallback: () => void;
|
||||
exited: Promise<void>;
|
||||
|
||||
constructor(browserName, args: string[]) {
|
||||
constructor(browserName: string, headless: boolean, args: string[]) {
|
||||
this.data = '';
|
||||
this.process = spawn('node', [
|
||||
path.join(__dirname, '..', '..', 'lib', 'cli', 'cli.js'),
|
||||
@ -172,7 +172,8 @@ class CLIMock {
|
||||
], {
|
||||
env: {
|
||||
...process.env,
|
||||
PWCLI_EXIT_FOR_TEST: '1'
|
||||
PWCLI_EXIT_FOR_TEST: '1',
|
||||
PWCLI_HEADLESS_FOR_TEST: headless ? '1' : undefined,
|
||||
},
|
||||
stdio: 'pipe'
|
||||
});
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { expect } from 'folio';
|
||||
import { Page } from '..';
|
||||
import { folio } from './recorder.fixtures';
|
||||
const { it, describe} = folio;
|
||||
|
||||
@ -119,4 +120,116 @@ describe('pause', (suite, { mode }) => {
|
||||
await recorderPage.click('[title="Step over"]');
|
||||
await scriptPromise;
|
||||
});
|
||||
|
||||
it('should skip input when resuming', async ({page, recorderPageGetter}) => {
|
||||
await page.setContent('<button>Submit</button>');
|
||||
const scriptPromise = (async () => {
|
||||
await page.pause();
|
||||
await page.click('button');
|
||||
await page.pause(); // 2
|
||||
})();
|
||||
const recorderPage = await recorderPageGetter();
|
||||
await recorderPage.click('[title="Resume"]');
|
||||
await recorderPage.waitForSelector('.source-line-paused:has-text("page.pause(); // 2")');
|
||||
await recorderPage.click('[title=Resume]');
|
||||
await scriptPromise;
|
||||
});
|
||||
|
||||
it('should populate log', async ({page, recorderPageGetter}) => {
|
||||
await page.setContent('<button>Submit</button>');
|
||||
const scriptPromise = (async () => {
|
||||
await page.pause();
|
||||
await page.click('button');
|
||||
await page.pause(); // 2
|
||||
})();
|
||||
const recorderPage = await recorderPageGetter();
|
||||
await recorderPage.click('[title="Resume"]');
|
||||
await recorderPage.waitForSelector('.source-line-paused:has-text("page.pause(); // 2")');
|
||||
expect(await sanitizeLog(recorderPage)).toEqual([
|
||||
'pause',
|
||||
'click',
|
||||
'waiting for selector "button"',
|
||||
'selector resolved to visible <button>Submit</button>',
|
||||
'attempting click action',
|
||||
'waiting for element to be visible, enabled and stable',
|
||||
'element is visible, enabled and stable',
|
||||
'scrolling into view if needed',
|
||||
'done scrolling',
|
||||
'checking that element receives pointer events at ()',
|
||||
'element does receive pointer events',
|
||||
'performing click action',
|
||||
'click action done',
|
||||
'waiting for scheduled navigations to finish',
|
||||
'navigations have finished',
|
||||
'pause',
|
||||
]);
|
||||
await recorderPage.click('[title="Resume"]');
|
||||
await scriptPromise;
|
||||
});
|
||||
|
||||
it('should populate log with waitForEvent', async ({page, recorderPageGetter}) => {
|
||||
await page.setContent('<button onclick="console.log(1)">Submit</button>');
|
||||
const scriptPromise = (async () => {
|
||||
await page.pause();
|
||||
await Promise.all([
|
||||
page.waitForEvent('console'),
|
||||
page.click('button'),
|
||||
]);
|
||||
await page.pause(); // 2
|
||||
})();
|
||||
const recorderPage = await recorderPageGetter();
|
||||
await recorderPage.click('[title="Resume"]');
|
||||
await recorderPage.waitForSelector('.source-line-paused:has-text("page.pause(); // 2")');
|
||||
expect(await sanitizeLog(recorderPage)).toEqual([
|
||||
'pause',
|
||||
'waitForEvent()',
|
||||
'waiting for event \"console\"',
|
||||
'click',
|
||||
'waiting for selector "button"',
|
||||
'selector resolved to visible <button onclick=\"console.log()\">Submit</button>',
|
||||
'attempting click action',
|
||||
'waiting for element to be visible, enabled and stable',
|
||||
'element is visible, enabled and stable',
|
||||
'scrolling into view if needed',
|
||||
'done scrolling',
|
||||
'checking that element receives pointer events at ()',
|
||||
'element does receive pointer events',
|
||||
'performing click action',
|
||||
'click action done',
|
||||
'waiting for scheduled navigations to finish',
|
||||
'navigations have finished',
|
||||
'pause',
|
||||
]);
|
||||
await recorderPage.click('[title="Resume"]');
|
||||
await scriptPromise;
|
||||
});
|
||||
|
||||
it('should populate log with error', async ({page, recorderPageGetter}) => {
|
||||
await page.setContent('<button onclick="console.log(1)">Submit</button>');
|
||||
const scriptPromise = (async () => {
|
||||
await page.pause();
|
||||
await page.isChecked('button');
|
||||
})().catch(e => e);
|
||||
const recorderPage = await recorderPageGetter();
|
||||
await recorderPage.click('[title="Resume"]');
|
||||
await recorderPage.waitForSelector('.source-line-error');
|
||||
expect(await sanitizeLog(recorderPage)).toEqual([
|
||||
'pause',
|
||||
'isChecked',
|
||||
'checking \"checked\" state of \"button\"',
|
||||
'selector resolved to <button onclick=\"console.log()\">Submit</button>',
|
||||
'Not a checkbox or radio button',
|
||||
]);
|
||||
const error = await scriptPromise;
|
||||
expect(error.message).toContain('Not a checkbox or radio button');
|
||||
});
|
||||
});
|
||||
|
||||
async function sanitizeLog(recorderPage: Page): Promise<string[]> {
|
||||
const text = await recorderPage.innerText('.recorder-log');
|
||||
return text.split('\n').filter(l => {
|
||||
return l !== 'element is not stable - waiting...';
|
||||
}).map(l => {
|
||||
return l.replace(/\(.*\)/, '()');
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user