Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/preview-snapshot-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Preview snapshot tests

on:
pull_request:
paths:
- .github/workflows/preview-snapshot-tests.yml
- apps/desktop/src/preview/**
- apps/server/src/mcp/**
- apps/server/src/preview/Snapshot*.ts
- packages/contracts/src/preview*.ts
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Snapshot tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, windows-2025, macos-15]
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
sparse-checkout: |
/*
!/.repos/
sparse-checkout-cone-mode: false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- uses: voidzero-dev/setup-vp@v1
with:
node-version-file: package.json
cache: false
run-install: false
- run: vp install
- run: >-
vp test run
apps/desktop/src/preview/SnapshotPage.test.ts
apps/desktop/src/preview/Manager.test.ts
apps/server/src/mcp/McpHttpServer.test.ts
apps/server/src/mcp/PreviewAutomationBroker.test.ts
apps/server/src/preview/Snapshot.test.ts
apps/server/src/mcp/toolkits/preview/tools.test.ts
packages/contracts/src/previewAutomationSnapshot.test.ts
packages/contracts/src/preview.test.ts
69 changes: 3 additions & 66 deletions apps/desktop/src/preview/Manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
previewAutomationEditingCommandExpression,
} from "./PreviewKeyboard.ts";
import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts";
import { snapshotPageExpression, type SnapshotPage } from "./SnapshotPage.ts";
import { DEFAULT_RECORDING_INPUT_OPTIONS, type RecordingInputOptions } from "./RecordingInput.ts";

export type PreviewNavStatus =
Expand Down Expand Up @@ -130,14 +131,6 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [
const DEFAULT_ZOOM_FACTOR = 1.0;
const ZOOM_EPSILON = 0.001;
const MAX_EVALUATION_BYTES = 64_000;
const MAX_VISIBLE_TEXT_LENGTH = 20_000;
const MAX_INTERACTIVE_ELEMENTS = 200;
/**
* A `[role]` container's innerText is its whole subtree, which turned one
* snapshot's element list into 60 KB of repeated page text. Names are labels,
* not content, so cap them where they are read.
*/
const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200;
const MAX_SCREENSHOT_WIDTH = 1280;
/** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */
const RECORDING_ARM_GRACE_MS = 10_000;
Expand Down Expand Up @@ -3744,66 +3737,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
concurrency: 2,
discard: true,
});
const page = yield* evaluateWithDebugger<{
url: string;
title: string;
loading: boolean;
visibleText: string;
interactiveElements: PreviewAutomationSnapshot["interactiveElements"];
}>(
const page = yield* evaluateWithDebugger<SnapshotPage>(
tabId,
send,
`(() => {
const selectorFor = (element) => {
if (element.id) return "#" + CSS.escape(element.id);
for (const attribute of ["data-testid", "name"]) {
const value = element.getAttribute(attribute);
if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]";
}
const buildParts = (current, parts = []) => {
if (!current || current.nodeType !== Node.ELEMENT_NODE || parts.length >= 8) {
return parts;
}
const parent = current.parentElement;
const siblings = parent
? Array.from(parent.children).filter((child) => child.tagName === current.tagName)
: [];
const base = current.tagName.toLowerCase();
const part = siblings.length > 1
? base + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"
: base;
return buildParts(parent, [part, ...parts]);
};
return buildParts(element).join(" > ");
};
const visible = (element) => {
const style = getComputedStyle(element);
const rect = element.getBoundingClientRect();
return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0;
};
const elements = Array.from(document.querySelectorAll(
"a[href],button,input,textarea,select,[role],[tabindex]"
)).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => {
const rect = element.getBoundingClientRect();
return {
tag: element.tagName.toLowerCase(),
role: element.getAttribute("role"),
name: (element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "").slice(0, ${MAX_INTERACTIVE_ELEMENT_NAME_LENGTH}),
selector: selectorFor(element),
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
};
});
return {
url: location.href,
title: document.title,
loading: document.readyState !== "complete",
visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}),
interactiveElements: elements
};
})()`,
snapshotPageExpression(),
true,
);
const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
Expand Down
Loading
Loading