Catch broken desktop packages before release (#2114)
* test(desktop): exercise packaged app startup The previous smoke exited before IPC, window creation, and preload execution, so a broken packaged preload could still pass. Launch the normal app and observe its renderer bridge and managed daemon through CDP instead. * test(desktop): preserve Linux sandbox in smoke Configure the unpacked artifact's chrome-sandbox helper with the permissions Electron requires, while keeping sandboxing enabled so preload failures remain observable. Bound startup probes and include process logs in daemon timeout failures. * ci(desktop): keep packaged smoke on releases Remove the temporary pull-request artifact build after it validated the Linux launch path. Keep the real packaged smoke in the native desktop release matrix. * ci(desktop): gate packaged smoke by changes Run the Linux real-artifact smoke in pull-request CI when packages/desktop changes, while leaving the regular desktop unit-test matrix unconditional.
This commit is contained in:
@@ -131,9 +131,22 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, windows-latest]
|
os: [ubuntu-latest, windows-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 30
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Detect desktop changes
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
id: desktop_changes
|
||||||
|
uses: dorny/paths-filter@v3
|
||||||
|
with:
|
||||||
|
filters: |
|
||||||
|
desktop:
|
||||||
|
- 'packages/desktop/**'
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
@@ -147,6 +160,27 @@ jobs:
|
|||||||
- name: Run desktop tests
|
- name: Run desktop tests
|
||||||
run: npm run test --workspace=@getpaseo/desktop
|
run: npm run test --workspace=@getpaseo/desktop
|
||||||
|
|
||||||
|
- name: Install virtual display
|
||||||
|
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||||
|
|
||||||
|
- name: Build and smoke unpacked desktop app
|
||||||
|
if: matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||||
|
run: npm run build:desktop -- --publish never --linux --x64 --dir
|
||||||
|
env:
|
||||||
|
EP_GH_IGNORE_TIME: true
|
||||||
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
|
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||||
|
|
||||||
|
- name: Upload packaged smoke diagnostics
|
||||||
|
if: failure() && matrix.os == 'ubuntu-latest' && steps.desktop_changes.outputs.desktop == 'true'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-packaged-smoke-linux-x64
|
||||||
|
path: ${{ runner.temp }}/desktop-smoke
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
app-tests:
|
app-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -158,6 +158,7 @@ jobs:
|
|||||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
PASEO_DESKTOP_SMOKE: "1"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
|
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
build_args=(-- --publish never --mac --${{ matrix.electron_arch }})
|
||||||
@@ -165,6 +166,15 @@ jobs:
|
|||||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
npm run build:desktop "${build_args[@]}"
|
npm run build:desktop "${build_args[@]}"
|
||||||
|
|
||||||
|
- name: Upload packaged smoke diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-smoke-macos-${{ matrix.electron_arch }}
|
||||||
|
path: ${{ runner.temp }}/desktop-smoke
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload desktop artifacts to release
|
- name: Upload desktop artifacts to release
|
||||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
@@ -248,7 +258,7 @@ jobs:
|
|||||||
NODE
|
NODE
|
||||||
|
|
||||||
- name: Install Linux smoke display
|
- name: Install Linux smoke display
|
||||||
run: sudo apt-get update && sudo apt-get install -y xvfb
|
run: sudo apt-get update && sudo apt-get install -y xvfb xauth
|
||||||
|
|
||||||
- name: Build desktop release
|
- name: Build desktop release
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -256,6 +266,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
EP_GH_IGNORE_TIME: true
|
EP_GH_IGNORE_TIME: true
|
||||||
PASEO_DESKTOP_SMOKE: "1"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
|
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
build_args=(-- --publish never --linux --x64)
|
build_args=(-- --publish never --linux --x64)
|
||||||
@@ -263,6 +274,15 @@ jobs:
|
|||||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
npm run build:desktop "${build_args[@]}"
|
npm run build:desktop "${build_args[@]}"
|
||||||
|
|
||||||
|
- name: Upload packaged smoke diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-smoke-linux-x64
|
||||||
|
path: ${{ runner.temp }}/desktop-smoke
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload desktop artifacts to release
|
- name: Upload desktop artifacts to release
|
||||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
@@ -351,6 +371,7 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
EP_GH_IGNORE_TIME: true
|
EP_GH_IGNORE_TIME: true
|
||||||
PASEO_DESKTOP_SMOKE: "1"
|
PASEO_DESKTOP_SMOKE: "1"
|
||||||
|
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR: ${{ runner.temp }}/desktop-smoke
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
build_args=(-- --publish never --win --x64 --arm64)
|
build_args=(-- --publish never --win --x64 --arm64)
|
||||||
@@ -358,6 +379,15 @@ jobs:
|
|||||||
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
build_args+=("-c.publish.channel=$RELEASE_CHANNEL")
|
||||||
npm run build:desktop "${build_args[@]}"
|
npm run build:desktop "${build_args[@]}"
|
||||||
|
|
||||||
|
- name: Upload packaged smoke diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: desktop-smoke-windows-x64
|
||||||
|
path: ${{ runner.temp }}/desktop-smoke
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
- name: Upload desktop artifacts to release
|
- name: Upload desktop artifacts to release
|
||||||
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
if: env.SHOULD_PUBLISH == 'true' && env.IS_SMOKE_TAG != 'true'
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -91,6 +91,27 @@ function createTestEmailSender() {
|
|||||||
|
|
||||||
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
|
When a test is labeled end-to-end, it calls the real service. No environment variable gates, no conditional skipping, no mocking the external dependency.
|
||||||
|
|
||||||
|
### Packaged desktop smoke
|
||||||
|
|
||||||
|
The packaged desktop smoke is an external observer of the production launch path. It must not add a smoke-only branch to Electron main or start the daemon itself.
|
||||||
|
|
||||||
|
The harness launches the unpacked packaged app with isolated user data and daemon state, connects to the real renderer over Chromium's debugging protocol, and requires all of these outcomes:
|
||||||
|
|
||||||
|
- the `paseo://app/` renderer mounts into `#root`;
|
||||||
|
- the sandboxed preload exposes the desktop bridge;
|
||||||
|
- the renderer starts a fresh desktop-managed daemon through the normal startup bootstrap;
|
||||||
|
- the bundled CLI can query that daemon and run a terminal command.
|
||||||
|
|
||||||
|
Pull-request CI runs the Linux x64 smoke under Xvfb when the cumulative PR diff changes `packages/desktop/**`. The desktop release matrix runs the harness against each host-native packaged app before publishing. All smoke jobs upload renderer, desktop, and daemon diagnostics on failure.
|
||||||
|
|
||||||
|
To exercise the smoke locally on Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PASEO_DESKTOP_SMOKE=1 \
|
||||||
|
PASEO_DESKTOP_SMOKE_ARTIFACT_DIR=/tmp/paseo-desktop-smoke \
|
||||||
|
npm run build:desktop -- --publish never --linux --x64 --dir
|
||||||
|
```
|
||||||
|
|
||||||
## Test organization
|
## Test organization
|
||||||
|
|
||||||
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
- Collocate tests with implementation: `thing.ts` + `thing.test.ts`
|
||||||
|
|||||||
@@ -4,12 +4,27 @@ const net = require("node:net");
|
|||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const { setTimeout: delay } = require("node:timers/promises");
|
const { setTimeout: delay } = require("node:timers/promises");
|
||||||
|
const { chromium } = require("playwright");
|
||||||
|
|
||||||
const EXECUTABLE_NAME = "Paseo";
|
const EXECUTABLE_NAME = "Paseo";
|
||||||
const SMOKE_TIMEOUT_MS = 60_000;
|
const SMOKE_TIMEOUT_MS = 60_000;
|
||||||
const EXIT_TIMEOUT_MS = 10_000;
|
const EXIT_TIMEOUT_MS = 10_000;
|
||||||
const TERMINAL_CAPTURE_ATTEMPTS = 20;
|
const TERMINAL_CAPTURE_ATTEMPTS = 20;
|
||||||
const TERMINAL_CAPTURE_INTERVAL_MS = 500;
|
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) {
|
function createTempDir(prefix) {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||||
@@ -52,6 +67,41 @@ function getMacMainExecutablePath(appPath) {
|
|||||||
return path.join(appPath, "Contents", "MacOS", EXECUTABLE_NAME);
|
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) {
|
function getLaunchCommand(executablePath) {
|
||||||
if (process.platform !== "linux") {
|
if (process.platform !== "linux") {
|
||||||
return {
|
return {
|
||||||
@@ -62,7 +112,7 @@ function getLaunchCommand(executablePath) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
command: "xvfb-run",
|
command: "xvfb-run",
|
||||||
args: ["-a", executablePath],
|
args: ["-a", "--server-args=-screen 0 1280x800x24", executablePath],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +153,34 @@ function createDefaultDaemonEnv(extraEnv) {
|
|||||||
return env;
|
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() {
|
function reserveLocalTcpPort() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
@@ -205,32 +283,13 @@ function assertDarwinProcessDoesNotUseMainAppExecutable({ appPath, pid, label })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function readIfExists(filePath) {
|
||||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLogs({ stdout, stderr, userData }) {
|
function formatLogs({ stdout, stderr, userData, daemonHome }) {
|
||||||
const desktopLog = readIfExists(path.join(userData, "logs", "main.log"));
|
const desktopLog = readIfExists(path.join(userData, "logs", "main.log"));
|
||||||
const daemonLog = readIfExists(path.join(os.homedir(), ".paseo", "daemon.log"));
|
const daemonLog = readIfExists(path.join(daemonHome, "daemon.log"));
|
||||||
return [
|
return [
|
||||||
`App stdout:\n${stdout.join("").trim() || "<empty>"}`,
|
`App stdout:\n${stdout.join("").trim() || "<empty>"}`,
|
||||||
`App stderr:\n${stderr.join("").trim() || "<empty>"}`,
|
`App stderr:\n${stderr.join("").trim() || "<empty>"}`,
|
||||||
@@ -239,6 +298,55 @@ function formatLogs({ stdout, stderr, userData }) {
|
|||||||
].join("\n\n");
|
].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) {
|
function isRunning(child) {
|
||||||
return child.exitCode === null && child.signalCode === null;
|
return child.exitCode === null && child.signalCode === null;
|
||||||
}
|
}
|
||||||
@@ -301,70 +409,119 @@ async function removeTempDir(tempDir) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForSmokeMessage({ child, stdout, stderr, userData, type, validate }) {
|
function remainingTime(deadline) {
|
||||||
return new Promise((resolve, reject) => {
|
return Math.max(1, deadline - Date.now());
|
||||||
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) {
|
async function connectToPackagedApp({
|
||||||
return (
|
child,
|
||||||
message.status?.status === "running" &&
|
cdpPort,
|
||||||
message.status?.desktopManaged === true &&
|
stdout,
|
||||||
typeof message.status?.pid === "number" &&
|
stderr,
|
||||||
typeof message.status?.listen === "string" &&
|
userData,
|
||||||
message.status.listen.length > 0
|
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 })}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,12 +791,26 @@ async function stopCliDaemon({ appPath, env }) {
|
|||||||
async function smokePackagedDesktopApp({ appPath }) {
|
async function smokePackagedDesktopApp({ appPath }) {
|
||||||
const executablePath = getExecutablePath(appPath);
|
const executablePath = getExecutablePath(appPath);
|
||||||
assertExecutable(executablePath, "Packaged app executable");
|
assertExecutable(executablePath, "Packaged app executable");
|
||||||
|
ensureLinuxSandboxPermissions(appPath);
|
||||||
await smokeColdCliDaemonStart({ appPath });
|
await smokeColdCliDaemonStart({ appPath });
|
||||||
|
|
||||||
const userData = createTempDir("paseo-smoke-user-data-");
|
const userData = createTempDir("paseo-smoke-user-data-");
|
||||||
const env = createDefaultDaemonEnv({
|
const daemonHome = createTempDir("paseo-smoke-daemon-home-");
|
||||||
PASEO_DESKTOP_SMOKE: "1",
|
const daemonPort = await reserveLocalTcpPort();
|
||||||
PASEO_ELECTRON_USER_DATA_DIR: userData,
|
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 stdout = [];
|
||||||
@@ -651,7 +822,15 @@ async function smokePackagedDesktopApp({ appPath }) {
|
|||||||
env,
|
env,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
let smokeStarted = false;
|
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;
|
let daemonStopped = false;
|
||||||
|
|
||||||
const stopDaemonForCleanup = async () => {
|
const stopDaemonForCleanup = async () => {
|
||||||
@@ -659,36 +838,53 @@ async function smokePackagedDesktopApp({ appPath }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await stopCliDaemon({ appPath, env: createDefaultDaemonEnv() });
|
await stopCliDaemon({ appPath, env });
|
||||||
daemonStopped = true;
|
daemonStopped = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const message = await waitForSmokeMessage({
|
browser = await connectToPackagedApp({
|
||||||
child,
|
child,
|
||||||
|
cdpPort,
|
||||||
stdout,
|
stdout,
|
||||||
stderr,
|
stderr,
|
||||||
userData,
|
userData,
|
||||||
type: "desktop-daemon-smoke-started",
|
daemonHome,
|
||||||
validate: assertRunningDesktopManagedDaemon,
|
deadline,
|
||||||
});
|
});
|
||||||
smokeStarted = true;
|
page = await waitForPackagedAppPage(browser, deadline);
|
||||||
console.log("Packaged desktop smoke: desktop-managed daemon reported running");
|
await assertPackagedRendererLoaded(page, deadline);
|
||||||
const cliEnv = createDefaultDaemonEnv();
|
console.log("Packaged desktop smoke: real app renderer and preload bridge loaded");
|
||||||
await smokeCliShim({ appPath, env: cliEnv });
|
const status = await waitForRendererStartedDaemon({
|
||||||
await smokeCliTerminal({ appPath, env: cliEnv });
|
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();
|
await stopDaemonForCleanup();
|
||||||
console.log(
|
console.log(
|
||||||
`Packaged desktop smoke passed: desktop-managed daemon pid ${message.status.pid}, listen ${message.status.listen}; CLI shim daemon status and terminal smoke succeeded`,
|
`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) {
|
} catch (error) {
|
||||||
if (smokeStarted && !daemonStopped) {
|
await writeFailureArtifacts({ page, stdout, stderr, userData, daemonHome, error }).catch(
|
||||||
|
(artifactError) => {
|
||||||
|
console.warn(`Packaged desktop smoke: failed to write failure artifacts: ${artifactError}`);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!daemonStopped) {
|
||||||
try {
|
try {
|
||||||
await stopDaemonForCleanup();
|
await stopDaemonForCleanup();
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
|
await browser?.close().catch(() => undefined);
|
||||||
if (isRunning(child)) {
|
if (isRunning(child)) {
|
||||||
terminateChild(child);
|
terminateChild(child);
|
||||||
if (!(await waitForChildExit(child))) {
|
if (!(await waitForChildExit(child))) {
|
||||||
@@ -698,6 +894,7 @@ async function smokePackagedDesktopApp({ appPath }) {
|
|||||||
}
|
}
|
||||||
releaseChildHandles(child);
|
releaseChildHandles(child);
|
||||||
await removeTempDir(userData);
|
await removeTempDir(userData);
|
||||||
|
await removeTempDir(daemonHome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
session,
|
session,
|
||||||
webContents,
|
webContents,
|
||||||
} from "electron";
|
} from "electron";
|
||||||
import { createDaemonCommandHandlers, registerDaemonManager } from "./daemon/daemon-manager.js";
|
import { registerDaemonManager } from "./daemon/daemon-manager.js";
|
||||||
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
|
import { parsePassthroughCliArgsFromArgv, runPassthroughCli } from "./daemon/cli/passthrough.js";
|
||||||
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
import { closeAllTransportSessions } from "./daemon/local-transport.js";
|
||||||
import {
|
import {
|
||||||
@@ -121,8 +121,6 @@ const FORWARDED_PASEO_SHORTCUT_KEYS = new Set([
|
|||||||
"arrowup",
|
"arrowup",
|
||||||
"arrowdown",
|
"arrowdown",
|
||||||
]);
|
]);
|
||||||
const DESKTOP_SMOKE_ENV = "PASEO_DESKTOP_SMOKE";
|
|
||||||
const DESKTOP_SMOKE_STOP_REQUEST = "paseo-smoke-stop";
|
|
||||||
app.setName(APP_NAME);
|
app.setName(APP_NAME);
|
||||||
|
|
||||||
interface AttachedBrowserInput {
|
interface AttachedBrowserInput {
|
||||||
@@ -822,53 +820,6 @@ async function runCliPassthroughIfRequested(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runDesktopSmokeIfRequested(): Promise<boolean> {
|
|
||||||
if (process.env[DESKTOP_SMOKE_ENV] !== "1") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlers = createDaemonCommandHandlers();
|
|
||||||
const startStatus = await handlers.start_desktop_daemon();
|
|
||||||
process.stdout.write(
|
|
||||||
`[paseo-smoke] ${JSON.stringify({
|
|
||||||
type: "desktop-daemon-smoke-started",
|
|
||||||
status: startStatus,
|
|
||||||
})}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitForDesktopSmokeStopRequest();
|
|
||||||
|
|
||||||
const stopStatus = await handlers.stop_desktop_daemon();
|
|
||||||
process.stdout.write(
|
|
||||||
`[paseo-smoke] ${JSON.stringify({
|
|
||||||
type: "desktop-daemon-smoke-stopped",
|
|
||||||
stopStatus,
|
|
||||||
})}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
app.exit(0);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function waitForDesktopSmokeStopRequest(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let buffer = "";
|
|
||||||
const stop = () => {
|
|
||||||
process.stdin.off("data", onData);
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
const onData = (chunk: Buffer | string) => {
|
|
||||||
buffer += chunk.toString();
|
|
||||||
if (buffer.includes(DESKTOP_SMOKE_STOP_REQUEST)) {
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.stdin.on("data", onData);
|
|
||||||
process.stdin.resume();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrap(): Promise<void> {
|
async function bootstrap(): Promise<void> {
|
||||||
if (!setupSingleInstanceLock()) {
|
if (!setupSingleInstanceLock()) {
|
||||||
return;
|
return;
|
||||||
@@ -912,9 +863,6 @@ async function bootstrap(): Promise<void> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
ensureNotificationCenterRegistration();
|
ensureNotificationCenterRegistration();
|
||||||
if (await runDesktopSmokeIfRequested()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
registerDaemonManager();
|
registerDaemonManager();
|
||||||
registerWindowManager();
|
registerWindowManager();
|
||||||
registerDialogHandlers();
|
registerDialogHandlers();
|
||||||
|
|||||||
Reference in New Issue
Block a user