Radish alpha
r
rad:z4V1sjrXqjvFdnCUbxPFqd5p4DtH5
Radicle web interface
Radicle
Git
Update execa
Merged did:key:z6Mki9XN...FvWF opened 1 year ago

The execa update allows us two simplify two things

  1. Output collection in a log file with a prefix can be done through stdout and stderr options of execa.
  2. We don’t need to ensure that all subprocesses are killed when the main process exits. execa takes care of this

Also includes more preparation commits that fix and simplify things.

check check-visual check-unit-test check-httpd-api-unit-test check-e2e check-build

👉 Preview 👉 Workflow runs 👉 Branch on GitHub

10 files changed +256 -176 fb32e01a ffd49e82
modified httpd-client/tests/support/fixtures.ts
@@ -27,6 +27,6 @@ export const testFixture = test.extend<TestFixtures>({
    await peer.startHttpd();
    const api = new HttpdClient(peer.httpdBaseUrl);
    await use({ api, peer });
-
    await peer.stopHttpd();
+
    await peer.shutdown();
  },
});
modified httpd-client/vite.config.ts
@@ -10,7 +10,7 @@ export default defineConfig({
    }),
  ],
  test: {
-
    environment: "happy-dom",
+
    environment: "node",
    include: ["httpd-client/tests/*.test.ts"],
    reporters: "verbose",
    globalSetup: "./tests/support/globalSetup",
modified package-lock.json
@@ -5,6 +5,7 @@
  "requires": true,
  "packages": {
    "": {
+
      "name": "radicle-explorer",
      "version": "1.0.0",
      "hasInstallScript": true,
      "dependencies": {
@@ -48,7 +49,7 @@
        "eslint-config-prettier": "^9.1.0",
        "eslint-plugin-no-only-tests": "^3.1.0",
        "eslint-plugin-svelte": "^2.35.1",
-
        "execa": "^8.0.1",
+
        "execa": "^9.0.0",
        "get-port": "^7.1.0",
        "happy-dom": "^14.3.8",
        "prettier": "^3.2.5",
@@ -907,6 +908,12 @@
        "win32"
      ]
    },
+
    "node_modules/@sec-ant/readable-stream": {
+
      "version": "0.4.1",
+
      "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz",
+
      "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==",
+
      "dev": true
+
    },
    "node_modules/@sideway/address": {
      "version": "4.1.5",
      "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -934,6 +941,18 @@
      "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
      "dev": true
    },
+
    "node_modules/@sindresorhus/merge-streams": {
+
      "version": "4.0.0",
+
      "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz",
+
      "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=18"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
    "node_modules/@sinonjs/commons": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz",
@@ -2429,23 +2448,26 @@
      }
    },
    "node_modules/execa": {
-
      "version": "8.0.1",
-
      "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
-
      "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+
      "version": "9.1.0",
+
      "resolved": "https://registry.npmjs.org/execa/-/execa-9.1.0.tgz",
+
      "integrity": "sha512-lSgHc4Elo2m6bUDhc3Hl/VxvUDJdQWI40RZ4KMY9bKRc+hgMOT7II/JjbNDhI8VnMtrCb7U/fhpJIkLORZozWw==",
      "dev": true,
      "dependencies": {
+
        "@sindresorhus/merge-streams": "^4.0.0",
        "cross-spawn": "^7.0.3",
-
        "get-stream": "^8.0.1",
-
        "human-signals": "^5.0.0",
-
        "is-stream": "^3.0.0",
-
        "merge-stream": "^2.0.0",
-
        "npm-run-path": "^5.1.0",
-
        "onetime": "^6.0.0",
+
        "figures": "^6.1.0",
+
        "get-stream": "^9.0.0",
+
        "human-signals": "^7.0.0",
+
        "is-plain-obj": "^4.1.0",
+
        "is-stream": "^4.0.1",
+
        "npm-run-path": "^5.2.0",
+
        "pretty-ms": "^9.0.0",
        "signal-exit": "^4.1.0",
-
        "strip-final-newline": "^3.0.0"
+
        "strip-final-newline": "^4.0.0",
+
        "yoctocolors": "^2.0.0"
      },
      "engines": {
-
        "node": ">=16.17"
+
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sindresorhus/execa?sponsor=1"
@@ -2517,6 +2539,21 @@
        "reusify": "^1.0.4"
      }
    },
+
    "node_modules/figures": {
+
      "version": "6.1.0",
+
      "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz",
+
      "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==",
+
      "dev": true,
+
      "dependencies": {
+
        "is-unicode-supported": "^2.0.0"
+
      },
+
      "engines": {
+
        "node": ">=18"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
    "node_modules/file-entry-cache": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
@@ -2674,12 +2711,16 @@
      }
    },
    "node_modules/get-stream": {
-
      "version": "8.0.1",
-
      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
-
      "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+
      "version": "9.0.1",
+
      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz",
+
      "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==",
      "dev": true,
+
      "dependencies": {
+
        "@sec-ant/readable-stream": "^0.4.1",
+
        "is-stream": "^4.0.1"
+
      },
      "engines": {
-
        "node": ">=16"
+
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
@@ -2956,12 +2997,12 @@
      }
    },
    "node_modules/human-signals": {
-
      "version": "5.0.0",
-
      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
-
      "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+
      "version": "7.0.0",
+
      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-7.0.0.tgz",
+
      "integrity": "sha512-74kytxOUSvNbjrT9KisAbaTZ/eJwD/LrbM/kh5j0IhPuJzwuA19dWvniFGwBzN9rVjg+O/e+F310PjObDXS+9Q==",
      "dev": true,
      "engines": {
-
        "node": ">=16.17.0"
+
        "node": ">=18.18.0"
      }
    },
    "node_modules/ieee754": {
@@ -3106,6 +3147,18 @@
        "node": ">=8"
      }
    },
+
    "node_modules/is-plain-obj": {
+
      "version": "4.1.0",
+
      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
+
      "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=12"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
    "node_modules/is-reference": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
@@ -3115,12 +3168,24 @@
      }
    },
    "node_modules/is-stream": {
-
      "version": "3.0.0",
-
      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
-
      "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+
      "version": "4.0.1",
+
      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
+
      "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
      "dev": true,
      "engines": {
-
        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+
        "node": ">=18"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
+
    "node_modules/is-unicode-supported": {
+
      "version": "2.0.0",
+
      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.0.0.tgz",
+
      "integrity": "sha512-FRdAyx5lusK1iHG0TWpVtk9+1i+GjrzRffhDg4ovQ7mcidMQ6mj+MhKPmvh7Xwyv5gIS06ns49CA7Sqg7lC22Q==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
@@ -3809,6 +3874,18 @@
        "node": ">=6"
      }
    },
+
    "node_modules/parse-ms": {
+
      "version": "4.0.0",
+
      "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
+
      "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=18"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
    "node_modules/parse5": {
      "version": "7.1.2",
      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz",
@@ -4114,6 +4191,21 @@
        "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
      }
    },
+
    "node_modules/pretty-ms": {
+
      "version": "9.0.0",
+
      "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.0.0.tgz",
+
      "integrity": "sha512-E9e9HJ9R9NasGOgPaPE8VMeiPKAyWR5jcFpNnwIejslIhWqdqOrb2wShBsncMPUb+BcCd2OPYfh7p2W6oemTng==",
+
      "dev": true,
+
      "dependencies": {
+
        "parse-ms": "^4.0.0"
+
      },
+
      "engines": {
+
        "node": ">=18"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
    "node_modules/property-information": {
      "version": "6.4.1",
      "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.4.1.tgz",
@@ -4493,12 +4585,12 @@
      }
    },
    "node_modules/strip-final-newline": {
-
      "version": "3.0.0",
-
      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
-
      "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+
      "version": "4.0.0",
+
      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
+
      "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==",
      "dev": true,
      "engines": {
-
        "node": ">=12"
+
        "node": ">=18"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
@@ -5152,6 +5244,74 @@
        }
      }
    },
+
    "node_modules/vitest/node_modules/execa": {
+
      "version": "8.0.1",
+
      "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+
      "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+
      "dev": true,
+
      "dependencies": {
+
        "cross-spawn": "^7.0.3",
+
        "get-stream": "^8.0.1",
+
        "human-signals": "^5.0.0",
+
        "is-stream": "^3.0.0",
+
        "merge-stream": "^2.0.0",
+
        "npm-run-path": "^5.1.0",
+
        "onetime": "^6.0.0",
+
        "signal-exit": "^4.1.0",
+
        "strip-final-newline": "^3.0.0"
+
      },
+
      "engines": {
+
        "node": ">=16.17"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+
      }
+
    },
+
    "node_modules/vitest/node_modules/get-stream": {
+
      "version": "8.0.1",
+
      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+
      "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=16"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
+
    "node_modules/vitest/node_modules/human-signals": {
+
      "version": "5.0.0",
+
      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+
      "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=16.17.0"
+
      }
+
    },
+
    "node_modules/vitest/node_modules/is-stream": {
+
      "version": "3.0.0",
+
      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+
      "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+
      "dev": true,
+
      "engines": {
+
        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
+
    "node_modules/vitest/node_modules/strip-final-newline": {
+
      "version": "3.0.0",
+
      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+
      "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=12"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
    "node_modules/vscode-oniguruma": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
@@ -5272,6 +5432,18 @@
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
+
    "node_modules/yoctocolors": {
+
      "version": "2.0.2",
+
      "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.0.2.tgz",
+
      "integrity": "sha512-Ct97huExsu7cWeEjmrXlofevF8CvzUglJ4iGUet5B8xn1oumtAZBpHU4GzYuoE6PVqcZ5hghtBrSlhwHuR1Jmw==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=18"
+
      },
+
      "funding": {
+
        "url": "https://github.com/sponsors/sindresorhus"
+
      }
+
    },
    "node_modules/zod": {
      "version": "3.22.4",
      "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
modified package.json
@@ -1,4 +1,6 @@
{
+
  "name": "radicle-explorer",
+
  "private": true,
  "version": "1.0.0",
  "scripts": {
    "start": "vite",
@@ -34,7 +36,7 @@
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-no-only-tests": "^3.1.0",
    "eslint-plugin-svelte": "^2.35.1",
-
    "execa": "^8.0.1",
+
    "execa": "^9.0.0",
    "get-port": "^7.1.0",
    "happy-dom": "^14.3.8",
    "prettier": "^3.2.5",
modified tests/e2e/node.spec.ts
@@ -20,8 +20,6 @@ test("node metadata", async ({ page, peerManager }) => {
    page.getByText(`${shortNodeRemote}@seed.radicle.test:8123`),
  ).toBeVisible();
  await expect(page.getByText("1.0.0-rc.8-")).toBeVisible();
-
  await peer.stopHttpd();
-
  await peer.stopNode();
});

test("node projects", async ({ page }) => {
modified tests/support/fixtures.ts
@@ -6,8 +6,8 @@ import * as Path from "node:path";
import assert from "node:assert";
import { fileURLToPath } from "node:url";
import { test as base, expect } from "@playwright/test";
+
import { execa } from "execa";

-
import * as Process from "./process.js";
import * as issue from "@tests/support/cobs/issue.js";
import * as logLabel from "@tests/support/logPrefix.js";
import * as patch from "@tests/support/cobs/patch.js";
@@ -190,7 +190,7 @@ export async function createSourceBrowsingFixture(
  const projectName = "source-browsing";
  const sourceBrowsingDir = Path.join(tmpDir, "repos", projectName);
  await Fs.mkdir(sourceBrowsingDir, { recursive: true });
-
  await Process.spawn("tar", [
+
  await execa("tar", [
    "-xf",
    Path.join(fixturesDir, `repos/${projectName}.tar.bz2`),
    "-C",
@@ -568,7 +568,7 @@ export async function createCobsFixture(peer: RadiclePeer) {

export async function createMarkdownFixture(peer: RadiclePeer) {
  await Fs.mkdir(Path.join(tmpDir, "repos", "markdown"));
-
  await Process.spawn("tar", [
+
  await execa("tar", [
    "-xf",
    Path.join(fixturesDir, "repos", "markdown.tar.bz2"),
    "-C",
modified tests/support/globalSetup.ts
@@ -13,7 +13,6 @@ import {
  gitOptions,
} from "@tests/support/fixtures.js";
import { createPeerManager } from "@tests/support/peerManager.js";
-
import { killAllProcesses } from "@tests/support/process.js";

export default async function globalSetup(): Promise<() => void> {
  try {
@@ -78,6 +77,5 @@ export default async function globalSetup(): Promise<() => void> {

  return async () => {
    await peerManager.shutdown();
-
    killAllProcesses();
  };
}
modified tests/support/peerManager.ts
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { BaseUrl } from "@httpd-client";
-
import type { ExecaChildProcess, Options as ExecaOptions } from "execa";
-

+
import type * as Execa from "execa";
+
import { execa } from "execa";
import * as Fs from "node:fs/promises";
import * as Os from "node:os";
import * as Path from "node:path";
@@ -10,10 +10,7 @@ import * as Util from "node:util";
import getPort from "get-port";
import matches from "lodash/matches.js";
import waitOn from "wait-on";
-
import { execa } from "execa";
import * as readline from "node:readline/promises";
-

-
import * as Process from "./process.js";
import { randomTag } from "@tests/support/support.js";
import { sleep } from "@app/lib/sleep.js";
import { array, literal, number, object, string, union, z } from "zod";
@@ -151,13 +148,20 @@ export const NodeConfigSchema = object({

export interface NodeConfig extends z.infer<typeof NodeConfigSchema> {}

-
// Options passed to `RadiclePeer#rad` and `RadiclePeer#git` that control how
-
// the process is spawned.
-
interface SpawnOptions extends ExecaOptions {
-
  // Sets the prefix for the commands output in the logs. If `null`, does not
-
  // log any output. Defaults to `<peer name> <binary name>`.
-
  logPrefix?: string | null;
-
}
+
// Specialize the return type of `execa()` to guarantee that `stdout` and
+
// `stderr` are strings.
+
type SpawnResult = Execa.ResultPromise<
+
  SpawnOptions & {
+
    stdout: (line: unknown) => AsyncGenerator<string, void, void>;
+
    stderr: (line: unknown) => AsyncGenerator<string, void, void>;
+
    encoding: "utf8";
+
  }
+
>;
+

+
type SpawnOptions = Omit<
+
  Execa.Options,
+
  "stdin" | "stdout" | "stderr" | "lines" | "encoding"
+
>;

export class RadiclePeer {
  public checkoutPath: string;
@@ -171,11 +175,10 @@ export class RadiclePeer {
  #gitOptions?: Record<string, string>;
  #listenSocketAddr?: string;
  #httpdBaseUrl?: BaseUrl;
-
  #nodeProcess?: ExecaChildProcess;
-
  #httpdProcess?: ExecaChildProcess;
+
  #nodeProcess?: SpawnResult;
  // Name for easy identification. Used on file system and in logs.
  #name: string;
-
  #childProcesses: ExecaChildProcess[] = [];
+
  #childProcesses: SpawnResult[] = [];

  private constructor(props: {
    checkoutPath: string;
@@ -266,7 +269,7 @@ export class RadiclePeer {
      port,
      scheme: "http",
    };
-
    this.#httpdProcess = this.spawn("radicle-httpd", [
+
    void this.spawn("radicle-httpd", [
      "--listen",
      `${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`,
    ]);
@@ -279,23 +282,6 @@ export class RadiclePeer {
    });
  }

-
  public async stopHttpd() {
-
    if (!this.#httpdBaseUrl || !this.#httpdProcess) {
-
      return;
-
    }
-
    this.#httpdProcess.kill("SIGTERM");
-

-
    await waitOn({
-
      resources: [
-
        `tcp:${this.#httpdBaseUrl.hostname}:${this.#httpdBaseUrl.port}`,
-
      ],
-
      reverse: true,
-
      timeout: 2000,
-
    });
-

-
    this.#httpdBaseUrl = undefined;
-
  }
-

  public async startNode(nodeParams: Partial<NodeConfig["node"]> = {}) {
    const listenPort = await getPort();
    this.#listenSocketAddr = `0.0.0.0:${listenPort}`;
@@ -314,7 +300,6 @@ export class RadiclePeer {

    const { stdout } = this.rad(["node", "events"], {
      cwd: this.#radHome,
-
      logPrefix: null,
    });

    if (!stdout) {
@@ -345,6 +330,8 @@ export class RadiclePeer {
  }

  public async stopNode() {
+
    // Don’t leak unhandled rejections when forcefully killing the process
+
    this.#nodeProcess?.catch(() => {});
    this.#nodeProcess?.kill("SIGTERM");

    await waitOn({
@@ -355,14 +342,17 @@ export class RadiclePeer {
  }

  /**
-
   * Kill all child processes created with `spawn()`, the node process and the
-
   * HTTP API process.
+
   * Kill all child processes created with `spawn()`, including the node and
+
   * httpd processes.
   */
  public async shutdown() {
    // We don’t care about proper cleanup. We just want to make sure that no
    // processes are running anymore.
-
    this.#childProcesses.forEach(p => p.kill("SIGKILL"));
-
    await Promise.all([this.stopNode(), this.stopHttpd()]);
+
    this.#childProcesses.forEach(p => {
+
      // Don’t leak unhandled rejections when forcefully killing the process
+
      p.catch(() => {});
+
      p.kill("SIGKILL");
+
    });
  }

  public get address(): string {
@@ -392,11 +382,11 @@ export class RadiclePeer {
    return this.#httpdBaseUrl;
  }

-
  public git(args: string[] = [], opts?: SpawnOptions): ExecaChildProcess {
+
  public git(args: string[] = [], opts?: SpawnOptions): SpawnResult {
    return this.spawn("git", args, { ...opts });
  }

-
  public rad(args: string[] = [], opts?: SpawnOptions): ExecaChildProcess {
+
  public rad(args: string[] = [], opts?: SpawnOptions): SpawnResult {
    return this.spawn("rad", args, { ...opts });
  }

@@ -404,8 +394,18 @@ export class RadiclePeer {
    cmd: string,
    args: string[] = [],
    opts?: SpawnOptions,
-
  ): ExecaChildProcess {
-
    opts = {
+
  ): SpawnResult {
+
    const prefix = logPrefix(`${this.#name} ${cmd}`);
+
    const outputLog = this.#outputLog;
+

+
    function* logWithPrefix(line: unknown) {
+
      if (typeof line === "string") {
+
        outputLog.write(`${prefix} ${line}\n`, "utf8");
+
      }
+
      yield line;
+
    }
+

+
    const childProcess = execa(cmd, args, {
      ...opts,
      env: {
        GIT_CONFIG_GLOBAL: "/dev/null",
@@ -418,17 +418,12 @@ export class RadiclePeer {
        ...opts?.env,
        ...this.#gitOptions,
      },
-
    };
-
    const childProcess = Process.spawn(cmd, args, opts);
-
    this.#childProcesses.push(childProcess);
+
      encoding: "utf8",
+
      stdout: logWithPrefix,
+
      stderr: logWithPrefix,
+
    });

-
    if (opts.logPrefix !== null) {
-
      void Process.prefixOutput(
-
        childProcess,
-
        opts.logPrefix || `${this.#name} ${cmd}`,
-
        this.#outputLog,
-
      );
-
    }
+
    this.#childProcesses.push(childProcess);

    return childProcess;
  }
deleted tests/support/process.ts
@@ -1,84 +0,0 @@
-
import type { ExecaChildProcess, Options } from "execa";
-

-
import * as Stream from "node:stream";
-
import { StringDecoder } from "string_decoder";
-
import { execa } from "execa";
-

-
import { logPrefix } from "./logPrefix.js";
-

-
// Processes that should be SIGKILLed when the Node process shutsdown.
-
// We add all proxy and node instances that we spawn to this list.
-
const processes: ExecaChildProcess[] = [];
-

-
process.on("exit", killAllProcesses);
-
process.on("SIGINT", killAllProcesses);
-
process.on("SIGTERM", killAllProcesses);
-

-
// Kill all processes with SIGKILL
-
export function killAllProcesses(): void {
-
  for (const process of processes) {
-
    if (process.exitCode === null) {
-
      process.kill("SIGKILL");
-
    }
-
  }
-
}
-

-
// Spawn a process with `execa` and register it.
-
//
-
// The process will be killed by `killAllProcesses`.
-
export function spawn(
-
  bin: string,
-
  args: string[],
-
  options?: Options,
-
): ExecaChildProcess {
-
  const child = execa(bin, args, options);
-
  processes.push(child);
-
  return child;
-
}
-

-
// Forwards piped `stdout` and `stderr` of a child process to `output`
-
// and prefixes it with the given label. The prefix is colored.
-
export function prefixOutput(
-
  childProcess: ExecaChildProcess,
-
  label: string,
-
  output: Stream.Writable,
-
): ExecaChildProcess {
-
  const pref = logPrefix(label);
-
  if (childProcess.stdout) {
-
    const stdoutPrefix = new LinePrefix(pref);
-
    childProcess.stdout.pipe(stdoutPrefix).pipe(output, { end: false });
-
  }
-
  if (childProcess.stderr) {
-
    const stderrPrefix = new LinePrefix(pref);
-
    childProcess.stderr.pipe(stderrPrefix).pipe(output, { end: false });
-
  }
-

-
  return childProcess;
-
}
-

-
// A transform that prefixes each line from the source with the given
-
// string and pushes it to the sink.
-
class LinePrefix extends Stream.Transform {
-
  private buffer: string = "";
-
  private stringDecoder = new StringDecoder();
-

-
  public constructor(private prefix: string) {
-
    super();
-
  }
-

-
  public _transform(data: Buffer, _encoding: string, next: () => void): void {
-
    const str = this.buffer + this.stringDecoder.write(data);
-
    const lines = str.split(/\r?\n/);
-
    this.buffer = lines.pop() || "";
-
    lines.forEach(line => this.push(`${this.prefix}${line}\n`));
-
    next();
-
  }
-

-
  public _flush(done: () => void): void {
-
    const rest = `${this.buffer}${this.stringDecoder.end()}`;
-
    if (rest) {
-
      this.push(`${this.prefix}${rest}\n`);
-
    }
-
    done();
-
  }
-
}
modified tests/support/project.ts
@@ -1,6 +1,5 @@
import type { Locator, Page } from "@playwright/test";
import type { RadiclePeer } from "@tests/support/peerManager";
-
import type { ExecaReturnValue } from "execa";

import * as Path from "node:path";
import { expect } from "@playwright/test";
@@ -52,7 +51,7 @@ export async function createProject(
  return { rid, projectFolder, defaultBranch };
}

-
export function extractPatchId(cmdOutput: ExecaReturnValue<string>) {
+
export function extractPatchId(cmdOutput: { stderr: string }) {
  const match = cmdOutput.stderr.match(/[0-9a-f]{40}/);
  if (match) {
    return match[0];