fix: make client installation observable
CI / Client installer (macos-latest) (push) Canceled after 0s
CI / Client installer (ubuntu-latest) (push) Canceled after 0s
CI / Client installer (windows-latest) (push) Canceled after 0s
CI / test (22) (push) Canceled after 0s
CI / test (24) (push) Canceled after 0s
CI / release-smoke (push) Canceled after 0s

This commit is contained in:
2026-08-18 14:16:17 +00:00
parent dd189c2acb
commit 40c5021c85
12 changed files with 233 additions and 21 deletions
+63 -3
View File
@@ -1,13 +1,16 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash, createPublicKey, generateKeyPairSync, sign } from "node:crypto";
import {
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, symlinkSync, writeFileSync,
chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync,
symlinkSync, utimesSync, writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, delimiter, dirname, join } from "node:path";
import test from "node:test";
import {
cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId, runClientInstaller, verifySignedManifest,
acquireClientInstallLock, cleanupLegacyInstallations, clientLauncherBodies, clientPaths, releaseKeyId,
npmFailureMessage, runClientInstaller, runNpm, verifySignedManifest,
type ClientReleaseManifest,
} from "../src/client-installer.js";
@@ -31,6 +34,58 @@ test("client launchers pin the selected Node executable on Windows and POSIX", (
assert.throws(() => clientLauncherBodies("win32", "C:\\bad\nnode.exe"), /不支持的字符/);
});
test("client installer lock rejects concurrency and recovers a dead owner", () => {
const root = mkdtempSync(join(tmpdir(), "ding-client-lock-"));
const appRoot = join(root, "dingtalk-approval");
mkdirSync(appRoot, { recursive: true });
const release = acquireClientInstallLock(appRoot);
assert.throws(() => acquireClientInstallLock(appRoot), /另一个安装或更新进程仍在运行/);
release();
const lockDir = join(appRoot, ".install-lock");
mkdirSync(lockDir, { recursive: true });
writeFileSync(join(lockDir, "owner.json"), JSON.stringify({ pid: 2_147_483_647, token: "dead" }));
const recovered = acquireClientInstallLock(appRoot);
assert.equal(existsSync(join(lockDir, "owner.json")), true);
recovered();
assert.equal(existsSync(lockDir), false);
mkdirSync(lockDir, { recursive: true });
writeFileSync(join(lockDir, "owner.json"), JSON.stringify({ pid: process.pid, token: "expired" }));
const expiredAt = new Date(Date.now() - 16 * 60_000);
utimesSync(lockDir, expiredAt, expiredAt);
const expired = acquireClientInstallLock(appRoot);
expired();
assert.equal(existsSync(lockDir), false, "an implausibly old lock cannot block installation forever after PID reuse");
rmSync(root, { recursive: true, force: true });
});
test("direct JSON entrypoint never silently handles a controlled failure", () => {
const result = spawnSync(process.execPath, ["dist/src/client-installer.js", "unsupported", "--json"], { encoding: "utf8" });
assert.equal(result.status, 1);
assert.match(result.stderr, /^\[dingtalk-approval\] 失败 .*unsupported/);
const error = JSON.parse(result.stdout);
assert.equal(error.ok, false);
assert.match(error.error, /unsupported/);
});
test("npm subprocess timeout is bounded and returns an actionable diagnostic", () => {
const root = mkdtempSync(join(tmpdir(), "ding-client-npm-timeout-"));
try {
const sleepyNpm = join(root, "sleepy-npm.mjs");
writeFileSync(sleepyNpm, "setTimeout(() => {}, 10_000);\n");
const result = runNpm(
{ env: { npm_execpath: sleepyNpm }, platform: process.platform },
["--version"],
{ timeoutMs: 50 },
);
assert.equal((result.error as NodeJS.ErrnoException | undefined)?.code, "ETIMEDOUT");
assert.match(npmFailureMessage(result, 50), /npm 超过 1 秒仍未完成.*宿主工具超时设置/);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test("signed manifest verification rejects tampering and a foreign key", () => {
const trusted = generateKeyPairSync("ed25519");
const foreign = generateKeyPairSync("ed25519");
@@ -127,9 +182,10 @@ test("generic installer preserves identity across safe updates, rollback, downgr
: join(fakeBin, "fake-npm.mjs"),
};
const output: string[] = [];
const progress: string[] = [];
const commandContext = {
env, platform: process.platform, home: join(root, "home"), fetchFn: fetchFn as typeof fetch,
stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { output.push(value); },
stdout: (value: string) => { output.push(value); }, stderr: (value: string) => { progress.push(value); },
};
const installed = await runClientInstaller([
"install", "--manifest-url", "https://approval.example.test/downloads/manifest.json",
@@ -137,6 +193,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
], commandContext);
assert.equal(installed, 0);
const installOutput = JSON.parse(output.pop()!);
assert.match(progress.join(""), /启动 正在取得单实例安装锁[\s\S]*1\/6[\s\S]*6\/6[\s\S]*完成 客户端安装成功/);
assert.equal(installOutput.restartRequired, undefined);
assert.match(installOutput.nextSteps.join(" "), /当前 Agent.*skillPath.*stdio MCP/);
assert.match(installOutput.nextSteps.join(" "), /Windows.*\.cmd.*禁止把网关 URL 配成 MCP/s);
@@ -217,6 +274,8 @@ test("generic installer preserves identity across safe updates, rollback, downgr
assert.equal(health.checks.find((check: { name: string }) => check.name === "nodeRuntime").ok, true);
output.length = 0;
const interruptedStaging = join(paths.versionsDir, ".staging-interrupted-test");
mkdirSync(interruptedStaging, { recursive: true });
assert.equal(await runClientInstaller([
"update", "--app-root", appRoot, "--json",
], { ...commandContext, env: { ...env, FAKE_BRIDGE_FAIL: "1" } }), 0, "login preparation failure does not roll back an installed client");
@@ -227,6 +286,7 @@ test("generic installer preserves identity across safe updates, rollback, downgr
assert.equal(sameVersionState.runtimeDir, initialRuntime, "same-version update reuses the complete runtime");
assert.deepEqual(readFileSync(paths.sessionFile), sessionBody, "same-version update preserves the OAuth session");
assert.deepEqual(readdirSync(paths.versionsDir), [basename(initialRuntime)]);
assert.equal(existsSync(interruptedStaging), false, "a dead installer staging directory is recovered on the next run");
output.length = 0;
assert.equal(await runClientInstaller(["update", "--host", "workbuddy", "--app-root", appRoot, "--json"], commandContext), 1);
+5
View File
@@ -20,6 +20,9 @@ test("customer distribution is a generic single-login gateway bridge", () => {
assert.match(installer, /onboarding\.firstRun=true.*简短示例.*不要自动执行/s);
assert.match(installer, /Windows(PowerShell)/);
assert.match(installer, /Invoke-WebRequest[\s\S]*Get-FileHash[\s\S]*ConvertFrom-Json/);
assert.match(installer, /至少 10 分钟超时[\s\S]*stderr[\s\S]*stdout/);
assert.match(installer, /Git Bash[\s\S]*不得.*内部.*installer\.mjs/s);
assert.match(installer, /安装长时间无最终 JSON[\s\S]*残留 staging/s);
assert.match(installer, /系统无需预装 Node\.js/);
assert.match(installer, /阿里云精确版本.*npmmirror 精确版本.*Node 官方精确版本/s);
assert.match(installer, /BOOTSTRAP_WINDOWS_URL|Windows 安装引导/);
@@ -46,9 +49,11 @@ test("customer distribution is a generic single-login gateway bridge", () => {
const posixBootstrap = readFileSync("distribution/bootstrap-posix.template.sh", "utf8");
assert.match(windowsBootstrap, /OSArchitecture[\s\S]*Save-DingDownload[\s\S]*Expand-Archive/);
assert.match(windowsBootstrap, /SHA-256 不匹配,停止安装/);
assert.match(windowsBootstrap, /Write-DingProgress[\s\S]*\[dingtalk-approval\]/);
assert.match(posixBootstrap, /^#!\/bin\/sh\nset -eu/);
assert.match(posixBootstrap, /uname -s[\s\S]*uname -m[\s\S]*tar -xzf/);
assert.match(posixBootstrap, /SHA-256 不匹配,停止安装/);
assert.match(posixBootstrap, /ding_progress[\s\S]*\[dingtalk-approval\]/);
assert.equal(spawnSync("sh", ["-n", "distribution/bootstrap-posix.template.sh"]).status, 0);
assert.doesNotMatch(`${windowsBootstrap}\n${posixBootstrap}`, /latest-v|npm install -g/);
+1
View File
@@ -20,6 +20,7 @@ test("POSIX bootstrap installs a private Node and falls back after a network fai
const result = runPosixFixture(fixture, "fallback");
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout.trim()), { ok: true, status: "installed", managedNode: true });
assert.match(result.stderr, /\[dingtalk-approval\] 1\/4[\s\S]*2\/4[\s\S]*3\/4[\s\S]*4\/4/);
assert.equal(existsSync(join(fixture.clientRoot, "node", RUNTIME_NAME, "bin", "node")), true);
const calls = readFileSync(fixture.callLog, "utf8").trim().split(/\r?\n/);
assert.deepEqual(calls.slice(0, 2), [