framewatch-mcp-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +537 -0
- package/dist/constants.d.ts +172 -0
- package/dist/constants.js +168 -0
- package/dist/constants.js.map +1 -0
- package/dist/engine/browser.d.ts +56 -0
- package/dist/engine/browser.js +142 -0
- package/dist/engine/browser.js.map +1 -0
- package/dist/engine/differ.d.ts +88 -0
- package/dist/engine/differ.js +373 -0
- package/dist/engine/differ.js.map +1 -0
- package/dist/engine/interaction.d.ts +76 -0
- package/dist/engine/interaction.js +254 -0
- package/dist/engine/interaction.js.map +1 -0
- package/dist/engine/layers/console.d.ts +63 -0
- package/dist/engine/layers/console.js +118 -0
- package/dist/engine/layers/console.js.map +1 -0
- package/dist/engine/layers/dom.d.ts +53 -0
- package/dist/engine/layers/dom.js +282 -0
- package/dist/engine/layers/dom.js.map +1 -0
- package/dist/engine/layers/index.d.ts +95 -0
- package/dist/engine/layers/index.js +184 -0
- package/dist/engine/layers/index.js.map +1 -0
- package/dist/engine/layers/network.d.ts +62 -0
- package/dist/engine/layers/network.js +169 -0
- package/dist/engine/layers/network.js.map +1 -0
- package/dist/engine/layers/performance.d.ts +55 -0
- package/dist/engine/layers/performance.js +215 -0
- package/dist/engine/layers/performance.js.map +1 -0
- package/dist/engine/layers/probe.d.ts +50 -0
- package/dist/engine/layers/probe.js +39 -0
- package/dist/engine/layers/probe.js.map +1 -0
- package/dist/engine/layers/session.d.ts +46 -0
- package/dist/engine/layers/session.js +131 -0
- package/dist/engine/layers/session.js.map +1 -0
- package/dist/engine/recorder.d.ts +61 -0
- package/dist/engine/recorder.js +256 -0
- package/dist/engine/recorder.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +125 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/accessibility.d.ts +140 -0
- package/dist/tools/accessibility.js +357 -0
- package/dist/tools/accessibility.js.map +1 -0
- package/dist/tools/capture.d.ts +279 -0
- package/dist/tools/capture.js +275 -0
- package/dist/tools/capture.js.map +1 -0
- package/dist/tools/compare.d.ts +86 -0
- package/dist/tools/compare.js +247 -0
- package/dist/tools/compare.js.map +1 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +25 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/interact.d.ts +160 -0
- package/dist/tools/interact.js +203 -0
- package/dist/tools/interact.js.map +1 -0
- package/dist/tools/responsive.d.ts +89 -0
- package/dist/tools/responsive.js +197 -0
- package/dist/tools/responsive.js.map +1 -0
- package/dist/tools/screenshot.d.ts +76 -0
- package/dist/tools/screenshot.js +117 -0
- package/dist/tools/screenshot.js.map +1 -0
- package/dist/tools/server.d.ts +89 -0
- package/dist/tools/server.js +201 -0
- package/dist/tools/server.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/bounded-log.d.ts +41 -0
- package/dist/utils/bounded-log.js +78 -0
- package/dist/utils/bounded-log.js.map +1 -0
- package/dist/utils/format.d.ts +56 -0
- package/dist/utils/format.js +130 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/image.d.ts +44 -0
- package/dist/utils/image.js +81 -0
- package/dist/utils/image.js.map +1 -0
- package/dist/utils/server-process.d.ts +84 -0
- package/dist/utils/server-process.js +251 -0
- package/dist/utils/server-process.js.map +1 -0
- package/package.json +74 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { closeBrowser } from "./engine/browser.js";
|
|
7
|
+
import { registerAllTools } from "./tools/index.js";
|
|
8
|
+
import { shutdownDevServer } from "./utils/server-process.js";
|
|
9
|
+
export const SERVER_NAME = "framewatch";
|
|
10
|
+
export const SERVER_VERSION = readPackageVersion();
|
|
11
|
+
/**
|
|
12
|
+
* Build a fully configured FrameWatch McpServer (not yet connected to a
|
|
13
|
+
* transport). Exported so tests and embedders can wire their own transport;
|
|
14
|
+
* importing this module has no side effects — the stdio server only starts
|
|
15
|
+
* when this file is executed directly (`node dist/index.js` / the npm bin).
|
|
16
|
+
*/
|
|
17
|
+
export function createServer() {
|
|
18
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION }, {
|
|
19
|
+
instructions: "FrameWatch gives you visual eyes on running web apps. Use framewatch_screenshot to see the " +
|
|
20
|
+
"current state of a page. Pass a `selector` to zoom in on one element, or `wait_for` to wait " +
|
|
21
|
+
"for an element before capturing. Use framewatch_capture to record a page for a few seconds " +
|
|
22
|
+
"and get back only the frames where something meaningful changed (animations, splash screens, " +
|
|
23
|
+
"transitions, anything that changes over time), each with a crop of the changed region and its " +
|
|
24
|
+
"position. Lower `sensitivity` to keep more frames, raise `max_frames` for longer sequences. " +
|
|
25
|
+
"Pass `interactions` to replay a click/type/scroll/tap/swipe script while it records — that is how you " +
|
|
26
|
+
"test a flow such as a login. Use framewatch_interact for one action at a time: it keeps the page open " +
|
|
27
|
+
"between calls, so you can click, look at the result, type, and look again without replaying the flow. " +
|
|
28
|
+
"Both capture and interact can attach the context behind a frame — console output and uncaught errors " +
|
|
29
|
+
"(on by default), plus network requests, DOM mutations and paint timing via `include_network`, " +
|
|
30
|
+
"`include_dom` and `include_performance` — which is how you find out why a frame looks wrong. " +
|
|
31
|
+
"Use framewatch_responsive to see one page at mobile, tablet and desktop widths at once (it also " +
|
|
32
|
+
"reports content that overflows its viewport), framewatch_accessibility to run an axe-core WCAG audit, " +
|
|
33
|
+
"and framewatch_compare to diff two URLs — or the page interact has open against a URL — with an " +
|
|
34
|
+
"overlay of every pixel that differs. If the app is not running yet, framewatch_start_server runs its " +
|
|
35
|
+
"dev server (e.g. `npm run dev`) and waits for the port; framewatch_stop_server stops it again.",
|
|
36
|
+
});
|
|
37
|
+
registerAllTools(server);
|
|
38
|
+
return server;
|
|
39
|
+
}
|
|
40
|
+
/** Run the server on stdio until the client disconnects or the process is signalled. */
|
|
41
|
+
export async function main() {
|
|
42
|
+
const server = createServer();
|
|
43
|
+
const transport = new StdioServerTransport();
|
|
44
|
+
// stderr may be closed by the host (e.g. parent died); never let a log write
|
|
45
|
+
// turn into an uncaught 'error' event that re-enters the handlers below.
|
|
46
|
+
process.stderr.on("error", () => { });
|
|
47
|
+
let shuttingDown = false;
|
|
48
|
+
const shutdown = async (reason, exitCode = 0) => {
|
|
49
|
+
if (shuttingDown)
|
|
50
|
+
return;
|
|
51
|
+
shuttingDown = true;
|
|
52
|
+
log(`shutting down (${reason})`);
|
|
53
|
+
try {
|
|
54
|
+
// The dev server is a child process of this one; leaving it behind would
|
|
55
|
+
// hold its port and outlive the client that asked for it.
|
|
56
|
+
await shutdownDevServer();
|
|
57
|
+
await closeBrowser();
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await server.close().catch(() => { });
|
|
61
|
+
process.exit(exitCode);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
// Client went away. The SDK transport only fires onclose on protocol errors,
|
|
65
|
+
// so watch stdin EOF directly — once Chromium is running it would otherwise
|
|
66
|
+
// keep the event loop (and a headless browser) alive forever.
|
|
67
|
+
process.stdin.on("end", () => void shutdown("stdin closed"));
|
|
68
|
+
process.stdin.on("close", () => void shutdown("stdin closed"));
|
|
69
|
+
transport.onclose = () => void shutdown("transport closed");
|
|
70
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
71
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
72
|
+
process.on("SIGHUP", () => void shutdown("SIGHUP"));
|
|
73
|
+
process.on("uncaughtException", (err) => {
|
|
74
|
+
log(`uncaught exception: ${formatError(err)}`);
|
|
75
|
+
void shutdown("uncaught exception", 1);
|
|
76
|
+
});
|
|
77
|
+
process.on("unhandledRejection", (err) => {
|
|
78
|
+
log(`unhandled rejection: ${formatError(err)}`);
|
|
79
|
+
});
|
|
80
|
+
await server.connect(transport);
|
|
81
|
+
log(`${SERVER_NAME} v${SERVER_VERSION} ready on stdio`);
|
|
82
|
+
}
|
|
83
|
+
/** stdout is the MCP channel — all diagnostics go to stderr, and never throw. */
|
|
84
|
+
function log(message) {
|
|
85
|
+
try {
|
|
86
|
+
process.stderr.write(`[framewatch] ${message}\n`);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// stderr is gone; nothing sensible to do.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function formatError(err) {
|
|
93
|
+
return err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
94
|
+
}
|
|
95
|
+
function readPackageVersion() {
|
|
96
|
+
try {
|
|
97
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
98
|
+
return pkg.version ?? "0.0.0";
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return "0.0.0";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* True when this file is the process entry point. npm's bin shim is a symlink,
|
|
106
|
+
* so compare real paths: import.meta.url is already resolved, argv[1] is not.
|
|
107
|
+
*/
|
|
108
|
+
function isRunDirectly() {
|
|
109
|
+
const entry = process.argv[1];
|
|
110
|
+
if (!entry)
|
|
111
|
+
return false;
|
|
112
|
+
try {
|
|
113
|
+
return import.meta.url === pathToFileURL(realpathSync(entry)).href;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (isRunDirectly()) {
|
|
120
|
+
main().catch((err) => {
|
|
121
|
+
log(`fatal: ${formatError(err)}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport { readFileSync, realpathSync } from \"node:fs\";\nimport { pathToFileURL } from \"node:url\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport { closeBrowser } from \"./engine/browser.js\";\nimport { registerAllTools } from \"./tools/index.js\";\nimport { shutdownDevServer } from \"./utils/server-process.js\";\n\nexport const SERVER_NAME = \"framewatch\";\nexport const SERVER_VERSION: string = readPackageVersion();\n\n/**\n * Build a fully configured FrameWatch McpServer (not yet connected to a\n * transport). Exported so tests and embedders can wire their own transport;\n * importing this module has no side effects — the stdio server only starts\n * when this file is executed directly (`node dist/index.js` / the npm bin).\n */\nexport function createServer(): McpServer {\n const server = new McpServer(\n { name: SERVER_NAME, version: SERVER_VERSION },\n {\n instructions:\n \"FrameWatch gives you visual eyes on running web apps. Use framewatch_screenshot to see the \" +\n \"current state of a page. Pass a `selector` to zoom in on one element, or `wait_for` to wait \" +\n \"for an element before capturing. Use framewatch_capture to record a page for a few seconds \" +\n \"and get back only the frames where something meaningful changed (animations, splash screens, \" +\n \"transitions, anything that changes over time), each with a crop of the changed region and its \" +\n \"position. Lower `sensitivity` to keep more frames, raise `max_frames` for longer sequences. \" +\n \"Pass `interactions` to replay a click/type/scroll/tap/swipe script while it records — that is how you \" +\n \"test a flow such as a login. Use framewatch_interact for one action at a time: it keeps the page open \" +\n \"between calls, so you can click, look at the result, type, and look again without replaying the flow. \" +\n \"Both capture and interact can attach the context behind a frame — console output and uncaught errors \" +\n \"(on by default), plus network requests, DOM mutations and paint timing via `include_network`, \" +\n \"`include_dom` and `include_performance` — which is how you find out why a frame looks wrong. \" +\n \"Use framewatch_responsive to see one page at mobile, tablet and desktop widths at once (it also \" +\n \"reports content that overflows its viewport), framewatch_accessibility to run an axe-core WCAG audit, \" +\n \"and framewatch_compare to diff two URLs — or the page interact has open against a URL — with an \" +\n \"overlay of every pixel that differs. If the app is not running yet, framewatch_start_server runs its \" +\n \"dev server (e.g. `npm run dev`) and waits for the port; framewatch_stop_server stops it again.\",\n },\n );\n registerAllTools(server);\n return server;\n}\n\n/** Run the server on stdio until the client disconnects or the process is signalled. */\nexport async function main(): Promise<void> {\n const server = createServer();\n const transport = new StdioServerTransport();\n\n // stderr may be closed by the host (e.g. parent died); never let a log write\n // turn into an uncaught 'error' event that re-enters the handlers below.\n process.stderr.on(\"error\", () => {});\n\n let shuttingDown = false;\n const shutdown = async (reason: string, exitCode = 0): Promise<void> => {\n if (shuttingDown) return;\n shuttingDown = true;\n log(`shutting down (${reason})`);\n try {\n // The dev server is a child process of this one; leaving it behind would\n // hold its port and outlive the client that asked for it.\n await shutdownDevServer();\n await closeBrowser();\n } finally {\n await server.close().catch(() => {});\n process.exit(exitCode);\n }\n };\n\n // Client went away. The SDK transport only fires onclose on protocol errors,\n // so watch stdin EOF directly — once Chromium is running it would otherwise\n // keep the event loop (and a headless browser) alive forever.\n process.stdin.on(\"end\", () => void shutdown(\"stdin closed\"));\n process.stdin.on(\"close\", () => void shutdown(\"stdin closed\"));\n transport.onclose = () => void shutdown(\"transport closed\");\n\n process.on(\"SIGINT\", () => void shutdown(\"SIGINT\"));\n process.on(\"SIGTERM\", () => void shutdown(\"SIGTERM\"));\n process.on(\"SIGHUP\", () => void shutdown(\"SIGHUP\"));\n process.on(\"uncaughtException\", (err) => {\n log(`uncaught exception: ${formatError(err)}`);\n void shutdown(\"uncaught exception\", 1);\n });\n process.on(\"unhandledRejection\", (err) => {\n log(`unhandled rejection: ${formatError(err)}`);\n });\n\n await server.connect(transport);\n log(`${SERVER_NAME} v${SERVER_VERSION} ready on stdio`);\n}\n\n/** stdout is the MCP channel — all diagnostics go to stderr, and never throw. */\nfunction log(message: string): void {\n try {\n process.stderr.write(`[framewatch] ${message}\\n`);\n } catch {\n // stderr is gone; nothing sensible to do.\n }\n}\n\nfunction formatError(err: unknown): string {\n return err instanceof Error ? (err.stack ?? err.message) : String(err);\n}\n\nfunction readPackageVersion(): string {\n try {\n const pkg = JSON.parse(readFileSync(new URL(\"../package.json\", import.meta.url), \"utf8\")) as { version?: string };\n return pkg.version ?? \"0.0.0\";\n } catch {\n return \"0.0.0\";\n }\n}\n\n/**\n * True when this file is the process entry point. npm's bin shim is a symlink,\n * so compare real paths: import.meta.url is already resolved, argv[1] is not.\n */\nfunction isRunDirectly(): boolean {\n const entry = process.argv[1];\n if (!entry) return false;\n try {\n return import.meta.url === pathToFileURL(realpathSync(entry)).href;\n } catch {\n return false;\n }\n}\n\nif (isRunDirectly()) {\n main().catch((err) => {\n log(`fatal: ${formatError(err)}`);\n process.exit(1);\n });\n}\n"]}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
export declare const ACCESSIBILITY_TOOL_NAME = "framewatch_accessibility";
|
|
5
|
+
/** WCAG conformance levels, and the axe-core tags each one runs. */
|
|
6
|
+
declare const STANDARDS: {
|
|
7
|
+
readonly wcag2a: readonly ["wcag2a"];
|
|
8
|
+
readonly wcag2aa: readonly ["wcag2a", "wcag2aa"];
|
|
9
|
+
readonly wcag21aa: readonly ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"];
|
|
10
|
+
};
|
|
11
|
+
export type A11yStandard = keyof typeof STANDARDS;
|
|
12
|
+
export declare const accessibilityInputShape: {
|
|
13
|
+
url: z.ZodString;
|
|
14
|
+
standard: z.ZodDefault<z.ZodEnum<["wcag2a", "wcag2aa", "wcag21aa"]>>;
|
|
15
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
16
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
17
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
18
|
+
max_violations: z.ZodDefault<z.ZodNumber>;
|
|
19
|
+
max_elements: z.ZodDefault<z.ZodNumber>;
|
|
20
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
21
|
+
width: z.ZodDefault<z.ZodNumber>;
|
|
22
|
+
height: z.ZodDefault<z.ZodNumber>;
|
|
23
|
+
}, "strip", z.ZodTypeAny, {
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
}, {
|
|
27
|
+
width?: number | undefined;
|
|
28
|
+
height?: number | undefined;
|
|
29
|
+
}>>;
|
|
30
|
+
};
|
|
31
|
+
export declare const accessibilityInputSchema: z.ZodObject<{
|
|
32
|
+
url: z.ZodString;
|
|
33
|
+
standard: z.ZodDefault<z.ZodEnum<["wcag2a", "wcag2aa", "wcag21aa"]>>;
|
|
34
|
+
wait_ms: z.ZodDefault<z.ZodNumber>;
|
|
35
|
+
wait_for: z.ZodOptional<z.ZodString>;
|
|
36
|
+
wait_for_timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
37
|
+
max_violations: z.ZodDefault<z.ZodNumber>;
|
|
38
|
+
max_elements: z.ZodDefault<z.ZodNumber>;
|
|
39
|
+
viewport: z.ZodOptional<z.ZodObject<{
|
|
40
|
+
width: z.ZodDefault<z.ZodNumber>;
|
|
41
|
+
height: z.ZodDefault<z.ZodNumber>;
|
|
42
|
+
}, "strip", z.ZodTypeAny, {
|
|
43
|
+
width: number;
|
|
44
|
+
height: number;
|
|
45
|
+
}, {
|
|
46
|
+
width?: number | undefined;
|
|
47
|
+
height?: number | undefined;
|
|
48
|
+
}>>;
|
|
49
|
+
}, "strip", z.ZodTypeAny, {
|
|
50
|
+
url: string;
|
|
51
|
+
standard: "wcag2a" | "wcag2aa" | "wcag21aa";
|
|
52
|
+
wait_ms: number;
|
|
53
|
+
wait_for_timeout_ms: number;
|
|
54
|
+
max_violations: number;
|
|
55
|
+
max_elements: number;
|
|
56
|
+
viewport?: {
|
|
57
|
+
width: number;
|
|
58
|
+
height: number;
|
|
59
|
+
} | undefined;
|
|
60
|
+
wait_for?: string | undefined;
|
|
61
|
+
}, {
|
|
62
|
+
url: string;
|
|
63
|
+
viewport?: {
|
|
64
|
+
width?: number | undefined;
|
|
65
|
+
height?: number | undefined;
|
|
66
|
+
} | undefined;
|
|
67
|
+
standard?: "wcag2a" | "wcag2aa" | "wcag21aa" | undefined;
|
|
68
|
+
wait_ms?: number | undefined;
|
|
69
|
+
wait_for?: string | undefined;
|
|
70
|
+
wait_for_timeout_ms?: number | undefined;
|
|
71
|
+
max_violations?: number | undefined;
|
|
72
|
+
max_elements?: number | undefined;
|
|
73
|
+
}>;
|
|
74
|
+
export type AccessibilityInput = z.input<typeof accessibilityInputSchema>;
|
|
75
|
+
/** The trimmed shape the in-page script sends back — never raw axe results. */
|
|
76
|
+
interface AxeNode {
|
|
77
|
+
target: string;
|
|
78
|
+
html: string;
|
|
79
|
+
summary: string;
|
|
80
|
+
}
|
|
81
|
+
interface AxeViolation {
|
|
82
|
+
id: string;
|
|
83
|
+
impact: string;
|
|
84
|
+
help: string;
|
|
85
|
+
description: string;
|
|
86
|
+
help_url: string;
|
|
87
|
+
tags: string[];
|
|
88
|
+
node_count: number;
|
|
89
|
+
nodes: AxeNode[];
|
|
90
|
+
}
|
|
91
|
+
interface AxeReport {
|
|
92
|
+
violations: AxeViolation[];
|
|
93
|
+
/** Rules that passed, that found nothing to test, and that need a human. */
|
|
94
|
+
passes: number;
|
|
95
|
+
inapplicable: number;
|
|
96
|
+
incomplete: number;
|
|
97
|
+
/** Total violation types found, before `max_violations` trimmed the list. */
|
|
98
|
+
total_violations: number;
|
|
99
|
+
/** Offending elements across every violation, including those not listed. */
|
|
100
|
+
total_nodes: number;
|
|
101
|
+
url: string;
|
|
102
|
+
axe_version: string;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Run an axe-core accessibility audit and report what it found.
|
|
106
|
+
*
|
|
107
|
+
* axe is injected into the page rather than reimplemented: it is the engine
|
|
108
|
+
* behind most commercial accessibility tooling, so a violation reported here
|
|
109
|
+
* is one an auditor would also raise. It is injected as a script through
|
|
110
|
+
* `evaluate`, into a context created with CSP bypassed, so a page with a
|
|
111
|
+
* strict Content-Security-Policy can still be audited.
|
|
112
|
+
*
|
|
113
|
+
* Only violations come back. Passing rules are counted, not listed — a list of
|
|
114
|
+
* two hundred rules that did not fire is noise, but the count is what tells
|
|
115
|
+
* you the audit actually ran.
|
|
116
|
+
*/
|
|
117
|
+
export declare function auditAccessibility(rawInput: AccessibilityInput): Promise<CallToolResult>;
|
|
118
|
+
/**
|
|
119
|
+
* Render the report as text.
|
|
120
|
+
*
|
|
121
|
+
* Each violation gets its impact, the rule that failed, what to do about it,
|
|
122
|
+
* the elements at fault and the URL of Deque's write-up — everything needed to
|
|
123
|
+
* fix it without a second lookup.
|
|
124
|
+
*/
|
|
125
|
+
export declare function formatReport(report: AxeReport, standard: A11yStandard): string;
|
|
126
|
+
export declare function axeSource(): string;
|
|
127
|
+
/**
|
|
128
|
+
* One actionable line for a failed audit. Mirrors `describeFailure` in
|
|
129
|
+
* screenshot.ts: match on the failing Playwright call, never on substrings of
|
|
130
|
+
* a user-supplied selector.
|
|
131
|
+
*/
|
|
132
|
+
export declare function describeAuditFailure(input: {
|
|
133
|
+
url: string;
|
|
134
|
+
wait_for?: string;
|
|
135
|
+
wait_for_timeout_ms: number;
|
|
136
|
+
}, error: unknown): string;
|
|
137
|
+
export declare function registerAccessibilityTool(server: McpServer): void;
|
|
138
|
+
/** Exported for tests: the axe tag list each standard maps to. */
|
|
139
|
+
export declare const A11Y_STANDARD_TAGS: Readonly<Record<A11yStandard, readonly string[]>>;
|
|
140
|
+
export {};
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { A11Y_FRAME_WAIT_MS, A11Y_RUN_TIMEOUT_MS, DEFAULT_A11Y_WAIT_MS, DEFAULT_VIEWPORT, MAX_A11Y_HTML_LENGTH, MAX_A11Y_NODES_PER_VIOLATION, MAX_A11Y_VIOLATIONS, MAX_VIEWPORT_HEIGHT, MAX_VIEWPORT_WIDTH, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS, } from "../constants.js";
|
|
5
|
+
import { withPage } from "../engine/browser.js";
|
|
6
|
+
export const ACCESSIBILITY_TOOL_NAME = "framewatch_accessibility";
|
|
7
|
+
/** WCAG conformance levels, and the axe-core tags each one runs. */
|
|
8
|
+
const STANDARDS = {
|
|
9
|
+
wcag2a: ["wcag2a"],
|
|
10
|
+
wcag2aa: ["wcag2a", "wcag2aa"],
|
|
11
|
+
wcag21aa: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
|
|
12
|
+
};
|
|
13
|
+
/** Worst first — this is the order violations are reported in. */
|
|
14
|
+
const IMPACT_ORDER = ["critical", "serious", "moderate", "minor"];
|
|
15
|
+
export const accessibilityInputShape = {
|
|
16
|
+
url: z.string().url().describe("URL to audit, e.g. http://localhost:3000 (http, https and file URLs are accepted)"),
|
|
17
|
+
standard: z
|
|
18
|
+
.enum(["wcag2a", "wcag2aa", "wcag21aa"])
|
|
19
|
+
.default("wcag2aa")
|
|
20
|
+
.describe("Conformance level to test against. wcag2aa is the usual legal baseline; wcag21aa adds WCAG 2.1."),
|
|
21
|
+
wait_ms: z
|
|
22
|
+
.number()
|
|
23
|
+
.int()
|
|
24
|
+
.min(0)
|
|
25
|
+
.default(DEFAULT_A11Y_WAIT_MS)
|
|
26
|
+
.describe("Wait time (ms) after page load before auditing, so the app can finish rendering"),
|
|
27
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) before auditing"),
|
|
28
|
+
wait_for_timeout_ms: z
|
|
29
|
+
.number()
|
|
30
|
+
.int()
|
|
31
|
+
.min(1)
|
|
32
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
33
|
+
.describe("Max time (ms) to wait for `wait_for` to appear (must be > 0)"),
|
|
34
|
+
max_violations: z
|
|
35
|
+
.number()
|
|
36
|
+
.int()
|
|
37
|
+
.min(1)
|
|
38
|
+
.max(MAX_A11Y_VIOLATIONS)
|
|
39
|
+
.default(MAX_A11Y_VIOLATIONS)
|
|
40
|
+
.describe("Maximum violation types to report (they are reported worst-impact first)"),
|
|
41
|
+
max_elements: z
|
|
42
|
+
.number()
|
|
43
|
+
.int()
|
|
44
|
+
.min(1)
|
|
45
|
+
.max(20)
|
|
46
|
+
.default(MAX_A11Y_NODES_PER_VIOLATION)
|
|
47
|
+
.describe("Maximum offending elements listed under each violation"),
|
|
48
|
+
viewport: z
|
|
49
|
+
.object({
|
|
50
|
+
width: z.number().int().min(1).max(MAX_VIEWPORT_WIDTH).default(DEFAULT_VIEWPORT.width),
|
|
51
|
+
height: z.number().int().min(1).max(MAX_VIEWPORT_HEIGHT).default(DEFAULT_VIEWPORT.height),
|
|
52
|
+
})
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("Viewport size (defaults to 1280x720). Some rules — reflow, target size — depend on it."),
|
|
55
|
+
};
|
|
56
|
+
export const accessibilityInputSchema = z.object(accessibilityInputShape);
|
|
57
|
+
/**
|
|
58
|
+
* Run an axe-core accessibility audit and report what it found.
|
|
59
|
+
*
|
|
60
|
+
* axe is injected into the page rather than reimplemented: it is the engine
|
|
61
|
+
* behind most commercial accessibility tooling, so a violation reported here
|
|
62
|
+
* is one an auditor would also raise. It is injected as a script through
|
|
63
|
+
* `evaluate`, into a context created with CSP bypassed, so a page with a
|
|
64
|
+
* strict Content-Security-Policy can still be audited.
|
|
65
|
+
*
|
|
66
|
+
* Only violations come back. Passing rules are counted, not listed — a list of
|
|
67
|
+
* two hundred rules that did not fire is noise, but the count is what tells
|
|
68
|
+
* you the audit actually ran.
|
|
69
|
+
*/
|
|
70
|
+
export async function auditAccessibility(rawInput) {
|
|
71
|
+
const parsed = accessibilityInputSchema.safeParse(rawInput);
|
|
72
|
+
if (!parsed.success) {
|
|
73
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
74
|
+
return errorResult(`Accessibility audit failed: invalid input — ${issues}`);
|
|
75
|
+
}
|
|
76
|
+
const input = parsed.data;
|
|
77
|
+
const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };
|
|
78
|
+
let source;
|
|
79
|
+
try {
|
|
80
|
+
source = axeSource();
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return errorResult("Accessibility audit failed: the axe-core library could not be loaded from this installation " +
|
|
84
|
+
`(${error instanceof Error ? error.message : String(error)}). Reinstall dependencies and try again.`);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
// `bypassCSP` so the audit can be injected into an app that forbids
|
|
88
|
+
// inline script and eval. The page's own CSP has no bearing on what axe
|
|
89
|
+
// measures, and without this a strictly configured app could not be
|
|
90
|
+
// audited at all.
|
|
91
|
+
const report = await withPage({ viewport, contextOptions: { bypassCSP: true } }, async (page) => {
|
|
92
|
+
await page.goto(input.url, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
93
|
+
if (input.wait_for) {
|
|
94
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms });
|
|
95
|
+
}
|
|
96
|
+
if (input.wait_ms > 0) {
|
|
97
|
+
await page.waitForTimeout(input.wait_ms);
|
|
98
|
+
}
|
|
99
|
+
await injectAxe(page, source);
|
|
100
|
+
// axe walks the whole DOM; on a big enough app that takes a while, and
|
|
101
|
+
// `page.evaluate` has no timeout of its own — a page that wedges
|
|
102
|
+
// mid-audit would otherwise hang the tool call forever.
|
|
103
|
+
return withTimeout(runAxe(page, {
|
|
104
|
+
tags: [...STANDARDS[input.standard]],
|
|
105
|
+
max_violations: input.max_violations,
|
|
106
|
+
max_elements: input.max_elements,
|
|
107
|
+
max_html: MAX_A11Y_HTML_LENGTH,
|
|
108
|
+
frame_wait_ms: A11Y_FRAME_WAIT_MS,
|
|
109
|
+
}), A11Y_RUN_TIMEOUT_MS, `axe-core did not finish within ${A11Y_RUN_TIMEOUT_MS}ms`);
|
|
110
|
+
});
|
|
111
|
+
return { content: [{ type: "text", text: formatReport(report, input.standard) }] };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return errorResult(describeAuditFailure(input, error));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Put axe in the main frame and in every child frame.
|
|
119
|
+
*
|
|
120
|
+
* axe audits iframes by talking to a copy of itself inside each one, so a page
|
|
121
|
+
* that embeds anything (a preview pane, an embedded checkout) needs it
|
|
122
|
+
* everywhere. Child frames are best-effort: one that refuses injection — it
|
|
123
|
+
* may be mid-navigation, or gone by the time we reach it — must not stop the
|
|
124
|
+
* audit of the page around it.
|
|
125
|
+
*/
|
|
126
|
+
async function injectAxe(page, source) {
|
|
127
|
+
await evaluateSource(page.mainFrame(), source);
|
|
128
|
+
for (const frame of page.frames()) {
|
|
129
|
+
if (frame === page.mainFrame())
|
|
130
|
+
continue;
|
|
131
|
+
await evaluateSource(frame, source).catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Evaluate the axe bundle as a script in `frame`.
|
|
136
|
+
*
|
|
137
|
+
* The source goes in as a *string*, which Playwright evaluates as a script in
|
|
138
|
+
* the frame — no script tag to be blocked, and the audit context is created
|
|
139
|
+
* with CSP bypassed so injection works on strictly configured apps too.
|
|
140
|
+
*
|
|
141
|
+
* `allowedOrigins` is widened because axe defaults to same-origin frame
|
|
142
|
+
* messaging only, and a cross-origin iframe would otherwise never answer.
|
|
143
|
+
*/
|
|
144
|
+
async function evaluateSource(frame, source) {
|
|
145
|
+
await frame.evaluate(source);
|
|
146
|
+
await frame.evaluate(() => {
|
|
147
|
+
const axe = globalThis.axe;
|
|
148
|
+
if (axe?.configure)
|
|
149
|
+
axe.configure({ allowedOrigins: ["<unsafe_all_origins>"] });
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Run axe in the page and bring back only what gets printed.
|
|
154
|
+
*
|
|
155
|
+
* The trimming happens in the page on purpose: a full axe result carries every
|
|
156
|
+
* passing rule with every element it checked, which on a real app is megabytes
|
|
157
|
+
* of JSON to serialise across the protocol and then throw away.
|
|
158
|
+
*/
|
|
159
|
+
async function runAxe(page, options) {
|
|
160
|
+
return page.evaluate(async (opts) => {
|
|
161
|
+
const g = globalThis;
|
|
162
|
+
const axe = g.axe;
|
|
163
|
+
if (!axe?.run)
|
|
164
|
+
throw new Error("axe-core did not load in the page");
|
|
165
|
+
const results = await axe.run(g.document, {
|
|
166
|
+
runOnly: { type: "tag", values: opts.tags },
|
|
167
|
+
// Only violations are reported, so this is the only result type whose
|
|
168
|
+
// element details need collecting.
|
|
169
|
+
resultTypes: ["violations"],
|
|
170
|
+
frameWaitTime: opts.frame_wait_ms,
|
|
171
|
+
});
|
|
172
|
+
const order = ["critical", "serious", "moderate", "minor"];
|
|
173
|
+
const rank = (impact) => {
|
|
174
|
+
const at = order.indexOf(impact);
|
|
175
|
+
return at === -1 ? order.length : at;
|
|
176
|
+
};
|
|
177
|
+
const all = (results.violations ?? []).slice().sort((a, b) => {
|
|
178
|
+
const byImpact = rank(String(a.impact)) - rank(String(b.impact));
|
|
179
|
+
if (byImpact !== 0)
|
|
180
|
+
return byImpact;
|
|
181
|
+
// Then by blast radius: the rule breaking twenty elements first.
|
|
182
|
+
return (b.nodes?.length ?? 0) - (a.nodes?.length ?? 0);
|
|
183
|
+
});
|
|
184
|
+
const elide = (text) => {
|
|
185
|
+
const flat = String(text ?? "")
|
|
186
|
+
.replace(/\s+/g, " ")
|
|
187
|
+
.trim();
|
|
188
|
+
return flat.length > opts.max_html ? `${flat.slice(0, opts.max_html)}…` : flat;
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
violations: all.slice(0, opts.max_violations).map((violation) => ({
|
|
192
|
+
id: String(violation.id ?? "unknown"),
|
|
193
|
+
impact: String(violation.impact ?? "unknown"),
|
|
194
|
+
help: String(violation.help ?? ""),
|
|
195
|
+
description: String(violation.description ?? ""),
|
|
196
|
+
help_url: String(violation.helpUrl ?? ""),
|
|
197
|
+
tags: (violation.tags ?? []).map((tag) => String(tag)),
|
|
198
|
+
node_count: violation.nodes?.length ?? 0,
|
|
199
|
+
nodes: (violation.nodes ?? []).slice(0, opts.max_elements).map((node) => ({
|
|
200
|
+
target: (node.target ?? []).map((part) => String(part)).join(" "),
|
|
201
|
+
html: elide(node.html),
|
|
202
|
+
// The first line is the rule that failed for this element; the rest
|
|
203
|
+
// repeats the violation's own help text.
|
|
204
|
+
summary: elide(String(node.failureSummary ?? "").split("\n").slice(1).join(" ")),
|
|
205
|
+
})),
|
|
206
|
+
})),
|
|
207
|
+
passes: results.passes?.length ?? 0,
|
|
208
|
+
inapplicable: results.inapplicable?.length ?? 0,
|
|
209
|
+
incomplete: results.incomplete?.length ?? 0,
|
|
210
|
+
total_violations: all.length,
|
|
211
|
+
total_nodes: all.reduce((sum, violation) => sum + (violation.nodes?.length ?? 0), 0),
|
|
212
|
+
url: String(results.url ?? ""),
|
|
213
|
+
axe_version: String(results.testEngine?.version ?? ""),
|
|
214
|
+
};
|
|
215
|
+
}, options);
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Render the report as text.
|
|
219
|
+
*
|
|
220
|
+
* Each violation gets its impact, the rule that failed, what to do about it,
|
|
221
|
+
* the elements at fault and the URL of Deque's write-up — everything needed to
|
|
222
|
+
* fix it without a second lookup.
|
|
223
|
+
*/
|
|
224
|
+
export function formatReport(report, standard) {
|
|
225
|
+
const scope = `${standard.toUpperCase()} (axe-core ${report.axe_version || "?"})`;
|
|
226
|
+
const lines = [];
|
|
227
|
+
if (report.total_violations === 0) {
|
|
228
|
+
lines.push(`No ${scope} violations on ${report.url}.`);
|
|
229
|
+
lines.push(auditFooter(report));
|
|
230
|
+
return lines.join("\n");
|
|
231
|
+
}
|
|
232
|
+
lines.push(`${report.total_violations} ${scope} violation ${report.total_violations === 1 ? "type" : "types"} on ${report.url}, ` +
|
|
233
|
+
`affecting ${report.total_nodes} ${report.total_nodes === 1 ? "element" : "elements"} — ${countByImpact(report)}.`);
|
|
234
|
+
lines.push(auditFooter(report));
|
|
235
|
+
lines.push("");
|
|
236
|
+
report.violations.forEach((violation, index) => {
|
|
237
|
+
lines.push(`${index + 1}. [${violation.impact}] ${violation.help} (${violation.id}) — ` +
|
|
238
|
+
`${violation.node_count} ${violation.node_count === 1 ? "element" : "elements"}`);
|
|
239
|
+
lines.push(` ${violation.description}`);
|
|
240
|
+
for (const node of violation.nodes) {
|
|
241
|
+
lines.push(` • ${node.target || "?"}`);
|
|
242
|
+
if (node.html)
|
|
243
|
+
lines.push(` ${node.html}`);
|
|
244
|
+
if (node.summary)
|
|
245
|
+
lines.push(` ${node.summary}`);
|
|
246
|
+
}
|
|
247
|
+
if (violation.node_count > violation.nodes.length) {
|
|
248
|
+
lines.push(` … and ${violation.node_count - violation.nodes.length} more elements`);
|
|
249
|
+
}
|
|
250
|
+
if (violation.help_url)
|
|
251
|
+
lines.push(` ${violation.help_url}`);
|
|
252
|
+
});
|
|
253
|
+
if (report.total_violations > report.violations.length) {
|
|
254
|
+
lines.push("");
|
|
255
|
+
lines.push(`… and ${report.total_violations - report.violations.length} more violation types not shown ` +
|
|
256
|
+
"(raise `max_violations` to see them).");
|
|
257
|
+
}
|
|
258
|
+
return lines.join("\n");
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The counts that say how much of the page was actually judged. `incomplete`
|
|
262
|
+
* is the important one: those are the checks axe could not decide on its own
|
|
263
|
+
* (typically colour contrast over an image) and they are the rules a human
|
|
264
|
+
* still has to look at.
|
|
265
|
+
*/
|
|
266
|
+
function auditFooter(report) {
|
|
267
|
+
return `${report.passes} rules passed, ${report.incomplete} need a human to check, ${report.inapplicable} did not apply.`;
|
|
268
|
+
}
|
|
269
|
+
function countByImpact(report) {
|
|
270
|
+
const counts = new Map();
|
|
271
|
+
for (const violation of report.violations) {
|
|
272
|
+
counts.set(violation.impact, (counts.get(violation.impact) ?? 0) + 1);
|
|
273
|
+
}
|
|
274
|
+
const known = IMPACT_ORDER.filter((impact) => counts.has(impact)).map((impact) => `${counts.get(impact)} ${impact}`);
|
|
275
|
+
const other = [...counts.keys()]
|
|
276
|
+
.filter((impact) => !IMPACT_ORDER.includes(impact))
|
|
277
|
+
.map((impact) => `${counts.get(impact)} ${impact}`);
|
|
278
|
+
return [...known, ...other].join(", ");
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* The axe-core bundle as source text, read once and cached.
|
|
282
|
+
*
|
|
283
|
+
* The minified build is preferred purely for injection size; the readable one
|
|
284
|
+
* is an equivalent fallback for installations that ship only it.
|
|
285
|
+
*/
|
|
286
|
+
let cachedSource = null;
|
|
287
|
+
export function axeSource() {
|
|
288
|
+
if (cachedSource !== null)
|
|
289
|
+
return cachedSource;
|
|
290
|
+
const require = createRequire(import.meta.url);
|
|
291
|
+
const candidates = ["axe-core/axe.min.js", "axe-core/axe.js", "axe-core"];
|
|
292
|
+
const problems = [];
|
|
293
|
+
for (const candidate of candidates) {
|
|
294
|
+
try {
|
|
295
|
+
cachedSource = readFileSync(require.resolve(candidate), "utf8");
|
|
296
|
+
return cachedSource;
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
problems.push(`${candidate}: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
throw new Error(problems.join("; "));
|
|
303
|
+
}
|
|
304
|
+
/** Reject with `message` if `work` has not settled within `ms`. */
|
|
305
|
+
async function withTimeout(work, ms, message) {
|
|
306
|
+
let timer;
|
|
307
|
+
const expire = new Promise((_, reject) => {
|
|
308
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
309
|
+
});
|
|
310
|
+
try {
|
|
311
|
+
return await Promise.race([work, expire]);
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
// The losing side has to be settled too: the audit outlives a timeout and
|
|
316
|
+
// would otherwise reject unobserved once the context is torn down.
|
|
317
|
+
void work.catch(() => { });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function errorResult(text) {
|
|
321
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* One actionable line for a failed audit. Mirrors `describeFailure` in
|
|
325
|
+
* screenshot.ts: match on the failing Playwright call, never on substrings of
|
|
326
|
+
* a user-supplied selector.
|
|
327
|
+
*/
|
|
328
|
+
export function describeAuditFailure(input, error) {
|
|
329
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
330
|
+
const firstLine = message.split("\n")[0];
|
|
331
|
+
const prefix = `Accessibility audit of ${input.url} failed:`;
|
|
332
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
333
|
+
return (`${prefix} Playwright's Chromium browser is not installed. ` +
|
|
334
|
+
`Run \`npx playwright install chromium\` and try again. (${firstLine})`);
|
|
335
|
+
}
|
|
336
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
337
|
+
return `${prefix} selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms}ms.`;
|
|
338
|
+
}
|
|
339
|
+
if (/^page\.goto:/.test(message)) {
|
|
340
|
+
return `${prefix} the page could not be opened — ${firstLine}`;
|
|
341
|
+
}
|
|
342
|
+
return `${prefix} ${firstLine}`;
|
|
343
|
+
}
|
|
344
|
+
export function registerAccessibilityTool(server) {
|
|
345
|
+
server.registerTool(ACCESSIBILITY_TOOL_NAME, {
|
|
346
|
+
title: "Accessibility",
|
|
347
|
+
description: "Run an axe-core accessibility audit on a page and report the violations: impact level, the rule that " +
|
|
348
|
+
"failed, the elements at fault and a link to how to fix each one. Choose the conformance level with " +
|
|
349
|
+
"`standard` (wcag2a, wcag2aa, wcag21aa). Rules that passed are counted rather than listed, and the " +
|
|
350
|
+
"'needs review' count tells you what axe could not decide on its own.",
|
|
351
|
+
inputSchema: accessibilityInputShape,
|
|
352
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
353
|
+
}, async (args) => auditAccessibility(args));
|
|
354
|
+
}
|
|
355
|
+
/** Exported for tests: the axe tag list each standard maps to. */
|
|
356
|
+
export const A11Y_STANDARD_TAGS = STANDARDS;
|
|
357
|
+
//# sourceMappingURL=accessibility.js.map
|