mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-28 11:22:57 +08:00
refactor: move comments formatting to CommentFormatter class (#2719)
This commit is contained in:
+43
-1
@@ -7,6 +7,10 @@
|
||||
import type {WebMCPTool} from 'puppeteer-core';
|
||||
|
||||
import type {ParsedArguments} from './config/mcp-options.js';
|
||||
import {
|
||||
CommentFormatter,
|
||||
type StructuredCommentThread,
|
||||
} from './formatters/CommentFormatter.js';
|
||||
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
|
||||
import {
|
||||
HeapSnapshotFormatter,
|
||||
@@ -51,7 +55,7 @@ import {
|
||||
getInsightOutput,
|
||||
getTraceSummary,
|
||||
} from './processors/PerformanceTrace.js';
|
||||
import type {PaginationOptions} from './types.js';
|
||||
import type {CD4ACommentThread, PaginationOptions} from './types.js';
|
||||
import type {WithSymbolId} from './utils/id.js';
|
||||
import {stableIdSymbol} from './utils/id.js';
|
||||
import {paginate} from './utils/pagination.js';
|
||||
@@ -125,6 +129,7 @@ export class McpResponse implements Response {
|
||||
#error?: Error;
|
||||
#attachedWaitForResult?: WaitForEventsResult;
|
||||
#reconnectNotice = false;
|
||||
#devToolsComments?: CD4ACommentThread[];
|
||||
|
||||
get #deviceScope(): DevTools.CrUXManager.DeviceScope {
|
||||
return this.#page?.viewport?.isMobile ? 'PHONE' : 'DESKTOP';
|
||||
@@ -327,6 +332,14 @@ export class McpResponse implements Response {
|
||||
this.#attachedWaitForResult = result;
|
||||
}
|
||||
|
||||
setDevToolsComments(threads: CD4ACommentThread[]): void {
|
||||
this.#devToolsComments = threads;
|
||||
}
|
||||
|
||||
get devToolsComments(): readonly CD4ACommentThread[] | undefined {
|
||||
return this.#devToolsComments;
|
||||
}
|
||||
|
||||
setHeapSnapshotAggregates(
|
||||
aggregateData: HeapSnapshotAggregateData,
|
||||
options?: PaginationOptions,
|
||||
@@ -668,6 +681,22 @@ export class McpResponse implements Response {
|
||||
);
|
||||
}
|
||||
|
||||
async #handleComments(): Promise<CommentFormatter | undefined> {
|
||||
const comments = this.#devToolsComments;
|
||||
if (!comments) {
|
||||
return undefined;
|
||||
}
|
||||
const page = this.#page;
|
||||
return await CommentFormatter.from(comments, {
|
||||
resolveBackendNodeId: page
|
||||
? (id: number) => page.resolveBackendNodeId(id)
|
||||
: undefined,
|
||||
resolveCdpRequestId: page
|
||||
? (id: string) => page.resolveCdpRequestId(id)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async handle(
|
||||
context: McpContext,
|
||||
dataFormat: DataFormat = 'default',
|
||||
@@ -683,6 +712,7 @@ export class McpResponse implements Response {
|
||||
webmcpTools,
|
||||
consoleMessages,
|
||||
networkRequests,
|
||||
comments,
|
||||
] = await Promise.all([
|
||||
this.#handleSnapshot(context),
|
||||
this.#handleAttachedNetworkRequest(context),
|
||||
@@ -691,6 +721,7 @@ export class McpResponse implements Response {
|
||||
this.#handleWebMCP(),
|
||||
this.#handleConsoleList(context),
|
||||
this.#handleNetworkRequestList(context),
|
||||
this.#handleComments(),
|
||||
]);
|
||||
|
||||
if (this.#includeExtensionServiceWorkers) {
|
||||
@@ -716,6 +747,7 @@ export class McpResponse implements Response {
|
||||
lighthouseResult: this.#attachedLighthouseResult,
|
||||
thirdPartyDeveloperTools,
|
||||
webmcpTools,
|
||||
comments,
|
||||
errorMessage: this.#error?.message,
|
||||
},
|
||||
dataFormat,
|
||||
@@ -746,6 +778,7 @@ export class McpResponse implements Response {
|
||||
lighthouseResult?: LighthouseData;
|
||||
thirdPartyDeveloperTools?: ToolGroups;
|
||||
webmcpTools?: WebMCPTool[];
|
||||
comments?: CommentFormatter;
|
||||
errorMessage?: string;
|
||||
},
|
||||
dataFormat: DataFormat = 'default',
|
||||
@@ -805,6 +838,7 @@ export class McpResponse implements Response {
|
||||
heapSnapshotObjectDetails?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo;
|
||||
extensionServiceWorkers?: object[];
|
||||
extensionPages?: object[];
|
||||
comments?: StructuredCommentThread[];
|
||||
errorMessage?: string;
|
||||
navigatedToUrl?: string;
|
||||
geolocation?: {latitude: number; longitude: number};
|
||||
@@ -1392,6 +1426,14 @@ Call ${handleDialog.name} to handle it before continuing.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.comments) {
|
||||
const commentsJson = data.comments.toJSON();
|
||||
structuredContent.comments = commentsJson;
|
||||
response.push(
|
||||
compactEncode ? compactEncode(commentsJson) : data.comments.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.errorMessage) {
|
||||
response.push(`Error: ${data.errorMessage}`);
|
||||
structuredContent.errorMessage = data.errorMessage;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {CD4ACommentThread, CD4AEditorAnchorSignature} from '../types.js';
|
||||
|
||||
export interface CommentFormatterOptions {
|
||||
resolveBackendNodeId?: (backendNodeId: number) => Promise<string | undefined>;
|
||||
resolveCdpRequestId?: (cdpRequestId: string) => number | undefined;
|
||||
}
|
||||
|
||||
export interface StructuredCommentThread {
|
||||
id: string;
|
||||
text: string;
|
||||
elementUid?: string;
|
||||
reqid?: number;
|
||||
editor?: CD4AEditorAnchorSignature;
|
||||
}
|
||||
|
||||
function formatCommentThread(thread: StructuredCommentThread): string {
|
||||
const lines: string[] = [
|
||||
`### Thread: ${thread.id}`,
|
||||
`- Comment: ${thread.text}`,
|
||||
];
|
||||
if (thread.elementUid) {
|
||||
lines.push(`- Target element (snapshot UID): ${thread.elementUid}`);
|
||||
}
|
||||
if (thread.reqid !== undefined) {
|
||||
lines.push(`- Network request ID (reqid): ${thread.reqid}`);
|
||||
}
|
||||
if (thread.editor) {
|
||||
const location = thread.editor.filePath
|
||||
? `${thread.editor.filePath}:${thread.editor.lineNumber}`
|
||||
: `line ${thread.editor.lineNumber}`;
|
||||
lines.push(`- Editor location: ${location}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function formatComments(threads: readonly StructuredCommentThread[]): string {
|
||||
if (threads.length === 0) {
|
||||
return 'No open DevTools comments found.';
|
||||
}
|
||||
const lines: string[] = [
|
||||
`Found ${threads.length} DevTools comment thread(s):`,
|
||||
];
|
||||
for (const thread of threads) {
|
||||
lines.push(`\n${formatCommentThread(thread)}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export class CommentFormatter {
|
||||
readonly #threads: readonly StructuredCommentThread[];
|
||||
|
||||
constructor(threads: readonly StructuredCommentThread[]) {
|
||||
this.#threads = threads;
|
||||
}
|
||||
|
||||
static async from(
|
||||
threads: readonly CD4ACommentThread[],
|
||||
options?: CommentFormatterOptions,
|
||||
): Promise<CommentFormatter> {
|
||||
const structuredThreads: StructuredCommentThread[] = [];
|
||||
for (const thread of threads) {
|
||||
let elementUid: string | undefined;
|
||||
const resolveBackendNodeId = options?.resolveBackendNodeId;
|
||||
if (thread.backendNodeId !== undefined && resolveBackendNodeId) {
|
||||
elementUid = await resolveBackendNodeId(thread.backendNodeId);
|
||||
}
|
||||
|
||||
let reqid: number | undefined;
|
||||
const resolveCdpRequestId = options?.resolveCdpRequestId;
|
||||
if (thread.networkRequestId !== undefined && resolveCdpRequestId) {
|
||||
reqid = resolveCdpRequestId(thread.networkRequestId);
|
||||
}
|
||||
|
||||
const item: StructuredCommentThread = {
|
||||
id: thread.id,
|
||||
text: thread.text,
|
||||
};
|
||||
if (elementUid) {
|
||||
item.elementUid = elementUid;
|
||||
}
|
||||
if (reqid !== undefined) {
|
||||
item.reqid = reqid;
|
||||
}
|
||||
if (thread.editor) {
|
||||
item.editor = thread.editor;
|
||||
}
|
||||
structuredThreads.push(item);
|
||||
}
|
||||
return new CommentFormatter(structuredThreads);
|
||||
}
|
||||
|
||||
static formatThread(thread: StructuredCommentThread): string {
|
||||
return formatCommentThread(thread);
|
||||
}
|
||||
|
||||
toJSON(): StructuredCommentThread[] {
|
||||
return [...this.#threads];
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return formatComments(this.toJSON());
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type {
|
||||
TextSnapshotNode,
|
||||
GeolocationOptions,
|
||||
ExtensionServiceWorker,
|
||||
CD4ACommentThread,
|
||||
} from '../types.js';
|
||||
import type {PaginationOptions} from '../types.js';
|
||||
import type {
|
||||
@@ -194,6 +195,7 @@ export interface Response {
|
||||
setListThirdPartyDeveloperTools(): void;
|
||||
setListWebMcpTools(): void;
|
||||
attachWaitForResult(result: WaitForEventsResult): void;
|
||||
setDevToolsComments(threads: CD4ACommentThread[]): void;
|
||||
}
|
||||
|
||||
export type SupportedExtensions =
|
||||
|
||||
+1
-34
@@ -68,40 +68,7 @@ export const getDevtoolsComments = definePageTool({
|
||||
return window.universe?.cd4aBridge?.getCommentThreads() ?? [];
|
||||
});
|
||||
|
||||
if (threads.length === 0) {
|
||||
response.appendResponseLine('No open DevTools comments found.');
|
||||
return;
|
||||
}
|
||||
|
||||
response.appendResponseLine(
|
||||
`Found ${threads.length} DevTools comment thread(s):`,
|
||||
);
|
||||
for (const thread of threads) {
|
||||
response.appendResponseLine(`\n### Thread: ${thread.id}`);
|
||||
response.appendResponseLine(`- Comment: ${thread.text}`);
|
||||
if (thread.backendNodeId !== undefined) {
|
||||
const elementUid = await page.resolveBackendNodeId(
|
||||
thread.backendNodeId,
|
||||
);
|
||||
if (elementUid) {
|
||||
response.appendResponseLine(
|
||||
`- Target element (snapshot UID): ${elementUid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (thread.networkRequestId) {
|
||||
const reqid = page.resolveCdpRequestId(thread.networkRequestId);
|
||||
if (reqid !== undefined) {
|
||||
response.appendResponseLine(`- Network request ID (reqid): ${reqid}`);
|
||||
}
|
||||
}
|
||||
if (thread.editor) {
|
||||
const location = thread.editor.filePath
|
||||
? `${thread.editor.filePath}:${thread.editor.lineNumber}`
|
||||
: `line ${thread.editor.lineNumber}`;
|
||||
response.appendResponseLine(`- Editor location: ${location}`);
|
||||
}
|
||||
}
|
||||
response.setDevToolsComments(threads);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
exports[`CommentFormatter > formats comments with targets and editor locations toJSON 1`] = `
|
||||
[
|
||||
{
|
||||
"id": "comment-1",
|
||||
"text": "Fix the color contrast here",
|
||||
"elementUid": "element-uid-42",
|
||||
"reqid": 7,
|
||||
"editor": {
|
||||
"filePath": "src/style.css",
|
||||
"lineNumber": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > formats comments with targets and editor locations toString 1`] = `
|
||||
Found 1 DevTools comment thread(s):
|
||||
|
||||
### Thread: comment-1
|
||||
- Comment: Fix the color contrast here
|
||||
- Target element (snapshot UID): element-uid-42
|
||||
- Network request ID (reqid): 7
|
||||
- Editor location: src/style.css:10
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > formats editor location when filePath is missing toJSON 1`] = `
|
||||
[
|
||||
{
|
||||
"id": "comment-2",
|
||||
"text": "Review this script line",
|
||||
"editor": {
|
||||
"lineNumber": 25
|
||||
}
|
||||
}
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > formats editor location when filePath is missing toString 1`] = `
|
||||
Found 1 DevTools comment thread(s):
|
||||
|
||||
### Thread: comment-2
|
||||
- Comment: Review this script line
|
||||
- Editor location: line 25
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > formats empty comments list toJSON 1`] = `
|
||||
[]
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > formats empty comments list toString 1`] = `
|
||||
No open DevTools comments found.
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > formats single thread using static formatThread 1`] = `
|
||||
### Thread: comment-3
|
||||
- Comment: Check padding
|
||||
- Target element (snapshot UID): node-99
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > omits unresolved targets when from() resolves undefined toJSON 1`] = `
|
||||
[
|
||||
{
|
||||
"id": "comment-2",
|
||||
"text": "Fix heading font size"
|
||||
}
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > omits unresolved targets when from() resolves undefined toString 1`] = `
|
||||
Found 1 DevTools comment thread(s):
|
||||
|
||||
### Thread: comment-2
|
||||
- Comment: Fix heading font size
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > resolves targets using from() method toJSON 1`] = `
|
||||
[
|
||||
{
|
||||
"id": "comment-1",
|
||||
"text": "Fix the color contrast here",
|
||||
"elementUid": "element-uid-42",
|
||||
"reqid": 7,
|
||||
"editor": {
|
||||
"filePath": "src/style.css",
|
||||
"lineNumber": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
`;
|
||||
|
||||
exports[`CommentFormatter > resolves targets using from() method toString 1`] = `
|
||||
Found 1 DevTools comment thread(s):
|
||||
|
||||
### Thread: comment-1
|
||||
- Comment: Fix the color contrast here
|
||||
- Target element (snapshot UID): element-uid-42
|
||||
- Network request ID (reqid): 7
|
||||
- Editor location: src/style.css:10
|
||||
`;
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {afterEach, describe, it} from 'node:test';
|
||||
|
||||
import sinon from 'sinon';
|
||||
|
||||
import {
|
||||
CommentFormatter,
|
||||
type StructuredCommentThread,
|
||||
} from '../../src/formatters/CommentFormatter.js';
|
||||
import type {CD4ACommentThread} from '../../src/types.js';
|
||||
|
||||
describe('CommentFormatter', () => {
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
function formatterTest(
|
||||
label: string,
|
||||
setup: (t: it.TestContext) => CommentFormatter | Promise<CommentFormatter>,
|
||||
) {
|
||||
it(label + ' toString', async t => {
|
||||
const formatter = await setup(t);
|
||||
t.assert.snapshot(formatter.toString());
|
||||
});
|
||||
it(label + ' toJSON', async t => {
|
||||
const formatter = await setup(t);
|
||||
t.assert.snapshot(JSON.stringify(formatter.toJSON(), null, 2));
|
||||
});
|
||||
}
|
||||
|
||||
formatterTest('formats empty comments list', () => {
|
||||
return new CommentFormatter([]);
|
||||
});
|
||||
|
||||
formatterTest('formats comments with targets and editor locations', () => {
|
||||
const thread: StructuredCommentThread = {
|
||||
id: 'comment-1',
|
||||
text: 'Fix the color contrast here',
|
||||
elementUid: 'element-uid-42',
|
||||
reqid: 7,
|
||||
editor: {
|
||||
filePath: 'src/style.css',
|
||||
lineNumber: 10,
|
||||
},
|
||||
};
|
||||
return new CommentFormatter([thread]);
|
||||
});
|
||||
|
||||
formatterTest('formats editor location when filePath is missing', () => {
|
||||
const thread: StructuredCommentThread = {
|
||||
id: 'comment-2',
|
||||
text: 'Review this script line',
|
||||
editor: {
|
||||
lineNumber: 25,
|
||||
},
|
||||
};
|
||||
return new CommentFormatter([thread]);
|
||||
});
|
||||
|
||||
it('formats single thread using static formatThread', t => {
|
||||
const thread: StructuredCommentThread = {
|
||||
id: 'comment-3',
|
||||
text: 'Check padding',
|
||||
elementUid: 'node-99',
|
||||
};
|
||||
t.assert.snapshot(CommentFormatter.formatThread(thread));
|
||||
});
|
||||
|
||||
formatterTest('resolves targets using from() method', async () => {
|
||||
const rawThread: CD4ACommentThread = {
|
||||
id: 'comment-1',
|
||||
text: 'Fix the color contrast here',
|
||||
backendNodeId: 42,
|
||||
networkRequestId: 'req-99',
|
||||
editor: {
|
||||
filePath: 'src/style.css',
|
||||
lineNumber: 10,
|
||||
},
|
||||
};
|
||||
|
||||
const resolveBackendNodeId = sinon.stub().resolves('element-uid-42');
|
||||
const resolveCdpRequestId = sinon.stub().returns(7);
|
||||
|
||||
const formatter = await CommentFormatter.from([rawThread], {
|
||||
resolveBackendNodeId,
|
||||
resolveCdpRequestId,
|
||||
});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(resolveBackendNodeId, 42);
|
||||
sinon.assert.calledOnceWithExactly(resolveCdpRequestId, 'req-99');
|
||||
|
||||
return formatter;
|
||||
});
|
||||
|
||||
formatterTest(
|
||||
'omits unresolved targets when from() resolves undefined',
|
||||
async () => {
|
||||
const rawThread: CD4ACommentThread = {
|
||||
id: 'comment-2',
|
||||
text: 'Fix heading font size',
|
||||
backendNodeId: 42,
|
||||
networkRequestId: 'req-99',
|
||||
};
|
||||
|
||||
const resolveBackendNodeId = sinon.stub().resolves(undefined);
|
||||
const resolveCdpRequestId = sinon.stub().returns(undefined);
|
||||
|
||||
const formatter = await CommentFormatter.from([rawThread], {
|
||||
resolveBackendNodeId,
|
||||
resolveCdpRequestId,
|
||||
});
|
||||
|
||||
sinon.assert.calledOnceWithExactly(resolveBackendNodeId, 42);
|
||||
sinon.assert.calledOnceWithExactly(resolveCdpRequestId, 'req-99');
|
||||
|
||||
return formatter;
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -45,34 +45,14 @@ describe('comments tools', () => {
|
||||
response.appendResponseLine,
|
||||
'DevTools window is not open for this page. Call open_devtools first to open DevTools.',
|
||||
);
|
||||
sinon.assert.notCalled(response.setDevToolsComments);
|
||||
t.assert.snapshot(lines.join('\n'));
|
||||
});
|
||||
|
||||
it('reports message when no comments are found', async t => {
|
||||
it('fetches comments and sets them on response', async () => {
|
||||
const {page, context, response} = createHandlerMocks();
|
||||
const lines = trackResponseLines(response);
|
||||
const devtoolsPage = createMockPuppeteerPage();
|
||||
page.getDevToolsPage.resolves(devtoolsPage);
|
||||
devtoolsPage.evaluate.resolves([]);
|
||||
|
||||
await getDevtoolsComments.handler({params: {}, page}, response, context);
|
||||
|
||||
sinon.assert.calledOnce(page.getDevToolsPage);
|
||||
sinon.assert.calledOnce(devtoolsPage.evaluate);
|
||||
sinon.assert.calledOnceWithExactly(
|
||||
response.appendResponseLine,
|
||||
'No open DevTools comments found.',
|
||||
);
|
||||
t.assert.snapshot(lines.join('\n'));
|
||||
});
|
||||
|
||||
it('formats comment threads with text, targets, and editor location', async t => {
|
||||
const {page, context, response} = createHandlerMocks();
|
||||
const lines = trackResponseLines(response);
|
||||
const devtoolsPage = createMockPuppeteerPage();
|
||||
page.getDevToolsPage.resolves(devtoolsPage);
|
||||
page.resolveBackendNodeId.resolves('element-uid-42');
|
||||
page.resolveCdpRequestId.returns(7);
|
||||
|
||||
const mockThread: CommentThreadPayload = {
|
||||
id: 'comment-1',
|
||||
@@ -91,31 +71,22 @@ describe('comments tools', () => {
|
||||
|
||||
sinon.assert.calledOnce(page.getDevToolsPage);
|
||||
sinon.assert.calledOnce(devtoolsPage.evaluate);
|
||||
sinon.assert.calledOnceWithExactly(page.resolveBackendNodeId, 42);
|
||||
sinon.assert.calledOnceWithExactly(page.resolveCdpRequestId, 'req-99');
|
||||
t.assert.snapshot(lines.join('\n'));
|
||||
sinon.assert.calledOnceWithExactly(response.setDevToolsComments, [
|
||||
mockThread,
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits target element and network request ID if resolution returns undefined', async t => {
|
||||
it('sets empty comments list when no comments are found', async () => {
|
||||
const {page, context, response} = createHandlerMocks();
|
||||
const lines = trackResponseLines(response);
|
||||
const devtoolsPage = createMockPuppeteerPage();
|
||||
page.getDevToolsPage.resolves(devtoolsPage);
|
||||
page.resolveBackendNodeId.resolves(undefined);
|
||||
page.resolveCdpRequestId.returns(undefined);
|
||||
|
||||
const mockThread: CommentThreadPayload = {
|
||||
id: 'comment-2',
|
||||
text: 'Fix heading font size',
|
||||
backendNodeId: 42,
|
||||
networkRequestId: 'req-99',
|
||||
};
|
||||
|
||||
devtoolsPage.evaluate.resolves([mockThread]);
|
||||
devtoolsPage.evaluate.resolves([]);
|
||||
|
||||
await getDevtoolsComments.handler({params: {}, page}, response, context);
|
||||
|
||||
t.assert.snapshot(lines.join('\n'));
|
||||
sinon.assert.calledOnce(page.getDevToolsPage);
|
||||
sinon.assert.calledOnce(devtoolsPage.evaluate);
|
||||
sinon.assert.calledOnceWithExactly(response.setDevToolsComments, []);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user