Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
215 lines
6.7 KiB
TypeScript
215 lines
6.7 KiB
TypeScript
export const browserElementSnapshotFormat = "tutti.browser-element.v1";
|
|
export const browserElementSnapshotMaxHtmlChars = 32_000;
|
|
export const browserElementSnapshotMaxTextChars = 4_000;
|
|
|
|
export interface BrowserElementSnapshot {
|
|
format: typeof browserElementSnapshotFormat;
|
|
capturedAt: string;
|
|
page: {
|
|
title: string;
|
|
url: string;
|
|
};
|
|
element: {
|
|
ariaLabel: string | null;
|
|
attributes: Record<string, string>;
|
|
bounds: { height: number; width: number; x: number; y: number };
|
|
classes: string[];
|
|
domPath: string;
|
|
html: string;
|
|
htmlTruncated: boolean;
|
|
id: string | null;
|
|
role: string | null;
|
|
selector: string;
|
|
styles: Record<string, string>;
|
|
tagName: string;
|
|
text: string;
|
|
textTruncated: boolean;
|
|
};
|
|
ancestors: Array<{
|
|
id: string | null;
|
|
role: string | null;
|
|
selector: string;
|
|
tagName: string;
|
|
}>;
|
|
viewport: {
|
|
devicePixelRatio: number;
|
|
height: number;
|
|
scrollX: number;
|
|
scrollY: number;
|
|
width: number;
|
|
};
|
|
}
|
|
|
|
export type BrowserElementSelectionResult =
|
|
| { status: "cancelled" }
|
|
| { snapshot: BrowserElementSnapshot; status: "selected" };
|
|
|
|
export function normalizeBrowserElementSelectionResult(
|
|
value: unknown
|
|
): BrowserElementSelectionResult | null {
|
|
if (!isRecord(value)) return null;
|
|
if (value.status === "cancelled") return { status: "cancelled" };
|
|
if (value.status !== "selected" || !isRecord(value.snapshot)) return null;
|
|
const snapshot = value.snapshot;
|
|
const page = isRecord(snapshot.page) ? snapshot.page : {};
|
|
const element = isRecord(snapshot.element) ? snapshot.element : {};
|
|
const bounds = isRecord(element.bounds) ? element.bounds : {};
|
|
const viewport = isRecord(snapshot.viewport) ? snapshot.viewport : {};
|
|
const normalized: BrowserElementSnapshot = {
|
|
format: browserElementSnapshotFormat,
|
|
capturedAt:
|
|
boundedString(snapshot.capturedAt, 64) || new Date().toISOString(),
|
|
page: {
|
|
title: boundedString(page.title, 500),
|
|
url: sanitizeSnapshotUrl(boundedString(page.url, 4_000))
|
|
},
|
|
element: {
|
|
ariaLabel: nullableBoundedString(element.ariaLabel, 1_000),
|
|
attributes: boundedStringRecord(element.attributes, 40, 200, 2_000),
|
|
bounds: {
|
|
height: finiteNumber(bounds.height),
|
|
width: finiteNumber(bounds.width),
|
|
x: finiteNumber(bounds.x),
|
|
y: finiteNumber(bounds.y)
|
|
},
|
|
classes: boundedStringArray(element.classes, 24, 200),
|
|
domPath:
|
|
boundedString(element.domPath, 4_000) ||
|
|
boundedString(element.selector, 2_000),
|
|
html: boundedString(element.html, browserElementSnapshotMaxHtmlChars),
|
|
htmlTruncated:
|
|
element.htmlTruncated === true ||
|
|
(typeof element.html === "string" &&
|
|
element.html.length > browserElementSnapshotMaxHtmlChars),
|
|
id: nullableBoundedString(element.id, 500),
|
|
role: nullableBoundedString(element.role, 200),
|
|
selector: boundedString(element.selector, 2_000),
|
|
styles: boundedStringRecord(element.styles, 32, 100, 500),
|
|
tagName: boundedString(element.tagName, 100).toLowerCase(),
|
|
text: boundedString(element.text, browserElementSnapshotMaxTextChars),
|
|
textTruncated:
|
|
element.textTruncated === true ||
|
|
(typeof element.text === "string" &&
|
|
element.text.length > browserElementSnapshotMaxTextChars)
|
|
},
|
|
ancestors: Array.isArray(snapshot.ancestors)
|
|
? snapshot.ancestors.slice(0, 8).flatMap((item) =>
|
|
isRecord(item)
|
|
? [
|
|
{
|
|
id: nullableBoundedString(item.id, 500),
|
|
role: nullableBoundedString(item.role, 200),
|
|
selector: boundedString(item.selector, 2_000),
|
|
tagName: boundedString(item.tagName, 100).toLowerCase()
|
|
}
|
|
]
|
|
: []
|
|
)
|
|
: [],
|
|
viewport: {
|
|
devicePixelRatio: finiteNumber(viewport.devicePixelRatio, 1),
|
|
height: finiteNumber(viewport.height),
|
|
scrollX: finiteNumber(viewport.scrollX),
|
|
scrollY: finiteNumber(viewport.scrollY),
|
|
width: finiteNumber(viewport.width)
|
|
}
|
|
};
|
|
if (!normalized.element.tagName || !normalized.element.domPath) return null;
|
|
return { snapshot: normalized, status: "selected" };
|
|
}
|
|
|
|
export function serializeBrowserElementSnapshot(
|
|
snapshot: BrowserElementSnapshot
|
|
): string {
|
|
const { bounds } = snapshot.element;
|
|
return [
|
|
`DOM Path: ${snapshot.element.domPath}`,
|
|
`Position: top=${formatPixel(bounds.y)}, left=${formatPixel(bounds.x)}, width=${formatPixel(bounds.width)}, height=${formatPixel(bounds.height)}`,
|
|
`HTML Element: ${compactHtmlFragment(snapshot.element.html)}`
|
|
].join("\n");
|
|
}
|
|
|
|
function formatPixel(value: number): string {
|
|
const rounded = Math.round(value * 100) / 100;
|
|
return `${Object.is(rounded, -0) ? 0 : rounded}px`;
|
|
}
|
|
|
|
function compactHtmlFragment(value: string): string {
|
|
return value
|
|
.replace(/\r?\n/gu, " ")
|
|
.replace(/\s{2,}/gu, " ")
|
|
.trim();
|
|
}
|
|
|
|
function sanitizeSnapshotUrl(value: string): string {
|
|
try {
|
|
const url = new URL(value);
|
|
url.username = "";
|
|
url.password = "";
|
|
url.hash = "";
|
|
for (const key of [...url.searchParams.keys()]) {
|
|
if (
|
|
/(?:token|auth|password|secret|session|cookie|key|signature)/iu.test(
|
|
key
|
|
)
|
|
) {
|
|
url.searchParams.set(key, "[redacted]");
|
|
}
|
|
}
|
|
return url.toString();
|
|
} catch {
|
|
return boundedString(value, 4_000);
|
|
}
|
|
}
|
|
|
|
function boundedString(value: unknown, maxChars: number): string {
|
|
return typeof value === "string" ? value.slice(0, maxChars) : "";
|
|
}
|
|
|
|
function nullableBoundedString(
|
|
value: unknown,
|
|
maxChars: number
|
|
): string | null {
|
|
const normalized = boundedString(value, maxChars).trim();
|
|
return normalized || null;
|
|
}
|
|
|
|
function boundedStringArray(
|
|
value: unknown,
|
|
maxItems: number,
|
|
maxChars: number
|
|
): string[] {
|
|
return Array.isArray(value)
|
|
? value
|
|
.slice(0, maxItems)
|
|
.filter((item): item is string => typeof item === "string")
|
|
.map((item) => item.slice(0, maxChars))
|
|
: [];
|
|
}
|
|
|
|
function boundedStringRecord(
|
|
value: unknown,
|
|
maxItems: number,
|
|
maxKeyChars: number,
|
|
maxValueChars: number
|
|
): Record<string, string> {
|
|
if (!isRecord(value)) return {};
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.slice(0, maxItems)
|
|
.flatMap(([key, item]) =>
|
|
typeof item === "string"
|
|
? [[key.slice(0, maxKeyChars), item.slice(0, maxValueChars)]]
|
|
: []
|
|
)
|
|
);
|
|
}
|
|
|
|
function finiteNumber(value: unknown, fallback = 0): number {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|