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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_SCREENSHOT_WAIT_MS, DEFAULT_VIEWPORT, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS } from "../constants.js";
|
|
3
|
+
import { withPage } from "../engine/browser.js";
|
|
4
|
+
import { getDimensions, resizeForOutput, toBase64 } from "../utils/image.js";
|
|
5
|
+
export const SCREENSHOT_TOOL_NAME = "framewatch_screenshot";
|
|
6
|
+
export const screenshotInputShape = {
|
|
7
|
+
url: z
|
|
8
|
+
.string()
|
|
9
|
+
.url()
|
|
10
|
+
.describe("URL to screenshot, e.g. http://localhost:3000 (http, https and file URLs are accepted)"),
|
|
11
|
+
wait_ms: z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.min(0)
|
|
15
|
+
.default(DEFAULT_SCREENSHOT_WAIT_MS)
|
|
16
|
+
.describe("Wait time (ms) after page load before screenshot"),
|
|
17
|
+
viewport: z
|
|
18
|
+
.object({
|
|
19
|
+
width: z.number().int().min(1).default(DEFAULT_VIEWPORT.width),
|
|
20
|
+
height: z.number().int().min(1).default(DEFAULT_VIEWPORT.height),
|
|
21
|
+
})
|
|
22
|
+
.optional()
|
|
23
|
+
.describe("Viewport size (defaults to 1280x720)"),
|
|
24
|
+
selector: z.string().optional().describe("CSS selector to screenshot a specific element instead of the viewport"),
|
|
25
|
+
wait_for: z.string().optional().describe("CSS selector to wait for (visible) before taking the screenshot"),
|
|
26
|
+
wait_for_timeout_ms: z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.min(1)
|
|
30
|
+
.default(SELECTOR_TIMEOUT_MS)
|
|
31
|
+
.describe("Max time (ms) to wait for `wait_for` / `selector` to appear (must be > 0)"),
|
|
32
|
+
};
|
|
33
|
+
export const screenshotInputSchema = z.object(screenshotInputShape);
|
|
34
|
+
/**
|
|
35
|
+
* Take a single screenshot of a page and return it as an MCP image content
|
|
36
|
+
* block (base64 PNG, resized to max OUTPUT_MAX_WIDTH wide) plus a one-line
|
|
37
|
+
* text summary. All failures — including invalid input — are reported as
|
|
38
|
+
* `isError` results rather than thrown so the MCP client sees a useful message.
|
|
39
|
+
*/
|
|
40
|
+
export async function takeScreenshot(rawInput) {
|
|
41
|
+
const parsed = screenshotInputSchema.safeParse(rawInput);
|
|
42
|
+
if (!parsed.success) {
|
|
43
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
44
|
+
return errorResult(`Screenshot failed: invalid input — ${issues}`);
|
|
45
|
+
}
|
|
46
|
+
const input = parsed.data;
|
|
47
|
+
const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };
|
|
48
|
+
try {
|
|
49
|
+
const shot = await withPage({ viewport }, async (page) => {
|
|
50
|
+
const response = await page.goto(input.url, { waitUntil: "load", timeout: NAVIGATION_TIMEOUT_MS });
|
|
51
|
+
if (input.wait_for) {
|
|
52
|
+
await page.waitForSelector(input.wait_for, { state: "visible", timeout: input.wait_for_timeout_ms });
|
|
53
|
+
}
|
|
54
|
+
if (input.wait_ms > 0) {
|
|
55
|
+
await page.waitForTimeout(input.wait_ms);
|
|
56
|
+
}
|
|
57
|
+
const png = input.selector
|
|
58
|
+
? await page.locator(input.selector).first().screenshot({ type: "png", timeout: input.wait_for_timeout_ms })
|
|
59
|
+
: await page.screenshot({ type: "png" });
|
|
60
|
+
return { png, title: await page.title(), finalUrl: page.url(), status: response?.status() ?? null };
|
|
61
|
+
});
|
|
62
|
+
const resized = await resizeForOutput(shot.png);
|
|
63
|
+
const { width, height } = await getDimensions(resized);
|
|
64
|
+
const summaryParts = [
|
|
65
|
+
`Screenshot of ${shot.finalUrl}`,
|
|
66
|
+
shot.status !== null && shot.status >= 400 ? `HTTP ${shot.status}` : null,
|
|
67
|
+
shot.title ? `"${shot.title}"` : null,
|
|
68
|
+
`${width}x${height}`,
|
|
69
|
+
`viewport ${viewport.width}x${viewport.height}`,
|
|
70
|
+
input.selector ? `element ${input.selector}` : null,
|
|
71
|
+
].filter((p) => p !== null);
|
|
72
|
+
return {
|
|
73
|
+
content: [
|
|
74
|
+
{ type: "image", data: toBase64(resized), mimeType: "image/png" },
|
|
75
|
+
{ type: "text", text: summaryParts.join(" — ") },
|
|
76
|
+
],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return errorResult(describeFailure(input, error));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function errorResult(text) {
|
|
84
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Turn a Playwright/Node error into a one-line, actionable message. Matches on
|
|
88
|
+
* the failing Playwright call (the message prefix) rather than on substrings
|
|
89
|
+
* of user-supplied selectors, so a navigation failure is never blamed on an
|
|
90
|
+
* element.
|
|
91
|
+
*/
|
|
92
|
+
export function describeFailure(input, error) {
|
|
93
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
94
|
+
const firstLine = message.split("\n")[0];
|
|
95
|
+
const prefix = `Screenshot of ${input.url} failed:`;
|
|
96
|
+
if (/Executable doesn't exist|browserType\.launch/i.test(message)) {
|
|
97
|
+
return (`${prefix} Playwright's Chromium browser is not installed. ` +
|
|
98
|
+
`Run \`npx playwright install chromium\` and try again. (${firstLine})`);
|
|
99
|
+
}
|
|
100
|
+
if (input.wait_for && /^page\.waitForSelector:/.test(message)) {
|
|
101
|
+
return `${prefix} selector "${input.wait_for}" did not become visible within ${input.wait_for_timeout_ms}ms.`;
|
|
102
|
+
}
|
|
103
|
+
if (input.selector && /^locator\.screenshot:/.test(message)) {
|
|
104
|
+
return `${prefix} element "${input.selector}" not found or not visible within ${input.wait_for_timeout_ms}ms. ${firstLine}`;
|
|
105
|
+
}
|
|
106
|
+
return `${prefix} ${firstLine}`;
|
|
107
|
+
}
|
|
108
|
+
export function registerScreenshotTool(server) {
|
|
109
|
+
server.registerTool(SCREENSHOT_TOOL_NAME, {
|
|
110
|
+
title: "Screenshot",
|
|
111
|
+
description: "Take a single screenshot of a web page (or one element on it) and return it as a PNG image. " +
|
|
112
|
+
"Good for checking the current visual state of a running app.",
|
|
113
|
+
inputSchema: screenshotInputShape,
|
|
114
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
115
|
+
}, async (args) => takeScreenshot(args));
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=screenshot.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"screenshot.js","sourceRoot":"","sources":["../../src/tools/screenshot.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from \"zod\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport { DEFAULT_SCREENSHOT_WAIT_MS, DEFAULT_VIEWPORT, NAVIGATION_TIMEOUT_MS, SELECTOR_TIMEOUT_MS } from \"../constants.js\";\nimport { withPage } from \"../engine/browser.js\";\nimport { getDimensions, resizeForOutput, toBase64 } from \"../utils/image.js\";\n\nexport const SCREENSHOT_TOOL_NAME = \"framewatch_screenshot\";\n\nexport const screenshotInputShape = {\n url: z\n .string()\n .url()\n .describe(\"URL to screenshot, e.g. http://localhost:3000 (http, https and file URLs are accepted)\"),\n wait_ms: z\n .number()\n .int()\n .min(0)\n .default(DEFAULT_SCREENSHOT_WAIT_MS)\n .describe(\"Wait time (ms) after page load before screenshot\"),\n viewport: z\n .object({\n width: z.number().int().min(1).default(DEFAULT_VIEWPORT.width),\n height: z.number().int().min(1).default(DEFAULT_VIEWPORT.height),\n })\n .optional()\n .describe(\"Viewport size (defaults to 1280x720)\"),\n selector: z.string().optional().describe(\"CSS selector to screenshot a specific element instead of the viewport\"),\n wait_for: z.string().optional().describe(\"CSS selector to wait for (visible) before taking the screenshot\"),\n wait_for_timeout_ms: z\n .number()\n .int()\n .min(1)\n .default(SELECTOR_TIMEOUT_MS)\n .describe(\"Max time (ms) to wait for `wait_for` / `selector` to appear (must be > 0)\"),\n};\n\nexport const screenshotInputSchema = z.object(screenshotInputShape);\nexport type ScreenshotInput = z.input<typeof screenshotInputSchema>;\ntype ParsedScreenshotInput = z.output<typeof screenshotInputSchema>;\n\n/**\n * Take a single screenshot of a page and return it as an MCP image content\n * block (base64 PNG, resized to max OUTPUT_MAX_WIDTH wide) plus a one-line\n * text summary. All failures — including invalid input — are reported as\n * `isError` results rather than thrown so the MCP client sees a useful message.\n */\nexport async function takeScreenshot(rawInput: ScreenshotInput): Promise<CallToolResult> {\n const parsed = screenshotInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`Screenshot failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n const viewport = input.viewport ?? { ...DEFAULT_VIEWPORT };\n\n try {\n const shot = await withPage({ viewport }, async (page) => {\n const response = await page.goto(input.url, { waitUntil: \"load\", timeout: NAVIGATION_TIMEOUT_MS });\n\n if (input.wait_for) {\n await page.waitForSelector(input.wait_for, { state: \"visible\", timeout: input.wait_for_timeout_ms });\n }\n if (input.wait_ms > 0) {\n await page.waitForTimeout(input.wait_ms);\n }\n\n const png = input.selector\n ? await page.locator(input.selector).first().screenshot({ type: \"png\", timeout: input.wait_for_timeout_ms })\n : await page.screenshot({ type: \"png\" });\n\n return { png, title: await page.title(), finalUrl: page.url(), status: response?.status() ?? null };\n });\n\n const resized = await resizeForOutput(shot.png);\n const { width, height } = await getDimensions(resized);\n\n const summaryParts = [\n `Screenshot of ${shot.finalUrl}`,\n shot.status !== null && shot.status >= 400 ? `HTTP ${shot.status}` : null,\n shot.title ? `\"${shot.title}\"` : null,\n `${width}x${height}`,\n `viewport ${viewport.width}x${viewport.height}`,\n input.selector ? `element ${input.selector}` : null,\n ].filter((p): p is string => p !== null);\n\n return {\n content: [\n { type: \"image\", data: toBase64(resized), mimeType: \"image/png\" },\n { type: \"text\", text: summaryParts.join(\" — \") },\n ],\n };\n } catch (error) {\n return errorResult(describeFailure(input, error));\n }\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * Turn a Playwright/Node error into a one-line, actionable message. Matches on\n * the failing Playwright call (the message prefix) rather than on substrings\n * of user-supplied selectors, so a navigation failure is never blamed on an\n * element.\n */\nexport function describeFailure(\n input: Pick<ParsedScreenshotInput, \"url\" | \"wait_for\" | \"selector\" | \"wait_for_timeout_ms\">,\n error: unknown,\n): string {\n const message = error instanceof Error ? error.message : String(error);\n const firstLine = message.split(\"\\n\")[0];\n const prefix = `Screenshot of ${input.url} failed:`;\n\n if (/Executable doesn't exist|browserType\\.launch/i.test(message)) {\n return (\n `${prefix} Playwright's Chromium browser is not installed. ` +\n `Run \\`npx playwright install chromium\\` and try again. (${firstLine})`\n );\n }\n if (input.wait_for && /^page\\.waitForSelector:/.test(message)) {\n return `${prefix} selector \"${input.wait_for}\" did not become visible within ${input.wait_for_timeout_ms}ms.`;\n }\n if (input.selector && /^locator\\.screenshot:/.test(message)) {\n return `${prefix} element \"${input.selector}\" not found or not visible within ${input.wait_for_timeout_ms}ms. ${firstLine}`;\n }\n return `${prefix} ${firstLine}`;\n}\n\nexport function registerScreenshotTool(server: McpServer): void {\n server.registerTool(\n SCREENSHOT_TOOL_NAME,\n {\n title: \"Screenshot\",\n description:\n \"Take a single screenshot of a web page (or one element on it) and return it as a PNG image. \" +\n \"Good for checking the current visual state of a running app.\",\n inputSchema: screenshotInputShape,\n annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async (args) => takeScreenshot(args),\n );\n}\n"]}
|
|
@@ -0,0 +1,89 @@
|
|
|
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 START_SERVER_TOOL_NAME = "framewatch_start_server";
|
|
5
|
+
export declare const STOP_SERVER_TOOL_NAME = "framewatch_stop_server";
|
|
6
|
+
export declare const startServerInputShape: {
|
|
7
|
+
command: z.ZodString;
|
|
8
|
+
port: z.ZodNumber;
|
|
9
|
+
ready_pattern: z.ZodDefault<z.ZodString>;
|
|
10
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
11
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
12
|
+
timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
13
|
+
};
|
|
14
|
+
export declare const startServerInputSchema: z.ZodObject<{
|
|
15
|
+
command: z.ZodString;
|
|
16
|
+
port: z.ZodNumber;
|
|
17
|
+
ready_pattern: z.ZodDefault<z.ZodString>;
|
|
18
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
19
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
20
|
+
timeout_ms: z.ZodDefault<z.ZodNumber>;
|
|
21
|
+
}, "strip", z.ZodTypeAny, {
|
|
22
|
+
timeout_ms: number;
|
|
23
|
+
command: string;
|
|
24
|
+
port: number;
|
|
25
|
+
ready_pattern: string;
|
|
26
|
+
cwd?: string | undefined;
|
|
27
|
+
env?: Record<string, string> | undefined;
|
|
28
|
+
}, {
|
|
29
|
+
command: string;
|
|
30
|
+
port: number;
|
|
31
|
+
timeout_ms?: number | undefined;
|
|
32
|
+
cwd?: string | undefined;
|
|
33
|
+
ready_pattern?: string | undefined;
|
|
34
|
+
env?: Record<string, string> | undefined;
|
|
35
|
+
}>;
|
|
36
|
+
export type StartServerInput = z.input<typeof startServerInputSchema>;
|
|
37
|
+
/** Structured result of `framewatch_start_server`. */
|
|
38
|
+
export declare const startServerOutputShape: {
|
|
39
|
+
status: z.ZodLiteral<"running">;
|
|
40
|
+
port: z.ZodNumber;
|
|
41
|
+
pid: z.ZodNumber;
|
|
42
|
+
url: z.ZodString;
|
|
43
|
+
command: z.ZodString;
|
|
44
|
+
cwd: z.ZodString;
|
|
45
|
+
ready_ms: z.ZodNumber;
|
|
46
|
+
ready_line: z.ZodOptional<z.ZodString>;
|
|
47
|
+
};
|
|
48
|
+
/** Structured result of `framewatch_stop_server`. */
|
|
49
|
+
export declare const stopServerOutputShape: {
|
|
50
|
+
status: z.ZodEnum<["stopped", "not_running"]>;
|
|
51
|
+
port: z.ZodOptional<z.ZodNumber>;
|
|
52
|
+
pid: z.ZodOptional<z.ZodNumber>;
|
|
53
|
+
command: z.ZodOptional<z.ZodString>;
|
|
54
|
+
uptime_ms: z.ZodOptional<z.ZodNumber>;
|
|
55
|
+
exit_code: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
56
|
+
exit_signal: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
57
|
+
/** True when the server ignored SIGTERM and had to be killed. */
|
|
58
|
+
forced: z.ZodOptional<z.ZodBoolean>;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Start the app's dev server so the rest of FrameWatch has something to look
|
|
62
|
+
* at.
|
|
63
|
+
*
|
|
64
|
+
* One server runs at a time. Asking for the one that is already running is a
|
|
65
|
+
* no-op that reports its status (the tool is idempotent); asking for a
|
|
66
|
+
* different one while it is up is an error naming what to stop first, because
|
|
67
|
+
* silently replacing a running server is not something a tool should decide.
|
|
68
|
+
*/
|
|
69
|
+
export declare function startServer(rawInput: StartServerInput): Promise<CallToolResult>;
|
|
70
|
+
/**
|
|
71
|
+
* Stop the dev server FrameWatch started.
|
|
72
|
+
*
|
|
73
|
+
* Never an error: there being nothing to stop is a normal answer, and a caller
|
|
74
|
+
* cleaning up after itself should not have to know whether the earlier start
|
|
75
|
+
* succeeded.
|
|
76
|
+
*/
|
|
77
|
+
export declare function stopServer(): Promise<CallToolResult>;
|
|
78
|
+
/**
|
|
79
|
+
* Explain a failed start, with the server's own output underneath.
|
|
80
|
+
*
|
|
81
|
+
* That output is the whole point: "exit code 1" says nothing, while the three
|
|
82
|
+
* lines above it are usually a missing script, a syntax error or a port
|
|
83
|
+
* clash — the actual answer.
|
|
84
|
+
*/
|
|
85
|
+
export declare function describeStartFailure(input: {
|
|
86
|
+
command: string;
|
|
87
|
+
port: number;
|
|
88
|
+
}, error: unknown): string;
|
|
89
|
+
export declare function registerServerTools(server: McpServer): void;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { DEFAULT_READY_PATTERN, DEFAULT_SERVER_TIMEOUT_MS, MAX_SERVER_TIMEOUT_MS, MIN_SERVER_TIMEOUT_MS, SERVER_OUTPUT_TAIL, } from "../constants.js";
|
|
3
|
+
import { DevServerError, devServerOutput, getDevServer, startDevServer, stopDevServer, } from "../utils/server-process.js";
|
|
4
|
+
export const START_SERVER_TOOL_NAME = "framewatch_start_server";
|
|
5
|
+
export const STOP_SERVER_TOOL_NAME = "framewatch_stop_server";
|
|
6
|
+
export const startServerInputShape = {
|
|
7
|
+
command: z.string().min(1).describe("Shell command to start the server, e.g. 'npm run dev'"),
|
|
8
|
+
port: z.number().int().min(1).max(65535).describe("Port the server will listen on"),
|
|
9
|
+
ready_pattern: z
|
|
10
|
+
.string()
|
|
11
|
+
.default(DEFAULT_READY_PATTERN)
|
|
12
|
+
.describe("Regex matched against the server's output. The matching line is reported back (dev servers print the URL " +
|
|
13
|
+
"they actually bound to), but readiness itself is decided by the port answering."),
|
|
14
|
+
cwd: z.string().optional().describe("Working directory to run the command in (defaults to the current one)"),
|
|
15
|
+
env: z.record(z.string()).optional().describe("Extra environment variables for the server process"),
|
|
16
|
+
timeout_ms: z
|
|
17
|
+
.number()
|
|
18
|
+
.int()
|
|
19
|
+
.min(MIN_SERVER_TIMEOUT_MS)
|
|
20
|
+
.max(MAX_SERVER_TIMEOUT_MS)
|
|
21
|
+
.default(DEFAULT_SERVER_TIMEOUT_MS)
|
|
22
|
+
.describe("Max time (ms) to wait for the port to start answering"),
|
|
23
|
+
};
|
|
24
|
+
export const startServerInputSchema = z.object(startServerInputShape);
|
|
25
|
+
/** Structured result of `framewatch_start_server`. */
|
|
26
|
+
export const startServerOutputShape = {
|
|
27
|
+
status: z.literal("running"),
|
|
28
|
+
port: z.number().int(),
|
|
29
|
+
pid: z.number().int(),
|
|
30
|
+
url: z.string(),
|
|
31
|
+
command: z.string(),
|
|
32
|
+
cwd: z.string(),
|
|
33
|
+
ready_ms: z.number().int(),
|
|
34
|
+
ready_line: z.string().optional(),
|
|
35
|
+
};
|
|
36
|
+
/** Structured result of `framewatch_stop_server`. */
|
|
37
|
+
export const stopServerOutputShape = {
|
|
38
|
+
status: z.enum(["stopped", "not_running"]),
|
|
39
|
+
port: z.number().int().optional(),
|
|
40
|
+
pid: z.number().int().optional(),
|
|
41
|
+
command: z.string().optional(),
|
|
42
|
+
uptime_ms: z.number().int().optional(),
|
|
43
|
+
exit_code: z.number().int().nullable().optional(),
|
|
44
|
+
exit_signal: z.string().nullable().optional(),
|
|
45
|
+
/** True when the server ignored SIGTERM and had to be killed. */
|
|
46
|
+
forced: z.boolean().optional(),
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Start the app's dev server so the rest of FrameWatch has something to look
|
|
50
|
+
* at.
|
|
51
|
+
*
|
|
52
|
+
* One server runs at a time. Asking for the one that is already running is a
|
|
53
|
+
* no-op that reports its status (the tool is idempotent); asking for a
|
|
54
|
+
* different one while it is up is an error naming what to stop first, because
|
|
55
|
+
* silently replacing a running server is not something a tool should decide.
|
|
56
|
+
*/
|
|
57
|
+
export async function startServer(rawInput) {
|
|
58
|
+
const parsed = startServerInputSchema.safeParse(rawInput);
|
|
59
|
+
if (!parsed.success) {
|
|
60
|
+
const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; ");
|
|
61
|
+
return errorResult(`Start server failed: invalid input — ${issues}`);
|
|
62
|
+
}
|
|
63
|
+
const input = parsed.data;
|
|
64
|
+
const running = getDevServer();
|
|
65
|
+
if (running && running.command === input.command && running.port === input.port) {
|
|
66
|
+
return runningResult(running, `Dev server is already running — ${describeRunning(running)}`);
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const started = await startDevServer({
|
|
70
|
+
command: input.command,
|
|
71
|
+
port: input.port,
|
|
72
|
+
ready_pattern: input.ready_pattern,
|
|
73
|
+
timeout_ms: input.timeout_ms,
|
|
74
|
+
...(input.cwd !== undefined ? { cwd: input.cwd } : {}),
|
|
75
|
+
...(input.env !== undefined ? { env: input.env } : {}),
|
|
76
|
+
});
|
|
77
|
+
return runningResult(started, `Dev server running — ${describeRunning(started)}`);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
return errorResult(describeStartFailure(input, error));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Stop the dev server FrameWatch started.
|
|
85
|
+
*
|
|
86
|
+
* Never an error: there being nothing to stop is a normal answer, and a caller
|
|
87
|
+
* cleaning up after itself should not have to know whether the earlier start
|
|
88
|
+
* succeeded.
|
|
89
|
+
*/
|
|
90
|
+
export async function stopServer() {
|
|
91
|
+
let stopped;
|
|
92
|
+
try {
|
|
93
|
+
stopped = await stopDevServer();
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
return errorResult(`Stop server failed: ${error instanceof Error ? error.message.split("\n")[0] : String(error)}`);
|
|
97
|
+
}
|
|
98
|
+
if (!stopped) {
|
|
99
|
+
return {
|
|
100
|
+
content: [{ type: "text", text: "No dev server is running (FrameWatch did not start one, or it has already been stopped)." }],
|
|
101
|
+
structuredContent: { status: "not_running" },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const how = stopped.forced
|
|
105
|
+
? "killed (it did not exit on SIGTERM)"
|
|
106
|
+
: stopped.exit_signal
|
|
107
|
+
? `stopped by ${stopped.exit_signal}`
|
|
108
|
+
: `stopped with exit code ${stopped.exit_code ?? "unknown"}`;
|
|
109
|
+
return {
|
|
110
|
+
content: [
|
|
111
|
+
{
|
|
112
|
+
type: "text",
|
|
113
|
+
text: `Dev server ${how} — pid ${stopped.pid}, port ${stopped.port}, up for ${seconds(stopped.uptime_ms)}s: ${stopped.command}`,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
structuredContent: {
|
|
117
|
+
status: "stopped",
|
|
118
|
+
port: stopped.port,
|
|
119
|
+
pid: stopped.pid,
|
|
120
|
+
command: stopped.command,
|
|
121
|
+
uptime_ms: stopped.uptime_ms,
|
|
122
|
+
exit_code: stopped.exit_code,
|
|
123
|
+
exit_signal: stopped.exit_signal,
|
|
124
|
+
forced: stopped.forced,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function runningResult(server, text) {
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text }],
|
|
131
|
+
structuredContent: {
|
|
132
|
+
status: "running",
|
|
133
|
+
port: server.port,
|
|
134
|
+
pid: server.pid,
|
|
135
|
+
url: server.url,
|
|
136
|
+
command: server.command,
|
|
137
|
+
cwd: server.cwd,
|
|
138
|
+
ready_ms: server.ready_ms,
|
|
139
|
+
...(server.ready_line !== undefined ? { ready_line: server.ready_line } : {}),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function describeRunning(server) {
|
|
144
|
+
const parts = [
|
|
145
|
+
`pid ${server.pid}`,
|
|
146
|
+
`port ${server.port}`,
|
|
147
|
+
server.url,
|
|
148
|
+
`ready in ${seconds(server.ready_ms)}s`,
|
|
149
|
+
`command: ${server.command}`,
|
|
150
|
+
];
|
|
151
|
+
const text = parts.join(", ");
|
|
152
|
+
return server.ready_line !== undefined ? `${text}\nIt said: ${server.ready_line}` : text;
|
|
153
|
+
}
|
|
154
|
+
function seconds(ms) {
|
|
155
|
+
return (ms / 1000).toFixed(1);
|
|
156
|
+
}
|
|
157
|
+
function errorResult(text) {
|
|
158
|
+
return { isError: true, content: [{ type: "text", text }] };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Explain a failed start, with the server's own output underneath.
|
|
162
|
+
*
|
|
163
|
+
* That output is the whole point: "exit code 1" says nothing, while the three
|
|
164
|
+
* lines above it are usually a missing script, a syntax error or a port
|
|
165
|
+
* clash — the actual answer.
|
|
166
|
+
*/
|
|
167
|
+
export function describeStartFailure(input, error) {
|
|
168
|
+
const message = error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
169
|
+
const lines = [`Start server failed: \`${input.command}\` on port ${input.port} — ${message}`];
|
|
170
|
+
const output = error instanceof DevServerError ? error.output : devServerOutput(SERVER_OUTPUT_TAIL);
|
|
171
|
+
const tail = output.slice(Math.max(0, output.length - SERVER_OUTPUT_TAIL));
|
|
172
|
+
if (tail.length > 0) {
|
|
173
|
+
lines.push("Last output from the server:");
|
|
174
|
+
for (const line of tail)
|
|
175
|
+
lines.push(` ${line}`);
|
|
176
|
+
}
|
|
177
|
+
else if (error instanceof DevServerError) {
|
|
178
|
+
lines.push("The server produced no output.");
|
|
179
|
+
}
|
|
180
|
+
return lines.join("\n");
|
|
181
|
+
}
|
|
182
|
+
export function registerServerTools(server) {
|
|
183
|
+
server.registerTool(START_SERVER_TOOL_NAME, {
|
|
184
|
+
title: "Start dev server",
|
|
185
|
+
description: "Start the app's dev server (e.g. `npm run dev`) and wait until its port answers, so the other FrameWatch " +
|
|
186
|
+
"tools have something to point at. One server runs at a time and FrameWatch stops it when it shuts down. " +
|
|
187
|
+
"If the command fails or the port never opens, the server's own output comes back with the error.",
|
|
188
|
+
inputSchema: startServerInputShape,
|
|
189
|
+
outputSchema: startServerOutputShape,
|
|
190
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
191
|
+
}, async (args) => startServer(args));
|
|
192
|
+
server.registerTool(STOP_SERVER_TOOL_NAME, {
|
|
193
|
+
title: "Stop dev server",
|
|
194
|
+
description: "Stop the dev server FrameWatch started, along with everything it spawned. Reports `not_running` rather " +
|
|
195
|
+
"than failing when there is nothing to stop.",
|
|
196
|
+
inputSchema: {},
|
|
197
|
+
outputSchema: stopServerOutputShape,
|
|
198
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
199
|
+
}, async () => stopServer());
|
|
200
|
+
}
|
|
201
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../../src/tools/server.ts"],"names":[],"mappings":"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","sourcesContent":["import { z } from \"zod\";\nimport type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport type { CallToolResult } from \"@modelcontextprotocol/sdk/types.js\";\nimport {\n DEFAULT_READY_PATTERN,\n DEFAULT_SERVER_TIMEOUT_MS,\n MAX_SERVER_TIMEOUT_MS,\n MIN_SERVER_TIMEOUT_MS,\n SERVER_OUTPUT_TAIL,\n} from \"../constants.js\";\nimport {\n DevServerError,\n devServerOutput,\n getDevServer,\n startDevServer,\n stopDevServer,\n type RunningServer,\n type StoppedServer,\n} from \"../utils/server-process.js\";\n\nexport const START_SERVER_TOOL_NAME = \"framewatch_start_server\";\nexport const STOP_SERVER_TOOL_NAME = \"framewatch_stop_server\";\n\nexport const startServerInputShape = {\n command: z.string().min(1).describe(\"Shell command to start the server, e.g. 'npm run dev'\"),\n port: z.number().int().min(1).max(65535).describe(\"Port the server will listen on\"),\n ready_pattern: z\n .string()\n .default(DEFAULT_READY_PATTERN)\n .describe(\n \"Regex matched against the server's output. The matching line is reported back (dev servers print the URL \" +\n \"they actually bound to), but readiness itself is decided by the port answering.\",\n ),\n cwd: z.string().optional().describe(\"Working directory to run the command in (defaults to the current one)\"),\n env: z.record(z.string()).optional().describe(\"Extra environment variables for the server process\"),\n timeout_ms: z\n .number()\n .int()\n .min(MIN_SERVER_TIMEOUT_MS)\n .max(MAX_SERVER_TIMEOUT_MS)\n .default(DEFAULT_SERVER_TIMEOUT_MS)\n .describe(\"Max time (ms) to wait for the port to start answering\"),\n};\n\nexport const startServerInputSchema = z.object(startServerInputShape);\nexport type StartServerInput = z.input<typeof startServerInputSchema>;\n\n/** Structured result of `framewatch_start_server`. */\nexport const startServerOutputShape = {\n status: z.literal(\"running\"),\n port: z.number().int(),\n pid: z.number().int(),\n url: z.string(),\n command: z.string(),\n cwd: z.string(),\n ready_ms: z.number().int(),\n ready_line: z.string().optional(),\n};\n\n/** Structured result of `framewatch_stop_server`. */\nexport const stopServerOutputShape = {\n status: z.enum([\"stopped\", \"not_running\"]),\n port: z.number().int().optional(),\n pid: z.number().int().optional(),\n command: z.string().optional(),\n uptime_ms: z.number().int().optional(),\n exit_code: z.number().int().nullable().optional(),\n exit_signal: z.string().nullable().optional(),\n /** True when the server ignored SIGTERM and had to be killed. */\n forced: z.boolean().optional(),\n};\n\n/**\n * Start the app's dev server so the rest of FrameWatch has something to look\n * at.\n *\n * One server runs at a time. Asking for the one that is already running is a\n * no-op that reports its status (the tool is idempotent); asking for a\n * different one while it is up is an error naming what to stop first, because\n * silently replacing a running server is not something a tool should decide.\n */\nexport async function startServer(rawInput: StartServerInput): Promise<CallToolResult> {\n const parsed = startServerInputSchema.safeParse(rawInput);\n if (!parsed.success) {\n const issues = parsed.error.issues.map((i) => `${i.path.join(\".\") || \"input\"}: ${i.message}`).join(\"; \");\n return errorResult(`Start server failed: invalid input — ${issues}`);\n }\n const input = parsed.data;\n\n const running = getDevServer();\n if (running && running.command === input.command && running.port === input.port) {\n return runningResult(running, `Dev server is already running — ${describeRunning(running)}`);\n }\n\n try {\n const started = await startDevServer({\n command: input.command,\n port: input.port,\n ready_pattern: input.ready_pattern,\n timeout_ms: input.timeout_ms,\n ...(input.cwd !== undefined ? { cwd: input.cwd } : {}),\n ...(input.env !== undefined ? { env: input.env } : {}),\n });\n return runningResult(started, `Dev server running — ${describeRunning(started)}`);\n } catch (error) {\n return errorResult(describeStartFailure(input, error));\n }\n}\n\n/**\n * Stop the dev server FrameWatch started.\n *\n * Never an error: there being nothing to stop is a normal answer, and a caller\n * cleaning up after itself should not have to know whether the earlier start\n * succeeded.\n */\nexport async function stopServer(): Promise<CallToolResult> {\n let stopped: StoppedServer | null;\n try {\n stopped = await stopDevServer();\n } catch (error) {\n return errorResult(`Stop server failed: ${error instanceof Error ? error.message.split(\"\\n\")[0] : String(error)}`);\n }\n\n if (!stopped) {\n return {\n content: [{ type: \"text\", text: \"No dev server is running (FrameWatch did not start one, or it has already been stopped).\" }],\n structuredContent: { status: \"not_running\" },\n };\n }\n\n const how = stopped.forced\n ? \"killed (it did not exit on SIGTERM)\"\n : stopped.exit_signal\n ? `stopped by ${stopped.exit_signal}`\n : `stopped with exit code ${stopped.exit_code ?? \"unknown\"}`;\n\n return {\n content: [\n {\n type: \"text\",\n text: `Dev server ${how} — pid ${stopped.pid}, port ${stopped.port}, up for ${seconds(stopped.uptime_ms)}s: ${stopped.command}`,\n },\n ],\n structuredContent: {\n status: \"stopped\",\n port: stopped.port,\n pid: stopped.pid,\n command: stopped.command,\n uptime_ms: stopped.uptime_ms,\n exit_code: stopped.exit_code,\n exit_signal: stopped.exit_signal,\n forced: stopped.forced,\n },\n };\n}\n\nfunction runningResult(server: RunningServer, text: string): CallToolResult {\n return {\n content: [{ type: \"text\", text }],\n structuredContent: {\n status: \"running\",\n port: server.port,\n pid: server.pid,\n url: server.url,\n command: server.command,\n cwd: server.cwd,\n ready_ms: server.ready_ms,\n ...(server.ready_line !== undefined ? { ready_line: server.ready_line } : {}),\n },\n };\n}\n\nfunction describeRunning(server: RunningServer): string {\n const parts = [\n `pid ${server.pid}`,\n `port ${server.port}`,\n server.url,\n `ready in ${seconds(server.ready_ms)}s`,\n `command: ${server.command}`,\n ];\n const text = parts.join(\", \");\n return server.ready_line !== undefined ? `${text}\\nIt said: ${server.ready_line}` : text;\n}\n\nfunction seconds(ms: number): string {\n return (ms / 1000).toFixed(1);\n}\n\nfunction errorResult(text: string): CallToolResult {\n return { isError: true, content: [{ type: \"text\", text }] };\n}\n\n/**\n * Explain a failed start, with the server's own output underneath.\n *\n * That output is the whole point: \"exit code 1\" says nothing, while the three\n * lines above it are usually a missing script, a syntax error or a port\n * clash — the actual answer.\n */\nexport function describeStartFailure(input: { command: string; port: number }, error: unknown): string {\n const message = error instanceof Error ? error.message.split(\"\\n\")[0] : String(error);\n const lines = [`Start server failed: \\`${input.command}\\` on port ${input.port} — ${message}`];\n\n const output = error instanceof DevServerError ? error.output : devServerOutput(SERVER_OUTPUT_TAIL);\n const tail = output.slice(Math.max(0, output.length - SERVER_OUTPUT_TAIL));\n if (tail.length > 0) {\n lines.push(\"Last output from the server:\");\n for (const line of tail) lines.push(` ${line}`);\n } else if (error instanceof DevServerError) {\n lines.push(\"The server produced no output.\");\n }\n\n return lines.join(\"\\n\");\n}\n\nexport function registerServerTools(server: McpServer): void {\n server.registerTool(\n START_SERVER_TOOL_NAME,\n {\n title: \"Start dev server\",\n description:\n \"Start the app's dev server (e.g. `npm run dev`) and wait until its port answers, so the other FrameWatch \" +\n \"tools have something to point at. One server runs at a time and FrameWatch stops it when it shuts down. \" +\n \"If the command fails or the port never opens, the server's own output comes back with the error.\",\n inputSchema: startServerInputShape,\n outputSchema: startServerOutputShape,\n annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async (args) => startServer(args),\n );\n\n server.registerTool(\n STOP_SERVER_TOOL_NAME,\n {\n title: \"Stop dev server\",\n description:\n \"Stop the dev server FrameWatch started, along with everything it spawned. Reports `not_running` rather \" +\n \"than failing when there is nothing to stop.\",\n inputSchema: {},\n outputSchema: stopServerOutputShape,\n annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },\n },\n async () => stopServer(),\n );\n}\n"]}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared TypeScript interfaces for FrameWatch.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 only needs a handful of these, but the full DiffCard shape is
|
|
5
|
+
* declared up front so later phases (recorder, differ, context layers) share
|
|
6
|
+
* one vocabulary.
|
|
7
|
+
*/
|
|
8
|
+
export type FrameTrigger = "initial" | "animation" | "navigation" | "interaction" | "network" | "dom_change" | "error";
|
|
9
|
+
export interface BoundingBox {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
width: number;
|
|
13
|
+
height: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ConsoleEntry {
|
|
16
|
+
level: "log" | "warn" | "error" | "info";
|
|
17
|
+
text: string;
|
|
18
|
+
timestamp_ms: number;
|
|
19
|
+
}
|
|
20
|
+
export interface NetworkEvent {
|
|
21
|
+
method: string;
|
|
22
|
+
url: string;
|
|
23
|
+
/** HTTP status, or 0 when the request never got a response (see `error`). */
|
|
24
|
+
status: number;
|
|
25
|
+
duration_ms: number;
|
|
26
|
+
/** Relative to recording start, at the moment the request settled. */
|
|
27
|
+
timestamp_ms: number;
|
|
28
|
+
/**
|
|
29
|
+
* Why the request never completed — Chromium's error text
|
|
30
|
+
* (e.g. "net::ERR_CONNECTION_REFUSED"), or "pending" for a request still in
|
|
31
|
+
* flight when the recording ended. Absent for requests that got a response.
|
|
32
|
+
*/
|
|
33
|
+
error?: string;
|
|
34
|
+
}
|
|
35
|
+
export interface PerformanceInfo {
|
|
36
|
+
/** First Contentful Paint, ms since this document's navigation start. */
|
|
37
|
+
paint_time_ms?: number;
|
|
38
|
+
/** Layout shifts observed since the previous card. */
|
|
39
|
+
layout_shifts?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Summed `value` of those shifts. This is the total for the window, not
|
|
42
|
+
* Chrome's CLS metric (which is the largest shift *session* over the whole
|
|
43
|
+
* page life), so it is comparable between cards but not with a Lighthouse score.
|
|
44
|
+
*/
|
|
45
|
+
layout_shift_score?: number;
|
|
46
|
+
/** Largest Contentful Paint, ms since this document's navigation start. */
|
|
47
|
+
lcp_ms?: number;
|
|
48
|
+
}
|
|
49
|
+
export interface ChangeRegion {
|
|
50
|
+
/**
|
|
51
|
+
* Base64 PNG cropped to the bounding box of the change. Omitted when the
|
|
52
|
+
* padded bounding box already covers nearly the whole frame (the full frame
|
|
53
|
+
* image shows the same thing).
|
|
54
|
+
*/
|
|
55
|
+
crop?: string;
|
|
56
|
+
bbox: BoundingBox;
|
|
57
|
+
/** Percentage (0–100) of total pixels that changed. */
|
|
58
|
+
change_percent: number;
|
|
59
|
+
}
|
|
60
|
+
export interface DiffCard {
|
|
61
|
+
index: number;
|
|
62
|
+
timestamp_ms: number;
|
|
63
|
+
trigger: FrameTrigger;
|
|
64
|
+
/** Base64 PNG — full frame, resized to max OUTPUT_MAX_WIDTH wide. */
|
|
65
|
+
full_frame: string;
|
|
66
|
+
change_region?: ChangeRegion;
|
|
67
|
+
dom_snapshot?: string;
|
|
68
|
+
console_entries?: ConsoleEntry[];
|
|
69
|
+
network_events?: NetworkEvent[];
|
|
70
|
+
performance?: PerformanceInfo;
|
|
71
|
+
component_state?: object;
|
|
72
|
+
}
|
|
73
|
+
/** One raw screenshot taken by the frame recorder. */
|
|
74
|
+
export interface RawFrame {
|
|
75
|
+
/** Encoded PNG of the full viewport. */
|
|
76
|
+
buffer: Buffer;
|
|
77
|
+
/** Milliseconds since recording start. */
|
|
78
|
+
timestamp_ms: number;
|
|
79
|
+
/** True for frames captured immediately after a replayed interaction. */
|
|
80
|
+
is_interaction: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Set on frames the recorder captured because of an event (navigation,
|
|
83
|
+
* interaction, error). These frames are always kept by the differ.
|
|
84
|
+
* Undefined for ordinary interval frames, which the differ classifies as
|
|
85
|
+
* "initial" (first frame) or "animation".
|
|
86
|
+
*/
|
|
87
|
+
trigger?: FrameTrigger;
|
|
88
|
+
}
|
|
89
|
+
/** Result of the low-res grid comparison between two frames. */
|
|
90
|
+
export interface GridDiffResult {
|
|
91
|
+
changedCells: number;
|
|
92
|
+
totalCells: number;
|
|
93
|
+
/** changedCells / totalCells, 0..1 */
|
|
94
|
+
changeRatio: number;
|
|
95
|
+
/** Union of changed cells, in the coordinates of the compared buffers. null when nothing changed. */
|
|
96
|
+
bbox: BoundingBox | null;
|
|
97
|
+
}
|
|
98
|
+
/** Result of a full-resolution per-pixel comparison between two frames. */
|
|
99
|
+
export interface PixelDiffResult {
|
|
100
|
+
/** Pixels whose absolute grayscale difference exceeds PIXEL_THRESHOLD. */
|
|
101
|
+
changedPixels: number;
|
|
102
|
+
totalPixels: number;
|
|
103
|
+
/** 0..100 */
|
|
104
|
+
changePercent: number;
|
|
105
|
+
/** Tight bounding box of all changed pixels (no padding). null when nothing changed. */
|
|
106
|
+
bbox: BoundingBox | null;
|
|
107
|
+
}
|
|
108
|
+
/** A per-pixel change mask alongside the counts, for rendering a diff overlay. */
|
|
109
|
+
export interface PixelMaskResult extends PixelDiffResult {
|
|
110
|
+
/** One byte per pixel, row-major: 1 where the pixel changed, 0 where it did not. */
|
|
111
|
+
mask: Uint8Array;
|
|
112
|
+
}
|
|
113
|
+
export interface Viewport {
|
|
114
|
+
width: number;
|
|
115
|
+
height: number;
|
|
116
|
+
}
|
|
117
|
+
export interface DevServerConfig {
|
|
118
|
+
command: string;
|
|
119
|
+
port: number;
|
|
120
|
+
ready_pattern: string;
|
|
121
|
+
cwd?: string;
|
|
122
|
+
env?: Record<string, string>;
|
|
123
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared TypeScript interfaces for FrameWatch.
|
|
3
|
+
*
|
|
4
|
+
* Phase 1 only needs a handful of these, but the full DiffCard shape is
|
|
5
|
+
* declared up front so later phases (recorder, differ, context layers) share
|
|
6
|
+
* one vocabulary.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|
|
9
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG","sourcesContent":["/**\n * Shared TypeScript interfaces for FrameWatch.\n *\n * Phase 1 only needs a handful of these, but the full DiffCard shape is\n * declared up front so later phases (recorder, differ, context layers) share\n * one vocabulary.\n */\n\nexport type FrameTrigger =\n | \"initial\"\n | \"animation\"\n | \"navigation\"\n | \"interaction\"\n | \"network\"\n | \"dom_change\"\n | \"error\";\n\nexport interface BoundingBox {\n x: number;\n y: number;\n width: number;\n height: number;\n}\n\nexport interface ConsoleEntry {\n level: \"log\" | \"warn\" | \"error\" | \"info\";\n text: string;\n timestamp_ms: number;\n}\n\nexport interface NetworkEvent {\n method: string;\n url: string;\n /** HTTP status, or 0 when the request never got a response (see `error`). */\n status: number;\n duration_ms: number;\n /** Relative to recording start, at the moment the request settled. */\n timestamp_ms: number;\n /**\n * Why the request never completed — Chromium's error text\n * (e.g. \"net::ERR_CONNECTION_REFUSED\"), or \"pending\" for a request still in\n * flight when the recording ended. Absent for requests that got a response.\n */\n error?: string;\n}\n\nexport interface PerformanceInfo {\n /** First Contentful Paint, ms since this document's navigation start. */\n paint_time_ms?: number;\n /** Layout shifts observed since the previous card. */\n layout_shifts?: number;\n /**\n * Summed `value` of those shifts. This is the total for the window, not\n * Chrome's CLS metric (which is the largest shift *session* over the whole\n * page life), so it is comparable between cards but not with a Lighthouse score.\n */\n layout_shift_score?: number;\n /** Largest Contentful Paint, ms since this document's navigation start. */\n lcp_ms?: number;\n}\n\nexport interface ChangeRegion {\n /**\n * Base64 PNG cropped to the bounding box of the change. Omitted when the\n * padded bounding box already covers nearly the whole frame (the full frame\n * image shows the same thing).\n */\n crop?: string;\n bbox: BoundingBox;\n /** Percentage (0–100) of total pixels that changed. */\n change_percent: number;\n}\n\nexport interface DiffCard {\n index: number;\n timestamp_ms: number;\n trigger: FrameTrigger;\n /** Base64 PNG — full frame, resized to max OUTPUT_MAX_WIDTH wide. */\n full_frame: string;\n change_region?: ChangeRegion;\n dom_snapshot?: string;\n console_entries?: ConsoleEntry[];\n network_events?: NetworkEvent[];\n performance?: PerformanceInfo;\n component_state?: object;\n}\n\n/** One raw screenshot taken by the frame recorder. */\nexport interface RawFrame {\n /** Encoded PNG of the full viewport. */\n buffer: Buffer;\n /** Milliseconds since recording start. */\n timestamp_ms: number;\n /** True for frames captured immediately after a replayed interaction. */\n is_interaction: boolean;\n /**\n * Set on frames the recorder captured because of an event (navigation,\n * interaction, error). These frames are always kept by the differ.\n * Undefined for ordinary interval frames, which the differ classifies as\n * \"initial\" (first frame) or \"animation\".\n */\n trigger?: FrameTrigger;\n}\n\n/** Result of the low-res grid comparison between two frames. */\nexport interface GridDiffResult {\n changedCells: number;\n totalCells: number;\n /** changedCells / totalCells, 0..1 */\n changeRatio: number;\n /** Union of changed cells, in the coordinates of the compared buffers. null when nothing changed. */\n bbox: BoundingBox | null;\n}\n\n/** Result of a full-resolution per-pixel comparison between two frames. */\nexport interface PixelDiffResult {\n /** Pixels whose absolute grayscale difference exceeds PIXEL_THRESHOLD. */\n changedPixels: number;\n totalPixels: number;\n /** 0..100 */\n changePercent: number;\n /** Tight bounding box of all changed pixels (no padding). null when nothing changed. */\n bbox: BoundingBox | null;\n}\n\n/** A per-pixel change mask alongside the counts, for rendering a diff overlay. */\nexport interface PixelMaskResult extends PixelDiffResult {\n /** One byte per pixel, row-major: 1 where the pixel changed, 0 where it did not. */\n mask: Uint8Array;\n}\n\nexport interface Viewport {\n width: number;\n height: number;\n}\n\nexport interface DevServerConfig {\n command: string;\n port: number;\n ready_pattern: string;\n cwd?: string;\n env?: Record<string, string>;\n}\n"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fixed-size log that gives priority to interesting entries.
|
|
3
|
+
*
|
|
4
|
+
* The context layers watch things a page can produce without limit — console
|
|
5
|
+
* output, network requests — so every collector needs a cap. A plain cap has
|
|
6
|
+
* the wrong failure mode: a page that logs in a render loop fills the budget
|
|
7
|
+
* with noise in the first second, and the console error thrown at second four
|
|
8
|
+
* (the one thing worth capturing) is dropped.
|
|
9
|
+
*
|
|
10
|
+
* So the log keeps `limit` entries, and once it is full an *important* entry
|
|
11
|
+
* still gets in by evicting the oldest unimportant one. Important entries are
|
|
12
|
+
* only ever dropped when the log holds nothing but important entries — at
|
|
13
|
+
* which point the page is genuinely producing more signal than the cap allows.
|
|
14
|
+
* `dropped` counts everything that did not make it, so the caller can say so.
|
|
15
|
+
*/
|
|
16
|
+
export declare class BoundedLog<T> {
|
|
17
|
+
#private;
|
|
18
|
+
/**
|
|
19
|
+
* @param limit Maximum entries kept. Values below 1 are treated as 1.
|
|
20
|
+
* @param isImportant Entries worth evicting an ordinary entry for. Defaults to "nothing is".
|
|
21
|
+
*/
|
|
22
|
+
constructor(limit: number, isImportant?: (item: T) => boolean);
|
|
23
|
+
/** Entries kept, oldest first. */
|
|
24
|
+
get items(): readonly T[];
|
|
25
|
+
get size(): number;
|
|
26
|
+
/** How many entries were refused or evicted. */
|
|
27
|
+
get dropped(): number;
|
|
28
|
+
add(item: T): void;
|
|
29
|
+
/**
|
|
30
|
+
* Forget everything, including the dropped count.
|
|
31
|
+
*
|
|
32
|
+
* A capture builds a log and throws it away, but `framewatch_interact` keeps
|
|
33
|
+
* one page — and so one set of collectors — alive across many calls, and
|
|
34
|
+
* each call reports only what its own action caused. Without this, call
|
|
35
|
+
* twenty would repeat the console output of calls one to nineteen and then
|
|
36
|
+
* start dropping the entries that actually mattered.
|
|
37
|
+
*/
|
|
38
|
+
clear(): void;
|
|
39
|
+
/** A plain copy of the entries (safe to hand out and mutate). */
|
|
40
|
+
toArray(): T[];
|
|
41
|
+
}
|