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
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:
@@ -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);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
|
||||
@@ -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), [
|
||||
|
||||
Reference in New Issue
Block a user