Skip unrelated required checks without losing their names (#2542)
* fix(ci): skip unrelated required checks cleanly Matrix jobs had to start no-op runners to preserve interpolated required-check names. Static named jobs let GitHub report genuine skipped conclusions while keeping those names stable. * fix(ci): gate changes to install patching script * fix(ci): route cross-package test contracts narrowly * fix(ci): keep routed contracts merge-blocking * fix(server): exclude nested test utilities from builds * fix(ci): cover app-owned native and browser domains * refactor(ci): align tests with runtime domains Own browser specs, desktop specs, and Electron-only app code by stable directories so PR routing does not depend on filename conventions. Keep desktop integration coverage inside the required desktop check. * fix(ci): keep routing and desktop suites isolated The routing regression must run before dependency installation, and desktop Vitest must not collect Playwright-owned specs. * fix(ci): isolate desktop e2e runtime * perf(test): isolate Playwright workers * perf(test): parallelize Playwright within specs * fix(test): keep Playwright file scheduling * fix(test): share dynamic Metro fixture * fix(test): preserve own-host restart fixtures * fix(ci): preserve routed contracts after rebase * Stabilize Playwright wheel-scroll assertions
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||
const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const path = require("node:path");
|
||||
|
||||
const { smokePackagedDesktopApp } = require("./smoke-packaged-desktop-app.js");
|
||||
const { smokePackagedDesktopApp } = require("../e2e/packaged-app-smoke.js");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
#!/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";
|
||||
|
||||
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 = [
|
||||
"tab-bridge-original",
|
||||
"tab-bridge-evict-one",
|
||||
"tab-bridge-evict-two",
|
||||
"tab-bridge-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: `Tab bridge 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: `Tab bridge workspace ${index + 1}`,
|
||||
title: `Tab bridge 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: false },
|
||||
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>Tab bridge target</title></head>
|
||||
<body>
|
||||
<button id="bridge-target" onclick="this.textContent = 'Clicked'">Bridge target</button>
|
||||
</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 createCallerAgent(daemonPort) {
|
||||
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: workspaceIds[0] },
|
||||
title: "Browser tab bridge E2E caller",
|
||||
provider: "mock/ten-second-stream",
|
||||
settings: { modeId: "load-test" },
|
||||
initialPrompt: "Remain available while the browser bridge regression runs.",
|
||||
background: true,
|
||||
},
|
||||
});
|
||||
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 === workspaceIds[0],
|
||||
`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,
|
||||
};
|
||||
}, browserId);
|
||||
}
|
||||
|
||||
async function runRegression({ page, client, serverId, targetUrl }) {
|
||||
const originalWorkspaceId = workspaceIds[0];
|
||||
const originalWorkspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${originalWorkspaceId}`,
|
||||
);
|
||||
await originalWorkspaceRow.waitFor({ state: "visible", timeout: timeoutMs });
|
||||
await originalWorkspaceRow.click();
|
||||
|
||||
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}"]`);
|
||||
return webview && webview.parentElement?.id !== "paseo-browser-resident-webviews";
|
||||
},
|
||||
browserId,
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
const firstGuest = await readGuest(page, browserId);
|
||||
assert(firstGuest, "Original browser guest was not attached to its workspace pane");
|
||||
|
||||
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?.id === "paseo-browser-resident-webviews" &&
|
||||
typeof webview.getWebContentsId === "function" &&
|
||||
webview.getWebContentsId() !== previousWebContentsId
|
||||
);
|
||||
},
|
||||
{ id: browserId, previousWebContentsId: firstGuest.webContentsId },
|
||||
{ timeout: timeoutMs },
|
||||
);
|
||||
const replacementGuest = await readGuest(page, browserId);
|
||||
assert(replacementGuest, "Replacement browser guest was not parked after workspace eviction");
|
||||
|
||||
const listed = await callBrowserTool(client, "browser_list_tabs");
|
||||
assert(
|
||||
listed.tabs.some((tab) => tab.browserId === browserId),
|
||||
"browser_list_tabs lost the original tab after guest replacement",
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
return {
|
||||
browserId,
|
||||
originalWebContentsId: firstGuest.webContentsId,
|
||||
replacementWebContentsId: replacementGuest.webContentsId,
|
||||
list: "passed",
|
||||
snapshot: "passed",
|
||||
click: "passed",
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifactDir =
|
||||
process.env.PASEO_TAB_BRIDGE_E2E_ARTIFACT_DIR ??
|
||||
fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-e2e-artifacts-"));
|
||||
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-tab-bridge-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] = await Promise.all([
|
||||
reservePort(),
|
||||
reservePort(),
|
||||
reservePort(),
|
||||
]);
|
||||
const listen = `127.0.0.1:${daemonPort}`;
|
||||
seedPaseoHome(paseoHome, listen, workspaceRoot);
|
||||
const target = await startTargetPage();
|
||||
const children = [];
|
||||
let browser = null;
|
||||
let client = null;
|
||||
|
||||
try {
|
||||
const commonEnv = {
|
||||
...process.env,
|
||||
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 desktopArgs = [
|
||||
process.execPath,
|
||||
devRunner,
|
||||
...(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 callerAgentId = await createCallerAgent(daemonPort);
|
||||
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,
|
||||
});
|
||||
writeJson(path.join(artifactDir, "result.json"), report);
|
||||
console.log(
|
||||
`Browser tab bridge E2E passed: WebContents ${report.originalWebContentsId} -> ${report.replacementWebContentsId}; list, snapshot, click passed.`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Browser tab bridge 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();
|
||||
@@ -1,932 +0,0 @@
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const EXECUTABLE_NAME = "Paseo";
|
||||
const SMOKE_TIMEOUT_MS = 60_000;
|
||||
const EXIT_TIMEOUT_MS = 10_000;
|
||||
const TERMINAL_CAPTURE_ATTEMPTS = 20;
|
||||
const TERMINAL_CAPTURE_INTERVAL_MS = 500;
|
||||
const REQUIRED_DESKTOP_BRIDGE_KEYS = [
|
||||
"platform",
|
||||
"invoke",
|
||||
"getPendingOpenProject",
|
||||
"events",
|
||||
"window",
|
||||
"dialog",
|
||||
"notification",
|
||||
"opener",
|
||||
"editor",
|
||||
"webUtils",
|
||||
"menu",
|
||||
"browser",
|
||||
];
|
||||
|
||||
function createTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function assertExecutable(filePath, label) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`${label} does not exist: ${filePath}`);
|
||||
}
|
||||
if (process.platform !== "win32") {
|
||||
fs.accessSync(filePath, fs.constants.X_OK);
|
||||
}
|
||||
}
|
||||
|
||||
function getExecutablePath(appPath) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return path.join(appPath, `${EXECUTABLE_NAME}.exe`);
|
||||
}
|
||||
|
||||
return path.join(appPath, EXECUTABLE_NAME);
|
||||
}
|
||||
|
||||
function getCliShimPath(appPath) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(appPath, "Contents", "Resources", "bin", "paseo");
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
return path.join(appPath, "resources", "bin", "paseo.cmd");
|
||||
}
|
||||
|
||||
return path.join(appPath, "resources", "bin", "paseo");
|
||||
}
|
||||
|
||||
function getMacMainExecutablePath(appPath) {
|
||||
return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
|
||||
}
|
||||
|
||||
function ensureLinuxSandboxPermissions(appPath) {
|
||||
if (process.platform !== "linux") {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandboxPath = path.join(appPath, "chrome-sandbox");
|
||||
if (!fs.existsSync(sandboxPath)) {
|
||||
throw new Error(`Chromium sandbox helper does not exist: ${sandboxPath}`);
|
||||
}
|
||||
|
||||
const hasRequiredPermissions = () => {
|
||||
const stat = fs.statSync(sandboxPath);
|
||||
return stat.uid === 0 && (stat.mode & 0o7777) === 0o4755;
|
||||
};
|
||||
if (hasRequiredPermissions()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chown = spawnSync("sudo", ["-n", "chown", "root:root", sandboxPath], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const chmod =
|
||||
chown.status === 0
|
||||
? spawnSync("sudo", ["-n", "chmod", "4755", sandboxPath], { encoding: "utf8" })
|
||||
: null;
|
||||
if (chown.error || chown.status !== 0 || chmod?.error || chmod?.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to configure Chromium sandbox helper ${sandboxPath}. Run: sudo chown root:root ${sandboxPath} && sudo chmod 4755 ${sandboxPath}.\n${chown.stderr?.trim() || chmod?.stderr?.trim() || chown.error || chmod?.error || "Permissions remained incorrect."}`,
|
||||
);
|
||||
}
|
||||
if (!hasRequiredPermissions()) {
|
||||
throw new Error(`Chromium sandbox helper permissions remained incorrect: ${sandboxPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getLaunchCommand(executablePath) {
|
||||
if (process.platform !== "linux") {
|
||||
return {
|
||||
command: executablePath,
|
||||
args: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "xvfb-run",
|
||||
args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath],
|
||||
};
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function shellQuoteCliArg(value) {
|
||||
if (process.platform === "win32") {
|
||||
return `"${String(value).replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
return shellQuote(String(value));
|
||||
}
|
||||
|
||||
function getTerminalHookSmokeCommand(marker) {
|
||||
if (process.platform === "win32") {
|
||||
const script = [
|
||||
"& $env:PASEO_HOOK_CLI hooks codex Stop",
|
||||
"if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }",
|
||||
`Write-Output '${marker}'`,
|
||||
].join("; ");
|
||||
const encodedScript = Buffer.from(script, "utf16le").toString("base64");
|
||||
return `powershell.exe -NoProfile -NonInteractive -EncodedCommand ${encodedScript}`;
|
||||
}
|
||||
|
||||
return `"$PASEO_HOOK_CLI" hooks codex Stop && echo ${marker}`;
|
||||
}
|
||||
|
||||
function getShellCommand(script) {
|
||||
if (process.platform === "win32") {
|
||||
return {
|
||||
command: "cmd.exe",
|
||||
args: ["/d", "/c", script],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "/bin/sh",
|
||||
args: ["-lc", script],
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultDaemonEnv(extraEnv) {
|
||||
const env = {
|
||||
...process.env,
|
||||
...extraEnv,
|
||||
};
|
||||
|
||||
delete env.PASEO_HOME;
|
||||
delete env.PASEO_LISTEN;
|
||||
return env;
|
||||
}
|
||||
|
||||
function createIsolatedDesktopEnv({ home, listen, userData, cdpPort }) {
|
||||
return {
|
||||
...process.env,
|
||||
PASEO_HOME: home,
|
||||
PASEO_LISTEN: listen,
|
||||
PASEO_ELECTRON_USER_DATA_DIR: userData,
|
||||
PASEO_ELECTRON_FLAGS: `--remote-debugging-address=127.0.0.1 --remote-debugging-port=${cdpPort}`,
|
||||
};
|
||||
}
|
||||
|
||||
function configureIsolatedDaemonHome(home, listen) {
|
||||
fs.writeFileSync(
|
||||
path.join(home, "config.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
daemon: {
|
||||
listen,
|
||||
relay: { enabled: false },
|
||||
mcp: { enabled: false, injectIntoAgents: false },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
function reserveLocalTcpPort() {
|
||||
return 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();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to reserve a TCP port for smoke test")));
|
||||
return;
|
||||
}
|
||||
const { port } = address;
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForFile(filePath, label) {
|
||||
const deadline = Date.now() + SMOKE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${label}: ${filePath}`);
|
||||
}
|
||||
|
||||
async function waitForChildPids(parentPid) {
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (Date.now() < deadline) {
|
||||
const childPids = listChildPids(parentPid);
|
||||
if (childPids.length > 0) {
|
||||
return childPids;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
throw new Error(`Timed out waiting for daemon worker child of supervisor PID ${parentPid}`);
|
||||
}
|
||||
|
||||
function listChildPids(parentPid) {
|
||||
const result = spawnSync("ps", ["-axo", "pid=,ppid="], { encoding: "utf8" });
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`ps failed while listing child processes: ${result.stderr.trim()}`);
|
||||
}
|
||||
|
||||
const children = [];
|
||||
for (const line of result.stdout.split(/\r?\n/)) {
|
||||
const [pidText, ppidText] = line.trim().split(/\s+/);
|
||||
const pid = Number(pidText);
|
||||
const ppid = Number(ppidText);
|
||||
if (Number.isInteger(pid) && ppid === parentPid) {
|
||||
children.push(pid);
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function listDarwinTextExecutables(pid) {
|
||||
const result = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "txt", "-Fn"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(
|
||||
`lsof failed while inspecting PID ${pid}: ${
|
||||
result.stderr.trim() || result.stdout.trim() || "<empty>"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.startsWith("n"))
|
||||
.map((line) => line.slice(1));
|
||||
}
|
||||
|
||||
function assertDarwinProcessDoesNotUseMainAppExecutable({ appPath, pid, label }) {
|
||||
const mainExecutablePath = getMacMainExecutablePath(appPath);
|
||||
assertExecutable(mainExecutablePath, "Packaged app executable");
|
||||
|
||||
const textExecutables = listDarwinTextExecutables(pid);
|
||||
if (textExecutables.includes(mainExecutablePath)) {
|
||||
throw new Error(
|
||||
`${label} PID ${pid} launched through the main app executable ${mainExecutablePath}.\nText executables:\n${textExecutables.join(
|
||||
"\n",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readIfExists(filePath) {
|
||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
||||
}
|
||||
|
||||
function formatLogs({ stdout, stderr, userData, daemonHome }) {
|
||||
const desktopLog = readIfExists(path.join(userData, "logs", "main.log"));
|
||||
const daemonLog = readIfExists(path.join(daemonHome, "daemon.log"));
|
||||
return [
|
||||
`App stdout:\n${stdout.join("").trim() || "<empty>"}`,
|
||||
`App stderr:\n${stderr.join("").trim() || "<empty>"}`,
|
||||
`Desktop log:\n${desktopLog?.trim() || "<missing>"}`,
|
||||
`Daemon log:\n${daemonLog?.trim() || "<missing>"}`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
async function writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }) {
|
||||
const artifactDir = process.env.PASEO_DESKTOP_SMOKE_ARTIFACT_DIR?.trim();
|
||||
if (!artifactDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(artifactDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(artifactDir, "failure.txt"),
|
||||
`${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n\n${formatLogs({
|
||||
stdout,
|
||||
stderr,
|
||||
userData,
|
||||
daemonHome,
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
const desktopLog = readIfExists(path.join(userData, "logs", "main.log"));
|
||||
if (desktopLog !== null) {
|
||||
fs.writeFileSync(path.join(artifactDir, "desktop-main.log"), desktopLog);
|
||||
}
|
||||
const daemonLog = readIfExists(path.join(daemonHome, "daemon.log"));
|
||||
if (daemonLog !== null) {
|
||||
fs.writeFileSync(path.join(artifactDir, "daemon.log"), daemonLog);
|
||||
}
|
||||
|
||||
if (page) {
|
||||
const renderer = await page
|
||||
.evaluate(() => ({
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
rootChildCount: document.querySelector("#root")?.childElementCount ?? 0,
|
||||
rootText: document.querySelector("#root")?.textContent?.trim().slice(0, 2_000) ?? "",
|
||||
bridgeKeys:
|
||||
typeof window.paseoDesktop === "object" && window.paseoDesktop !== null
|
||||
? Object.keys(window.paseoDesktop)
|
||||
: [],
|
||||
}))
|
||||
.catch((evaluationError) => ({ evaluationError: String(evaluationError) }));
|
||||
fs.writeFileSync(
|
||||
path.join(artifactDir, "renderer.json"),
|
||||
`${JSON.stringify(renderer, null, 2)}\n`,
|
||||
);
|
||||
await page
|
||||
.screenshot({ path: path.join(artifactDir, "renderer.png"), fullPage: true })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function isRunning(child) {
|
||||
return child.exitCode === null && child.signalCode === null;
|
||||
}
|
||||
|
||||
function terminateChild(child, signal = "SIGTERM") {
|
||||
if (!isRunning(child)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform === "win32" && child.pid) {
|
||||
spawnSync("taskkill.exe", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
child.kill(signal);
|
||||
}
|
||||
|
||||
function waitForChildExit(child, timeoutMs = EXIT_TIMEOUT_MS) {
|
||||
if (!isRunning(child)) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
let onExit;
|
||||
const exitPromise = new Promise((resolve) => {
|
||||
onExit = () => resolve(true);
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
|
||||
return Promise.race([exitPromise, delay(timeoutMs, false)]).finally(() => {
|
||||
child.off("exit", onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function releaseChildHandles(child) {
|
||||
child.stdin?.destroy();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
child.unref();
|
||||
}
|
||||
|
||||
async function removeTempDir(tempDir) {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!["EBUSY", "ENOTEMPTY", "EPERM"].includes(error?.code) || attempt === 4) {
|
||||
console.warn(`Packaged desktop smoke: failed to remove temp dir ${tempDir}: ${error}`);
|
||||
return;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function remainingTime(deadline) {
|
||||
return Math.max(1, deadline - Date.now());
|
||||
}
|
||||
|
||||
async function connectToPackagedApp({
|
||||
child,
|
||||
cdpPort,
|
||||
stdout,
|
||||
stderr,
|
||||
userData,
|
||||
daemonHome,
|
||||
deadline,
|
||||
}) {
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (!isRunning(child)) {
|
||||
throw new Error(
|
||||
`Packaged app exited before opening its debugging endpoint (code ${child.exitCode}, signal ${
|
||||
child.signalCode ?? "none"
|
||||
}).\n${formatLogs({ stdout, stderr, userData, daemonHome })}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await delay(250);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out connecting to the packaged app over CDP: ${lastError}.\n${formatLogs({
|
||||
stdout,
|
||||
stderr,
|
||||
userData,
|
||||
daemonHome,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForPackagedAppPage(browser, deadline) {
|
||||
while (Date.now() < deadline) {
|
||||
const page = browser
|
||||
.contexts()
|
||||
.flatMap((context) => context.pages())
|
||||
.find((candidate) => candidate.url().startsWith("paseo://app/"));
|
||||
if (page) {
|
||||
return page;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
throw new Error("Timed out waiting for the packaged paseo://app/ renderer");
|
||||
}
|
||||
|
||||
async function assertPackagedRendererLoaded(page, deadline) {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.querySelector("#root");
|
||||
return root instanceof HTMLElement && root.childElementCount > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: remainingTime(deadline) },
|
||||
);
|
||||
|
||||
const bridgeKeys = await page.evaluate(() =>
|
||||
typeof window.paseoDesktop === "object" && window.paseoDesktop !== null
|
||||
? Object.keys(window.paseoDesktop)
|
||||
: [],
|
||||
);
|
||||
const missingBridgeKeys = REQUIRED_DESKTOP_BRIDGE_KEYS.filter((key) => !bridgeKeys.includes(key));
|
||||
if (missingBridgeKeys.length > 0) {
|
||||
throw new Error(
|
||||
`Packaged renderer is missing desktop preload bridge keys: ${missingBridgeKeys.join(", ")}. Present keys: ${bridgeKeys.join(", ") || "<none>"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRendererStartedDaemon({
|
||||
page,
|
||||
daemonHome,
|
||||
listen,
|
||||
stdout,
|
||||
stderr,
|
||||
userData,
|
||||
deadline,
|
||||
}) {
|
||||
let lastStatus = null;
|
||||
let lastError = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
lastStatus = await page.evaluate(() => window.paseoDesktop.invoke("desktop_daemon_status"));
|
||||
if (
|
||||
lastStatus?.status === "running" &&
|
||||
lastStatus.desktopManaged === true &&
|
||||
typeof lastStatus.pid === "number" &&
|
||||
typeof lastStatus.serverId === "string" &&
|
||||
lastStatus.serverId.length > 0 &&
|
||||
lastStatus.listen === listen &&
|
||||
path.resolve(lastStatus.home) === path.resolve(daemonHome)
|
||||
) {
|
||||
return lastStatus;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Packaged renderer did not start its desktop-managed daemon. Last status: ${JSON.stringify(lastStatus)}. Last error: ${lastError}.\n${formatLogs({ stdout, stderr, userData, daemonHome })}`,
|
||||
);
|
||||
}
|
||||
|
||||
function runShellCommand({ script, env, label }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const shell = getShellCommand(script);
|
||||
const child = spawn(shell.command, shell.args, {
|
||||
detached: process.platform !== "win32",
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsVerbatimArguments: process.platform === "win32",
|
||||
});
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
|
||||
const finish = (callback, value) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
releaseChildHandles(child);
|
||||
callback(value);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
terminateChild(child);
|
||||
finish(
|
||||
reject,
|
||||
new Error(
|
||||
`${label} timed out.\nStdout:\n${stdout.join("").trim() || "<empty>"}\n\nStderr:\n${
|
||||
stderr.join("").trim() || "<empty>"
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}, SMOKE_TIMEOUT_MS);
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(chunk.toString()));
|
||||
child.stderr.on("data", (chunk) => stderr.push(chunk.toString()));
|
||||
child.once("error", (error) => finish(reject, error));
|
||||
child.once("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
finish(resolve, {
|
||||
stdout: stdout.join(""),
|
||||
stderr: stderr.join(""),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
finish(
|
||||
reject,
|
||||
new Error(
|
||||
`${label} failed (code ${code}, signal ${signal ?? "none"}).\nStdout:\n${
|
||||
stdout.join("").trim() || "<empty>"
|
||||
}\n\nStderr:\n${stderr.join("").trim() || "<empty>"}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getCliShimScript(cliShimPath, args) {
|
||||
const commandArgs = args.map(shellQuoteCliArg).join(" ");
|
||||
if (process.platform === "win32") {
|
||||
return `call "${cliShimPath}" ${commandArgs}`;
|
||||
}
|
||||
|
||||
return `${shellQuote(cliShimPath)} ${commandArgs}`;
|
||||
}
|
||||
|
||||
async function runCliShimCommand({ appPath, env, args, label }) {
|
||||
const cliShimPath = getCliShimPath(appPath);
|
||||
assertExecutable(cliShimPath, "Bundled CLI shim");
|
||||
|
||||
return await runShellCommand({
|
||||
script: getCliShimScript(cliShimPath, args),
|
||||
env,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
async function runCliShimJsonCommand({ appPath, env, args, label }) {
|
||||
const result = await runCliShimCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: [...args, "--json"],
|
||||
label,
|
||||
});
|
||||
const output = result.stdout.trim();
|
||||
if (!output) {
|
||||
throw new Error(`${label} produced empty JSON output`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(output);
|
||||
} catch (error) {
|
||||
throw new Error(`${label} produced invalid JSON: ${output}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeCliShim({ appPath, env }) {
|
||||
console.log("Packaged desktop smoke: running bundled CLI shim daemon status");
|
||||
const result = await runCliShimCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: ["daemon", "status"],
|
||||
label: "Bundled CLI shim daemon status",
|
||||
});
|
||||
assertCleanDaemonStatusOutput(`${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
|
||||
async function smokeColdCliDaemonStart({ appPath }) {
|
||||
const home = createTempDir("paseo-smoke-cli-daemon-home-");
|
||||
const pidPath = path.join(home, "paseo.pid");
|
||||
const port = await reserveLocalTcpPort();
|
||||
const listen = `127.0.0.1:${port}`;
|
||||
const env = createDefaultDaemonEnv();
|
||||
|
||||
try {
|
||||
console.log("Packaged desktop smoke: cold-starting daemon through bundled CLI shim");
|
||||
await runCliShimCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: [
|
||||
"daemon",
|
||||
"start",
|
||||
"--home",
|
||||
home,
|
||||
"--listen",
|
||||
listen,
|
||||
"--no-relay",
|
||||
"--no-mcp",
|
||||
"--no-inject-mcp",
|
||||
],
|
||||
label: "Bundled CLI shim cold daemon start",
|
||||
});
|
||||
|
||||
const pidInfo = JSON.parse(await waitForFile(pidPath, "cold CLI daemon pid file"));
|
||||
if (!pidInfo || typeof pidInfo.pid !== "number") {
|
||||
throw new Error(`Cold CLI daemon wrote invalid pid file: ${JSON.stringify(pidInfo)}`);
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
assertDarwinProcessDoesNotUseMainAppExecutable({
|
||||
appPath,
|
||||
pid: pidInfo.pid,
|
||||
label: "Cold CLI daemon supervisor",
|
||||
});
|
||||
const childPids = await waitForChildPids(pidInfo.pid);
|
||||
for (const childPid of childPids) {
|
||||
assertDarwinProcessDoesNotUseMainAppExecutable({
|
||||
appPath,
|
||||
pid: childPid,
|
||||
label: "Cold CLI daemon worker",
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (fs.existsSync(pidPath)) {
|
||||
await runCliShimCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: ["daemon", "stop", "--home", home, "--force"],
|
||||
label: "Bundled CLI shim cold daemon stop",
|
||||
}).catch((error) => {
|
||||
console.warn(`Packaged desktop smoke: failed to stop cold CLI daemon: ${error}`);
|
||||
});
|
||||
}
|
||||
await removeTempDir(home);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCleanDaemonStatusOutput(output) {
|
||||
const failureNeedles = [
|
||||
"Get-CimInstance",
|
||||
"Get-Process :",
|
||||
"Cannot bind parameter",
|
||||
"wmic failed",
|
||||
];
|
||||
const failure = failureNeedles.find((needle) => output.includes(needle));
|
||||
if (failure) {
|
||||
throw new Error(`Bundled CLI shim daemon status included process lookup failure: ${failure}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function smokeCliTerminal({ appPath, env }) {
|
||||
const cwd = createTempDir("paseo-smoke-terminal-cwd-");
|
||||
const marker = `paseo-packaged-terminal-smoke-${Date.now()}`;
|
||||
const name = `packaged-smoke-${process.pid}-${Date.now()}`;
|
||||
let terminalId = null;
|
||||
|
||||
try {
|
||||
console.log("Packaged desktop smoke: creating terminal through bundled CLI shim");
|
||||
const created = await runCliShimJsonCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: ["terminal", "create", "--cwd", cwd, "--name", name],
|
||||
label: "Bundled CLI shim terminal create",
|
||||
});
|
||||
if (!created || typeof created.id !== "string" || created.id.length === 0) {
|
||||
throw new Error(`Terminal create returned unexpected payload: ${JSON.stringify(created)}`);
|
||||
}
|
||||
terminalId = created.id;
|
||||
|
||||
const terminals = await runCliShimJsonCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: ["terminal", "ls", "--all"],
|
||||
label: "Bundled CLI shim terminal ls",
|
||||
});
|
||||
if (!Array.isArray(terminals) || !terminals.some((terminal) => terminal.id === terminalId)) {
|
||||
throw new Error(`Terminal ${terminalId} was not listed after create`);
|
||||
}
|
||||
|
||||
await runCliShimJsonCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: ["terminal", "send-keys", terminalId, getTerminalHookSmokeCommand(marker), "Enter"],
|
||||
label: "Bundled CLI shim terminal hook command",
|
||||
});
|
||||
|
||||
for (let attempt = 1; attempt <= TERMINAL_CAPTURE_ATTEMPTS; attempt += 1) {
|
||||
const capture = await runCliShimJsonCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: ["terminal", "capture", terminalId, "--scrollback"],
|
||||
label: "Bundled CLI shim terminal capture",
|
||||
});
|
||||
const lines = Array.isArray(capture?.lines) ? capture.lines : [];
|
||||
if (lines.join("\n").includes(marker)) {
|
||||
console.log("Packaged desktop smoke: terminal hook command completed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (attempt < TERMINAL_CAPTURE_ATTEMPTS) {
|
||||
await delay(TERMINAL_CAPTURE_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for terminal capture marker ${marker}`);
|
||||
} finally {
|
||||
if (terminalId) {
|
||||
await runCliShimJsonCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: ["terminal", "kill", terminalId],
|
||||
label: "Bundled CLI shim terminal kill",
|
||||
}).catch((error) => {
|
||||
console.warn(`Packaged desktop smoke: failed to kill terminal ${terminalId}: ${error}`);
|
||||
});
|
||||
}
|
||||
await removeTempDir(cwd);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopCliDaemon({ appPath, env }) {
|
||||
console.log("Packaged desktop smoke: stopping daemon through bundled CLI shim");
|
||||
await runCliShimCommand({
|
||||
appPath,
|
||||
env,
|
||||
args: ["daemon", "stop", "--force"],
|
||||
label: "Bundled CLI shim daemon stop",
|
||||
});
|
||||
}
|
||||
|
||||
async function smokePackagedDesktopApp({ appPath }) {
|
||||
const executablePath = getExecutablePath(appPath);
|
||||
assertExecutable(executablePath, "Packaged app executable");
|
||||
ensureLinuxSandboxPermissions(appPath);
|
||||
await smokeColdCliDaemonStart({ appPath });
|
||||
|
||||
const userData = createTempDir("paseo-smoke-user-data-");
|
||||
const daemonHome = createTempDir("paseo-smoke-daemon-home-");
|
||||
const daemonPort = await reserveLocalTcpPort();
|
||||
let cdpPort = await reserveLocalTcpPort();
|
||||
for (let attempt = 0; cdpPort === daemonPort && attempt < 10; attempt += 1) {
|
||||
cdpPort = await reserveLocalTcpPort();
|
||||
}
|
||||
if (cdpPort === daemonPort) {
|
||||
throw new Error("Failed to reserve distinct TCP ports for the daemon and CDP");
|
||||
}
|
||||
const listen = `127.0.0.1:${daemonPort}`;
|
||||
configureIsolatedDaemonHome(daemonHome, listen);
|
||||
const env = createIsolatedDesktopEnv({
|
||||
home: daemonHome,
|
||||
listen,
|
||||
userData,
|
||||
cdpPort,
|
||||
});
|
||||
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
const launch = getLaunchCommand(executablePath);
|
||||
console.log(`Packaged desktop smoke: launching ${launch.command} ${launch.args.join(" ")}`);
|
||||
const child = spawn(launch.command, launch.args, {
|
||||
detached: process.platform !== "win32",
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.on("data", (chunk) => stdout.push(chunk.toString()));
|
||||
child.stderr.on("data", (chunk) => stderr.push(chunk.toString()));
|
||||
child.once("error", (error) =>
|
||||
stderr.push(`Packaged app launch error: ${error.stack ?? error}\n`),
|
||||
);
|
||||
const deadline = Date.now() + SMOKE_TIMEOUT_MS;
|
||||
|
||||
let browser = null;
|
||||
let page = null;
|
||||
let daemonStopped = false;
|
||||
|
||||
const stopDaemonForCleanup = async () => {
|
||||
if (daemonStopped) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stopCliDaemon({ appPath, env });
|
||||
daemonStopped = true;
|
||||
};
|
||||
|
||||
try {
|
||||
browser = await connectToPackagedApp({
|
||||
child,
|
||||
cdpPort,
|
||||
stdout,
|
||||
stderr,
|
||||
userData,
|
||||
daemonHome,
|
||||
deadline,
|
||||
});
|
||||
page = await waitForPackagedAppPage(browser, deadline);
|
||||
await assertPackagedRendererLoaded(page, deadline);
|
||||
console.log("Packaged desktop smoke: real app renderer and preload bridge loaded");
|
||||
const status = await waitForRendererStartedDaemon({
|
||||
page,
|
||||
daemonHome,
|
||||
listen,
|
||||
stdout,
|
||||
stderr,
|
||||
userData,
|
||||
deadline,
|
||||
});
|
||||
console.log("Packaged desktop smoke: renderer-started desktop daemon reported running");
|
||||
await smokeCliShim({ appPath, env });
|
||||
await smokeCliTerminal({ appPath, env });
|
||||
await stopDaemonForCleanup();
|
||||
console.log(
|
||||
`Packaged desktop smoke passed: real renderer and preload loaded; renderer-started desktop daemon pid ${status.pid}, listen ${status.listen}; CLI shim daemon status and terminal smoke succeeded`,
|
||||
);
|
||||
} catch (error) {
|
||||
await writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch(
|
||||
(artifactError) => {
|
||||
console.warn(`Packaged desktop smoke: failed to write failure artifacts: ${artifactError}`);
|
||||
},
|
||||
);
|
||||
if (!daemonStopped) {
|
||||
try {
|
||||
await stopDaemonForCleanup();
|
||||
} catch {}
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await browser?.close().catch(() => undefined);
|
||||
if (isRunning(child)) {
|
||||
terminateChild(child);
|
||||
if (!(await waitForChildExit(child))) {
|
||||
terminateChild(child, "SIGKILL");
|
||||
await waitForChildExit(child);
|
||||
}
|
||||
}
|
||||
releaseChildHandles(child);
|
||||
await removeTempDir(userData);
|
||||
await removeTempDir(daemonHome);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
smokePackagedDesktopApp,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
const appIndex = process.argv.indexOf("--app");
|
||||
const appPath = appIndex >= 0 ? process.argv[appIndex + 1] : null;
|
||||
if (!appPath) {
|
||||
process.stderr.write("Usage: node smoke-packaged-desktop-app.js --app <Paseo.app>\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
smokePackagedDesktopApp({ appPath }).catch((error) => {
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user