chrome-devtools-frontend
Version:
Chrome DevTools UI
492 lines (449 loc) • 19.4 kB
text/typescript
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as Host from '../../../core/host/host.js';
import * as i18n from '../../../core/i18n/i18n.js';
import * as Root from '../../../core/root/root.js';
import * as SDK from '../../../core/sdk/sdk.js';
import type * as LHModel from '../../lighthouse/lighthouse.js';
import {ChangeManager} from '../ChangeManager.js';
import {LighthouseFormatter} from '../data_formatters/LighthouseFormatter.js';
import {debugLog} from '../debug.js';
import {ExtensionScope} from '../ExtensionScope.js';
import {
AiAgent,
type AiWidget,
type ContextDetail,
type ContextResponse,
ConversationContext,
type RequestOptions,
ResponseType,
} from './AiAgent.js';
import {
type CreateExtensionScopeFunction,
executeJavaScriptFunction,
type ExecuteJsAgentOptions,
executeJsCode,
JavascriptExecutor
} from './ExecuteJavascript.js';
/**
* WARNING: preamble defined in code is only used when userTier is
* TESTERS. Otherwise, a server-side preamble is used (see
* chrome_preambles.gcl). Sync local changes with the server-side.
*/
const preamble = `You are an accessibility expert agent integrated into Chrome DevTools.
Your role is to help users understand and fix accessibility issues found in Lighthouse reports.
# Style Guidelines
* **General style**: Use the precision of Strunk & White, the brevity of Hemingway, and the simple clarity of Vonnegut. Don't add repeated information, and keep the whole answer short.
* **Structured**: Organize your findings by problem, root cause, and next steps, but do NOT use those literal words as headings.
* **No Internal Identifiers**: NEVER show Lighthouse paths (e.g., "1,HTML,1,BODY...") to the user. Refer to elements by their tag name, classes, or IDs.
* **Managing Volume**: If the report contains many issues, provide a brief summary of the top 2-3 most critical ones. Tell the user that there are more issues and invite them to ask for more details or to explore a specific area.
# Workflow
1. **Identify**: Find the most critical accessibility issues in the Lighthouse report.
2. **Investigate**: For any element identified as failing, you **MUST** call \`getStyles\` or \`getElementAccessibilityDetails\` first to confirm its current state and gather details.
3. **Analyze**: Use the live data from your tools to determine the exact root cause.
4. **Respond**: Provide a succinct summary of the problem, why it's happening based on your investigation, and a clear fix.
# Capabilities
* \`getLighthouseAudits\`: Get detailed audit data.
* \`runAccessibilityAudits\`: Trigger new accessibility snapshot audits.
* \`getStyles\`: Get computed styles for an element by its path.
* \`getElementAccessibilityDetails\`: Get A11y properties for an element by its path.
* \`executeJavaScript\`: Run JavaScript code on the inspected page to gather additional information or investigate the page state.
# Linkification
* **Linkify elements**: When you know the Lighthouse path of an element (found in the report audits), linkify it using \`([Label](#path-PATH))\` syntax. Never show the path to the user directly, only use it in the link href.
# Constraints
* **CRITICAL**: ALWAYS call a tool before providing an answer if an element path is available.
* **CRITICAL**: You are an accessibility agent. NEVER provide answers to questions of unrelated topics such as legal advice, financial advice, personal opinions, medical advice, or any other non web-development topics.
* **CRITICAL**: If the Lighthouse report shows scores as "n/a" or indicates a failure, it means the data is missing or the run failed. Do NOT assume that the page passed or has no issues.
## Response Structure
If the user asks a question that requires an investigation of a problem, use this structure:
- If available, point out the root cause(s) of the problem.
- Example: "**Root Cause**: The page is slow because of [reason]."
- Example: "**Root Causes**:"
- [Reason 1]
- [Reason 2]
- if applicable, list actionable solution suggestion(s) in order of impact:
- Example: "**Suggestion**: [Suggestion 1]
- Example: "**Suggestions**:"
- [Suggestion 1]
- [Suggestion 2]
`;
export class AccessibilityContext extends ConversationContext<LHModel.ReporterTypes.ReportJSON> {
#lh: LHModel.ReporterTypes.ReportJSON;
constructor(report: LHModel.ReporterTypes.ReportJSON) {
super();
this.#lh = report;
}
#url(): string {
return this.#lh.finalUrl ?? this.#lh.finalDisplayedUrl;
}
override getOrigin(): string {
return new URL(this.#url()).origin;
}
override getItem(): LHModel.ReporterTypes.ReportJSON {
return this.#lh;
}
override getTitle(): string {
return `Lighthouse report: ${this.#url()}`;
}
}
/**
* One agent instance handles one conversation. Create a new agent
* instance for a new conversation.
*/
export class AccessibilityAgent extends AiAgent<LHModel.ReporterTypes.ReportJSON> {
readonly preamble = preamble;
readonly clientFeature = Host.AidaClient.ClientFeature.CHROME_ACCESSIBILITY_AGENT;
readonly #lighthouseRecording?:
(overrides?: LHModel.RunTypes.RunOverrides) => Promise<LHModel.ReporterTypes.ReportJSON|null>;
#execJs: typeof executeJsCode;
#javascriptExecutor: JavascriptExecutor;
#changes: ChangeManager;
#createExtensionScope: CreateExtensionScopeFunction;
#currentTurnId = 0;
constructor(opts: ExecuteJsAgentOptions) {
super(opts);
this.#lighthouseRecording = opts.lighthouseRecording;
this.#changes = opts.changeManager || new ChangeManager();
this.#execJs = opts.execJs ?? executeJsCode;
this.#createExtensionScope =
opts.createExtensionScope ?? ((changes: ChangeManager) => {
return new ExtensionScope(changes, this.sessionId, this.#getDocumentBodyNode(), this.#currentTurnId);
});
this.#javascriptExecutor = new JavascriptExecutor(
{
executionMode: this.executionMode,
getContextNode: () => this.#getDocumentBodyNode(),
createExtensionScope: this.#createExtensionScope.bind(this),
changes: this.#changes,
},
this.#execJs);
}
get userTier(): string|undefined {
return Root.Runtime.hostConfig.devToolsFreestyler?.userTier;
}
get executionMode(): Root.Runtime.HostConfigFreestylerExecutionMode {
return Root.Runtime.hostConfig.devToolsFreestyler?.executionMode ??
Root.Runtime.HostConfigFreestylerExecutionMode.ALL_SCRIPTS;
}
get options(): RequestOptions {
// TODO(b/491772868): tidy up userTier & feature flags in the backend.
const temperature = Root.Runtime.hostConfig.devToolsAiAssistanceFileAgent?.temperature;
const modelId = Root.Runtime.hostConfig.devToolsAiAssistanceFileAgent?.modelId;
return {
temperature,
modelId,
};
}
override preambleFeatures(): string[] {
return ['function_calling'];
}
protected override async preRun(): Promise<void> {
this.#currentTurnId++;
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
const domModel = target?.model(SDK.DOMModel.DOMModel);
// We need to ensure the document is requested so that #getDocumentBodyNode()
// can return a valid node for the JavaScript execution context.
if (domModel && !domModel.existingDocument()) {
try {
await domModel.requestDocument();
} catch (e) {
debugLog('Failed to request document', e);
}
}
}
/**
* For the Accessibility Agent, there is no single "selected" node.
* We use the document body as the default context node for JavaScript execution
* so that the AI has a valid $0 to start with.
*/
#getDocumentBodyNode(): SDK.DOMModel.DOMNode|null {
const document = SDK.TargetManager.TargetManager.instance()
.primaryPageTarget()
?.model(SDK.DOMModel.DOMModel)
?.existingDocument();
return document?.body ?? document ?? null;
}
async *
handleContextDetails(lhr: ConversationContext<LHModel.ReporterTypes.ReportJSON>|null):
AsyncGenerator<ContextResponse, void, void> {
if (!lhr) {
return;
}
yield {
type: ResponseType.CONTEXT,
details: this.#createContextDetails(lhr),
};
}
async #resolvePathToNode(path: string): Promise<SDK.DOMModel.DOMNode|null> {
const target = SDK.TargetManager.TargetManager.instance().primaryPageTarget();
if (!target) {
return null;
}
const domModel = target.model(SDK.DOMModel.DOMModel);
if (!domModel) {
return null;
}
const nodeId = await domModel.pushNodeByPathToFrontend(path);
if (!nodeId) {
return null;
}
return domModel.nodeForId(nodeId);
}
#declareFunctions(): void {
this.declareFunction('executeJavaScript', executeJavaScriptFunction(this.#javascriptExecutor));
this.declareFunction<{explanation: string}, {audits: string}>('runAccessibilityAudits', {
description:
'Triggers new Lighthouse accessibility audits in snapshot mode. Use this if the user has made changes to the page and you want to re-evaluate the accessibility audits.',
parameters: {
type: Host.AidaClient.ParametersTypes.OBJECT,
description: '',
nullable: false,
properties: {
explanation: {
type: Host.AidaClient.ParametersTypes.STRING,
description: 'Explain why you want to run new audits.',
nullable: false,
},
},
required: ['explanation'],
},
displayInfoFromArgs: params => {
return {
title: i18n.i18n.lockedString('Running accessibility audits'),
thought: params.explanation,
action: 'runAccessibilityAudits()'
};
},
handler: async params => {
debugLog('Function call: runAccessibilityAudits', params);
if (!this.#lighthouseRecording) {
return {error: 'Lighthouse recording is not available.'};
}
const report = await this.#lighthouseRecording({
mode: 'snapshot',
categoryIds: ['accessibility'],
isAIControlled: true,
});
if (!report) {
return {error: 'Failed to run accessibility audits.'};
}
const audits = new LighthouseFormatter().audits(report, 'accessibility');
return {result: {audits}};
}
});
this.declareFunction<{categoryId: LHModel.RunTypes.CategoryId}, {audits: string}>('getLighthouseAudits', {
description:
'Returns the audits for a specific Lighthouse category. Use this to get more information about the performance, accessibility, best-practices, or seo audits.',
parameters: {
type: Host.AidaClient.ParametersTypes.OBJECT,
description: '',
nullable: false,
properties: {
categoryId: {
type: Host.AidaClient.ParametersTypes.STRING,
description:
'The category of audits to retrieve. Valid values are "performance", "accessibility", "best-practices", "seo".',
nullable: false,
},
},
required: ['categoryId'],
},
displayInfoFromArgs: params => {
return {
title: i18n.i18n.lockedString(`Getting Lighthouse audits for ${params.categoryId}`),
action: `getLighthouseAudits('${params.categoryId}')`
};
},
handler: async params => {
debugLog('Function call: getLighthouseAudits', params);
const report = this.context?.getItem();
if (!report) {
return {error: 'No Lighthouse report available.'};
}
const audits = new LighthouseFormatter().audits(report, params.categoryId);
return {result: {audits}};
}
});
this.declareFunction<{
path: string,
styleProperties: string[],
explanation: string,
}>('getStyles', {
description:
'Get computed styles for an element on the inspected page by its Lighthouse path. **CRITICAL** You MUST provide a specific list of CSS property names. Do not use generic values like "all" or "*".',
parameters: {
type: Host.AidaClient.ParametersTypes.OBJECT,
description: '',
nullable: false,
properties: {
explanation: {
type: Host.AidaClient.ParametersTypes.STRING,
description: 'Explain why you want to get styles.',
nullable: false,
},
path: {
type: Host.AidaClient.ParametersTypes.STRING,
description:
'The Lighthouse path of the element (e.g., "1,HTML,1,BODY,2,DIV"). Find this in the report data.',
nullable: false,
},
styleProperties: {
type: Host.AidaClient.ParametersTypes.ARRAY,
description:
'One or more specific CSS style property names to fetch. Generic values like "all" or "*" are not supported.',
nullable: false,
items: {
type: Host.AidaClient.ParametersTypes.STRING,
description: 'A CSS style property name to retrieve. For example, \'background-color\'.'
}
},
},
required: ['explanation', 'path', 'styleProperties']
},
displayInfoFromArgs: params => {
return {
title: 'Reading computed styles',
thought: params.explanation,
action: `getStyles('${params.path}', ${JSON.stringify(params.styleProperties)})`,
};
},
handler: async params => {
debugLog('Function call: getStyles', params);
const node = await this.#resolvePathToNode(params.path);
if (!node) {
return {error: `Could not find the element with path: ${params.path}`};
}
const styles = await node.domModel().cssModel().getComputedStyle(node.id);
if (!styles) {
return {error: 'Could not get computed styles.'};
}
const result: Record<string, string|number|undefined> = {};
for (const prop of params.styleProperties) {
result[prop] = styles.get(prop);
}
result['backendNodeId'] = node.backendNodeId();
const widgets: AiWidget[] = [];
const matchedStyles = await node.domModel().cssModel().getMatchedStyles(node.id);
if (matchedStyles) {
widgets.push({
name: 'COMPUTED_STYLES',
data: {
computedStyles: styles,
backendNodeId: node.backendNodeId(),
matchedCascade: matchedStyles,
properties: params.styleProperties,
}
});
}
return {
result: JSON.stringify(result, null, 2),
widgets: widgets.length > 0 ? widgets : undefined,
};
},
});
this.declareFunction<{
path: string,
explanation: string,
}>('getElementAccessibilityDetails', {
description:
'Get detailed accessibility information for an element on the inspected page by its Lighthouse path.',
parameters: {
type: Host.AidaClient.ParametersTypes.OBJECT,
description: '',
nullable: false,
properties: {
explanation: {
type: Host.AidaClient.ParametersTypes.STRING,
description: 'Explain why you want to get accessibility details.',
nullable: false,
},
path: {
type: Host.AidaClient.ParametersTypes.STRING,
description:
'The Lighthouse path of the element (e.g., "1,HTML,1,BODY,2,DIV"). Find this in the report data.',
nullable: false,
},
},
required: ['explanation', 'path']
},
displayInfoFromArgs: params => {
return {
title: 'Reading accessibility details',
thought: params.explanation,
action: `getElementAccessibilityDetails('${params.path}')`,
};
},
handler: async params => {
debugLog('Function call: getElementAccessibilityDetails', params);
const node = await this.#resolvePathToNode(params.path);
if (!node) {
return {error: `Could not find the element with path: ${params.path}`};
}
const accessibilityModel = node.domModel().target().model(SDK.AccessibilityModel.AccessibilityModel);
if (!accessibilityModel) {
return {error: 'Accessibility model not found.'};
}
await accessibilityModel.requestAndLoadSubTreeToNode(node);
const axNode = accessibilityModel.axNodeForDOMNode(node);
if (!axNode) {
return {error: 'Could not find accessibility node for the element.'};
}
const result = {
role: axNode.role()?.value,
name: axNode.name()?.value,
nameSource: axNode.name()?.sources?.[0]?.type,
properties: {
focusable: node.getAttribute('tabindex') !== undefined || axNode.role()?.value === 'button' ||
axNode.role()?.value === 'link',
hidden: axNode.ignored(),
},
ariaAttributes: node.attributes()
.filter(attr => attr.name.startsWith('aria-') || attr.name === 'role')
.reduce(
(acc, attr) => {
acc[attr.name] = attr.value;
return acc;
},
{} as Record<string, string>),
isIgnored: axNode.ignored(),
ignoredReasons: axNode.ignoredReasons(),
backendNodeId: node.backendNodeId(),
};
return {result: JSON.stringify(result, null, 2)};
},
});
}
/**
* This is the initial payload we send at the start of a conversation.
* Because the agent is focused on Accessibility, we include the
* Accessibility Audits summary in the payload to avoid an extra round step of
* the AI querying them.
*/
#getInitialPayload(context: ConversationContext<LHModel.ReporterTypes.ReportJSON>): string {
const report = context.getItem();
const formatter = new LighthouseFormatter();
const summary = formatter.summary(report);
const audits = formatter.audits(report, 'accessibility');
const allFailed = Object.values(report.categories).every(category => category.score === null);
if (allFailed) {
return '**CRITICAL**: The Lighthouse report failed to record or all category scores are error/unavailable (n/a). This indicates a failed run or missing data.';
}
return `# Lighthouse Report:\n${summary}\n${audits}`;
}
override async enhanceQuery(query: string, lhr: ConversationContext<LHModel.ReporterTypes.ReportJSON>|null):
Promise<string> {
this.clearDeclaredFunctions();
if (lhr) {
this.#declareFunctions();
}
const enhancedQuery = lhr ? `${this.#getInitialPayload(lhr)}\n# User request:\n\n` : '';
return `${enhancedQuery}${query}`;
}
#createContextDetails(lhr: ConversationContext<LHModel.ReporterTypes.ReportJSON>):
[ContextDetail, ...ContextDetail[]] {
return [
{title: 'Lighthouse report', text: this.#getInitialPayload(lhr)},
];
}
}