mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Workspace suggestions now use the indexed search backend directly, so dotfiles and dot directories are included without a second ignore layer. Packaged desktop smoke covers the native search path.
815 lines
23 KiB
JavaScript
815 lines
23 KiB
JavaScript
const { execFileSync, 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 { pathToFileURL } = require("node:url");
|
|
|
|
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;
|
|
|
|
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 getLaunchCommand(executablePath) {
|
|
if (process.platform !== "linux") {
|
|
return {
|
|
command: executablePath,
|
|
args: [],
|
|
};
|
|
}
|
|
|
|
return {
|
|
command: "xvfb-run",
|
|
args: ["-a", 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 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 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 parseSmokeLine(line) {
|
|
const prefix = "[paseo-smoke] ";
|
|
if (!line.startsWith(prefix)) {
|
|
return null;
|
|
}
|
|
return JSON.parse(line.slice(prefix.length));
|
|
}
|
|
|
|
function appendChunk(lines, chunk, onSmokeMessage) {
|
|
const text = chunk.toString();
|
|
lines.push(text);
|
|
for (const line of text.split(/\r?\n/)) {
|
|
const parsed = parseSmokeLine(line.trim());
|
|
if (parsed) {
|
|
onSmokeMessage(parsed);
|
|
}
|
|
}
|
|
}
|
|
|
|
function readIfExists(filePath) {
|
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
|
}
|
|
|
|
function formatLogs({ stdout, stderr, userData }) {
|
|
const desktopLog = readIfExists(path.join(userData, "logs", "main.log"));
|
|
const daemonLog = readIfExists(path.join(os.homedir(), ".paseo", "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");
|
|
}
|
|
|
|
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 waitForSmokeMessage({ child, stdout, stderr, userData, type, validate }) {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
|
|
const finish = (callback, value) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
callback(value);
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
terminateChild(child);
|
|
finish(
|
|
reject,
|
|
new Error(
|
|
`Timed out waiting for packaged desktop smoke result.\n${formatLogs({
|
|
stdout,
|
|
stderr,
|
|
userData,
|
|
})}`,
|
|
),
|
|
);
|
|
}, SMOKE_TIMEOUT_MS);
|
|
|
|
const onSmokeMessage = (message) => {
|
|
if (message.type !== type) {
|
|
return;
|
|
}
|
|
if (validate(message)) {
|
|
finish(resolve, message);
|
|
return;
|
|
}
|
|
finish(reject, new Error(`Unexpected desktop smoke message: ${JSON.stringify(message)}`));
|
|
};
|
|
|
|
child.stdout.on("data", (chunk) => appendChunk(stdout, chunk, onSmokeMessage));
|
|
child.stderr.on("data", (chunk) => appendChunk(stderr, chunk, onSmokeMessage));
|
|
child.once("error", (error) => finish(reject, error));
|
|
child.once("exit", (code, signal) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
finish(
|
|
reject,
|
|
new Error(
|
|
`Packaged app exited before reporting smoke success (code ${code}, signal ${
|
|
signal ?? "none"
|
|
}).\n${formatLogs({ stdout, stderr, userData })}`,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function assertRunningDesktopManagedDaemon(message) {
|
|
return (
|
|
message.status?.status === "running" &&
|
|
message.status?.desktopManaged === true &&
|
|
typeof message.status?.pid === "number" &&
|
|
typeof message.status?.listen === "string" &&
|
|
message.status.listen.length > 0
|
|
);
|
|
}
|
|
|
|
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, "echo", "Space", marker, "Enter"],
|
|
label: "Bundled CLI shim terminal send-keys",
|
|
});
|
|
|
|
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 command output captured");
|
|
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 loadConnectToDaemon() {
|
|
const clientModulePath = path.join(__dirname, "..", "..", "cli", "dist", "utils", "client.js");
|
|
const clientModule = await import(pathToFileURL(clientModulePath).href);
|
|
if (typeof clientModule.connectToDaemon !== "function") {
|
|
throw new Error(`CLI client module did not export connectToDaemon: ${clientModulePath}`);
|
|
}
|
|
return clientModule.connectToDaemon;
|
|
}
|
|
|
|
function createWorkspaceSearchFixture() {
|
|
const cwd = createTempDir("paseo-smoke-search-cwd-");
|
|
execFileSync("git", ["init"], { cwd, stdio: "ignore" });
|
|
fs.writeFileSync(path.join(cwd, ".env.local"), "PASEO_PACKAGED_SMOKE=1\n");
|
|
fs.mkdirSync(path.join(cwd, ".opencode"), { recursive: true });
|
|
fs.writeFileSync(path.join(cwd, ".opencode", "settings.json"), "{}\n");
|
|
fs.writeFileSync(path.join(cwd, ".gitignore"), "ignored.secret\n");
|
|
fs.writeFileSync(path.join(cwd, "ignored.secret"), "ignore me\n");
|
|
return cwd;
|
|
}
|
|
|
|
function assertSuggestionPayload(payload, label) {
|
|
if (payload?.error) {
|
|
throw new Error(`${label} returned error: ${payload.error}`);
|
|
}
|
|
if (!Array.isArray(payload?.entries)) {
|
|
throw new Error(`${label} returned invalid payload: ${JSON.stringify(payload)}`);
|
|
}
|
|
}
|
|
|
|
function assertSuggestionEntry(payload, expected, label) {
|
|
assertSuggestionPayload(payload, label);
|
|
const found = payload.entries.some(
|
|
(entry) => entry.path === expected.path && entry.kind === expected.kind,
|
|
);
|
|
if (!found) {
|
|
throw new Error(
|
|
`${label} did not include ${expected.kind} ${expected.path}. Entries: ${JSON.stringify(
|
|
payload.entries,
|
|
)}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function smokeWorkspaceSearch({ host }) {
|
|
console.log("Packaged desktop smoke: querying workspace search through daemon RPC");
|
|
const cwd = createWorkspaceSearchFixture();
|
|
const connectToDaemon = await loadConnectToDaemon();
|
|
const client = await connectToDaemon({ host, timeout: SMOKE_TIMEOUT_MS });
|
|
|
|
try {
|
|
const envResults = await client.getDirectorySuggestions({
|
|
cwd,
|
|
query: "env",
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
limit: 20,
|
|
});
|
|
assertSuggestionEntry(envResults, { path: ".env.local", kind: "file" }, "Dotfile search");
|
|
|
|
const opencodeResults = await client.getDirectorySuggestions({
|
|
cwd,
|
|
query: "opencode",
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
limit: 20,
|
|
});
|
|
assertSuggestionEntry(
|
|
opencodeResults,
|
|
{ path: ".opencode/settings.json", kind: "file" },
|
|
"Dot-directory search",
|
|
);
|
|
|
|
const ignoredResults = await client.getDirectorySuggestions({
|
|
cwd,
|
|
query: "ignored",
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
limit: 20,
|
|
});
|
|
assertSuggestionPayload(ignoredResults, "Ignored file search");
|
|
if (ignoredResults.entries.some((entry) => entry.path === "ignored.secret")) {
|
|
throw new Error(
|
|
`Ignored file search returned ignored.secret: ${JSON.stringify(ignoredResults.entries)}`,
|
|
);
|
|
}
|
|
} finally {
|
|
await client.close().catch(() => {});
|
|
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");
|
|
await smokeColdCliDaemonStart({ appPath });
|
|
|
|
const userData = createTempDir("paseo-smoke-user-data-");
|
|
const env = createDefaultDaemonEnv({
|
|
PASEO_DESKTOP_SMOKE: "1",
|
|
PASEO_ELECTRON_USER_DATA_DIR: userData,
|
|
});
|
|
|
|
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"],
|
|
});
|
|
let smokeStarted = false;
|
|
let daemonStopped = false;
|
|
|
|
const stopDaemonForCleanup = async () => {
|
|
if (daemonStopped) {
|
|
return;
|
|
}
|
|
|
|
await stopCliDaemon({ appPath, env: createDefaultDaemonEnv() });
|
|
daemonStopped = true;
|
|
};
|
|
|
|
try {
|
|
const message = await waitForSmokeMessage({
|
|
child,
|
|
stdout,
|
|
stderr,
|
|
userData,
|
|
type: "desktop-daemon-smoke-started",
|
|
validate: assertRunningDesktopManagedDaemon,
|
|
});
|
|
smokeStarted = true;
|
|
console.log("Packaged desktop smoke: desktop-managed daemon reported running");
|
|
const cliEnv = createDefaultDaemonEnv();
|
|
await smokeCliShim({ appPath, env: cliEnv });
|
|
await smokeCliTerminal({ appPath, env: cliEnv });
|
|
await smokeWorkspaceSearch({ host: message.status.listen });
|
|
await stopDaemonForCleanup();
|
|
console.log(
|
|
`Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status, terminal smoke, and workspace search succeeded`,
|
|
);
|
|
} catch (error) {
|
|
if (smokeStarted && !daemonStopped) {
|
|
try {
|
|
await stopDaemonForCleanup();
|
|
} catch {}
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (isRunning(child)) {
|
|
terminateChild(child);
|
|
if (!(await waitForChildExit(child))) {
|
|
terminateChild(child, "SIGKILL");
|
|
await waitForChildExit(child);
|
|
}
|
|
}
|
|
releaseChildHandles(child);
|
|
await removeTempDir(userData);
|
|
}
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|