mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Add Linux packaged client parity smoke coverage (#1204)
* docs: plan linux client issue 709 * fix: complete linux headless lifecycle routing * feat: add linux packaged inspect * test: add linux headless packaged smoke * ci: add linux headless packaged smoke * ci: smoke linux AppImage release artifacts * docs: document linux packaged client status * chore: finalize linux client audit remediation * docs: add linux client publication packet * test: harden linux client smoke coverage * ci: preserve linux smoke audit evidence * refactor: consolidate linux e2e helpers Move pathExists and the desktop/web/daemon app-key array out of linux.spec.ts into linux-helpers.ts, where expectPathInside and linuxUserHome already live. Keeps the spec file focused on tests and the helpers file as the canonical home for shared Linux e2e utilities. * fix: move linux e2e helpers to lib * fix: address linux release review blockers * fix: drop npm dependency from containerized linux build writeAssembledApp() previously called runNpmInstall() which executed `npm install` directly. Inside the containerized build path, electronuserland/builder:base strips npm/npx/corepack, so the inner tools-pack build would fail at the assembled-app install step. Route the install through OD_TOOLS_PACK_PNPM_BIN: buildDockerArgs sets the env to the standalone pnpm binary it bootstraps, and the new resolveProductionInstallCommand helper consumes that env to run `<bin> install --prod --no-lockfile --config.node-linker=hoisted`. Host invocations with no env set keep the prior npm behavior. --config.node-linker=hoisted preserves the flat node_modules layout that electron-builder packs the same way as npm-installed trees. New tests cover the resolver branches and assert the docker-arg-to- resolver chain end-to-end so reviewers can see the container's inner build receives the env that switches its install away from npm. * fix: harden linux container bootstrap * fix: validate desktop marker liveness in headless cleanup cleanup --headless previously skipped on any parseable desktop-root.json, trapping recovery when the AppImage had crashed and left a stale marker. Validate the marker the same way stopPackedLinuxApp does: if the PID is not in the live snapshot list, proceed through cleanup instead of skipping. Extract the validation into validateDesktopAppImageMarker so the stop and cleanup paths share one definition of live and owned. Tests cover both branches: a stale marker drives cleanup to remove the runtime/output roots, while a live marker drives cleanup to skip and preserve them.
This commit is contained in:
parent
4e19c3f4f3
commit
74637f1cb5
18 changed files with 1754 additions and 132 deletions
100
.github/workflows/ci.yml
vendored
100
.github/workflows/ci.yml
vendored
|
|
@ -70,23 +70,7 @@ jobs:
|
|||
required=true
|
||||
fi
|
||||
done
|
||||
if [[ "$file" == "apps/daemon/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
|
||||
daemon_tests_required=true
|
||||
fi
|
||||
if [[ "$file" == "apps/web/"* || "$file" == "packages/contracts/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
|
||||
web_tests_required=true
|
||||
fi
|
||||
if [[ "$file" == "scripts/"* || "$file" == "assets/"* || "$file" == "skills/"* || "$file" == "prompt-templates/"* || "$file" == "design-systems/"* || "$file" == "design-templates/"* || "$file" == "craft/"* ]]; then
|
||||
daemon_tests_required=true
|
||||
web_tests_required=true
|
||||
fi
|
||||
if [[ "$file" == "tools/dev/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
|
||||
tools_dev_tests_required=true
|
||||
fi
|
||||
if [[ "$file" == "tools/pack/"* || "$file" == "apps/packaged/"* || "$file" == "apps/desktop/"* || "$file" == "packages/platform/"* || "$file" == "packages/sidecar/"* || "$file" == "packages/sidecar-proto/"* ]]; then
|
||||
tools_pack_tests_required=true
|
||||
fi
|
||||
if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" ]]; then
|
||||
if [[ "$file" == "e2e/specs/mac.spec.ts" || "$file" == "e2e/specs/win.spec.ts" || "$file" == "e2e/specs/linux.spec.ts" || "$file" == "e2e/lib/linux-helpers.ts" || "$file" == "package.json" || "$file" == "pnpm-lock.yaml" || "$file" == "pnpm-workspace.yaml" || "$file" == ".github/workflows/ci.yml" || "$file" == ".github/workflows/release-beta.yml" || "$file" == ".github/workflows/release-stable.yml" ]]; then
|
||||
required=true
|
||||
daemon_tests_required=true
|
||||
web_tests_required=true
|
||||
|
|
@ -523,3 +507,85 @@ jobs:
|
|||
with:
|
||||
path: ${{ runner.temp }}/tools-pack-cache
|
||||
key: tools-pack-win-v6-${{ runner.os }}-${{ steps.win_tools_pack_cache_key.outputs.epoch }}-${{ github.sha }}
|
||||
|
||||
packaged_smoke_linux_headless:
|
||||
name: Packaged linux headless smoke
|
||||
needs: [validate, packaged_changes]
|
||||
if: ${{ needs.packaged_changes.outputs.required == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
version: 10.33.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build PR linux headless artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tools_pack_dir="$RUNNER_TEMP/tools-pack"
|
||||
report_dir="$RUNNER_TEMP/packaged-report/linux-headless"
|
||||
build_json_path="$report_dir/linux-tools-pack-build.json"
|
||||
build_log_path="$report_dir/linux-tools-pack-build.log"
|
||||
rm -rf "$tools_pack_dir" "$report_dir"
|
||||
mkdir -p "$report_dir"
|
||||
: > "$build_log_path"
|
||||
build_args=(
|
||||
exec tools-pack linux build
|
||||
--dir "$tools_pack_dir"
|
||||
--namespace ci-pr-linux
|
||||
--to dir
|
||||
--json
|
||||
)
|
||||
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then
|
||||
printf '%s\n' "$build_output" | tee "$build_json_path"
|
||||
node -e 'const fs = require("node:fs"); JSON.parse(fs.readFileSync(process.argv[1], "utf8"));' "$build_json_path"
|
||||
else
|
||||
build_status=$?
|
||||
printf '%s\n' "$build_output" | tee "$build_json_path"
|
||||
exit "$build_status"
|
||||
fi
|
||||
cat > "$report_dir/manifest.json" <<EOF
|
||||
{
|
||||
"platform": "linux",
|
||||
"mode": "headless",
|
||||
"spec": "specs/linux.spec.ts",
|
||||
"namespace": "ci-pr-linux",
|
||||
"githubRunId": "$GITHUB_RUN_ID",
|
||||
"githubRunAttempt": "$GITHUB_RUN_ATTEMPT",
|
||||
"commit": "$GITHUB_SHA"
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Smoke PR linux headless packaged runtime
|
||||
working-directory: e2e
|
||||
env:
|
||||
OD_PACKAGED_E2E_LINUX_HEADLESS: "1"
|
||||
OD_PACKAGED_E2E_NAMESPACE: ci-pr-linux
|
||||
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report_dir="$RUNNER_TEMP/packaged-report/linux-headless"
|
||||
mkdir -p "$report_dir"
|
||||
pnpm test specs/linux.spec.ts 2>&1 | tee "$report_dir/vitest.log"
|
||||
|
||||
- name: Upload linux headless e2e spec report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: open-design-pr-linux-headless-e2e-report
|
||||
path: ${{ runner.temp }}/packaged-report/linux-headless
|
||||
if-no-files-found: warn
|
||||
|
|
|
|||
72
.github/workflows/release-beta.yml
vendored
72
.github/workflows/release-beta.yml
vendored
|
|
@ -564,14 +564,65 @@ jobs:
|
|||
- name: Build beta linux artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm exec tools-pack linux build \
|
||||
--dir "$RUNNER_TEMP/tools-pack" \
|
||||
--namespace release-beta-linux \
|
||||
--portable \
|
||||
--app-version "${{ needs.metadata.outputs.beta_version }}" \
|
||||
--to appimage \
|
||||
--containerized \
|
||||
tools_pack_dir="$RUNNER_TEMP/tools-pack"
|
||||
report_dir="$RUNNER_TEMP/release-report/linux"
|
||||
build_json_path="$report_dir/tools-pack.json"
|
||||
build_log_path="$report_dir/tools-pack.log"
|
||||
rm -rf "$tools_pack_dir"
|
||||
mkdir -p "$report_dir"
|
||||
: > "$build_log_path"
|
||||
build_args=(
|
||||
exec tools-pack linux build
|
||||
--dir "$tools_pack_dir"
|
||||
--namespace release-beta-linux
|
||||
--portable
|
||||
--app-version "${{ needs.metadata.outputs.beta_version }}"
|
||||
--to appimage
|
||||
--containerized
|
||||
--json
|
||||
)
|
||||
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then
|
||||
printf '%s\n' "$build_output" | tee "$build_json_path"
|
||||
node -e 'const fs = require("node:fs"); JSON.parse(fs.readFileSync(process.argv[1], "utf8"));' "$build_json_path"
|
||||
else
|
||||
build_status=$?
|
||||
printf '%s\n' "$build_output" | tee "$build_json_path"
|
||||
exit "$build_status"
|
||||
fi
|
||||
|
||||
- name: Smoke beta linux AppImage runtime
|
||||
working-directory: e2e
|
||||
env:
|
||||
OD_PACKAGED_E2E_LINUX_APPIMAGE: "1"
|
||||
OD_PACKAGED_E2E_NAMESPACE: release-beta-linux
|
||||
OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/release-report/linux/screenshots/open-design-linux-smoke.png
|
||||
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report_dir="$RUNNER_TEMP/release-report/linux"
|
||||
mkdir -p "$report_dir/screenshots"
|
||||
cat > "$report_dir/manifest.json" <<EOF
|
||||
{
|
||||
"platform": "linux",
|
||||
"spec": "specs/linux.spec.ts",
|
||||
"namespace": "release-beta-linux",
|
||||
"screenshot": "screenshots/open-design-linux-smoke.png",
|
||||
"githubRunId": "$GITHUB_RUN_ID",
|
||||
"githubRunAttempt": "$GITHUB_RUN_ATTEMPT",
|
||||
"commit": "$GITHUB_SHA"
|
||||
}
|
||||
EOF
|
||||
sudo apt-get update 2>&1 | tee "$report_dir/apt-get-update.log"
|
||||
sudo apt-get install -y xvfb 2>&1 | tee "$report_dir/apt-get-install-xvfb.log"
|
||||
xvfb-run -a pnpm test specs/linux.spec.ts 2>&1 | tee "$report_dir/vitest.log"
|
||||
|
||||
- name: Upload linux e2e spec report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: open-design-beta-linux-e2e-report
|
||||
path: ${{ runner.temp }}/release-report/linux
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Prepare linux beta assets
|
||||
env:
|
||||
|
|
@ -667,6 +718,13 @@ jobs:
|
|||
name: open-design-beta-linux-release-assets
|
||||
path: ${{ runner.temp }}/release-assets/linux
|
||||
|
||||
- name: Download linux e2e spec report
|
||||
if: ${{ inputs.enable_linux }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: open-design-beta-linux-e2e-report
|
||||
path: ${{ runner.temp }}/release-report/linux
|
||||
|
||||
- name: Download mac e2e spec report
|
||||
if: ${{ inputs.enable_mac }}
|
||||
uses: actions/download-artifact@v8
|
||||
|
|
|
|||
74
.github/workflows/release-stable.yml
vendored
74
.github/workflows/release-stable.yml
vendored
|
|
@ -633,14 +633,67 @@ jobs:
|
|||
- name: Build release linux artifacts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm exec tools-pack linux build \
|
||||
--dir "$RUNNER_TEMP/tools-pack" \
|
||||
--namespace release-stable-linux \
|
||||
--portable \
|
||||
--app-version "${{ needs.metadata.outputs.stable_version }}" \
|
||||
--to appimage \
|
||||
--containerized \
|
||||
tools_pack_dir="$RUNNER_TEMP/tools-pack"
|
||||
report_dir="$RUNNER_TEMP/release-report/linux"
|
||||
build_json_path="$report_dir/tools-pack.json"
|
||||
build_log_path="$report_dir/tools-pack.log"
|
||||
rm -rf "$tools_pack_dir"
|
||||
mkdir -p "$report_dir"
|
||||
: > "$build_log_path"
|
||||
build_args=(
|
||||
exec tools-pack linux build
|
||||
--dir "$tools_pack_dir"
|
||||
--namespace release-stable-linux
|
||||
--portable
|
||||
--app-version "${{ needs.metadata.outputs.stable_version }}"
|
||||
--to appimage
|
||||
--containerized
|
||||
--json
|
||||
)
|
||||
if build_output="$(pnpm "${build_args[@]}" 2> >(tee -a "$build_log_path" >&2))"; then
|
||||
printf '%s\n' "$build_output" | tee "$build_json_path"
|
||||
node -e 'const fs = require("node:fs"); JSON.parse(fs.readFileSync(process.argv[1], "utf8"));' "$build_json_path"
|
||||
else
|
||||
build_status=$?
|
||||
printf '%s\n' "$build_output" | tee "$build_json_path"
|
||||
exit "$build_status"
|
||||
fi
|
||||
|
||||
- name: Smoke release linux AppImage runtime
|
||||
working-directory: e2e
|
||||
env:
|
||||
OD_PACKAGED_E2E_LINUX_APPIMAGE: "1"
|
||||
OD_PACKAGED_E2E_NAMESPACE: release-stable-linux
|
||||
OD_PACKAGED_E2E_SCREENSHOT_PATH: ${{ runner.temp }}/release-report/linux/screenshots/open-design-linux-smoke.png
|
||||
OD_PACKAGED_E2E_TOOLS_PACK_DIR: ${{ runner.temp }}/tools-pack
|
||||
run: |
|
||||
set -euo pipefail
|
||||
report_dir="$RUNNER_TEMP/release-report/linux"
|
||||
mkdir -p "$report_dir/screenshots"
|
||||
cat > "$report_dir/manifest.json" <<EOF
|
||||
{
|
||||
"channel": "${{ needs.metadata.outputs.channel }}",
|
||||
"platform": "linux",
|
||||
"releaseVersion": "${{ needs.metadata.outputs.release_version }}",
|
||||
"spec": "specs/linux.spec.ts",
|
||||
"namespace": "release-stable-linux",
|
||||
"screenshot": "screenshots/open-design-linux-smoke.png",
|
||||
"githubRunId": "$GITHUB_RUN_ID",
|
||||
"githubRunAttempt": "$GITHUB_RUN_ATTEMPT",
|
||||
"commit": "$GITHUB_SHA"
|
||||
}
|
||||
EOF
|
||||
sudo apt-get update 2>&1 | tee "$report_dir/apt-get-update.log"
|
||||
sudo apt-get install -y xvfb 2>&1 | tee "$report_dir/apt-get-install-xvfb.log"
|
||||
xvfb-run -a pnpm test specs/linux.spec.ts 2>&1 | tee "$report_dir/vitest.log"
|
||||
|
||||
- name: Upload linux e2e spec report
|
||||
if: ${{ always() }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: open-design-release-linux-e2e-report
|
||||
path: ${{ runner.temp }}/release-report/linux
|
||||
if-no-files-found: warn
|
||||
|
||||
- name: Prepare linux release assets
|
||||
env:
|
||||
|
|
@ -749,6 +802,13 @@ jobs:
|
|||
name: open-design-release-linux-release-assets
|
||||
path: ${{ runner.temp }}/release-assets/linux
|
||||
|
||||
- name: Download linux e2e spec report
|
||||
if: ${{ needs.build_linux.result == 'success' }}
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: open-design-release-linux-e2e-report
|
||||
path: ${{ runner.temp }}/release-report/linux
|
||||
|
||||
- name: Download mac e2e spec report
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ OD stands on four open-source shoulders:
|
|||
| **Deployable to** | Local (`pnpm tools-dev`) · Vercel web layer · packaged Electron desktop app for macOS (Apple Silicon) and Windows (x64) — download from [open-design.ai](https://open-design.ai/) or the [latest release](https://github.com/nexu-io/open-design/releases) |
|
||||
| **License** | Apache-2.0 |
|
||||
|
||||
Linux AppImage packaging is available through the optional release lane and is covered by the Linux packaged smoke workflow, but public stable downloads remain gated until the release maintainers enable the Linux stable lane.
|
||||
|
||||
[acd2]: https://github.com/VoltAgent/awesome-design-md
|
||||
[ads]: https://github.com/bergside/awesome-design-skills
|
||||
|
||||
|
|
|
|||
|
|
@ -101,9 +101,14 @@ async function main(): Promise<void> {
|
|||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
});
|
||||
|
||||
// Write the identity marker so `tools-pack linux stop` can find and stop
|
||||
// this process by PID via the same mechanism as the Electron packaged path.
|
||||
const identity = await writePackagedDesktopIdentity({ paths, stamp });
|
||||
// Write a headless-specific identity marker so `tools-pack linux stop --headless`
|
||||
// can find this process without confusing it for a menu-launched
|
||||
// AppImage that owns desktop-root.json in the same namespace.
|
||||
const identity = await writePackagedDesktopIdentity({
|
||||
identityPath: paths.headlessIdentityPath,
|
||||
paths,
|
||||
stamp,
|
||||
});
|
||||
|
||||
const sidecars = await startPackagedSidecars(runtime, paths, {
|
||||
appVersion: config.appVersion,
|
||||
|
|
|
|||
|
|
@ -57,14 +57,16 @@ function createPackagedDesktopRootIdentity(options: {
|
|||
}
|
||||
|
||||
export async function writePackagedDesktopIdentity(options: {
|
||||
identityPath?: string;
|
||||
paths: PackagedNamespacePaths;
|
||||
stamp: SidecarStamp;
|
||||
}): Promise<PackagedDesktopIdentityHandle> {
|
||||
const identity = createPackagedDesktopRootIdentity(options);
|
||||
const identityPath = options.identityPath ?? options.paths.desktopIdentityPath;
|
||||
|
||||
const writeIdentity = async () => {
|
||||
identity.updatedAt = new Date().toISOString();
|
||||
await writeJsonFile(options.paths.desktopIdentityPath, identity);
|
||||
await writeJsonFile(identityPath, identity);
|
||||
};
|
||||
|
||||
await writeIdentity();
|
||||
|
|
@ -76,7 +78,7 @@ export async function writePackagedDesktopIdentity(options: {
|
|||
return {
|
||||
async close() {
|
||||
clearInterval(heartbeat);
|
||||
await removeFile(options.paths.desktopIdentityPath).catch(() => undefined);
|
||||
await removeFile(identityPath).catch(() => undefined);
|
||||
},
|
||||
identity,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export type PackagedNamespacePaths = {
|
|||
desktopLogsRoot: string;
|
||||
electronSessionDataRoot: string;
|
||||
electronUserDataRoot: string;
|
||||
headlessIdentityPath: string;
|
||||
logsRoot: string;
|
||||
namespaceRoot: string;
|
||||
resourceRoot: string;
|
||||
|
|
@ -33,6 +34,7 @@ export function resolvePackagedNamespacePaths(
|
|||
desktopLogsRoot: join(namespaceRoot, "logs", APP_KEYS.DESKTOP),
|
||||
electronSessionDataRoot: join(namespaceRoot, "user-data", "session"),
|
||||
electronUserDataRoot: join(namespaceRoot, "user-data"),
|
||||
headlessIdentityPath: join(namespaceRoot, "runtime", "headless-root.json"),
|
||||
logsRoot: join(namespaceRoot, "logs"),
|
||||
namespaceRoot,
|
||||
resourceRoot: config.resourceRoot,
|
||||
|
|
|
|||
76
apps/packaged/tests/identity.test.ts
Normal file
76
apps/packaged/tests/identity.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_MODES,
|
||||
SIDECAR_SOURCES,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import { resolveAppIpcPath } from "@open-design/sidecar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { writePackagedDesktopIdentity } from "../src/identity.js";
|
||||
import type { PackagedNamespacePaths } from "../src/paths.js";
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await readFile(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function fakePaths(root: string): PackagedNamespacePaths {
|
||||
return {
|
||||
cacheRoot: join(root, "cache"),
|
||||
dataRoot: join(root, "data"),
|
||||
desktopIdentityPath: join(root, "runtime", "desktop-root.json"),
|
||||
desktopLogPath: join(root, "logs", "desktop", "latest.log"),
|
||||
desktopLogsRoot: join(root, "logs", "desktop"),
|
||||
electronSessionDataRoot: join(root, "user-data", "session"),
|
||||
electronUserDataRoot: join(root, "user-data"),
|
||||
headlessIdentityPath: join(root, "runtime", "headless-root.json"),
|
||||
logsRoot: join(root, "logs"),
|
||||
namespaceRoot: root,
|
||||
resourceRoot: join(root, "resources"),
|
||||
runtimeRoot: join(root, "runtime"),
|
||||
webIdentityPath: join(root, "runtime", "web-root.json"),
|
||||
};
|
||||
}
|
||||
|
||||
describe("packaged identity markers", () => {
|
||||
it("can write and close the desktop identity shape at the headless marker path", async () => {
|
||||
const root = join(tmpdir(), `od-packaged-identity-${process.pid}-${Date.now()}`);
|
||||
const paths = fakePaths(root);
|
||||
const stamp = {
|
||||
app: APP_KEYS.DESKTOP,
|
||||
ipc: resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace: "default",
|
||||
}),
|
||||
mode: SIDECAR_MODES.RUNTIME,
|
||||
namespace: "default",
|
||||
source: SIDECAR_SOURCES.PACKAGED,
|
||||
};
|
||||
|
||||
try {
|
||||
const handle = await writePackagedDesktopIdentity({
|
||||
identityPath: paths.headlessIdentityPath,
|
||||
paths,
|
||||
stamp,
|
||||
});
|
||||
|
||||
expect(await pathExists(paths.headlessIdentityPath)).toBe(true);
|
||||
expect(await pathExists(paths.desktopIdentityPath)).toBe(false);
|
||||
|
||||
await handle.close();
|
||||
expect(await pathExists(paths.headlessIdentityPath)).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -158,6 +158,7 @@ describe('buildPackagedDaemonSpawnEnv', () => {
|
|||
desktopLogsRoot: '/tmp/od-pkg/logs/desktop',
|
||||
electronSessionDataRoot: '/tmp/od-pkg/user-data/session',
|
||||
electronUserDataRoot: '/tmp/od-pkg/user-data',
|
||||
headlessIdentityPath: '/tmp/od-pkg/runtime/headless-root.json',
|
||||
logsRoot: '/tmp/od-pkg/logs',
|
||||
namespaceRoot: '/tmp/od-pkg',
|
||||
resourceRoot: '/tmp/od-pkg/resources',
|
||||
|
|
|
|||
134
docs/superpowers/plans/2026-05-10-linux-client-parity.md
Normal file
134
docs/superpowers/plans/2026-05-10-linux-client-parity.md
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
# Linux Client Parity Plan
|
||||
|
||||
## Purpose
|
||||
|
||||
Issue: https://github.com/nexu-io/open-design/issues/709
|
||||
|
||||
This plan defines the durable Linux packaged-client scope: bring Linux closer
|
||||
to the macOS and Windows packaged lifecycle without claiming stable public Linux
|
||||
downloads before release maintainers enable that lane.
|
||||
|
||||
## Status
|
||||
|
||||
Implemented in PR #1204:
|
||||
|
||||
- Linux headless lifecycle parity for install, start, stop, logs, uninstall,
|
||||
and cleanup.
|
||||
- Linux `inspect` support:
|
||||
- AppImage mode supports status, eval, and screenshot.
|
||||
- Headless mode is status-only.
|
||||
- Linux packaged e2e smoke coverage:
|
||||
- PR lane uses headless runtime smoke on Ubuntu.
|
||||
- Release lanes use AppImage smoke under Xvfb when Linux release jobs run.
|
||||
- Linux release smoke report artifacts preserve build logs, build JSON, smoke
|
||||
logs, apt logs, manifest, and screenshots.
|
||||
- Release Linux AppImage build uses the containerized glibc compatibility target.
|
||||
|
||||
## Ownership Boundaries
|
||||
|
||||
- `tools/pack` owns Linux packaged build, install, lifecycle, logs, cleanup,
|
||||
inspect, containerized build, and release-artifact command behavior.
|
||||
- `apps/packaged` owns the packaged Electron/headless entrypoints used by
|
||||
`tools/pack`.
|
||||
- `e2e/specs/linux.spec.ts` owns high-signal Linux packaged smoke coverage.
|
||||
- Release workflows own beta/stable AppImage smoke and evidence upload.
|
||||
- README and `tools/pack/README.md` own public contributor/operator guidance.
|
||||
|
||||
Do not move test logic into application packages to avoid e2e layout drift.
|
||||
Reusable e2e helpers belong in `e2e/lib/`.
|
||||
|
||||
## Known Dependencies
|
||||
|
||||
- PR #560 identified that `electronuserland/builder:base` strips npm, npx, and
|
||||
corepack from PATH.
|
||||
- PR #1204 adopts the same standalone `pnpm-linuxstatic-<arch>` bootstrap
|
||||
approach directly because its new Linux release smoke depends on the
|
||||
containerized build path.
|
||||
- Merge order: PR #1204 no longer depends on PR #560 for the pnpm bootstrap.
|
||||
If PR #1204 merges first, PR #560 should be closed or rebased to avoid
|
||||
reintroducing a duplicate container bootstrap change.
|
||||
|
||||
## In Scope
|
||||
|
||||
- Headless runtime lifecycle parity for Linux.
|
||||
- AppImage desktop lifecycle smoke in release lanes.
|
||||
- Linux `inspect` command coverage.
|
||||
- Build/test evidence preservation for Linux release jobs.
|
||||
- Containerized Linux AppImage build compatibility using
|
||||
`electronuserland/builder:base`.
|
||||
- Documentation that distinguishes supported behavior, release gates, and known
|
||||
Linux packaging caveats.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
- `.deb` and `.rpm` packages: require per-distro package metadata, maintainer
|
||||
scripts, signing/repository decisions, install/remove hook validation, and a
|
||||
release matrix that is larger than the first Linux lane needs.
|
||||
- Snap: requires store/review ownership, confinement decisions, and a separate
|
||||
update/distribution path.
|
||||
- Flatpak: requires manifest/runtime ownership, portal testing, sandbox file
|
||||
access validation, and distribution through Flathub or a custom remote.
|
||||
- AppImage signing: deferred until there is GPG/signing-key infrastructure and
|
||||
a user-facing verification flow.
|
||||
- AppImage auto-update feed: deferred until Linux publish metadata is wired to a
|
||||
real update endpoint.
|
||||
- Full AppImage PR smoke: deferred because it needs display setup and is slower;
|
||||
PR validation uses headless smoke, while AppImage smoke runs in release lanes.
|
||||
|
||||
## Release Readiness
|
||||
|
||||
Do not flip `vars.ENABLE_STABLE_LINUX` until all checklist items are true:
|
||||
|
||||
- Owner: release maintainer responsible for the stable Linux lane is identified
|
||||
in the release issue or PR.
|
||||
- Stable Linux workflow has completed successfully with
|
||||
`vars.ENABLE_STABLE_LINUX == 'true'`.
|
||||
- Linux release artifact bundle contains the expected AppImage, metadata, and
|
||||
uploaded `open-design-release-linux-e2e-report`.
|
||||
- The report artifact contains:
|
||||
- `tools-pack.json`
|
||||
- `tools-pack.log`
|
||||
- `manifest.json`
|
||||
- `vitest.log`
|
||||
- apt logs
|
||||
- screenshot output
|
||||
- Smoke proves install, start, inspect status, inspect eval, inspect screenshot,
|
||||
logs, stop, uninstall, and cleanup.
|
||||
- Public README download/status copy is updated only after the successful stable
|
||||
Linux release run.
|
||||
- Follow-up checkpoint: review the first successful beta Linux release before
|
||||
enabling stable Linux, then review the first stable Linux run before promoting
|
||||
public download copy.
|
||||
|
||||
## Regression Risks
|
||||
|
||||
- PR Linux headless smoke adds one Ubuntu job only when packaged-relevant files
|
||||
change. Expected runtime target: under 10 minutes on GitHub-hosted Ubuntu.
|
||||
- Release AppImage smoke adds one Linux job step behind existing beta/stable
|
||||
Linux gates. Expected additional runtime target: under 15 minutes for build
|
||||
plus smoke after dependencies are installed.
|
||||
- AppImage runtime smoke uses Xvfb and can fail if Electron cannot acquire a
|
||||
display or if AppImage extraction/runtime startup exceeds the smoke timeout.
|
||||
Acceptable flake budget before stable enablement: zero repeated failures
|
||||
across the release-readiness checkpoint runs.
|
||||
- Containerized build depends on Docker availability on GitHub-hosted Ubuntu and
|
||||
on `curl` plus `sha256sum` inside `electronuserland/builder:base`; the build
|
||||
command fails explicitly if either contract changes.
|
||||
- The standalone pnpm asset is pinned to the root `packageManager` version and
|
||||
verified by SHA-256 before execution.
|
||||
|
||||
## Validation
|
||||
|
||||
Use the focused checks below after Linux packaged-client changes:
|
||||
|
||||
```bash
|
||||
corepack pnpm guard
|
||||
corepack pnpm --filter @open-design/tools-pack test -- linux.test.ts
|
||||
corepack pnpm --filter @open-design/tools-pack typecheck
|
||||
corepack pnpm --filter @open-design/e2e test -- tests/linux-helpers.test.ts tests/packaged-smoke-workflow.test.ts
|
||||
corepack pnpm --filter @open-design/e2e typecheck
|
||||
git diff --check
|
||||
```
|
||||
|
||||
When changing release workflow YAML, also parse the affected workflows and
|
||||
inspect the Linux release report artifact layout before requesting review.
|
||||
38
e2e/lib/linux-helpers.ts
Normal file
38
e2e/lib/linux-helpers.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { access } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { resolve, sep } from 'node:path';
|
||||
|
||||
import { expect } from 'vitest';
|
||||
|
||||
export const PACKAGED_APP_KEYS = ['desktop', 'web', 'daemon'] as const;
|
||||
|
||||
export function linuxUserHome(): string {
|
||||
// Match tools-pack path resolution instead of deriving expectations from HOME directly.
|
||||
return homedir();
|
||||
}
|
||||
|
||||
export function expectPathInside(filePath: string, expectedRoot: string): void {
|
||||
const normalizedPath = resolve(filePath);
|
||||
const normalizedRoot = resolve(expectedRoot);
|
||||
expect(
|
||||
normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}${sep}`),
|
||||
`${normalizedPath} should be inside ${normalizedRoot}`,
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
export async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function linuxRemovalStatusMessage(label: string, status: string): string {
|
||||
return `${label} removal status was ${status}; skipped-process-running means the process remained running before removal. Inspect packaged logs and stop lifecycle output before retrying.`;
|
||||
}
|
||||
|
||||
export function expectLinuxRemovedStatus(label: string, status: string): void {
|
||||
expect(status, linuxRemovalStatusMessage(label, status)).toMatch(/^(ok|already-removed)$/);
|
||||
}
|
||||
411
e2e/specs/linux.spec.ts
Normal file
411
e2e/specs/linux.spec.ts
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
PACKAGED_APP_KEYS,
|
||||
expectLinuxRemovedStatus,
|
||||
expectPathInside,
|
||||
linuxUserHome,
|
||||
pathExists,
|
||||
} from '../lib/linux-helpers.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const e2eRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const workspaceRoot = dirname(e2eRoot);
|
||||
const toolsPackDir = resolveFromWorkspace(process.env.OD_PACKAGED_E2E_TOOLS_PACK_DIR ?? '.tmp/tools-pack');
|
||||
const namespace = process.env.OD_PACKAGED_E2E_NAMESPACE ?? 'ci-pr-linux';
|
||||
const toolsPackBin = join(workspaceRoot, 'tools', 'pack', 'bin', 'tools-pack.mjs');
|
||||
const screenshotPath = resolveFromWorkspace(
|
||||
process.env.OD_PACKAGED_E2E_SCREENSHOT_PATH ?? join(toolsPackDir, 'screenshots', `${namespace}.png`),
|
||||
);
|
||||
const healthExpression = "fetch('/api/health').then(async response => ({ health: await response.json(), href: location.href, status: response.status, title: document.title }))";
|
||||
const shouldRunLinuxHeadlessSmoke =
|
||||
process.platform === 'linux' && process.env.OD_PACKAGED_E2E_LINUX_HEADLESS === '1';
|
||||
const linuxHeadlessDescribe = shouldRunLinuxHeadlessSmoke ? describe : describe.skip;
|
||||
const shouldRunLinuxAppImageSmoke =
|
||||
process.platform === 'linux' && process.env.OD_PACKAGED_E2E_LINUX_APPIMAGE === '1';
|
||||
const linuxAppImageDescribe = shouldRunLinuxAppImageSmoke ? describe : describe.skip;
|
||||
|
||||
const runtimeNamespaceRoot = join(toolsPackDir, 'runtime', 'linux', 'namespaces', namespace);
|
||||
const userHome = linuxUserHome();
|
||||
|
||||
type LinuxHeadlessInstallResult = {
|
||||
launcherPath: string;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
type LinuxHeadlessStartResult = {
|
||||
launcherPath: string;
|
||||
logPath: string;
|
||||
namespace: string;
|
||||
pid: number;
|
||||
status: {
|
||||
namespace: string;
|
||||
pid: number;
|
||||
startedAt: string;
|
||||
url: string;
|
||||
version: 1;
|
||||
};
|
||||
};
|
||||
|
||||
type LinuxInspectResult = {
|
||||
eval?: {
|
||||
error?: string;
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
};
|
||||
screenshot?: {
|
||||
path: string;
|
||||
};
|
||||
status: {
|
||||
pid?: number;
|
||||
state?: string;
|
||||
url?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type LinuxStopResult = {
|
||||
namespace: string;
|
||||
remainingPids: number[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
type LinuxHeadlessUninstallResult = {
|
||||
launcherPath: string;
|
||||
namespace: string;
|
||||
removed: string;
|
||||
stop: LinuxStopResult;
|
||||
};
|
||||
|
||||
type LinuxCleanupResult = {
|
||||
skipped: boolean;
|
||||
};
|
||||
|
||||
type LinuxAppImageInstallResult = {
|
||||
appImagePath: string;
|
||||
desktopFilePath: string;
|
||||
iconPath: string;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
type LinuxAppImageStartResult = {
|
||||
appImagePath: string;
|
||||
executablePath: string;
|
||||
logPath: string;
|
||||
namespace: string;
|
||||
pid: number;
|
||||
source: string;
|
||||
status: {
|
||||
state?: string;
|
||||
url?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type LinuxAppImageUninstallResult = {
|
||||
namespace: string;
|
||||
removed: {
|
||||
appImage: string;
|
||||
desktop: string;
|
||||
icon: string;
|
||||
};
|
||||
stop: LinuxStopResult;
|
||||
};
|
||||
|
||||
type LogsResult = {
|
||||
logs: Record<string, { lines: string[]; logPath: string }>;
|
||||
namespace: string;
|
||||
};
|
||||
|
||||
type HealthEvalValue = {
|
||||
health: {
|
||||
ok?: unknown;
|
||||
service?: unknown;
|
||||
version?: unknown;
|
||||
};
|
||||
href: string;
|
||||
status: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
linuxHeadlessDescribe('packaged linux headless runtime smoke', () => {
|
||||
let installed = false;
|
||||
let started = false;
|
||||
|
||||
test('installs, starts, inspects status, logs, stops, uninstalls, and cleans up headless runtime', async () => {
|
||||
let passed = false;
|
||||
try {
|
||||
const install = await runToolsPackJson<LinuxHeadlessInstallResult>('install', ['--headless']);
|
||||
installed = true;
|
||||
expect(install.namespace).toBe(namespace);
|
||||
expectPathInside(install.launcherPath, join(userHome, '.local', 'bin'));
|
||||
expect(await pathExists(install.launcherPath)).toBe(true);
|
||||
|
||||
const start = await runToolsPackJson<LinuxHeadlessStartResult>('start', ['--headless']);
|
||||
started = true;
|
||||
expect(start.namespace).toBe(namespace);
|
||||
expect(start.pid).toBeGreaterThan(0);
|
||||
expect(start.status.namespace).toBe(namespace);
|
||||
expect(start.status.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/?$/);
|
||||
expectPathInside(start.logPath, join(runtimeNamespaceRoot, 'logs', 'desktop'));
|
||||
|
||||
const inspect = await runToolsPackJson<LinuxInspectResult>('inspect', ['--headless']);
|
||||
expect(inspect.status?.state).toBe('running');
|
||||
expect(inspect.status?.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/?$/);
|
||||
|
||||
const logs = await runToolsPackJson<LogsResult>('logs');
|
||||
expect(logs.namespace).toBe(namespace);
|
||||
const desktopLog = logs.logs.desktop;
|
||||
if (desktopLog == null) {
|
||||
throw new Error('expected desktop log entry');
|
||||
}
|
||||
expectPathInside(desktopLog.logPath, join(runtimeNamespaceRoot, 'logs', 'desktop'));
|
||||
expect(desktopLog.lines.join('\n')).toContain('Open Design is running');
|
||||
|
||||
const stop = await runToolsPackJson<LinuxStopResult>('stop', ['--headless']);
|
||||
started = false;
|
||||
expect(stop.namespace).toBe(namespace);
|
||||
expect(stop.status).not.toBe('partial');
|
||||
expect(stop.remainingPids).toEqual([]);
|
||||
|
||||
const uninstall = await runToolsPackJson<LinuxHeadlessUninstallResult>('uninstall', ['--headless']);
|
||||
installed = false;
|
||||
expect(uninstall.namespace).toBe(namespace);
|
||||
expectLinuxRemovedStatus('headless launcher', uninstall.removed);
|
||||
expect(await pathExists(install.launcherPath)).toBe(false);
|
||||
|
||||
const cleanup = await runToolsPackJson<LinuxCleanupResult>('cleanup', ['--headless']);
|
||||
expect(cleanup.skipped).toBe(false);
|
||||
passed = true;
|
||||
} finally {
|
||||
if (!passed) {
|
||||
await printPackagedLogs().catch((error: unknown) => {
|
||||
console.error('failed to read packaged linux logs after failure', error);
|
||||
});
|
||||
}
|
||||
if (started || installed) {
|
||||
await runToolsPackJson<LinuxHeadlessUninstallResult>('uninstall', ['--headless']).catch((error: unknown) => {
|
||||
console.error('failed to uninstall packaged linux headless runtime during cleanup', error);
|
||||
});
|
||||
started = false;
|
||||
installed = false;
|
||||
}
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
linuxAppImageDescribe('packaged linux AppImage runtime smoke', () => {
|
||||
let installed = false;
|
||||
let started = false;
|
||||
|
||||
test('installs, starts, inspects with eval and screenshot, stops, and uninstalls the built AppImage', async () => {
|
||||
let passed = false;
|
||||
try {
|
||||
const install = await runToolsPackJson<LinuxAppImageInstallResult>('install');
|
||||
installed = true;
|
||||
|
||||
expect(install.namespace).toBe(namespace);
|
||||
expectPathInside(install.appImagePath, join(userHome, '.local', 'bin'));
|
||||
expectPathInside(install.desktopFilePath, join(userHome, '.local', 'share', 'applications'));
|
||||
expectPathInside(install.iconPath, join(userHome, '.local', 'share', 'icons', 'hicolor'));
|
||||
|
||||
const start = await runToolsPackJson<LinuxAppImageStartResult>('start');
|
||||
started = true;
|
||||
|
||||
expect(start.namespace).toBe(namespace);
|
||||
expect(start.source).toBe('installed');
|
||||
expectPathInside(start.appImagePath, join(userHome, '.local', 'bin'));
|
||||
expectPathInside(start.executablePath, join(userHome, '.local', 'bin'));
|
||||
expectPathInside(start.logPath, join(runtimeNamespaceRoot, 'logs', 'desktop'));
|
||||
expect(start.pid).toBeGreaterThan(0);
|
||||
if (start.status != null) {
|
||||
expect(start.status.state).toBe('running');
|
||||
}
|
||||
|
||||
const inspect = await waitForHealthyAppImageDesktop();
|
||||
expect(inspect.status?.state).toBe('running');
|
||||
expect(inspect.status?.url).toMatch(/^(od:\/\/app\/|http:\/\/127\.0\.0\.1:\d+\/?$)/);
|
||||
|
||||
const value = assertHealthEvalValue(inspect.eval?.value);
|
||||
expect(value.status).toBe(200);
|
||||
expect(value.health.ok).toBe(true);
|
||||
expect(value.health.version).toEqual(expect.any(String));
|
||||
|
||||
const screenshot = await runToolsPackJson<LinuxInspectResult>('inspect', ['--path', screenshotPath]);
|
||||
expect(screenshot.screenshot?.path).toBe(screenshotPath);
|
||||
expect(await fileSizeBytes(screenshotPath)).toBeGreaterThan(0);
|
||||
|
||||
assertLogPathsAndContent(await runToolsPackJson<LogsResult>('logs'));
|
||||
|
||||
const stop = await runToolsPackJson<LinuxStopResult>('stop');
|
||||
started = false;
|
||||
expect(stop.namespace).toBe(namespace);
|
||||
expect(stop.status).not.toBe('partial');
|
||||
expect(stop.remainingPids).toEqual([]);
|
||||
|
||||
const uninstall = await runToolsPackJson<LinuxAppImageUninstallResult>('uninstall');
|
||||
installed = false;
|
||||
expect(uninstall.namespace).toBe(namespace);
|
||||
expectLinuxRemovedStatus('AppImage', uninstall.removed.appImage);
|
||||
expectLinuxRemovedStatus('desktop file', uninstall.removed.desktop);
|
||||
expectLinuxRemovedStatus('icon', uninstall.removed.icon);
|
||||
expect(await pathExists(install.appImagePath)).toBe(false);
|
||||
passed = true;
|
||||
} finally {
|
||||
if (!passed) {
|
||||
await printPackagedLogs().catch((error: unknown) => {
|
||||
console.error('failed to read packaged linux logs after failure', error);
|
||||
});
|
||||
}
|
||||
if (started || installed) {
|
||||
await runToolsPackJson<LinuxAppImageUninstallResult>('uninstall').catch((error: unknown) => {
|
||||
console.error('failed to uninstall packaged linux AppImage during cleanup', error);
|
||||
});
|
||||
started = false;
|
||||
installed = false;
|
||||
}
|
||||
}
|
||||
}, 240_000);
|
||||
});
|
||||
|
||||
async function runToolsPackJson<T>(action: string, extraArgs: string[] = []): Promise<T> {
|
||||
const args = [
|
||||
toolsPackBin,
|
||||
'linux',
|
||||
action,
|
||||
'--dir',
|
||||
toolsPackDir,
|
||||
'--namespace',
|
||||
namespace,
|
||||
'--json',
|
||||
...extraArgs,
|
||||
];
|
||||
const result = await execFileAsync(process.execPath, args, {
|
||||
cwd: workspaceRoot,
|
||||
env: process.env,
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
}).catch((error: unknown) => {
|
||||
if (isExecError(error)) {
|
||||
throw new Error(
|
||||
[
|
||||
`tools-pack linux ${action} failed`,
|
||||
`message:\n${error.message}`,
|
||||
`stdout:\n${error.stdout}`,
|
||||
`stderr:\n${error.stderr}`,
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
try {
|
||||
return JSON.parse(result.stdout) as T;
|
||||
} catch (error) {
|
||||
throw new Error(`tools-pack linux ${action} did not print JSON: ${String(error)}\n${result.stdout}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealthyAppImageDesktop(): Promise<LinuxInspectResult> {
|
||||
const timeoutMs = 90_000;
|
||||
const startedAt = Date.now();
|
||||
let lastResult: unknown = null;
|
||||
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
const inspect = await runToolsPackJson<LinuxInspectResult>('inspect', ['--expr', healthExpression]);
|
||||
lastResult = inspect;
|
||||
if (inspect.status?.state === 'running' && inspect.eval?.ok === true) {
|
||||
const value = asHealthEvalValue(inspect.eval.value);
|
||||
if (value?.status === 200 && value.health.ok === true && typeof value.health.version === 'string') {
|
||||
return inspect;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lastResult = error;
|
||||
}
|
||||
await delay(1000);
|
||||
}
|
||||
|
||||
throw new Error(`packaged linux AppImage runtime did not become healthy: ${formatUnknown(lastResult)}`);
|
||||
}
|
||||
|
||||
function assertLogPathsAndContent(result: LogsResult): void {
|
||||
expect(result.namespace).toBe(namespace);
|
||||
for (const app of PACKAGED_APP_KEYS) {
|
||||
const entry = result.logs[app];
|
||||
if (entry == null) {
|
||||
throw new Error(`expected ${app} log entry`);
|
||||
}
|
||||
expectPathInside(entry.logPath, join(runtimeNamespaceRoot, 'logs', app));
|
||||
}
|
||||
|
||||
const combined = Object.values(result.logs)
|
||||
.flatMap((entry) => entry.lines)
|
||||
.join('\n');
|
||||
expect(combined).not.toMatch(/ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING/);
|
||||
expect(combined).not.toMatch(/packaged runtime failed/i);
|
||||
expect(combined).not.toMatch(/standalone Next\.js server exited/i);
|
||||
}
|
||||
|
||||
async function printPackagedLogs(): Promise<void> {
|
||||
const result = await runToolsPackJson<LogsResult>('logs');
|
||||
for (const [app, entry] of Object.entries(result.logs)) {
|
||||
console.error(`[${app}] ${entry.logPath}`);
|
||||
console.error(entry.lines.join('\n') || '(no log lines)');
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFromWorkspace(filePath: string): string {
|
||||
return isAbsolute(filePath) ? filePath : resolve(workspaceRoot, filePath);
|
||||
}
|
||||
|
||||
function assertHealthEvalValue(value: unknown): HealthEvalValue {
|
||||
const normalized = asHealthEvalValue(value);
|
||||
if (normalized == null) {
|
||||
throw new Error(`unexpected health eval value: ${formatUnknown(value)}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function asHealthEvalValue(value: unknown): HealthEvalValue | null {
|
||||
if (!isRecord(value)) return null;
|
||||
if (typeof value.href !== 'string' || typeof value.status !== 'number' || typeof value.title !== 'string') return null;
|
||||
if (!isRecord(value.health)) return null;
|
||||
return value as HealthEvalValue;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value != null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function fileSizeBytes(filePath: string): Promise<number> {
|
||||
return (await stat(filePath)).size;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
|
||||
}
|
||||
|
||||
function formatUnknown(value: unknown): string {
|
||||
if (value instanceof Error) return value.stack ?? value.message;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
type ExecError = Error & {
|
||||
stderr?: string;
|
||||
stdout?: string;
|
||||
};
|
||||
|
||||
function isExecError(error: unknown): error is ExecError {
|
||||
return error instanceof Error && ('stderr' in error || 'stdout' in error);
|
||||
}
|
||||
24
e2e/tests/linux-helpers.test.ts
Normal file
24
e2e/tests/linux-helpers.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { linuxRemovalStatusMessage } from "../lib/linux-helpers.js";
|
||||
|
||||
describe("linux e2e helpers", () => {
|
||||
it("delegates user-home resolution to node os.homedir", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("node:os", () => ({ homedir: () => "/tmp/open-design-test-home" }));
|
||||
|
||||
try {
|
||||
const { linuxUserHome } = await import("../lib/linux-helpers.js");
|
||||
expect(linuxUserHome()).toBe("/tmp/open-design-test-home");
|
||||
} finally {
|
||||
vi.doUnmock("node:os");
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces skipped-process-running as a lifecycle cleanup diagnostic", () => {
|
||||
expect(linuxRemovalStatusMessage("appImage", "skipped-process-running")).toContain(
|
||||
"process remained running before removal",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,6 +7,8 @@ import { describe, expect, it } from "vitest";
|
|||
const e2eRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const workspaceRoot = dirname(e2eRoot);
|
||||
const ciWorkflowPath = join(workspaceRoot, ".github", "workflows", "ci.yml");
|
||||
const releaseBetaWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-beta.yml");
|
||||
const releaseStableWorkflowPath = join(workspaceRoot, ".github", "workflows", "release-stable.yml");
|
||||
|
||||
describe("packaged smoke workflow", () => {
|
||||
it("builds the PR mac smoke artifact without portable mode", async () => {
|
||||
|
|
@ -16,4 +18,88 @@ describe("packaged smoke workflow", () => {
|
|||
expect(macBuildStep?.[0]).toBeDefined();
|
||||
expect(macBuildStep?.[0]).not.toContain("--portable");
|
||||
});
|
||||
|
||||
it("runs a linux headless packaged smoke job when packaged changes require smoke", async () => {
|
||||
const workflow = await readFile(ciWorkflowPath, "utf8");
|
||||
expect(workflow).toContain("packaged_smoke_linux_headless:");
|
||||
expect(workflow).toContain("e2e/lib/linux-helpers.ts");
|
||||
expect(workflow).toContain("Build PR linux headless artifacts");
|
||||
expect(workflow).toContain('OD_PACKAGED_E2E_LINUX_HEADLESS: "1"');
|
||||
expect(workflow).toContain("pnpm test specs/linux.spec.ts");
|
||||
expect(workflow).toContain("manifest.json");
|
||||
expect(workflow).toContain("linux-tools-pack-build.json");
|
||||
expect(workflow).toContain("Upload linux headless e2e spec report");
|
||||
expect(workflow).toContain("open-design-pr-linux-headless-e2e-report");
|
||||
expectPrLinuxBuildPreservesEvidence(workflow, "Build PR linux headless artifacts");
|
||||
});
|
||||
|
||||
it("preserves beta linux AppImage smoke reports for release publication", async () => {
|
||||
const workflow = await readFile(releaseBetaWorkflowPath, "utf8");
|
||||
const linuxBuildStep = workflow.match(
|
||||
/- name: Build beta linux artifacts\n(?:.+\n)+?(?=\n - name: Smoke beta linux AppImage runtime)/m,
|
||||
);
|
||||
expect(linuxBuildStep?.[0]).toBeDefined();
|
||||
expect(linuxBuildStep?.[0]).toContain(
|
||||
'node -e \'const fs = require("node:fs"); JSON.parse(fs.readFileSync(process.argv[1], "utf8"));\' "$build_json_path"',
|
||||
);
|
||||
expect(workflow).toContain("Smoke beta linux AppImage runtime");
|
||||
expect(workflow).toContain("manifest.json");
|
||||
expect(workflow).toContain("tools-pack.json");
|
||||
expect(workflow).toContain("Upload linux e2e spec report");
|
||||
expect(workflow).toContain("open-design-beta-linux-e2e-report");
|
||||
expect(workflow).toContain("Download linux e2e spec report");
|
||||
expectReleaseLinuxBuildPreservesEvidence(workflow, "Build beta linux artifacts");
|
||||
expectReleaseLinuxSmokePreservesEvidenceBeforeApt(workflow, "Smoke beta linux AppImage runtime");
|
||||
});
|
||||
|
||||
it("preserves stable linux AppImage smoke reports for release publication", async () => {
|
||||
const workflow = await readFile(releaseStableWorkflowPath, "utf8");
|
||||
const linuxBuildStep = workflow.match(
|
||||
/- name: Build release linux artifacts\n(?:.+\n)+?(?=\n - name: Smoke release linux AppImage runtime)/m,
|
||||
);
|
||||
expect(linuxBuildStep?.[0]).toBeDefined();
|
||||
expect(linuxBuildStep?.[0]).toContain(
|
||||
'node -e \'const fs = require("node:fs"); JSON.parse(fs.readFileSync(process.argv[1], "utf8"));\' "$build_json_path"',
|
||||
);
|
||||
expect(workflow).toContain("Smoke release linux AppImage runtime");
|
||||
expect(workflow).toContain("manifest.json");
|
||||
expect(workflow).toContain("tools-pack.json");
|
||||
expect(workflow).toContain("Upload linux e2e spec report");
|
||||
expect(workflow).toContain("open-design-release-linux-e2e-report");
|
||||
expect(workflow).toContain("Download linux e2e spec report");
|
||||
expectReleaseLinuxBuildPreservesEvidence(workflow, "Build release linux artifacts");
|
||||
expectReleaseLinuxSmokePreservesEvidenceBeforeApt(workflow, "Smoke release linux AppImage runtime");
|
||||
});
|
||||
});
|
||||
|
||||
function expectPrLinuxBuildPreservesEvidence(workflow: string, stepName: string): void {
|
||||
const step = workflow.match(new RegExp(`- name: ${stepName}\\n(?:.+\\n)+?(?=\\n - name: Smoke PR linux headless packaged runtime)`, "m"))?.[0];
|
||||
expect(step).toBeDefined();
|
||||
expect(step).toContain('report_dir="$RUNNER_TEMP/packaged-report/linux-headless"');
|
||||
expect(step).toContain('mkdir -p "$report_dir"');
|
||||
expect(step).toContain('build_json_path="$report_dir/linux-tools-pack-build.json"');
|
||||
expect(step).toContain('build_log_path="$report_dir/linux-tools-pack-build.log"');
|
||||
const capturedStdoutWrites = step?.match(/printf '%s\\n' "\$build_output" \| tee "\$build_json_path"/g) ?? [];
|
||||
expect(capturedStdoutWrites).toHaveLength(2);
|
||||
}
|
||||
|
||||
function expectReleaseLinuxBuildPreservesEvidence(workflow: string, stepName: string): void {
|
||||
const step = workflow.match(new RegExp(`- name: ${stepName}\\n(?:.+\\n)+?(?=\\n - name: Smoke .+ linux AppImage runtime)`, "m"))?.[0];
|
||||
expect(step).toBeDefined();
|
||||
expect(step).toContain('report_dir="$RUNNER_TEMP/release-report/linux"');
|
||||
expect(step).toContain('mkdir -p "$report_dir"');
|
||||
expect(step).toContain('build_json_path="$report_dir/tools-pack.json"');
|
||||
expect(step).toContain('build_log_path="$report_dir/tools-pack.log"');
|
||||
expect(step).toContain('printf \'%s\\n\' "$build_output" | tee "$build_json_path"');
|
||||
}
|
||||
|
||||
function expectReleaseLinuxSmokePreservesEvidenceBeforeApt(workflow: string, stepName: string): void {
|
||||
const step = workflow.match(new RegExp(`- name: ${stepName}\\n(?:.+\\n)+?(?=\\n - name: Upload linux e2e spec report)`, "m"))?.[0];
|
||||
expect(step).toBeDefined();
|
||||
const aptIndex = step?.indexOf("sudo apt-get update") ?? -1;
|
||||
const reportDirIndex = step?.indexOf('report_dir="$RUNNER_TEMP/release-report/linux"') ?? -1;
|
||||
|
||||
expect(aptIndex).toBeGreaterThan(-1);
|
||||
expect(reportDirIndex).toBeGreaterThan(-1);
|
||||
expect(reportDirIndex).toBeLessThan(aptIndex);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ Local lifecycle commands:
|
|||
- `tools-pack linux build --to all` (default; produces AppImage)
|
||||
- `tools-pack linux build --to appimage` (explicit AppImage)
|
||||
- `tools-pack linux build --to dir` (unpacked output for fast iteration)
|
||||
- `tools-pack linux build --containerized` (run electron-builder inside `electronuserland/builder:base` Docker for distro-agnostic glibc compat — requires Docker)
|
||||
- `tools-pack linux build --containerized` (run electron-builder inside `electronuserland/builder:base` Docker for a wider glibc compatibility target — requires Docker)
|
||||
- `tools-pack linux build --to all --portable` (release artifacts that must not bake local tools-pack runtime paths)
|
||||
- `tools-pack linux install`
|
||||
- `tools-pack linux install --headless` (install the headless launcher script instead of the AppImage)
|
||||
|
|
@ -95,9 +95,13 @@ Local lifecycle commands:
|
|||
- `tools-pack linux start --headless` (start the headless entry — daemon + web, no Electron)
|
||||
- `tools-pack linux stop`
|
||||
- `tools-pack linux stop --headless` (stop a running headless process)
|
||||
- `tools-pack linux inspect` (desktop status, eval, and screenshot for AppImage mode)
|
||||
- `tools-pack linux inspect --headless` (status only)
|
||||
- `tools-pack linux logs`
|
||||
- `tools-pack linux uninstall`
|
||||
- `tools-pack linux uninstall --headless`
|
||||
- `tools-pack linux cleanup`
|
||||
- `tools-pack linux cleanup --headless`
|
||||
|
||||
Build artifacts are namespace-scoped under `.tmp/tools-pack/out/linux/namespaces/<namespace>/`. Packaged runtime state is namespace-scoped under `.tmp/tools-pack/runtime/linux/namespaces/<namespace>/{data,logs,runtime,cache,user-data}/`. Containerized build cache lives under `.tmp/tools-pack/.docker-cache/{electron,electron-builder}/`.
|
||||
|
||||
|
|
@ -113,11 +117,14 @@ The `<namespace>` suffix is unconditional so multiple developer namespaces can c
|
|||
|
||||
Headless mode targets environments without a display (WSL2, headless servers, CI) where Electron can't run. If you have a desktop, use the AppImage; if you're SSH'd into a machine or in WSL, use headless.
|
||||
|
||||
`--headless` makes `install`, `start`, and `stop` operate on the headless entry (`@open-design/packaged/dist/headless.mjs`) instead of the AppImage. Headless mode runs daemon + web without Electron.
|
||||
`--headless` makes `install`, `start`, `stop`, `uninstall`, and `cleanup` operate on the headless entry (`@open-design/packaged/dist/headless.mjs`) instead of the AppImage. Headless mode runs daemon + web without Electron.
|
||||
|
||||
- `install --headless` writes a shell launcher at `~/.local/bin/open-design-headless-<namespace>` that bakes in the namespace and resource paths. The launcher is self-contained, but the assembled app directory at those paths must remain in place — don't move it after install.
|
||||
- `start --headless` spawns the headless process directly, redirects stdout/stderr to `logs/desktop/latest.log`, and waits up to 95s (35s for identity marker + 60s for web URL) before returning.
|
||||
- `stop --headless` reads the same `runtime/desktop-root.json` identity marker as the AppImage path, validates `stamp.source === PACKAGED`, sends a graceful SHUTDOWN over IPC, then terminates the process tree. It does not perform the AppImage-specific process-command check.
|
||||
- `inspect --headless` returns status only. Eval and screenshot require AppImage mode because there is no Electron renderer in headless mode.
|
||||
- `uninstall --headless` removes the headless launcher after a safe stop.
|
||||
- `cleanup --headless` stops the headless process before removing namespace output/runtime roots.
|
||||
|
||||
`logs` always reads `logs/desktop/latest.log` regardless of mode, so headless output is visible via `tools-pack linux logs`.
|
||||
|
||||
|
|
@ -141,20 +148,26 @@ Headless mode targets environments without a display (WSL2, headless servers, CI
|
|||
|
||||
Electron 41 on Linux requires `kernel.unprivileged_userns_clone=1` (default on Arch, Ubuntu 24+, Debian 12+) or AppImage's `--no-sandbox` fallback. Most modern distros need no extra setup.
|
||||
|
||||
### Distro-agnostic guarantee
|
||||
### Distro compatibility target
|
||||
|
||||
AppImages built natively on a rolling distro (e.g., Arch / CachyOS) link against recent glibc and may not run on stable distros (Ubuntu 22.04, Debian 12). Use `--containerized` to build against the wide-compat `electronuserland/builder:base` baseline (Ubuntu 18.04 / glibc 2.27).
|
||||
AppImages built natively on a rolling distro (e.g., Arch / CachyOS) link against recent glibc and may not run on stable distros (Ubuntu 22.04, Debian 12). Use `--containerized` to build against the `electronuserland/builder:base` baseline (Ubuntu 18.04 / glibc 2.27), which is the compatibility target for release AppImages rather than a guarantee for every Linux distribution.
|
||||
|
||||
Verified smoke coverage in this repository currently includes:
|
||||
|
||||
- PR lane: Ubuntu GitHub-hosted runner, headless Linux runtime.
|
||||
- Release lane: Ubuntu GitHub-hosted runner, containerized AppImage build plus Xvfb AppImage runtime smoke when the Linux release lane is enabled.
|
||||
- Manual AppImage behavior used to choose `--appimage-extract-and-run`: Ubuntu 24.04 and Arch Linux.
|
||||
|
||||
### Format choice: why AppImage first
|
||||
|
||||
Linux desktop apps in this space split across formats: VS Code ships `.deb` + `.rpm` + Snap; Discord ships AppImage + `.deb`; Slack ships `.deb` + `.rpm`; Cursor and Obsidian ship AppImage. We start with AppImage because it is universal (one artifact runs on any glibc-compatible distro), needs no repo plumbing, and integrates cleanly with the namespace-scoped install layout. `.deb` / `.rpm` / Snap / Flatpak can land incrementally if user demand surfaces.
|
||||
Linux desktop apps in this space split across formats: VS Code ships `.deb` + `.rpm` + Snap; Discord ships AppImage + `.deb`; Slack ships `.deb` + `.rpm`; Cursor and Obsidian ship AppImage. We start with AppImage because one artifact can cover the widest glibc-compatible target without distro repositories, store packaging, signing infrastructure, or per-format install scripts, and it integrates cleanly with the namespace-scoped install layout. `.deb` / `.rpm` / Snap / Flatpak can land incrementally when user demand justifies the extra release ownership.
|
||||
|
||||
### Out of scope (later phases)
|
||||
|
||||
- AppImage signing (`--signed`) — deferred pending a GPG key infrastructure decision and a user-facing verification flow design (no ETA).
|
||||
- AppImage auto-update feed (`latest-linux.yml`) — the linux electron-builder config has no `publish` block wired, so a generated feed would point users at a feed that never updates. Tracked alongside signing.
|
||||
- Additional package formats: `.deb`, `.rpm`, Snap, Flatpak.
|
||||
- Linux entry in `ci.yml` (release lanes only build linux; PR validation does not yet).
|
||||
- Additional package formats: `.deb`, `.rpm`, Snap, Flatpak — deferred until there is demand and an owner for per-distro metadata, signing/store/repository plumbing, install/remove hooks, and release validation.
|
||||
- Full Linux AppImage PR smoke remains release-lane only; PR validation runs the Linux headless packaged smoke because it does not require a display server.
|
||||
|
||||
`--to dmg` is manual-install DMG output only. Any builder-generated updater metadata such as `latest-mac.yml` or
|
||||
`.blockmap` files is treated as scratch and cleaned from the builder directory; release-beta generates the authoritative
|
||||
|
|
|
|||
|
|
@ -28,13 +28,16 @@ import {
|
|||
cleanupPackedLinuxNamespace,
|
||||
installPackedLinuxApp,
|
||||
installPackedLinuxHeadless,
|
||||
inspectPackedLinuxApp,
|
||||
packLinux,
|
||||
readPackedLinuxLogs,
|
||||
resolveLinuxLifecycleMode,
|
||||
startPackedLinuxApp,
|
||||
startPackedLinuxHeadless,
|
||||
stopPackedLinuxApp,
|
||||
stopPackedLinuxHeadless,
|
||||
uninstallPackedLinuxApp,
|
||||
uninstallPackedLinuxHeadless,
|
||||
} from "./linux.js";
|
||||
|
||||
type CliOptions = ToolPackCliOptions;
|
||||
|
|
@ -182,32 +185,47 @@ addWinLifecycleOptions(
|
|||
}
|
||||
});
|
||||
|
||||
addBuildOptions(addSharedOptions(cli.command("linux <action>", "Linux packaging commands: build|install|start|stop|logs|uninstall|cleanup")), "linux")
|
||||
.option("--containerized", "build inside electronuserland/builder Docker for distro-agnostic glibc compat")
|
||||
.option("--headless", "install/start/stop the headless (no-Electron) entry instead of the full desktop app")
|
||||
addBuildOptions(addSharedOptions(cli.command("linux <action>", "Linux packaging commands: build|install|start|stop|logs|uninstall|cleanup|inspect")), "linux")
|
||||
.option("--containerized", "build inside electronuserland/builder Docker for wider glibc compatibility")
|
||||
.option("--headless", "install/start/stop/uninstall/cleanup the headless entry; inspect returns status only")
|
||||
.action(async (action: string, options: CliOptions) => {
|
||||
const config = resolveToolPackConfig("linux", options);
|
||||
switch (action) {
|
||||
case "build":
|
||||
printJson(await packLinux(config));
|
||||
return;
|
||||
case "install":
|
||||
printJson(await (options.headless ? installPackedLinuxHeadless(config) : installPackedLinuxApp(config)));
|
||||
case "install": {
|
||||
const mode = resolveLinuxLifecycleMode(options, "install");
|
||||
printJson(await (mode === "headless" ? installPackedLinuxHeadless(config) : installPackedLinuxApp(config)));
|
||||
return;
|
||||
case "start":
|
||||
printJson(await (options.headless ? startPackedLinuxHeadless(config) : startPackedLinuxApp(config)));
|
||||
}
|
||||
case "start": {
|
||||
const mode = resolveLinuxLifecycleMode(options, "start");
|
||||
printJson(await (mode === "headless" ? startPackedLinuxHeadless(config) : startPackedLinuxApp(config)));
|
||||
return;
|
||||
case "stop":
|
||||
printJson(await (options.headless ? stopPackedLinuxHeadless(config) : stopPackedLinuxApp(config)));
|
||||
}
|
||||
case "stop": {
|
||||
const mode = resolveLinuxLifecycleMode(options, "stop");
|
||||
printJson(await (mode === "headless" ? stopPackedLinuxHeadless(config) : stopPackedLinuxApp(config)));
|
||||
return;
|
||||
}
|
||||
case "logs":
|
||||
printLogs(await readPackedLinuxLogs(config), options);
|
||||
return;
|
||||
case "uninstall":
|
||||
printJson(await uninstallPackedLinuxApp(config));
|
||||
case "inspect":
|
||||
printJson(await inspectPackedLinuxApp(config, {
|
||||
expr: options.expr,
|
||||
headless: options.headless === true,
|
||||
path: options.path,
|
||||
}));
|
||||
return;
|
||||
case "uninstall": {
|
||||
const mode = resolveLinuxLifecycleMode(options, "uninstall");
|
||||
printJson(await (mode === "headless" ? uninstallPackedLinuxHeadless(config) : uninstallPackedLinuxApp(config)));
|
||||
return;
|
||||
}
|
||||
case "cleanup":
|
||||
printJson(await cleanupPackedLinuxNamespace(config));
|
||||
printJson(await cleanupPackedLinuxNamespace(config, options));
|
||||
return;
|
||||
default:
|
||||
throw new Error(`unsupported linux action: ${action}`);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ import {
|
|||
SIDECAR_MESSAGES,
|
||||
SIDECAR_MODES,
|
||||
SIDECAR_SOURCES,
|
||||
type DesktopEvalResult,
|
||||
type DesktopScreenshotResult,
|
||||
type DesktopStatusSnapshot,
|
||||
type SidecarStamp,
|
||||
} from "@open-design/sidecar-proto";
|
||||
|
|
@ -32,6 +34,14 @@ const execFileAsync = promisify(execFile);
|
|||
const PRODUCT_NAME = "Open Design";
|
||||
const APP_IMAGE_PRODUCT_NAME = "Open-Design";
|
||||
const DESKTOP_LOG_ECHO_ENV = "OD_DESKTOP_LOG_ECHO";
|
||||
// The containerized build sets this to the standalone pnpm binary fetched by
|
||||
// buildDockerArgs; runProductionInstall reads it to avoid invoking `npm` inside
|
||||
// `electronuserland/builder:base`, which strips npm/npx/corepack.
|
||||
const PRODUCTION_INSTALL_PNPM_BIN_ENV = "OD_TOOLS_PACK_PNPM_BIN";
|
||||
const CONTAINER_PNPM_PATH = "/tmp/pnpm";
|
||||
const CONTAINER_PNPM_HOME = "/tmp/pnpm-home";
|
||||
const CONTAINER_NODE_VERSION = "24.14.1";
|
||||
const CONTAINER_TOOLS_PACK_CLI_PATH = "tools/pack/bin/tools-pack.mjs";
|
||||
|
||||
const INTERNAL_PACKAGES = [
|
||||
{ directory: "packages/contracts", name: "@open-design/contracts" },
|
||||
|
|
@ -48,6 +58,16 @@ export function sanitizeNamespace(value: string): string {
|
|||
return value.replace(/[^A-Za-z0-9._-]+/g, "-");
|
||||
}
|
||||
|
||||
export type LinuxLifecycleAction = "cleanup" | "install" | "start" | "stop" | "uninstall";
|
||||
export type LinuxLifecycleMode = "appimage" | "headless";
|
||||
|
||||
export function resolveLinuxLifecycleMode(
|
||||
options: { headless?: boolean },
|
||||
_action: LinuxLifecycleAction,
|
||||
): LinuxLifecycleMode {
|
||||
return options.headless === true ? "headless" : "appimage";
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
|
|
@ -106,22 +126,46 @@ export function buildDockerArgs(
|
|||
// - config.appVersion is shell-quoted below because release versions can
|
||||
// carry punctuation that is not part of the namespace / target enums.
|
||||
//
|
||||
// We can't rely on `corepack pnpm` here: although Node 16.10+ ships corepack,
|
||||
// the `electronuserland/builder:base` image strips the corepack binary, so
|
||||
// the inner `bash -lc` fails with `corepack: command not found`. We also
|
||||
// can't `corepack enable` ourselves — the container runs as the host's
|
||||
// non-root uid (--user above) and corepack would try to write shims next
|
||||
// to the system Node binary, which is owned by root in this image.
|
||||
// The `electronuserland/builder:base` image is intentionally minimal: it
|
||||
// strips node/npm/npx/corepack from PATH. Every "ask the image to invoke a
|
||||
// package-manager shim" path fails with `command not found`.
|
||||
//
|
||||
// Use `npx --yes pnpm@<version>` instead: `npx` ships with npm (always
|
||||
// present in the image), `--yes` skips the install confirmation, and the
|
||||
// package gets cached under `$HOME/.npm/_npx`, which is writable by the
|
||||
// unprivileged user. The pinned version matches the `packageManager`
|
||||
// field in the root package.json so reproducibility is preserved.
|
||||
// Download the official pnpm `linuxstatic-<arch>` standalone binary at
|
||||
// container start. The binary bundles its own Node runtime, so it does not
|
||||
// depend on the image's npm tooling. Select the asset by the container CPU so
|
||||
// amd64 GitHub runners and arm64 local Docker hosts both work. Stage it under
|
||||
// `/tmp/pnpm`, which is writable by the unprivileged container user. Then use
|
||||
// it to install a pinned Node into PNPM_HOME so root lifecycle scripts and the
|
||||
// final tools-pack CLI can run through an explicit `node .../tools-pack.mjs`
|
||||
// entrypoint instead of generated `node_modules/.bin/*` shims.
|
||||
//
|
||||
// Route bootstrap and install diagnostics to stderr so stdout remains
|
||||
// machine-readable when the inner `tools-pack linux build --json` emits JSON.
|
||||
//
|
||||
// The pinned version matches the `packageManager` field in the root
|
||||
// package.json so reproducibility is preserved.
|
||||
const PNPM_VERSION = "10.33.2";
|
||||
const pnpmCmd = `npx --yes pnpm@${PNPM_VERSION}`;
|
||||
const pnpmLinuxStaticX64Sha256 = "a47be715939bafa420fbdc5e34f7f9d8292c032402162c89ccb611e944e526d6";
|
||||
const pnpmLinuxStaticArm64Sha256 = "4d402d0ef12cdc4d81ca339904e68638d841f4e27c73e460534d06e6b56048a9";
|
||||
const pnpmReleaseUrl = `https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}`;
|
||||
const setupPnpm =
|
||||
`command -v curl >/dev/null || { echo "curl not found in container image" >&2; exit 127; } && ` +
|
||||
`mkdir -p ${CONTAINER_PNPM_HOME} && ` +
|
||||
`case "$(uname -m)" in ` +
|
||||
`x86_64) PNPM_ASSET=pnpm-linuxstatic-x64; PNPM_SHA256=${pnpmLinuxStaticX64Sha256} ;; ` +
|
||||
`aarch64) PNPM_ASSET=pnpm-linuxstatic-arm64; PNPM_SHA256=${pnpmLinuxStaticArm64Sha256} ;; ` +
|
||||
`*) echo "unsupported container arch: $(uname -m)" >&2; exit 1 ;; ` +
|
||||
`esac && ` +
|
||||
`curl --retry 3 --retry-all-errors --connect-timeout 10 --max-time 60 -fsSL "${pnpmReleaseUrl}/$PNPM_ASSET" -o ${CONTAINER_PNPM_PATH}.tmp && ` +
|
||||
`echo "$PNPM_SHA256 ${CONTAINER_PNPM_PATH}.tmp" | sha256sum -c - && ` +
|
||||
`mv ${CONTAINER_PNPM_PATH}.tmp ${CONTAINER_PNPM_PATH} && ` +
|
||||
`chmod +x ${CONTAINER_PNPM_PATH} && ` +
|
||||
`PNPM_HOME=${CONTAINER_PNPM_HOME} PATH=${CONTAINER_PNPM_HOME}:$PATH ${CONTAINER_PNPM_PATH} env use --global ${CONTAINER_NODE_VERSION} && ` +
|
||||
`export PNPM_HOME=${CONTAINER_PNPM_HOME} PATH=${CONTAINER_PNPM_HOME}:$PATH && ` +
|
||||
`command -v node >/dev/null`;
|
||||
const pnpmCmd = CONTAINER_PNPM_PATH;
|
||||
const innerArgs = [
|
||||
`${pnpmCmd} tools-pack linux build`,
|
||||
`node ${CONTAINER_TOOLS_PACK_CLI_PATH} linux build`,
|
||||
`--to ${config.to}`,
|
||||
`--namespace ${config.namespace}`,
|
||||
"--dir /tools-pack",
|
||||
|
|
@ -132,7 +176,7 @@ export function buildDockerArgs(
|
|||
if (config.appVersion != null) {
|
||||
innerArgs.push(`--app-version ${shellQuote(config.appVersion)}`);
|
||||
}
|
||||
const innerCommand = `${pnpmCmd} install --frozen-lockfile && ` + innerArgs.join(" ");
|
||||
const innerCommand = `{ ${setupPnpm} && ${pnpmCmd} install --frozen-lockfile; } >&2 && ` + innerArgs.join(" ");
|
||||
|
||||
const dockerArgs = [
|
||||
"run",
|
||||
|
|
@ -155,6 +199,8 @@ export function buildDockerArgs(
|
|||
"ELECTRON_CACHE=/home/builder/.cache/electron",
|
||||
"-e",
|
||||
"ELECTRON_BUILDER_CACHE=/home/builder/.cache/electron-builder",
|
||||
"-e",
|
||||
`${PRODUCTION_INSTALL_PNPM_BIN_ENV}=${CONTAINER_PNPM_PATH}`,
|
||||
];
|
||||
if (config.telemetryRelayUrl != null) {
|
||||
dockerArgs.push("-e", `OPEN_DESIGN_TELEMETRY_RELAY_URL=${config.telemetryRelayUrl}`);
|
||||
|
|
@ -276,8 +322,31 @@ async function runPnpm(
|
|||
});
|
||||
}
|
||||
|
||||
async function runNpmInstall(appRoot: string): Promise<void> {
|
||||
await execFileAsync("npm", ["install", "--omit=dev", "--no-package-lock"], {
|
||||
export type ProductionInstallCommand = { command: string; args: string[] };
|
||||
|
||||
// Picks the package manager used to materialize the assembled-app node_modules
|
||||
// during writeAssembledApp. The default (`npm`) preserves host behavior for
|
||||
// developer-machine builds. When the build runs inside
|
||||
// `electronuserland/builder:base` (which strips npm, npx, and corepack),
|
||||
// buildDockerArgs sets OD_TOOLS_PACK_PNPM_BIN to the standalone pnpm binary it
|
||||
// bootstrapped, and this resolver routes the install through that binary.
|
||||
// `--config.node-linker=hoisted` keeps the resulting layout flat so
|
||||
// electron-builder packs node_modules the same way it does for npm-installed
|
||||
// trees.
|
||||
export function resolveProductionInstallCommand(env: NodeJS.ProcessEnv): ProductionInstallCommand {
|
||||
const pnpmBin = env[PRODUCTION_INSTALL_PNPM_BIN_ENV];
|
||||
if (pnpmBin != null && pnpmBin.length > 0) {
|
||||
return {
|
||||
command: pnpmBin,
|
||||
args: ["install", "--prod", "--no-lockfile", "--config.node-linker=hoisted"],
|
||||
};
|
||||
}
|
||||
return { command: "npm", args: ["install", "--omit=dev", "--no-package-lock"] };
|
||||
}
|
||||
|
||||
async function runProductionInstall(appRoot: string): Promise<void> {
|
||||
const { command, args } = resolveProductionInstallCommand(process.env);
|
||||
await execFileAsync(command, args, {
|
||||
cwd: appRoot,
|
||||
env: process.env,
|
||||
});
|
||||
|
|
@ -402,7 +471,7 @@ async function writeAssembledApp(
|
|||
"utf8",
|
||||
);
|
||||
|
||||
await runNpmInstall(paths.assembledAppRoot);
|
||||
await runProductionInstall(paths.assembledAppRoot);
|
||||
}
|
||||
|
||||
// --- Step 5: writeLinuxBuilderConfig helper ---
|
||||
|
|
@ -649,6 +718,19 @@ export type LinuxStartResult = {
|
|||
status: DesktopStatusSnapshot | null;
|
||||
};
|
||||
|
||||
export type LinuxInspectResult = {
|
||||
eval?: DesktopEvalResult;
|
||||
screenshot?: DesktopScreenshotResult;
|
||||
status: DesktopStatusSnapshot | null;
|
||||
};
|
||||
|
||||
export function shouldRejectLinuxHeadlessInspectOptions(options: {
|
||||
expr?: string;
|
||||
path?: string;
|
||||
}): boolean {
|
||||
return options.expr != null || options.path != null;
|
||||
}
|
||||
|
||||
type DesktopRootIdentityMarker = {
|
||||
appPath: string;
|
||||
executablePath: string;
|
||||
|
|
@ -678,6 +760,14 @@ export type LinuxStopResult = {
|
|||
stoppedPids: number[];
|
||||
};
|
||||
|
||||
type ProcessSnapshots = Awaited<ReturnType<typeof listProcessSnapshots>>;
|
||||
type ProcessSnapshot = ProcessSnapshots[number];
|
||||
|
||||
type DesktopAppImageMarkerValidation =
|
||||
| { status: "valid"; candidate: ProcessSnapshot }
|
||||
| { status: "not-running" }
|
||||
| { status: "invalid"; candidate: ProcessSnapshot; processCommand: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value != null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -698,11 +788,10 @@ function isDesktopRootIdentityMarker(value: unknown): value is DesktopRootIdenti
|
|||
);
|
||||
}
|
||||
|
||||
async function readDesktopRootIdentityMarker(config: ToolPackConfig): Promise<{
|
||||
async function readRootIdentityMarker(markerPath: string): Promise<{
|
||||
fallback: DesktopRootIdentityFallback;
|
||||
marker: DesktopRootIdentityMarker | null;
|
||||
}> {
|
||||
const markerPath = desktopIdentityPath(config);
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(await readFile(markerPath, "utf8"));
|
||||
|
|
@ -722,6 +811,20 @@ async function readDesktopRootIdentityMarker(config: ToolPackConfig): Promise<{
|
|||
};
|
||||
}
|
||||
|
||||
async function readDesktopRootIdentityMarker(config: ToolPackConfig): Promise<{
|
||||
fallback: DesktopRootIdentityFallback;
|
||||
marker: DesktopRootIdentityMarker | null;
|
||||
}> {
|
||||
return readRootIdentityMarker(desktopIdentityPath(config));
|
||||
}
|
||||
|
||||
async function readHeadlessRootIdentityMarker(config: ToolPackConfig): Promise<{
|
||||
fallback: DesktopRootIdentityFallback;
|
||||
marker: DesktopRootIdentityMarker | null;
|
||||
}> {
|
||||
return readRootIdentityMarker(headlessIdentityPath(config));
|
||||
}
|
||||
|
||||
async function readProcessEnv(pid: number): Promise<Record<string, string>> {
|
||||
try {
|
||||
const raw = await readFile(`/proc/${pid}/environ`, "utf8");
|
||||
|
|
@ -745,6 +848,55 @@ async function readProcessExe(pid: number): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
async function validateDesktopAppImageMarker(
|
||||
config: ToolPackConfig,
|
||||
marker: DesktopRootIdentityMarker,
|
||||
snapshots: ProcessSnapshots,
|
||||
): Promise<DesktopAppImageMarkerValidation> {
|
||||
const candidate = snapshots.find((s) => s.pid === marker.pid);
|
||||
if (candidate == null) return { status: "not-running" };
|
||||
|
||||
// Validate the marker stamp (file content written by apps/packaged itself)
|
||||
// rather than the process command line. Menu launches via the .desktop
|
||||
// entry don't pass createProcessStampArgs to the AppImage -- they only set
|
||||
// OD_PACKAGED_NAMESPACE -- so apps/packaged falls back to a SIDECAR_SOURCES.PACKAGED
|
||||
// stamp. Validating the process command would reject those legitimate
|
||||
// launches as `unmanaged`, which on uninstall would also remove the
|
||||
// AppImage/desktop/icon files out from under the still-running app.
|
||||
// Accept either TOOLS_PACK (CLI start) or PACKAGED (menu launch). Mirrors
|
||||
// the dual-source acceptance pattern in mac/lifecycle.ts.
|
||||
const expectedIpc = resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace: config.namespace,
|
||||
});
|
||||
const stampOk =
|
||||
marker.stamp.app === APP_KEYS.DESKTOP &&
|
||||
marker.stamp.mode === SIDECAR_MODES.RUNTIME &&
|
||||
marker.stamp.namespace === config.namespace &&
|
||||
marker.stamp.ipc === expectedIpc &&
|
||||
(marker.stamp.source === SIDECAR_SOURCES.TOOLS_PACK ||
|
||||
marker.stamp.source === SIDECAR_SOURCES.PACKAGED);
|
||||
const paths = resolveLinuxPaths(config);
|
||||
const exePath = await readProcessExe(marker.pid);
|
||||
const env = await readProcessEnv(marker.pid);
|
||||
// marker.appPath is unreliable on Linux (apps/packaged writes "/"). Use the
|
||||
// canonical install path we know about, falling back to the built AppImage
|
||||
// for not-yet-installed builds.
|
||||
const candidateAppImagePath =
|
||||
(await pathExists(paths.installAppImagePath)) ? paths.installAppImagePath : await findBuiltAppImage(paths);
|
||||
const cmdOk = candidateAppImagePath != null && matchesAppImageProcess(
|
||||
{ pid: marker.pid, executable: exePath, env },
|
||||
candidateAppImagePath,
|
||||
);
|
||||
|
||||
if (stampOk && cmdOk && marker.namespaceRoot === config.roots.runtime.namespaceRoot) {
|
||||
return { candidate, status: "valid" };
|
||||
}
|
||||
|
||||
return { candidate, processCommand: candidate.command, status: "invalid" };
|
||||
}
|
||||
|
||||
function desktopLogPath(config: ToolPackConfig): string {
|
||||
return join(config.roots.runtime.namespaceRoot, "logs", APP_KEYS.DESKTOP, "latest.log");
|
||||
}
|
||||
|
|
@ -753,6 +905,10 @@ function desktopIdentityPath(config: ToolPackConfig): string {
|
|||
return join(config.roots.runtime.namespaceRoot, "runtime", "desktop-root.json");
|
||||
}
|
||||
|
||||
function headlessIdentityPath(config: ToolPackConfig): string {
|
||||
return join(config.roots.runtime.namespaceRoot, "runtime", "headless-root.json");
|
||||
}
|
||||
|
||||
function linuxDesktopStamp(config: ToolPackConfig): SidecarStamp {
|
||||
return {
|
||||
app: APP_KEYS.DESKTOP,
|
||||
|
|
@ -872,7 +1028,6 @@ async function teardownOrphanedStart(rootPid: number): Promise<void> {
|
|||
}
|
||||
|
||||
export async function stopPackedLinuxApp(config: ToolPackConfig): Promise<LinuxStopResult> {
|
||||
const paths = resolveLinuxPaths(config);
|
||||
const { fallback, marker } = await readDesktopRootIdentityMarker(config);
|
||||
|
||||
if (marker == null) {
|
||||
|
|
@ -888,8 +1043,8 @@ export async function stopPackedLinuxApp(config: ToolPackConfig): Promise<LinuxS
|
|||
|
||||
// Validate the marker still represents a live, owned process.
|
||||
const snapshots = await listProcessSnapshots();
|
||||
const candidate = snapshots.find((s) => s.pid === marker.pid);
|
||||
if (candidate == null) {
|
||||
const validation = await validateDesktopAppImageMarker(config, marker, snapshots);
|
||||
if (validation.status === "not-running") {
|
||||
return {
|
||||
fallback: { ...fallback, reason: "marker-pid-not-running" },
|
||||
gracefulRequested: false,
|
||||
|
|
@ -900,45 +1055,12 @@ export async function stopPackedLinuxApp(config: ToolPackConfig): Promise<LinuxS
|
|||
};
|
||||
}
|
||||
|
||||
// Validate the marker stamp (file content written by apps/packaged itself)
|
||||
// rather than the process command line. Menu launches via the .desktop
|
||||
// entry don't pass createProcessStampArgs to the AppImage -- they only set
|
||||
// OD_PACKAGED_NAMESPACE -- so apps/packaged falls back to a SIDECAR_SOURCES.PACKAGED
|
||||
// stamp. Validating the process command would reject those legitimate
|
||||
// launches as `unmanaged`, which on uninstall would also remove the
|
||||
// AppImage/desktop/icon files out from under the still-running app.
|
||||
// Accept either TOOLS_PACK (CLI start) or PACKAGED (menu launch). Mirrors
|
||||
// the dual-source acceptance pattern in mac/lifecycle.ts.
|
||||
const expectedIpc = resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace: config.namespace,
|
||||
});
|
||||
const stampOk =
|
||||
marker.stamp.app === APP_KEYS.DESKTOP &&
|
||||
marker.stamp.mode === SIDECAR_MODES.RUNTIME &&
|
||||
marker.stamp.namespace === config.namespace &&
|
||||
marker.stamp.ipc === expectedIpc &&
|
||||
(marker.stamp.source === SIDECAR_SOURCES.TOOLS_PACK ||
|
||||
marker.stamp.source === SIDECAR_SOURCES.PACKAGED);
|
||||
const exePath = await readProcessExe(marker.pid);
|
||||
const env = await readProcessEnv(marker.pid);
|
||||
// marker.appPath is unreliable on Linux (apps/packaged writes "/"). Use the
|
||||
// canonical install path we know about, falling back to the built AppImage
|
||||
// for not-yet-installed builds.
|
||||
const candidateAppImagePath =
|
||||
(await pathExists(paths.installAppImagePath)) ? paths.installAppImagePath : await findBuiltAppImage(paths);
|
||||
const cmdOk = candidateAppImagePath != null && matchesAppImageProcess(
|
||||
{ pid: marker.pid, executable: exePath, env },
|
||||
candidateAppImagePath,
|
||||
);
|
||||
|
||||
if (!stampOk || !cmdOk || marker.namespaceRoot !== config.roots.runtime.namespaceRoot) {
|
||||
if (validation.status === "invalid") {
|
||||
return {
|
||||
fallback: {
|
||||
...fallback,
|
||||
marker: { pid: marker.pid, stamp: marker.stamp },
|
||||
processCommand: candidate.command,
|
||||
processCommand: validation.processCommand,
|
||||
reason: "marker-validation-failed",
|
||||
},
|
||||
gracefulRequested: false,
|
||||
|
|
@ -993,6 +1115,48 @@ export async function readPackedLinuxLogs(config: ToolPackConfig): Promise<{
|
|||
return { logs, namespace: config.namespace };
|
||||
}
|
||||
|
||||
export async function inspectPackedLinuxApp(
|
||||
config: ToolPackConfig,
|
||||
options: { expr?: string; headless?: boolean; path?: string },
|
||||
): Promise<LinuxInspectResult> {
|
||||
if (options.headless === true && shouldRejectLinuxHeadlessInspectOptions(options)) {
|
||||
throw new Error("linux inspect --headless supports status only; omit --expr and --path");
|
||||
}
|
||||
|
||||
const stamp = linuxDesktopStamp(config);
|
||||
const status = await requestJsonIpc<DesktopStatusSnapshot>(
|
||||
stamp.ipc,
|
||||
{ type: SIDECAR_MESSAGES.STATUS },
|
||||
{ timeoutMs: 2000 },
|
||||
).catch(() => null);
|
||||
|
||||
if (options.headless === true) {
|
||||
return { status };
|
||||
}
|
||||
|
||||
return {
|
||||
...(options.expr == null
|
||||
? {}
|
||||
: {
|
||||
eval: await requestJsonIpc<DesktopEvalResult>(
|
||||
stamp.ipc,
|
||||
{ input: { expression: options.expr }, type: SIDECAR_MESSAGES.EVAL },
|
||||
{ timeoutMs: 5000 },
|
||||
),
|
||||
}),
|
||||
...(options.path == null
|
||||
? {}
|
||||
: {
|
||||
screenshot: await requestJsonIpc<DesktopScreenshotResult>(
|
||||
stamp.ipc,
|
||||
{ input: { path: options.path }, type: SIDECAR_MESSAGES.SCREENSHOT },
|
||||
{ timeoutMs: 10000 },
|
||||
),
|
||||
}),
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
export type LinuxUninstallResult = {
|
||||
namespace: string;
|
||||
removed: {
|
||||
|
|
@ -1061,6 +1225,36 @@ export async function uninstallPackedLinuxApp(config: ToolPackConfig): Promise<L
|
|||
};
|
||||
}
|
||||
|
||||
export type LinuxHeadlessUninstallResult = {
|
||||
launcherPath: string;
|
||||
namespace: string;
|
||||
removed: "ok" | "already-removed" | "skipped-process-running";
|
||||
stop: LinuxStopResult;
|
||||
};
|
||||
|
||||
export async function uninstallPackedLinuxHeadless(
|
||||
config: ToolPackConfig,
|
||||
): Promise<LinuxHeadlessUninstallResult> {
|
||||
const stop = await stopPackedLinuxHeadless(config);
|
||||
const launcherPath = headlessLauncherPath(config);
|
||||
|
||||
if (!isSafeToRemoveInstallFiles(stop)) {
|
||||
return {
|
||||
launcherPath,
|
||||
namespace: config.namespace,
|
||||
removed: "skipped-process-running",
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
launcherPath,
|
||||
namespace: config.namespace,
|
||||
removed: await tryRemove(launcherPath),
|
||||
stop,
|
||||
};
|
||||
}
|
||||
|
||||
export type LinuxCleanupResult = {
|
||||
namespace: string;
|
||||
outputRoot: string;
|
||||
|
|
@ -1211,9 +1405,11 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise<
|
|||
await mkdir(dirname(logPath), { recursive: true });
|
||||
await writeFile(logPath, "", "utf8");
|
||||
|
||||
// Remove stale identity markers from a previous run so waitForMarker and
|
||||
// waitForWebIdentity below wait for the newly spawned process.
|
||||
await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined);
|
||||
// Remove stale headless identity markers from a previous run so waitForMarker
|
||||
// and waitForWebIdentity below wait for the newly spawned process. Leave
|
||||
// desktop-root.json alone: a menu-launched AppImage uses that marker and
|
||||
// headless start/stop must not claim or erase it.
|
||||
await rm(headlessIdentityPath(config), { force: true }).catch(() => undefined);
|
||||
await rm(webIdentityPath(config), { force: true }).catch(() => undefined);
|
||||
|
||||
// Open the log file so stdout/stderr from the headless process are captured.
|
||||
|
|
@ -1243,11 +1439,11 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise<
|
|||
await logHandle.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
const markerPath = desktopIdentityPath(config);
|
||||
const markerPath = headlessIdentityPath(config);
|
||||
const ready = await waitForMarker(markerPath, 35_000);
|
||||
if (!ready) {
|
||||
await teardownOrphanedStart(child.pid).catch(() => undefined);
|
||||
throw new Error(`headless identity marker not written within 35s at ${markerPath}`);
|
||||
throw new Error(`headless-root.json not written within 35s at ${markerPath}`);
|
||||
}
|
||||
|
||||
const webIdentity = await waitForWebIdentity(config, child.pid, 60_000);
|
||||
|
|
@ -1266,7 +1462,7 @@ export async function startPackedLinuxHeadless(config: ToolPackConfig): Promise<
|
|||
}
|
||||
|
||||
export async function stopPackedLinuxHeadless(config: ToolPackConfig): Promise<LinuxStopResult> {
|
||||
const { fallback, marker } = await readDesktopRootIdentityMarker(config);
|
||||
const { fallback, marker } = await readHeadlessRootIdentityMarker(config);
|
||||
|
||||
if (marker == null) {
|
||||
return {
|
||||
|
|
@ -1292,9 +1488,10 @@ export async function stopPackedLinuxHeadless(config: ToolPackConfig): Promise<L
|
|||
};
|
||||
}
|
||||
|
||||
// Validate the stamp. Headless writes source=PACKAGED; skip the AppImage
|
||||
// process-command check used by stopPackedLinuxApp since the headless entry
|
||||
// is a plain Node process, not an AppImage.
|
||||
// Validate the stamp from headless-root.json. A menu-launched AppImage writes
|
||||
// the same PACKAGED source to desktop-root.json, so the distinct marker path
|
||||
// is the ownership boundary that keeps --headless stop/cleanup from claiming
|
||||
// the AppImage runtime.
|
||||
const expectedIpc = resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
|
|
@ -1335,7 +1532,7 @@ export async function stopPackedLinuxHeadless(config: ToolPackConfig): Promise<L
|
|||
const result = await stopProcesses(treePids);
|
||||
|
||||
if (result.remainingPids.length === 0) {
|
||||
await rm(desktopIdentityPath(config), { force: true }).catch(() => undefined);
|
||||
await rm(headlessIdentityPath(config), { force: true }).catch(() => undefined);
|
||||
await rm(webIdentityPath(config), { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
|
|
@ -1348,8 +1545,14 @@ export async function stopPackedLinuxHeadless(config: ToolPackConfig): Promise<L
|
|||
};
|
||||
}
|
||||
|
||||
export async function cleanupPackedLinuxNamespace(config: ToolPackConfig): Promise<LinuxCleanupResult> {
|
||||
const stop = await stopPackedLinuxApp(config);
|
||||
export async function cleanupPackedLinuxNamespace(
|
||||
config: ToolPackConfig,
|
||||
options: { headless?: boolean } = {},
|
||||
): Promise<LinuxCleanupResult> {
|
||||
const mode = resolveLinuxLifecycleMode(options, "cleanup");
|
||||
const stop = mode === "headless"
|
||||
? await stopPackedLinuxHeadless(config)
|
||||
: await stopPackedLinuxApp(config);
|
||||
const outputRoot = config.roots.output.namespaceRoot;
|
||||
const runtimeNamespaceRoot = config.roots.runtime.namespaceRoot;
|
||||
|
||||
|
|
@ -1365,6 +1568,24 @@ export async function cleanupPackedLinuxNamespace(config: ToolPackConfig): Promi
|
|||
};
|
||||
}
|
||||
|
||||
if (mode === "headless") {
|
||||
const { marker } = await readDesktopRootIdentityMarker(config);
|
||||
if (marker != null) {
|
||||
const desktop = await validateDesktopAppImageMarker(config, marker, await listProcessSnapshots());
|
||||
if (desktop.status !== "not-running") {
|
||||
return {
|
||||
namespace: config.namespace,
|
||||
outputRoot,
|
||||
removedOutputRoot: false,
|
||||
removedRuntimeNamespaceRoot: false,
|
||||
runtimeNamespaceRoot,
|
||||
skipped: true,
|
||||
stop,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hadOutput = await pathExists(outputRoot);
|
||||
if (hadOutput) await rm(outputRoot, { force: true, recursive: true });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,52 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { posix } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { requestJsonIpc, resolveAppIpcPath } from "@open-design/sidecar";
|
||||
import {
|
||||
APP_KEYS,
|
||||
OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
SIDECAR_MODES,
|
||||
SIDECAR_SOURCES,
|
||||
} from "@open-design/sidecar-proto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@open-design/sidecar", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@open-design/sidecar")>();
|
||||
return {
|
||||
...actual,
|
||||
requestJsonIpc: vi.fn(async () => {
|
||||
throw new Error("requestJsonIpc should not be called for invalid headless inspect options");
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import type { ToolPackConfig } from "../src/config.js";
|
||||
import {
|
||||
buildDockerArgs,
|
||||
cleanupPackedLinuxNamespace,
|
||||
inspectPackedLinuxApp,
|
||||
matchesAppImageProcess,
|
||||
renderDesktopTemplate,
|
||||
resolveLinuxLifecycleMode,
|
||||
resolveProductionInstallCommand,
|
||||
shouldRejectLinuxHeadlessInspectOptions,
|
||||
sanitizeNamespace,
|
||||
stopPackedLinuxHeadless,
|
||||
} from "../src/linux.js";
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function makeConfig(): ToolPackConfig {
|
||||
return {
|
||||
containerized: true,
|
||||
|
|
@ -97,25 +131,69 @@ describe("buildDockerArgs", () => {
|
|||
expect(args).toContain("OPEN_DESIGN_TELEMETRY_RELAY_URL=https://telemetry.open-design.ai/api/langfuse");
|
||||
});
|
||||
|
||||
it("re-invokes pnpm tools-pack linux build inside the container without --containerized", () => {
|
||||
it("runs the built tools-pack CLI through node inside the container without generated package-bin shims", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toMatch(/npx --yes pnpm@\d+\.\d+\.\d+ install --frozen-lockfile/);
|
||||
expect(last).toMatch(/npx --yes pnpm@\d+\.\d+\.\d+ tools-pack linux build --to all --namespace default/);
|
||||
expect(last).toMatch(/command -v curl >\/dev\/null/);
|
||||
expect(last).toMatch(/case "\$\(uname -m\)" in/);
|
||||
expect(last).toMatch(/x86_64\) PNPM_ASSET=pnpm-linuxstatic-x64; PNPM_SHA256=[a-f0-9]{64}/);
|
||||
expect(last).toMatch(/aarch64\) PNPM_ASSET=pnpm-linuxstatic-arm64; PNPM_SHA256=[a-f0-9]{64}/);
|
||||
expect(last).toMatch(
|
||||
/curl --retry 3 --retry-all-errors --connect-timeout 10 --max-time 60 -fsSL "https:\/\/github\.com\/pnpm\/pnpm\/releases\/download\/v\d+\.\d+\.\d+\/\$PNPM_ASSET" -o \/tmp\/pnpm\.tmp/,
|
||||
);
|
||||
expect(last).toMatch(/echo "\$PNPM_SHA256 \/tmp\/pnpm\.tmp" \| sha256sum -c -/);
|
||||
expect(last).toMatch(/mv \/tmp\/pnpm\.tmp \/tmp\/pnpm/);
|
||||
expect(last).toMatch(/chmod \+x \/tmp\/pnpm/);
|
||||
expect(last).toMatch(/\/tmp\/pnpm env use --global 24\.\d+\.\d+/);
|
||||
expect(last).toMatch(/\/tmp\/pnpm install --frozen-lockfile/);
|
||||
expect(last).toMatch(/node tools\/pack\/bin\/tools-pack\.mjs linux build --to all --namespace default/);
|
||||
expect(last).not.toMatch(/\/tmp\/pnpm tools-pack linux build/);
|
||||
expect(last).not.toMatch(/--containerized/);
|
||||
});
|
||||
|
||||
it("invokes pnpm via `npx --yes pnpm@<version>` (electronuserland/builder:base strips corepack, and the non-root container can't write Node shim dir)", () => {
|
||||
it("fetches pnpm standalone binary instead of relying on image npm tooling", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).not.toMatch(/corepack/);
|
||||
expect(last).toMatch(/npx --yes pnpm@/);
|
||||
expect(last).not.toMatch(/\bnpx\b/);
|
||||
expect(last).not.toMatch(/(^|[;&|]\s*)npm(\s|$)/);
|
||||
expect(last).toMatch(/pnpm-linuxstatic-x64/);
|
||||
expect(last).toMatch(/pnpm-linuxstatic-arm64/);
|
||||
});
|
||||
|
||||
it("routes container setup and install output to stderr before the JSON-emitting build", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toContain("{ command -v curl");
|
||||
expect(last).toContain("/tmp/pnpm install --frozen-lockfile; } >&2 && node tools/pack/bin/tools-pack.mjs linux build");
|
||||
expect(last.indexOf("/tmp/pnpm install --frozen-lockfile")).toBeLessThan(
|
||||
last.indexOf("} >&2 && node tools/pack/bin/tools-pack.mjs linux build"),
|
||||
);
|
||||
});
|
||||
|
||||
it("picks the pnpm asset by container CPU so amd64 and arm64 hosts both work", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toContain('case "$(uname -m)" in');
|
||||
expect(last).toContain("x86_64) PNPM_ASSET=pnpm-linuxstatic-x64");
|
||||
expect(last).toContain("aarch64) PNPM_ASSET=pnpm-linuxstatic-arm64");
|
||||
expect(last).toMatch(/unsupported container arch/);
|
||||
});
|
||||
|
||||
it("verifies the downloaded standalone pnpm binary before executing it", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toMatch(/PNPM_SHA256=[a-f0-9]{64}/);
|
||||
expect(last).toMatch(/sha256sum -c -/);
|
||||
expect(last).toMatch(/\/tmp\/pnpm\.tmp/);
|
||||
expect(last.indexOf("sha256sum -c -")).toBeLessThan(last.indexOf("mv /tmp/pnpm.tmp /tmp/pnpm"));
|
||||
expect(last.indexOf("mv /tmp/pnpm.tmp /tmp/pnpm")).toBeLessThan(last.indexOf("chmod +x /tmp/pnpm"));
|
||||
});
|
||||
|
||||
it("hardcoded pnpm version stays in lockstep with root package.json `packageManager`", () => {
|
||||
// Guard against silent drift: if someone bumps packageManager in the
|
||||
// root package.json but forgets to update PNPM_VERSION in linux.ts,
|
||||
// the Linux container build would silently keep using the old pnpm.
|
||||
// the Linux container build would silently keep downloading the old pnpm.
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const rootPkg = JSON.parse(readFileSync(join(repoRoot, "package.json"), "utf-8")) as {
|
||||
packageManager?: string;
|
||||
|
|
@ -126,7 +204,17 @@ describe("buildDockerArgs", () => {
|
|||
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
expect(last).toContain(`npx --yes pnpm@${expectedVersion}`);
|
||||
expect(last).toContain(`pnpm/releases/download/v${expectedVersion}/$PNPM_ASSET`);
|
||||
});
|
||||
|
||||
it("container Node major stays in lockstep with root .node-version", () => {
|
||||
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const expectedMajor = readFileSync(join(repoRoot, ".node-version"), "utf-8").trim();
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const last = args[args.length - 1];
|
||||
const match = last.match(/\/tmp\/pnpm env use --global (\d+)\.\d+\.\d+/);
|
||||
expect(match, "expected container bootstrap to install an explicit Node version").not.toBeNull();
|
||||
expect(match?.[1]).toBe(expectedMajor);
|
||||
});
|
||||
|
||||
it("forwards --dir /tools-pack so inner build output lands under the mounted host dir", () => {
|
||||
|
|
@ -164,6 +252,252 @@ describe("buildDockerArgs", () => {
|
|||
const last = args[args.length - 1];
|
||||
expect(last).toContain("--app-version '0.5.0-beta.1'\\''quoted'");
|
||||
});
|
||||
|
||||
it("exports OD_TOOLS_PACK_PNPM_BIN=/tmp/pnpm so the inner build's production install skips npm", () => {
|
||||
const args = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const envFlagIndex = args.findIndex(
|
||||
(arg, i) => arg === "-e" && args[i + 1] === "OD_TOOLS_PACK_PNPM_BIN=/tmp/pnpm",
|
||||
);
|
||||
expect(envFlagIndex).toBeGreaterThan(-1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopPackedLinuxHeadless", () => {
|
||||
it("ignores the desktop AppImage identity marker and reads only the headless marker", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "od-linux-headless-marker-"));
|
||||
const namespace = "marker-split";
|
||||
const namespaceRoot = join(root, "runtime", "linux", "namespaces", namespace);
|
||||
const config: ToolPackConfig = {
|
||||
...makeConfig(),
|
||||
namespace,
|
||||
roots: {
|
||||
...makeConfig().roots,
|
||||
runtime: {
|
||||
namespaceBaseRoot: join(root, "runtime", "linux", "namespaces"),
|
||||
namespaceRoot,
|
||||
},
|
||||
},
|
||||
};
|
||||
const markerPath = join(namespaceRoot, "runtime", "desktop-root.json");
|
||||
const stamp = {
|
||||
app: APP_KEYS.DESKTOP,
|
||||
ipc: resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace,
|
||||
}),
|
||||
mode: SIDECAR_MODES.RUNTIME,
|
||||
namespace,
|
||||
source: SIDECAR_SOURCES.PACKAGED,
|
||||
};
|
||||
|
||||
try {
|
||||
await mkdir(dirname(markerPath), { recursive: true });
|
||||
await writeFile(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
appPath: "/tmp/Open-Design.AppImage",
|
||||
executablePath: "/tmp/.mount_od/AppRun",
|
||||
logPath: join(namespaceRoot, "logs", "desktop", "latest.log"),
|
||||
namespaceRoot,
|
||||
pid: Number.MAX_SAFE_INTEGER,
|
||||
ppid: 1,
|
||||
stamp,
|
||||
startedAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
version: 1,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await stopPackedLinuxHeadless(config);
|
||||
|
||||
expect(result.status).toBe("not-running");
|
||||
expect(result.fallback?.reason).toBe("marker-not-found");
|
||||
expect(result.fallback?.markerPath).toBe(join(namespaceRoot, "runtime", "headless-root.json"));
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes stale desktop AppImage markers during headless cleanup", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "od-linux-headless-cleanup-"));
|
||||
const namespace = "cleanup-split";
|
||||
const namespaceRoot = join(root, "runtime", "linux", "namespaces", namespace);
|
||||
const config: ToolPackConfig = {
|
||||
...makeConfig(),
|
||||
namespace,
|
||||
roots: {
|
||||
...makeConfig().roots,
|
||||
output: {
|
||||
...makeConfig().roots.output,
|
||||
namespaceRoot: join(root, "out", "linux", "namespaces", namespace),
|
||||
},
|
||||
runtime: {
|
||||
namespaceBaseRoot: join(root, "runtime", "linux", "namespaces"),
|
||||
namespaceRoot,
|
||||
},
|
||||
},
|
||||
};
|
||||
const markerPath = join(namespaceRoot, "runtime", "desktop-root.json");
|
||||
const stamp = {
|
||||
app: APP_KEYS.DESKTOP,
|
||||
ipc: resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace,
|
||||
}),
|
||||
mode: SIDECAR_MODES.RUNTIME,
|
||||
namespace,
|
||||
source: SIDECAR_SOURCES.PACKAGED,
|
||||
};
|
||||
|
||||
try {
|
||||
await mkdir(dirname(markerPath), { recursive: true });
|
||||
await writeFile(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
appPath: "/tmp/Open-Design.AppImage",
|
||||
executablePath: "/tmp/.mount_od/AppRun",
|
||||
logPath: join(namespaceRoot, "logs", "desktop", "latest.log"),
|
||||
namespaceRoot,
|
||||
pid: Number.MAX_SAFE_INTEGER,
|
||||
ppid: 1,
|
||||
stamp,
|
||||
startedAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
version: 1,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(config.roots.output.namespaceRoot, { recursive: true });
|
||||
|
||||
const result = await cleanupPackedLinuxNamespace(config, { headless: true });
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(result.removedOutputRoot).toBe(true);
|
||||
expect(result.removedRuntimeNamespaceRoot).toBe(true);
|
||||
expect(await pathExists(markerPath)).toBe(false);
|
||||
expect(await pathExists(namespaceRoot)).toBe(false);
|
||||
expect(await pathExists(config.roots.output.namespaceRoot)).toBe(false);
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips headless cleanup while the desktop marker PID is live in the snapshot table", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "od-linux-headless-cleanup-live-"));
|
||||
const namespace = "cleanup-split-live";
|
||||
const namespaceRoot = join(root, "runtime", "linux", "namespaces", namespace);
|
||||
const config: ToolPackConfig = {
|
||||
...makeConfig(),
|
||||
namespace,
|
||||
roots: {
|
||||
...makeConfig().roots,
|
||||
output: {
|
||||
...makeConfig().roots.output,
|
||||
namespaceRoot: join(root, "out", "linux", "namespaces", namespace),
|
||||
},
|
||||
runtime: {
|
||||
namespaceBaseRoot: join(root, "runtime", "linux", "namespaces"),
|
||||
namespaceRoot,
|
||||
},
|
||||
},
|
||||
};
|
||||
const markerPath = join(namespaceRoot, "runtime", "desktop-root.json");
|
||||
const stamp = {
|
||||
app: APP_KEYS.DESKTOP,
|
||||
ipc: resolveAppIpcPath({
|
||||
app: APP_KEYS.DESKTOP,
|
||||
contract: OPEN_DESIGN_SIDECAR_CONTRACT,
|
||||
namespace,
|
||||
}),
|
||||
mode: SIDECAR_MODES.RUNTIME,
|
||||
namespace,
|
||||
source: SIDECAR_SOURCES.PACKAGED,
|
||||
};
|
||||
|
||||
try {
|
||||
await mkdir(dirname(markerPath), { recursive: true });
|
||||
// Use the test runner's own PID -- guaranteed to be in the live snapshot
|
||||
// table returned by listProcessSnapshots, so validateDesktopAppImageMarker
|
||||
// returns a non-"not-running" status. The cleanup defense skips on any
|
||||
// status other than "not-running", regardless of whether stamp/exe match.
|
||||
await writeFile(
|
||||
markerPath,
|
||||
`${JSON.stringify({
|
||||
appPath: "/tmp/Open-Design.AppImage",
|
||||
executablePath: "/tmp/.mount_od/AppRun",
|
||||
logPath: join(namespaceRoot, "logs", "desktop", "latest.log"),
|
||||
namespaceRoot,
|
||||
pid: process.pid,
|
||||
ppid: 1,
|
||||
stamp,
|
||||
startedAt: new Date(0).toISOString(),
|
||||
updatedAt: new Date(0).toISOString(),
|
||||
version: 1,
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(config.roots.output.namespaceRoot, { recursive: true });
|
||||
|
||||
const result = await cleanupPackedLinuxNamespace(config, { headless: true });
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(result.removedOutputRoot).toBe(false);
|
||||
expect(result.removedRuntimeNamespaceRoot).toBe(false);
|
||||
expect(await pathExists(markerPath)).toBe(true);
|
||||
expect(await pathExists(namespaceRoot)).toBe(true);
|
||||
expect(await pathExists(config.roots.output.namespaceRoot)).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveProductionInstallCommand", () => {
|
||||
it("defaults to npm install --omit=dev --no-package-lock when OD_TOOLS_PACK_PNPM_BIN is unset", () => {
|
||||
expect(resolveProductionInstallCommand({})).toEqual({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-package-lock"],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats an empty OD_TOOLS_PACK_PNPM_BIN as unset and keeps the npm host default", () => {
|
||||
expect(resolveProductionInstallCommand({ OD_TOOLS_PACK_PNPM_BIN: "" })).toEqual({
|
||||
command: "npm",
|
||||
args: ["install", "--omit=dev", "--no-package-lock"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses OD_TOOLS_PACK_PNPM_BIN with hoisted-layout pnpm flags when set", () => {
|
||||
// --config.node-linker=hoisted intentionally matches the prior
|
||||
// npm/electron-builder packaging layout so the AppImage pack step keeps
|
||||
// working when the assembled-app install runs through pnpm.
|
||||
expect(
|
||||
resolveProductionInstallCommand({ OD_TOOLS_PACK_PNPM_BIN: "/tmp/pnpm" }),
|
||||
).toEqual({
|
||||
command: "/tmp/pnpm",
|
||||
args: ["install", "--prod", "--no-lockfile", "--config.node-linker=hoisted"],
|
||||
});
|
||||
});
|
||||
|
||||
it("chains end-to-end with buildDockerArgs: docker exports OD_TOOLS_PACK_PNPM_BIN and the resolver returns the standalone pnpm install for that value", () => {
|
||||
const dockerArgs = buildDockerArgs(makeConfig(), { uid: 1000, gid: 1000 });
|
||||
const envFlagIndex = dockerArgs.findIndex(
|
||||
(arg, i) => arg === "-e" && dockerArgs[i + 1]?.startsWith("OD_TOOLS_PACK_PNPM_BIN="),
|
||||
);
|
||||
expect(envFlagIndex).toBeGreaterThan(-1);
|
||||
const envValue = dockerArgs[envFlagIndex + 1]?.split("=")[1];
|
||||
expect(envValue).toBe("/tmp/pnpm");
|
||||
|
||||
const resolved = resolveProductionInstallCommand({ OD_TOOLS_PACK_PNPM_BIN: envValue });
|
||||
expect(resolved).toEqual({
|
||||
command: "/tmp/pnpm",
|
||||
args: ["install", "--prod", "--no-lockfile", "--config.node-linker=hoisted"],
|
||||
});
|
||||
expect(resolved.command).not.toBe("npm");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderDesktopTemplate", () => {
|
||||
|
|
@ -232,6 +566,77 @@ describe("sanitizeNamespace", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("resolveLinuxLifecycleMode", () => {
|
||||
it("uses headless mode for every lifecycle action when --headless is set", () => {
|
||||
expect(resolveLinuxLifecycleMode({ headless: true }, "install")).toBe("headless");
|
||||
expect(resolveLinuxLifecycleMode({ headless: true }, "start")).toBe("headless");
|
||||
expect(resolveLinuxLifecycleMode({ headless: true }, "stop")).toBe("headless");
|
||||
expect(resolveLinuxLifecycleMode({ headless: true }, "uninstall")).toBe("headless");
|
||||
expect(resolveLinuxLifecycleMode({ headless: true }, "cleanup")).toBe("headless");
|
||||
});
|
||||
|
||||
it("uses appimage mode when --headless is omitted", () => {
|
||||
expect(resolveLinuxLifecycleMode({}, "install")).toBe("appimage");
|
||||
expect(resolveLinuxLifecycleMode({}, "start")).toBe("appimage");
|
||||
expect(resolveLinuxLifecycleMode({}, "stop")).toBe("appimage");
|
||||
expect(resolveLinuxLifecycleMode({}, "uninstall")).toBe("appimage");
|
||||
expect(resolveLinuxLifecycleMode({}, "cleanup")).toBe("appimage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRejectLinuxHeadlessInspectOptions", () => {
|
||||
it("allows status-only headless inspect", () => {
|
||||
expect(shouldRejectLinuxHeadlessInspectOptions({})).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects headless eval and screenshot requests", () => {
|
||||
expect(shouldRejectLinuxHeadlessInspectOptions({ expr: "document.title" })).toBe(true);
|
||||
expect(shouldRejectLinuxHeadlessInspectOptions({ path: "/tmp/open-design-linux.png" })).toBe(true);
|
||||
expect(
|
||||
shouldRejectLinuxHeadlessInspectOptions({
|
||||
expr: "document.title",
|
||||
path: "/tmp/open-design-linux.png",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inspectPackedLinuxApp", () => {
|
||||
it("rejects unsupported headless inspect options before opening IPC", async () => {
|
||||
const requestJsonIpcMock = vi.mocked(requestJsonIpc);
|
||||
requestJsonIpcMock.mockClear();
|
||||
|
||||
await expect(
|
||||
inspectPackedLinuxApp(makeConfig(), {
|
||||
expr: "document.title",
|
||||
headless: true,
|
||||
}),
|
||||
).rejects.toThrow("linux inspect --headless supports status only; omit --expr and --path");
|
||||
expect(requestJsonIpcMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows desktop inspect eval and screenshot options when headless is omitted", async () => {
|
||||
const requestJsonIpcMock = vi.mocked(requestJsonIpc);
|
||||
requestJsonIpcMock.mockReset();
|
||||
requestJsonIpcMock
|
||||
.mockResolvedValueOnce({ state: "running", url: "od://app/" })
|
||||
.mockResolvedValueOnce({ ok: true, value: "Open Design" })
|
||||
.mockResolvedValueOnce({ path: "/tmp/open-design-linux.png" });
|
||||
|
||||
const result = await inspectPackedLinuxApp(makeConfig(), {
|
||||
expr: "document.title",
|
||||
path: "/tmp/open-design-linux.png",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
eval: { ok: true, value: "Open Design" },
|
||||
screenshot: { path: "/tmp/open-design-linux.png" },
|
||||
status: { state: "running", url: "od://app/" },
|
||||
});
|
||||
expect(requestJsonIpcMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchesAppImageProcess", () => {
|
||||
const installPath = "/home/u/.local/bin/Open-Design.default.AppImage";
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue