mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* feat(server): let clients create internal agents The daemon's own branch-name and commit-message generators run as internal agents: never persisted, listed, flagged, announced, or seen by plugin lifecycle hooks. Nothing outside the daemon could ask for one, because the wire config schema strips `internal` and the agent.create transform re-pins it. A plugin that runs a one-turn helper per event therefore left an archived session in History every time. Add an optional `internal` flag to `create_agent_request` (inherited by `agent.create.request`), copy it onto the config in the session's create path, and skip provider session persistence for it like the generators do. The client SDK forwards the flag on both creation paths and gates it once on `server_info.features.internalAgents`. Two daemon paths assumed every agent is public and had to follow: `resolveAgentIdentifier` now accepts a live agent by exact id, since the id set it matched against came from listings that exclude internal agents and left `wait_for_finish` answering "Agent not found"; and archiving an internal agent now closes it without writing the record that `registry.applySnapshot` used to leave behind, so `autoArchive` does not accumulate hidden files on disk. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(server): keep internal agents whole through failure, archive, and wait Review follow-ups for internal agents: - A create that fails after the runtime is registered now closes the internal agent before rethrowing, so a hidden runtime cannot outlive the failed request. Public agents keep today's behaviour. - Archiving an internal agent cascades to attached children like any parent. cascadeArchiveChildren takes the parent record instead of reading it back from storage, where an internal agent never is. - The archived snapshot and last message of an internal agent stay readable by exact id for ten minutes, so create-then-waitForFinish returns the finished turn whether the wait lands before or after an auto-archive. Storage plays that role for public agents. - The internal-agent e2e scenarios live in the existing create_agent_request lifecycle suite rather than a new file. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(server): cascade to live internal children and keep a failed internal create's worktree until it closes Internal children never reach storage, so the parent archive cascade scans live agents for them as well. A failed internal create now clears the created id only after the runtime is archived, so worktree cleanup cannot delete the directory under a runtime that would not close. Stored agent payloads carry lastError, so a retained failed helper reports its error through wait_for_finish instead of the generic message. * feat(cli,mcp): expose internal agents and document --no-wait paseo run gains --internal and create_agent gains internal, both forwarded to the create request. --no-wait becomes the documented spelling for returning immediately, matching paseo send; --background and --detach stay as hidden aliases under COMPAT tags. * test: follow the --no-wait rename and stub getRetiredInternalAgent * feat: list internal agents on request and add internal workspaces Internal agents stay hidden by default, but a caller can now ask for them: `fetch_agents_request` takes `filter.includeInternal`, snapshots carry `internal: true`, and `paseo ls --internal` shows them in an INTERNAL column. The app never passes the flag. Workspaces get the same flag. `workspace.create.request` with `internal: true` persists a hidden workspace: listings, update subscriptions, sync reads, empty-project counting, open-by-path, MCP `list_workspaces`, and plugin workspace hooks all skip it unless the caller opts in with `filter.includeInternal`. Every agent created inside an internal workspace is internal and unpersisted; AgentManager applies that from the workspace id before the plugin hook so creators cannot opt out. Archive and shutdown enumerate internal agents so nothing inside an internal workspace outlives it. Surfaces: `paseo workspace create --internal`, `paseo workspace ls --internal`, `paseo run --internal --new-workspace`, MCP `create_workspace({ internal })`, SDK `workspaces.create({ internal })` and `list({ filter: { includeInternal } })`. Clients gate on `features.internalWorkspaces`. Co-Authored-By: Claude <noreply@anthropic.com> * feat(workspaces): add background visibility with inherited creation defaults * fix(cli): resolve workspace callers on the selected daemon * test(workspaces): keep caller integration coverage in server suite * test(cli): exercise workspace callers through CLI integration harness * test(mcp): remove obsolete execution flags from browser fixtures --------- Co-authored-by: marvin-ambrozio <marvin@gustavo.eng.br> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
1264 lines
46 KiB
JavaScript
1264 lines
46 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { spawn } from "node:child_process";
|
||
import fs from "node:fs";
|
||
import { createServer } from "node:http";
|
||
import net from "node:net";
|
||
import os from "node:os";
|
||
import path from "node:path";
|
||
import process from "node:process";
|
||
import { setTimeout as delay } from "node:timers/promises";
|
||
import { fileURLToPath } from "node:url";
|
||
import { experimental_createMCPClient } from "ai";
|
||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||
import { chromium } from "playwright";
|
||
import { runAppearanceFontSizeRegression } from "./appearance-font-size.electron.mjs";
|
||
import { runSettingsMemoryRegression } from "./settings-memory.electron.mjs";
|
||
|
||
import { seedPluginLinks, runPluginLinksRegression } from "./plugin-links.electron.mjs";
|
||
|
||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||
const desktopDir = path.resolve(scriptDir, "..");
|
||
const rootDir = path.resolve(desktopDir, "../..");
|
||
const devRunner = path.join(desktopDir, "scripts", "dev-runner.mjs");
|
||
const workspaceIds = [
|
||
"desktop-browser-original",
|
||
"desktop-browser-evict-one",
|
||
"desktop-browser-evict-two",
|
||
"desktop-browser-evict-three",
|
||
];
|
||
const timeoutMs = 90_000;
|
||
|
||
function assert(condition, message) {
|
||
if (!condition) throw new Error(message);
|
||
}
|
||
|
||
async function reservePort() {
|
||
return await new Promise((resolve, reject) => {
|
||
const server = net.createServer();
|
||
server.unref();
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", () => {
|
||
const address = server.address();
|
||
server.close((error) => {
|
||
if (error) return reject(error);
|
||
if (!address || typeof address === "string") {
|
||
return reject(new Error("Failed to reserve a local port"));
|
||
}
|
||
resolve(address.port);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
async function waitForPort(port, label, processInfo) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
if (
|
||
processInfo &&
|
||
(processInfo.child.exitCode !== null || processInfo.child.signalCode !== null)
|
||
) {
|
||
throw new Error(
|
||
`${label} process exited before opening its port; see ${processInfo.logPath}`,
|
||
);
|
||
}
|
||
const connected = await new Promise((resolve) => {
|
||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||
socket.setTimeout(500);
|
||
socket.once("connect", () => {
|
||
socket.destroy();
|
||
resolve(true);
|
||
});
|
||
socket.once("timeout", () => {
|
||
socket.destroy();
|
||
resolve(false);
|
||
});
|
||
socket.once("error", () => resolve(false));
|
||
});
|
||
if (connected) return;
|
||
await delay(200);
|
||
}
|
||
throw new Error(`Timed out waiting for ${label} on port ${port}`);
|
||
}
|
||
|
||
function writeJson(filePath, value) {
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
||
}
|
||
|
||
function seedPaseoHome(paseoHome, listen, workspaceRoot) {
|
||
const timestamp = "2026-01-01T00:00:00.000Z";
|
||
const projects = workspaceIds.map((workspaceId, index) => {
|
||
const cwd = path.join(workspaceRoot, `workspace-${index + 1}`);
|
||
fs.mkdirSync(cwd, { recursive: true });
|
||
return {
|
||
projectId: `project-${workspaceId}`,
|
||
rootPath: cwd,
|
||
kind: "non_git",
|
||
displayName: `Desktop browser project ${index + 1}`,
|
||
customName: null,
|
||
createdAt: timestamp,
|
||
updatedAt: timestamp,
|
||
archivedAt: null,
|
||
};
|
||
});
|
||
const workspaces = workspaceIds.map((workspaceId, index) => ({
|
||
workspaceId,
|
||
projectId: projects[index].projectId,
|
||
cwd: projects[index].rootPath,
|
||
kind: "directory",
|
||
displayName: `Desktop browser workspace ${index + 1}`,
|
||
title: `Desktop browser workspace ${index + 1}`,
|
||
branch: null,
|
||
baseBranch: null,
|
||
createdAt: timestamp,
|
||
updatedAt: timestamp,
|
||
archivedAt: null,
|
||
pinnedAt: null,
|
||
}));
|
||
|
||
writeJson(path.join(paseoHome, "config.json"), {
|
||
version: 1,
|
||
daemon: {
|
||
listen,
|
||
relay: { enabled: false },
|
||
mcp: { enabled: true, injectIntoAgents: true },
|
||
browserTools: { enabled: true },
|
||
cors: { allowedOrigins: ["*"] },
|
||
},
|
||
});
|
||
writeJson(path.join(paseoHome, "projects", "projects.json"), projects);
|
||
writeJson(path.join(paseoHome, "projects", "workspaces.json"), workspaces);
|
||
}
|
||
|
||
function spawnLogged(name, command, args, options, logDir) {
|
||
const logPath = path.join(logDir, `${name}.log`);
|
||
const log = fs.createWriteStream(logPath, { flags: "a" });
|
||
const child = spawn(command, args, {
|
||
...options,
|
||
detached: process.platform !== "win32",
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
let openStreams = 2;
|
||
const closeLogStream = () => {
|
||
openStreams -= 1;
|
||
if (openStreams === 0) log.end();
|
||
};
|
||
child.stdout.pipe(log, { end: false });
|
||
child.stderr.pipe(log, { end: false });
|
||
child.stdout.once("end", closeLogStream);
|
||
child.stderr.once("end", closeLogStream);
|
||
return { child, logPath };
|
||
}
|
||
|
||
function stopProcess(child) {
|
||
if (!child?.pid || child.exitCode !== null) return;
|
||
try {
|
||
if (process.platform === "win32") child.kill("SIGTERM");
|
||
else process.kill(-child.pid, "SIGTERM");
|
||
} catch {
|
||
// The process may exit between the liveness check and signal delivery.
|
||
}
|
||
}
|
||
|
||
async function waitForAppPage(browser, expoPort) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
while (Date.now() < deadline) {
|
||
for (const context of browser.contexts()) {
|
||
for (const page of context.pages()) {
|
||
if (page.url().includes(`localhost:${expoPort}`)) return page;
|
||
}
|
||
}
|
||
await delay(250);
|
||
}
|
||
throw new Error("Timed out waiting for the real Electron app renderer");
|
||
}
|
||
|
||
async function waitForDesktopStatus(page) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
let lastError;
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const status = await page.evaluate(async () => {
|
||
if (typeof window.paseoDesktop?.invoke !== "function") return null;
|
||
return await window.paseoDesktop.invoke("desktop_daemon_status");
|
||
});
|
||
if (typeof status?.serverId === "string") return status;
|
||
} catch (error) {
|
||
// Metro may replace the renderer execution context during its initial load.
|
||
lastError = error;
|
||
}
|
||
await delay(250);
|
||
}
|
||
throw new Error(
|
||
`Timed out waiting for the Electron desktop bridge${lastError ? `: ${String(lastError)}` : ""}`,
|
||
);
|
||
}
|
||
|
||
async function startTargetPage() {
|
||
const server = createServer((_request, response) => {
|
||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||
response.end(`<!doctype html>
|
||
<html>
|
||
<head><title>Desktop browser target</title>
|
||
<style>body { min-height: 100vh; background: rgb(255, 0, 0); }</style>
|
||
<script>
|
||
window.frameCount = 0;
|
||
function tick() { window.frameCount++; requestAnimationFrame(tick); }
|
||
requestAnimationFrame(tick);
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<button id="bridge-target" onclick="this.textContent = 'Clicked'">Bridge target</button>
|
||
<label for="typing-target">Typing target</label>
|
||
<input id="typing-target" />
|
||
</body>
|
||
</html>`);
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", resolve);
|
||
});
|
||
const address = server.address();
|
||
assert(address && typeof address !== "string", "Target page did not bind a TCP port");
|
||
return { server, url: `http://127.0.0.1:${address.port}/` };
|
||
}
|
||
|
||
async function closeServer(server) {
|
||
await new Promise((resolve) => server.close(resolve));
|
||
}
|
||
|
||
function mcpPayload(result, command) {
|
||
const payload = result.structuredContent;
|
||
assert(
|
||
payload && typeof payload === "object",
|
||
`${command} returned no structured payload: ${JSON.stringify(result)}`,
|
||
);
|
||
assert(payload.ok === true, `${command} failed: ${JSON.stringify(payload)}`);
|
||
return payload.result;
|
||
}
|
||
|
||
async function callBrowserTool(client, name, args = {}) {
|
||
return mcpPayload(await client.callTool({ name, args }), name);
|
||
}
|
||
|
||
async function waitForGuestSelector(client, browserId) {
|
||
const deadline = Date.now() + 5_000;
|
||
while (Date.now() < deadline) {
|
||
const evaluated = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => Boolean(globalThis.__paseoSelector)",
|
||
});
|
||
if (JSON.parse(evaluated.resultJson) === true) {
|
||
return true;
|
||
}
|
||
await delay(50);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function waitForGuestActiveElement(client, browserId, elementId) {
|
||
const deadline = Date.now() + 5_000;
|
||
while (Date.now() < deadline) {
|
||
const evaluated = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => document.activeElement?.id ?? null",
|
||
});
|
||
if (JSON.parse(evaluated.resultJson) === elementId) {
|
||
return true;
|
||
}
|
||
await delay(50);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) {
|
||
const transport = new StreamableHTTPClientTransport(
|
||
new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`),
|
||
);
|
||
const client = await experimental_createMCPClient({ transport });
|
||
try {
|
||
const response = await client.callTool({
|
||
name: "create_agent",
|
||
args: {
|
||
relationship: { kind: "detached" },
|
||
workspace: { kind: "existing", workspaceId },
|
||
title: "Browser desktop browser E2E caller",
|
||
provider: "mock/ten-second-stream",
|
||
settings: { modeId: "load-test" },
|
||
initialPrompt: "Remain available while the browser bridge regression runs.",
|
||
},
|
||
});
|
||
const result = response.structuredContent;
|
||
assert(
|
||
result && typeof result === "object",
|
||
`create_agent returned no structured payload: ${JSON.stringify(response)}`,
|
||
);
|
||
assert(typeof result.agentId === "string", "create_agent returned no caller agent id");
|
||
assert(
|
||
result.workspaceId === workspaceId,
|
||
`MCP caller attached to unexpected workspace ${result.workspaceId}`,
|
||
);
|
||
return result.agentId;
|
||
} finally {
|
||
await client.close();
|
||
}
|
||
}
|
||
|
||
async function readGuest(page, browserId) {
|
||
return await page.evaluate((id) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
if (!(webview instanceof HTMLElement) || typeof webview.getWebContentsId !== "function") {
|
||
return null;
|
||
}
|
||
return {
|
||
webContentsId: webview.getWebContentsId(),
|
||
parentId: webview.parentElement?.id ?? null,
|
||
width: Math.round(webview.getBoundingClientRect().width),
|
||
height: Math.round(webview.getBoundingClientRect().height),
|
||
};
|
||
}, browserId);
|
||
}
|
||
|
||
async function readPresentation(page, browserId) {
|
||
return await page.evaluate((id) => {
|
||
const surface = document.querySelector(`[data-paseo-browser-surface="${id}"]`);
|
||
const clip = document.querySelector(`[data-testid="browser-webview-clip-${id}"]`);
|
||
if (!(surface instanceof HTMLElement) || !(clip instanceof HTMLElement)) return null;
|
||
const surfaceRect = surface.getBoundingClientRect();
|
||
const clipRect = clip.getBoundingClientRect();
|
||
const webview = surface.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
if (!(webview instanceof HTMLElement)) return null;
|
||
const webviewRect = webview.getBoundingClientRect();
|
||
const outsidePoint = {
|
||
x: Math.max(0, Math.round(clipRect.left - 1)),
|
||
y: Math.round(clipRect.top + clipRect.height / 2),
|
||
};
|
||
const outsideTarget = document.elementFromPoint(outsidePoint.x, outsidePoint.y);
|
||
return {
|
||
surface: {
|
||
left: Math.round(surfaceRect.left),
|
||
top: Math.round(surfaceRect.top),
|
||
right: Math.round(surfaceRect.right),
|
||
bottom: Math.round(surfaceRect.bottom),
|
||
},
|
||
clip: {
|
||
left: Math.round(clipRect.left),
|
||
top: Math.round(clipRect.top),
|
||
right: Math.round(clipRect.right),
|
||
bottom: Math.round(clipRect.bottom),
|
||
},
|
||
webview: {
|
||
left: Math.round(webviewRect.left),
|
||
top: Math.round(webviewRect.top),
|
||
},
|
||
capturesOutsideInput: surface.contains(outsideTarget),
|
||
};
|
||
}, browserId);
|
||
}
|
||
|
||
async function readViewport(client, browserId) {
|
||
const evaluated = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function:
|
||
"() => ({ width: window.innerWidth, height: window.innerHeight, scale: window.devicePixelRatio })",
|
||
});
|
||
return JSON.parse(evaluated.resultJson);
|
||
}
|
||
|
||
async function clickGuestElement(page, client, browserId, selector) {
|
||
const evaluated = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: `() => {
|
||
const element = document.querySelector(${JSON.stringify(selector)});
|
||
if (!(element instanceof HTMLElement)) return null;
|
||
const rect = element.getBoundingClientRect();
|
||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||
}`,
|
||
});
|
||
const elementRect = JSON.parse(evaluated.resultJson);
|
||
assert(elementRect, `Guest element ${selector} was unavailable`);
|
||
await page.locator(`[data-paseo-browser-id="${browserId}"]`).click({
|
||
position: {
|
||
x: elementRect.x + elementRect.width / 2,
|
||
y: elementRect.y + elementRect.height / 2,
|
||
},
|
||
});
|
||
}
|
||
|
||
async function selectDeviceSize(page, label) {
|
||
await page.locator('[aria-label="Device size"]').click();
|
||
const item = page.getByText(label, { exact: true });
|
||
await item.waitFor({ state: "visible", timeout: timeoutMs });
|
||
const rect = await item.boundingBox();
|
||
assert(rect, `Device size menu item ${label} had no bounds`);
|
||
const clip = {
|
||
x: Math.max(0, rect.x),
|
||
y: Math.max(0, rect.y),
|
||
width: rect.width,
|
||
height: rect.height,
|
||
};
|
||
const openPixels = await page.screenshot({ clip });
|
||
await page.keyboard.press("Escape");
|
||
await item.waitFor({ state: "hidden", timeout: timeoutMs });
|
||
const closedPixels = await page.screenshot({ clip });
|
||
|
||
await page.locator('[aria-label="Device size"]').click();
|
||
await item.waitFor({ state: "visible", timeout: timeoutMs });
|
||
await page.mouse.click(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
||
await page.keyboard.press("Escape");
|
||
return !openPixels.equals(closedPixels);
|
||
}
|
||
|
||
async function selectElementAndReadAnnotationPaint({ page, client, browserId, artifactDir }) {
|
||
await clickGuestElement(page, client, browserId, "#bridge-target");
|
||
const comment = page.getByRole("textbox", {
|
||
name: "Message to the agent about this element…",
|
||
});
|
||
await comment.waitFor({ state: "visible", timeout: timeoutMs });
|
||
const bounds = await comment.boundingBox();
|
||
assert(bounds, "Element annotation comment box had no bounds");
|
||
const receivesInput = await comment.evaluate((element) => {
|
||
const elementBounds = element.getBoundingClientRect();
|
||
const target = document.elementFromPoint(
|
||
elementBounds.left + elementBounds.width / 2,
|
||
elementBounds.top + elementBounds.height / 2,
|
||
);
|
||
return target === element || element.contains(target);
|
||
});
|
||
const clip = {
|
||
x: Math.max(0, bounds.x),
|
||
y: Math.max(0, bounds.y),
|
||
width: bounds.width,
|
||
height: bounds.height,
|
||
};
|
||
const openPixels = await page.screenshot({
|
||
clip,
|
||
path: path.join(artifactDir, "element-annotation-open.png"),
|
||
});
|
||
await page.keyboard.press("Escape");
|
||
await comment.waitFor({ state: "hidden", timeout: timeoutMs });
|
||
const closedPixels = await page.screenshot({
|
||
clip,
|
||
path: path.join(artifactDir, "element-annotation-closed.png"),
|
||
});
|
||
return receivesInput && !openPixels.equals(closedPixels);
|
||
}
|
||
|
||
function recordViewportMismatch(failures, label, actual, expected) {
|
||
if (actual.width === expected.width && actual.height === expected.height) {
|
||
return;
|
||
}
|
||
failures.push(
|
||
`${label}: expected ${expected.width}x${expected.height}, received ${actual.width}x${actual.height}`,
|
||
);
|
||
}
|
||
|
||
async function setWindowHidden(inspectorPort, hidden) {
|
||
const [target] = await (await fetch(`http://127.0.0.1:${inspectorPort}/json/list`)).json();
|
||
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
socket.addEventListener("error", reject, { once: true });
|
||
socket.addEventListener("message", ({ data }) => {
|
||
const response = JSON.parse(data);
|
||
if (response.id !== 1) return;
|
||
if (response.error || response.result?.exceptionDetails) reject(new Error(data));
|
||
else resolve();
|
||
});
|
||
socket.addEventListener(
|
||
"open",
|
||
() =>
|
||
socket.send(
|
||
JSON.stringify({
|
||
id: 1,
|
||
method: "Runtime.evaluate",
|
||
params: {
|
||
expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change'); })()`,
|
||
},
|
||
}),
|
||
),
|
||
{ once: true },
|
||
);
|
||
});
|
||
} finally {
|
||
socket.close();
|
||
}
|
||
}
|
||
|
||
async function verifyHiddenBrowserScreenshots({
|
||
page,
|
||
client,
|
||
browserId,
|
||
artifactDir,
|
||
inspectorPort,
|
||
}) {
|
||
const readFrames = async () => {
|
||
const result = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => window.frameCount",
|
||
});
|
||
return JSON.parse(result.resultJson);
|
||
};
|
||
const expectAnimation = async () => {
|
||
const start = await readFrames();
|
||
assert(typeof start === "number", "Animation fixture was not loaded");
|
||
const deadline = Date.now() + 5_000;
|
||
while (Date.now() < deadline) {
|
||
if ((await readFrames()) > start + 2) return;
|
||
await delay(50);
|
||
}
|
||
throw new Error("Browser did not animate in the restored window");
|
||
};
|
||
const measurements = [];
|
||
const expectIdle = async (label) => {
|
||
await delay(500); // Let Chromium consume the visibility change before sampling.
|
||
const start = await readFrames();
|
||
await delay(1_000); // A fixed sampling interval is the behavior under test.
|
||
const frames = (await readFrames()) - start;
|
||
measurements.push({ label, frames });
|
||
writeJson(path.join(artifactDir, "hidden-browser-frames.json"), measurements);
|
||
assert(frames === 0, `${label}: hidden browser produced ${frames} animation frames`);
|
||
};
|
||
try {
|
||
await expectAnimation();
|
||
await setWindowHidden(inspectorPort, true);
|
||
await expectIdle("before-capture");
|
||
await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => { document.body.style.background = 'rgb(0,255,0)'; }",
|
||
});
|
||
const response = await client.callTool({ name: "browser_screenshot", args: { browserId } });
|
||
mcpPayload(response, "browser_screenshot");
|
||
const screenshot = response.content.find((item) => item.type === "image");
|
||
assert(screenshot, "browser_screenshot returned no image");
|
||
fs.writeFileSync(
|
||
path.join(artifactDir, "hidden-browser-viewport.png"),
|
||
Buffer.from(screenshot.data, "base64"),
|
||
);
|
||
const pixel = await page.evaluate(async (base64) => {
|
||
const image = new Image();
|
||
image.src = `data:image/png;base64,${base64}`;
|
||
await image.decode();
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = image.width;
|
||
canvas.height = image.height;
|
||
const context = canvas.getContext("2d");
|
||
context.drawImage(image, 0, 0);
|
||
return [
|
||
...context.getImageData(Math.floor(image.width / 2), Math.floor(image.height / 2), 1, 1)
|
||
.data,
|
||
];
|
||
}, screenshot.data);
|
||
assert(
|
||
JSON.stringify(pixel) === "[0,255,0,255]",
|
||
`Hidden screenshot returned stale pixels: ${pixel}`,
|
||
);
|
||
await expectIdle("after-capture");
|
||
} finally {
|
||
await setWindowHidden(inspectorPort, false);
|
||
}
|
||
await expectAnimation();
|
||
}
|
||
|
||
async function verifyBackgroundTypingStaysInGuest({ page, client, serverId, browserId, agent }) {
|
||
await page
|
||
.getByTestId(`workspace-deck-entry-${serverId}:${agent.workspaceId}`)
|
||
.getByTestId(`workspace-tab-agent_${agent.agentId}`)
|
||
.click({ timeout: timeoutMs });
|
||
const composer = page
|
||
.getByRole("textbox", { name: "Message agent..." })
|
||
.filter({ visible: true })
|
||
.first();
|
||
const draft = "draft the user is typing";
|
||
await composer.click();
|
||
await composer.pressSequentially(draft);
|
||
assert(
|
||
await composer.evaluate((element) => element === document.activeElement),
|
||
"Composer did not keep focus while the user typed",
|
||
);
|
||
|
||
const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId });
|
||
const ref = snapshot.snapshot.match(/textbox "Typing target" \[ref=(@e\d+)\]/)?.[1];
|
||
assert(ref, `browser_snapshot did not expose the typing target: ${snapshot.snapshot}`);
|
||
await callBrowserTool(client, "browser_type", { browserId, ref, text: "agent text" });
|
||
await callBrowserTool(client, "browser_keypress", { browserId, ref, key: "!" });
|
||
|
||
// The user keeps typing in the composer while the agent types into the tab's focused field.
|
||
await composer.click();
|
||
await composer.press("End");
|
||
assert(
|
||
await composer.evaluate((element) => element === document.activeElement),
|
||
"Composer did not take focus back from the browser tab",
|
||
);
|
||
await callBrowserTool(client, "browser_type", { browserId, text: " more" });
|
||
await callBrowserTool(client, "browser_keypress", { browserId, key: "?" });
|
||
|
||
const guestValue = JSON.parse(
|
||
(
|
||
await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => document.getElementById('typing-target').value",
|
||
})
|
||
).resultJson,
|
||
);
|
||
const composerValue = await composer.inputValue();
|
||
assert(
|
||
composerValue === draft,
|
||
`browser_type and browser_keypress in a background tab changed the focused composer: ${JSON.stringify(composerValue)}`,
|
||
);
|
||
assert(
|
||
guestValue === "agent text! more?",
|
||
`browser_type and browser_keypress did not reach the browser tab: ${JSON.stringify(guestValue)}`,
|
||
);
|
||
await composer.fill("");
|
||
}
|
||
|
||
async function runRegression({
|
||
page,
|
||
client,
|
||
serverId,
|
||
targetUrl,
|
||
callerAgentId,
|
||
typingAgent,
|
||
artifactDir,
|
||
inspectorPort,
|
||
}) {
|
||
const failures = [];
|
||
const originalWorkspaceId = workspaceIds[0];
|
||
const originalWorkspaceRow = page.getByTestId(
|
||
`sidebar-workspace-row-${serverId}:${originalWorkspaceId}`,
|
||
);
|
||
await originalWorkspaceRow.waitFor({ state: "visible", timeout: timeoutMs });
|
||
await originalWorkspaceRow.click();
|
||
|
||
await page.evaluate(() => {
|
||
if (document.getElementById("overlay-root")) return;
|
||
const overlayRoot = document.createElement("div");
|
||
overlayRoot.id = "overlay-root";
|
||
overlayRoot.style.position = "fixed";
|
||
overlayRoot.style.inset = "0";
|
||
overlayRoot.style.pointerEvents = "none";
|
||
document.body.appendChild(overlayRoot);
|
||
});
|
||
|
||
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
|
||
const browserId = created.browserId;
|
||
assert(typeof browserId === "string", "browser_new_tab returned no browserId");
|
||
|
||
const originalDeck = page.getByTestId(`workspace-deck-entry-${serverId}:${originalWorkspaceId}`);
|
||
await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click();
|
||
await page.waitForFunction(
|
||
(id) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
const surface = webview?.parentElement;
|
||
return (
|
||
surface?.getAttribute("aria-hidden") === "false" && surface.style.pointerEvents === "auto"
|
||
);
|
||
},
|
||
browserId,
|
||
{ timeout: timeoutMs },
|
||
);
|
||
const firstGuest = await readGuest(page, browserId);
|
||
assert(firstGuest, "Original browser guest was not attached to its workspace pane");
|
||
recordViewportMismatch(
|
||
failures,
|
||
"Responsive viewport follows the visible browser pane",
|
||
await readViewport(client, browserId),
|
||
{ width: firstGuest.width, height: firstGuest.height },
|
||
);
|
||
|
||
await clickGuestElement(page, client, browserId, "#typing-target");
|
||
assert(
|
||
await waitForGuestActiveElement(client, browserId, "typing-target"),
|
||
"Physical browser click did not focus the guest input",
|
||
);
|
||
const focusedGuest = await page.evaluate(
|
||
(id) => window.paseoDesktop?.browser?.focus?.(id),
|
||
browserId,
|
||
);
|
||
assert(focusedGuest === true, "Electron did not focus the registered browser guest");
|
||
|
||
const deviceSizeMenuPainted = await selectDeviceSize(page, "iPhone SE · 375×667");
|
||
assert(deviceSizeMenuPainted, "Device size menu did not paint above the browser surface");
|
||
const deviceViewport = await readViewport(client, browserId);
|
||
recordViewportMismatch(
|
||
failures,
|
||
"device size menu paints and receives input above the browser surface",
|
||
deviceViewport,
|
||
{ width: 375, height: 667 },
|
||
);
|
||
|
||
await callBrowserTool(client, "browser_wait", {
|
||
browserId,
|
||
text: "Bridge target",
|
||
timeoutMs: 5_000,
|
||
});
|
||
const resizedScreenshot = await callBrowserTool(client, "browser_screenshot", { browserId });
|
||
assert(
|
||
resizedScreenshot.width === Math.round(375 * deviceViewport.scale) &&
|
||
resizedScreenshot.height === Math.round(667 * deviceViewport.scale),
|
||
`Screenshot after resize returned ${resizedScreenshot.width}×${resizedScreenshot.height}`,
|
||
);
|
||
const requestedViewport = { width: 640, height: 480 };
|
||
await callBrowserTool(client, "browser_resize", { browserId, ...requestedViewport });
|
||
recordViewportMismatch(
|
||
failures,
|
||
"browser_resize updates the visible shared viewport",
|
||
await readViewport(client, browserId),
|
||
requestedViewport,
|
||
);
|
||
|
||
const oversizedViewport = { width: 2560, height: 1440 };
|
||
await callBrowserTool(client, "browser_resize", { browserId, ...oversizedViewport });
|
||
recordViewportMismatch(
|
||
failures,
|
||
"oversized preset preserves the requested guest viewport",
|
||
await readViewport(client, browserId),
|
||
oversizedViewport,
|
||
);
|
||
await page.waitForFunction(
|
||
({ id, width, height }) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
return (
|
||
webview instanceof HTMLElement &&
|
||
Math.round(webview.getBoundingClientRect().width) === width &&
|
||
Math.round(webview.getBoundingClientRect().height) === height
|
||
);
|
||
},
|
||
{ id: browserId, ...oversizedViewport },
|
||
{ timeout: timeoutMs },
|
||
);
|
||
const presentation = await readPresentation(page, browserId);
|
||
assert(presentation, "Oversized browser presentation geometry was unavailable");
|
||
if (
|
||
presentation.surface.left < presentation.clip.left ||
|
||
presentation.surface.top < presentation.clip.top ||
|
||
presentation.surface.right > presentation.clip.right ||
|
||
presentation.surface.bottom > presentation.clip.bottom
|
||
) {
|
||
failures.push(
|
||
`oversized browser surface stays inside its pane: ${JSON.stringify(presentation)}`,
|
||
);
|
||
}
|
||
if (presentation.capturesOutsideInput) {
|
||
failures.push("oversized browser surface captures input outside its pane");
|
||
}
|
||
await callBrowserTool(client, "browser_resize", { browserId, ...oversizedViewport });
|
||
const repeatedResizePresentation = await readPresentation(page, browserId);
|
||
assert(repeatedResizePresentation, "Repeated resize presentation geometry was unavailable");
|
||
if (
|
||
repeatedResizePresentation.webview.left !== presentation.webview.left ||
|
||
repeatedResizePresentation.webview.top !== presentation.webview.top
|
||
) {
|
||
failures.push(
|
||
`repeating an oversized resize preserves the guest offset: before ${JSON.stringify(presentation.webview)}, after ${JSON.stringify(repeatedResizePresentation.webview)}`,
|
||
);
|
||
}
|
||
await callBrowserTool(client, "browser_resize", { browserId, ...requestedViewport });
|
||
|
||
await selectDeviceSize(page, "Responsive");
|
||
const responsiveViewport = await readViewport(client, browserId);
|
||
|
||
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
||
await page.waitForFunction(
|
||
({ id, webContentsId }) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
return (
|
||
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
|
||
webview.parentElement.style.width === "1px" &&
|
||
webview.parentElement.style.pointerEvents === "none" &&
|
||
webview.getWebContentsId() === webContentsId
|
||
);
|
||
},
|
||
{ id: browserId, webContentsId: firstGuest.webContentsId },
|
||
{ timeout: timeoutMs },
|
||
);
|
||
try {
|
||
await callBrowserTool(client, "browser_screenshot", { browserId });
|
||
} catch (error) {
|
||
failures.push(`inactive browser remains captureable: ${String(error)}`);
|
||
}
|
||
recordViewportMismatch(
|
||
failures,
|
||
"inactive browser preserves the shared viewport",
|
||
await readViewport(client, browserId),
|
||
responsiveViewport,
|
||
);
|
||
const focusContinuitySentinel = "preserve-browser-document-across-focus";
|
||
await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: `() => {
|
||
globalThis.__paseoFocusContinuity = ${JSON.stringify(focusContinuitySentinel)};
|
||
globalThis.__paseoViewportTransitions = [{ width: innerWidth, height: innerHeight }];
|
||
addEventListener('resize', () => {
|
||
globalThis.__paseoViewportTransitions.push({ width: innerWidth, height: innerHeight });
|
||
});
|
||
return globalThis.__paseoFocusContinuity;
|
||
}`,
|
||
});
|
||
await page.evaluate((id) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
if (!webview) throw new Error(`Browser webview ${id} was unavailable`);
|
||
const events = [];
|
||
globalThis.__paseoBrowserReactivationEvents = events;
|
||
for (const name of [
|
||
"did-start-loading",
|
||
"did-navigate-in-page",
|
||
"load-commit",
|
||
"did-stop-loading",
|
||
]) {
|
||
webview.addEventListener(name, (event) => {
|
||
events.push({
|
||
name,
|
||
url: event.url ?? null,
|
||
isMainFrame: event.isMainFrame ?? null,
|
||
});
|
||
});
|
||
}
|
||
}, browserId);
|
||
|
||
await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click();
|
||
await page.waitForFunction(
|
||
({ id, webContentsId }) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
return (
|
||
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
|
||
webview.parentElement.style.pointerEvents === "auto" &&
|
||
webview.getWebContentsId() === webContentsId
|
||
);
|
||
},
|
||
{ id: browserId, webContentsId: firstGuest.webContentsId },
|
||
{ timeout: timeoutMs },
|
||
);
|
||
await page.waitForTimeout(1_500);
|
||
const reactivationEvents = await page.evaluate(
|
||
() => globalThis.__paseoBrowserReactivationEvents ?? [],
|
||
);
|
||
const unexpectedReactivationCommits = reactivationEvents.filter(
|
||
(event) => event.name === "did-navigate-in-page" || event.name === "load-commit",
|
||
);
|
||
assert(
|
||
unexpectedReactivationCommits.length === 0,
|
||
`responsive browser reactivation committed the current document: ${JSON.stringify(unexpectedReactivationCommits)}`,
|
||
);
|
||
const viewportTransitionsResult = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => globalThis.__paseoViewportTransitions ?? []",
|
||
});
|
||
const viewportTransitions = JSON.parse(viewportTransitionsResult.resultJson);
|
||
const collapsedViewport = viewportTransitions.find(
|
||
(viewport) => viewport.width <= 1 || viewport.height <= 1,
|
||
);
|
||
assert(
|
||
collapsedViewport === undefined,
|
||
`responsive browser reactivation collapsed the guest viewport: ${JSON.stringify(viewportTransitions)}`,
|
||
);
|
||
const continuityResult = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => globalThis.__paseoFocusContinuity ?? null",
|
||
});
|
||
const continuityValue = JSON.parse(continuityResult.resultJson);
|
||
if (continuityValue !== focusContinuitySentinel) {
|
||
failures.push(
|
||
`focusing the browser tab preserves the current document: expected ${JSON.stringify(focusContinuitySentinel)}, received ${JSON.stringify(continuityValue)}`,
|
||
);
|
||
}
|
||
|
||
for (const workspaceId of workspaceIds.slice(1)) {
|
||
await page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`).click();
|
||
await page
|
||
.getByTestId(`workspace-deck-entry-${serverId}:${workspaceId}`)
|
||
.waitFor({ state: "visible" });
|
||
}
|
||
|
||
await page.waitForFunction(
|
||
({ id, previousWebContentsId }) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
return (
|
||
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
|
||
webview.parentElement.style.width === "1px" &&
|
||
typeof webview.getWebContentsId === "function" &&
|
||
webview.getWebContentsId() === previousWebContentsId
|
||
);
|
||
},
|
||
{ id: browserId, previousWebContentsId: firstGuest.webContentsId },
|
||
{ timeout: timeoutMs },
|
||
);
|
||
const parkedGuest = await readGuest(page, browserId);
|
||
assert(parkedGuest, "Browser guest was not parked after workspace eviction");
|
||
|
||
await verifyHiddenBrowserScreenshots({ page, client, browserId, artifactDir, inspectorPort });
|
||
|
||
const listed = await callBrowserTool(client, "browser_list_tabs");
|
||
assert(
|
||
listed.tabs.some((tab) => tab.browserId === browserId),
|
||
"browser_list_tabs lost the original tab after workspace eviction",
|
||
);
|
||
|
||
const snapshot = await callBrowserTool(client, "browser_snapshot", { browserId });
|
||
const ref = snapshot.snapshot.match(/button "Bridge target" \[ref=(@e\d+)\]/)?.[1];
|
||
assert(ref, `browser_snapshot did not expose the target button: ${snapshot.snapshot}`);
|
||
|
||
const clicked = await callBrowserTool(client, "browser_click", { browserId, ref });
|
||
assert(
|
||
clicked.browserId === browserId && clicked.ref === ref,
|
||
"browser_click targeted another tab",
|
||
);
|
||
await callBrowserTool(client, "browser_wait", {
|
||
browserId,
|
||
text: "Clicked",
|
||
timeoutMs: 5_000,
|
||
});
|
||
await verifyBackgroundTypingStaysInGuest({
|
||
page,
|
||
client,
|
||
serverId,
|
||
browserId,
|
||
agent: typingAgent,
|
||
});
|
||
|
||
await originalWorkspaceRow.click();
|
||
await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).click();
|
||
await page.waitForFunction(
|
||
({ id, webContentsId }) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
return (
|
||
webview?.parentElement?.getAttribute("data-paseo-browser-surface") === id &&
|
||
webview.parentElement.style.pointerEvents === "auto" &&
|
||
webview.getWebContentsId() === webContentsId
|
||
);
|
||
},
|
||
{ id: browserId, webContentsId: firstGuest.webContentsId },
|
||
{ timeout: timeoutMs },
|
||
);
|
||
recordViewportMismatch(
|
||
failures,
|
||
"browser viewport survives workspace eviction and reattachment",
|
||
await readViewport(client, browserId),
|
||
responsiveViewport,
|
||
);
|
||
|
||
const annotateButton = originalDeck.getByRole("button", { name: "Annotate element" });
|
||
await page.evaluate((id) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
if (!(webview instanceof HTMLElement)) throw new Error(`Browser webview ${id} was unavailable`);
|
||
globalThis.__paseoOriginalIsLoadingDescriptor = Object.getOwnPropertyDescriptor(
|
||
webview,
|
||
"isLoading",
|
||
);
|
||
Object.defineProperty(webview, "isLoading", {
|
||
configurable: true,
|
||
value: () => true,
|
||
});
|
||
}, browserId);
|
||
await annotateButton.click();
|
||
await page.waitForTimeout(100);
|
||
assert(
|
||
!(await originalDeck.getByRole("button", { name: "Cancel element selector" }).isVisible()),
|
||
"Element selector started while the guest reported a genuine load",
|
||
);
|
||
const selectorDuringLoad = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => Boolean(globalThis.__paseoSelector)",
|
||
});
|
||
assert(
|
||
JSON.parse(selectorDuringLoad.resultJson) === false,
|
||
"Selector injected while the guest reported a genuine load",
|
||
);
|
||
assert(
|
||
await page.getByText("Wait for the page to finish loading").isVisible(),
|
||
"Element selector loading failure was not visible",
|
||
);
|
||
await page.evaluate((id) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
if (!(webview instanceof HTMLElement)) throw new Error(`Browser webview ${id} was unavailable`);
|
||
const descriptor = globalThis.__paseoOriginalIsLoadingDescriptor;
|
||
if (descriptor) Object.defineProperty(webview, "isLoading", descriptor);
|
||
else delete webview.isLoading;
|
||
delete globalThis.__paseoOriginalIsLoadingDescriptor;
|
||
}, browserId);
|
||
|
||
const readyStateResult = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => document.readyState",
|
||
});
|
||
assert(
|
||
JSON.parse(readyStateResult.resultJson) === "complete",
|
||
"Local browser document did not finish loading",
|
||
);
|
||
// Reproduce the report's mismatch: the guest is complete, but the pane's
|
||
// last loading signal says it is not ready.
|
||
await page.evaluate((id) => {
|
||
const webview = document.querySelector(`[data-paseo-browser-id="${id}"]`);
|
||
if (!(webview instanceof HTMLElement)) {
|
||
throw new Error(`Browser webview ${id} was unavailable`);
|
||
}
|
||
webview.dispatchEvent(new Event("did-start-loading"));
|
||
}, browserId);
|
||
|
||
await annotateButton.click();
|
||
const annotateSelectorActive = await waitForGuestSelector(client, browserId);
|
||
await page.screenshot({ path: path.join(artifactDir, "local-page-annotate-selector.png") });
|
||
if (!annotateSelectorActive) {
|
||
failures.push("loaded local browser remains ready for element annotation");
|
||
}
|
||
|
||
await originalDeck.getByRole("button", { name: "Cancel element selector" }).click();
|
||
await delay(10_000);
|
||
const screenshotButton = originalDeck.getByRole("button", { name: "Screenshot element" });
|
||
await screenshotButton.click();
|
||
const screenshotSelectorActive = await waitForGuestSelector(client, browserId);
|
||
if (!screenshotSelectorActive) {
|
||
failures.push("loaded local browser remains ready for element screenshots");
|
||
}
|
||
await delay(20_500);
|
||
const selectorAfterPriorTimeout = await callBrowserTool(client, "browser_evaluate", {
|
||
browserId,
|
||
function: "() => Boolean(globalThis.__paseoSelector)",
|
||
});
|
||
if (JSON.parse(selectorAfterPriorTimeout.resultJson) !== true) {
|
||
failures.push("a previous selector timeout does not destroy the current selector session");
|
||
}
|
||
await page.screenshot({ path: path.join(artifactDir, "local-page-screenshot-selector.png") });
|
||
await originalDeck.getByRole("button", { name: "Cancel element selector" }).click();
|
||
|
||
await originalDeck.getByTestId(`workspace-tab-agent_${callerAgentId}`).click();
|
||
await page.getByTestId("sidebar-search").click();
|
||
await page.getByTestId("command-center-input").fill("Split pane right");
|
||
await page.getByText("Split pane right", { exact: true }).click();
|
||
assert(
|
||
(await originalDeck.getByTestId("workspace-tabs-row").filter({ visible: true }).count()) === 2,
|
||
"Split pane command did not produce two visible panes",
|
||
);
|
||
await originalDeck.getByTestId(`workspace-tab-browser_${browserId}`).last().click();
|
||
await originalDeck
|
||
.getByTestId(`browser-webview-clip-${browserId}`)
|
||
.waitFor({ state: "visible", timeout: timeoutMs });
|
||
|
||
const splitAnnotateButton = originalDeck.getByRole("button", { name: "Annotate element" });
|
||
await splitAnnotateButton.click();
|
||
assert(
|
||
await waitForGuestSelector(client, browserId),
|
||
"Element selector did not start in the split browser pane",
|
||
);
|
||
assert(
|
||
await selectElementAndReadAnnotationPaint({
|
||
page,
|
||
client,
|
||
browserId,
|
||
artifactDir,
|
||
}),
|
||
"Element annotation comment box did not paint or receive input above the browser surface",
|
||
);
|
||
|
||
if (failures.length > 0) {
|
||
throw new Error(`Browser viewport regressions:\n- ${failures.join("\n- ")}`);
|
||
}
|
||
|
||
return {
|
||
browserId,
|
||
originalWebContentsId: firstGuest.webContentsId,
|
||
finalWebContentsId: parkedGuest.webContentsId,
|
||
viewport: "passed",
|
||
guestFocus: "passed",
|
||
overlayPlane: "passed",
|
||
inactiveCapture: "passed",
|
||
hiddenWindowCapture: "passed",
|
||
list: "passed",
|
||
snapshot: "passed",
|
||
click: "passed",
|
||
localPageSelectors: "passed",
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
const artifactDir =
|
||
process.env.PASEO_DESKTOP_BROWSER_E2E_ARTIFACT_DIR ??
|
||
fs.mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-browser-e2e-artifacts-"));
|
||
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-desktop-browser-e2e-"));
|
||
fs.mkdirSync(artifactDir, { recursive: true });
|
||
const paseoHome = path.join(runtimeDir, "paseo-home");
|
||
const userData = path.join(runtimeDir, "electron-user-data");
|
||
const workspaceRoot = path.join(runtimeDir, "workspaces");
|
||
fs.mkdirSync(paseoHome, { recursive: true });
|
||
|
||
const [daemonPort, expoPort, cdpPort, inspectorPort, remotePort] = await Promise.all([
|
||
reservePort(),
|
||
reservePort(),
|
||
reservePort(),
|
||
reservePort(),
|
||
reservePort(),
|
||
]);
|
||
const listen = `127.0.0.1:${daemonPort}`;
|
||
seedPaseoHome(paseoHome, listen, workspaceRoot);
|
||
const target = await startTargetPage();
|
||
seedPluginLinks(paseoHome, workspaceIds[0], target.url, workspaceIds[1]);
|
||
const remoteHome = path.join(runtimeDir, "remote-home");
|
||
seedPaseoHome(remoteHome, `127.0.0.1:${remotePort}`, path.join(runtimeDir, "remote-workspaces"));
|
||
const children = [];
|
||
let browser = null;
|
||
let client = null;
|
||
|
||
try {
|
||
// Observe the real Electron shell handoff without launching a user's browser.
|
||
const openerDirectory = path.join(runtimeDir, "url-handler");
|
||
const externalOpenLog = path.join(artifactDir, "external-opens.txt");
|
||
fs.mkdirSync(openerDirectory, { recursive: true });
|
||
fs.writeFileSync(externalOpenLog, "");
|
||
fs.writeFileSync(
|
||
path.join(openerDirectory, "xdg-open"),
|
||
'#!/bin/sh\nprintf "%s\\n" "$1" >> "$PASEO_TEST_EXTERNAL_OPEN_LOG"\n',
|
||
{ mode: 0o755 },
|
||
);
|
||
const commonEnv = {
|
||
...process.env,
|
||
PATH: `${openerDirectory}${path.delimiter}${process.env.PATH}`,
|
||
PASEO_TEST_EXTERNAL_OPEN_LOG: externalOpenLog,
|
||
PASEO_HOME: paseoHome,
|
||
PASEO_LISTEN: listen,
|
||
PASEO_DAEMON_ENDPOINT: `localhost:${daemonPort}`,
|
||
PASEO_CORS_ORIGINS: "*",
|
||
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: "0",
|
||
PASEO_DICTATION_ENABLED: "0",
|
||
PASEO_VOICE_MODE_ENABLED: "0",
|
||
FORCE_COLOR: "0",
|
||
NO_COLOR: "1",
|
||
};
|
||
const daemon = spawnLogged(
|
||
"daemon",
|
||
process.execPath,
|
||
["--import", "tsx", path.join(rootDir, "packages/server/scripts/dev-runner.ts")],
|
||
{ cwd: rootDir, env: { ...commonEnv, PASEO_NODE_ENV: "development" } },
|
||
artifactDir,
|
||
);
|
||
children.push(daemon.child);
|
||
await waitForPort(daemonPort, "daemon", daemon);
|
||
|
||
const remoteDaemon = spawnLogged(
|
||
"remote-daemon",
|
||
process.execPath,
|
||
["--import", "tsx", path.join(rootDir, "packages/server/scripts/dev-runner.ts")],
|
||
{
|
||
cwd: rootDir,
|
||
env: {
|
||
...commonEnv,
|
||
PASEO_HOME: remoteHome,
|
||
PASEO_LISTEN: `127.0.0.1:${remotePort}`,
|
||
PASEO_SERVER_ID: "plugin-links-remote",
|
||
PASEO_NODE_ENV: "development",
|
||
},
|
||
},
|
||
artifactDir,
|
||
);
|
||
children.push(remoteDaemon.child);
|
||
await waitForPort(remotePort, "remote daemon", remoteDaemon);
|
||
|
||
const desktopArgs = [
|
||
process.execPath,
|
||
devRunner,
|
||
`--inspect=127.0.0.1:${inspectorPort}`,
|
||
...(process.platform === "linux" ? ["--no-sandbox"] : []),
|
||
];
|
||
const desktopCommand = process.platform === "linux" ? "xvfb-run" : desktopArgs.shift();
|
||
const desktopCommandArgs =
|
||
process.platform === "linux"
|
||
? ["-a", "--server-args=-screen 0 1280x800x24", ...desktopArgs]
|
||
: desktopArgs;
|
||
const desktop = spawnLogged(
|
||
"desktop",
|
||
desktopCommand,
|
||
desktopCommandArgs,
|
||
{
|
||
cwd: rootDir,
|
||
env: {
|
||
...commonEnv,
|
||
EXPO_PORT: String(expoPort),
|
||
EXPO_DEV_URL: `http://localhost:${expoPort}`,
|
||
PASEO_ELECTRON_REMOTE_DEBUGGING_PORT: String(cdpPort),
|
||
PASEO_ELECTRON_USER_DATA_DIR: userData,
|
||
PASEO_ELECTRON_FLAGS: `--remote-debugging-address=127.0.0.1 --remote-debugging-port=${cdpPort}`,
|
||
},
|
||
},
|
||
artifactDir,
|
||
);
|
||
children.push(desktop.child);
|
||
await waitForPort(cdpPort, "Electron CDP", desktop);
|
||
|
||
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
|
||
const page = await waitForAppPage(browser, expoPort);
|
||
const status = await waitForDesktopStatus(page);
|
||
|
||
const checkPluginLinks = () =>
|
||
runPluginLinksRegression({
|
||
page,
|
||
remotePort,
|
||
workspaceId: workspaceIds[0],
|
||
remoteWorkspaceId: workspaceIds[1],
|
||
url: target.url,
|
||
artifactDir,
|
||
externalOpenLog: process.platform === "linux" ? externalOpenLog : null,
|
||
});
|
||
if (process.env.PASEO_DESKTOP_PLUGIN_LINKS_ONLY === "1") {
|
||
const pluginLinks = await checkPluginLinks();
|
||
writeJson(path.join(artifactDir, "result.json"), { pluginLinks });
|
||
console.log("Plugin external links and workspace browser passed.");
|
||
return;
|
||
}
|
||
const settingsMemory = await runSettingsMemoryRegression(page);
|
||
if (process.env.PASEO_DESKTOP_SETTINGS_MEMORY_ONLY === "1") {
|
||
writeJson(path.join(artifactDir, "result.json"), { settingsMemory });
|
||
console.log(
|
||
`Desktop Settings memory regression passed: ${settingsMemory.detachedAfterWarmRound} detached panes after warm and stress rotations.`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
await runAppearanceFontSizeRegression(page);
|
||
|
||
const callerAgentId = await createCallerAgent(daemonPort);
|
||
const typingAgent = {
|
||
workspaceId: workspaceIds.at(-1),
|
||
agentId: await createCallerAgent(daemonPort, workspaceIds.at(-1)),
|
||
};
|
||
const transport = new StreamableHTTPClientTransport(
|
||
new URL(
|
||
`http://127.0.0.1:${daemonPort}/mcp/agents?callerAgentId=${encodeURIComponent(callerAgentId)}`,
|
||
),
|
||
);
|
||
client = await experimental_createMCPClient({ transport });
|
||
const report = await runRegression({
|
||
page,
|
||
client,
|
||
serverId: status.serverId,
|
||
targetUrl: target.url,
|
||
inspectorPort,
|
||
callerAgentId,
|
||
typingAgent,
|
||
artifactDir,
|
||
});
|
||
const pluginLinks = await checkPluginLinks();
|
||
writeJson(path.join(artifactDir, "result.json"), { ...report, settingsMemory, pluginLinks });
|
||
console.log(
|
||
`Browser desktop browser E2E passed: WebContents ${report.originalWebContentsId} remained ${report.finalWebContentsId}; viewport, inactive capture, focus continuity, list, snapshot, click, local-page selectors passed.`,
|
||
);
|
||
} catch (error) {
|
||
console.error(`Browser desktop browser E2E failed. Artifacts: ${artifactDir}`);
|
||
console.error(error);
|
||
throw error;
|
||
} finally {
|
||
await client?.close().catch(() => undefined);
|
||
await browser?.close().catch(() => undefined);
|
||
for (const child of children.toReversed()) stopProcess(child);
|
||
await closeServer(target.server);
|
||
await delay(1_000);
|
||
try {
|
||
fs.rmSync(runtimeDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||
} catch (error) {
|
||
console.warn(`Failed to remove isolated E2E state ${runtimeDir}`, error);
|
||
}
|
||
}
|
||
}
|
||
|
||
await main();
|