fix: use python3 pty helper for admin-init tests on CI
TallyNote release / linux-x64 (push) Successful in 6m11s

BSD script injects a stray EOT byte from file input, corrupting the
first prompt value; expect is not installed on the Linux CI runner.
Switch to a tiny python3 pty.fork helper that ships on both macOS and
the CI image. Skip gracefully if python3 is unavailable.
release: 1.2.10
This commit is contained in:
Qiufeng
2026-09-10 17:50:13 +08:00
parent 19a3b5157c
commit 14bf093e1d
3 changed files with 118 additions and 53 deletions
+51 -52
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -8,6 +8,9 @@ import Database from "better-sqlite3";
const root = path.resolve(process.cwd());
const cli = path.join(root, "server", "cli", "admin-init.ts");
const tsx = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
const ptyHelper = path.join(root, "tests", "helpers", "pty-run.py");
const hasPython3 = spawnSync("python3", ["--version"]).status === 0;
const ttyTest = hasPython3 ? it : it.skip;
function runAdmin(dataDir: string, args: string[]) {
return spawnSync(process.execPath, [tsx, cli, ...args], {
@@ -24,6 +27,42 @@ function runAdmin(dataDir: string, args: string[]) {
});
}
function testEnv(dataDir: string) {
return {
...process.env,
NODE_ENV: "test",
TALLYNOTE_DATA_DIR: dataDir,
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
TALLYNOTE_COOKIE_SECURE: "false",
TALLYNOTE_UPDATE_STRATEGY: "disabled",
};
}
// The CI runner has no `expect` binary. Drive the interactive CLI through a
// real pseudo-terminal via a tiny Python pty helper (python3 ships on both
// macOS and the Linux CI image). This avoids `expect` (not installed on CI)
// and BSD `script` (injects a stray EOT byte from file input, corrupting the
// first prompt value). If python3 is unavailable the tests are skipped rather
// than failing the build.
function runAdminTTY(dataDir: string, args: string[], inputText: string) {
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-tty-"));
const inputFile = path.join(parent, "input");
const exitFile = path.join(parent, "exit-code");
writeFileSync(inputFile, inputText);
try {
const result = spawnSync("python3", [ptyHelper, process.execPath, tsx, cli, ...args], {
cwd: root,
env: { ...testEnv(dataDir), PTY_STDIN_FILE: inputFile, PTY_EXIT_FILE: exitFile },
encoding: "utf8",
timeout: 30_000,
});
const exitCode = existsSync(exitFile) ? Number(readFileSync(exitFile, "utf8")) : null;
return { exitCode, output: `${result.stdout}${result.stderr}`, spawnError: result.error };
} finally {
rmSync(parent, { recursive: true, force: true });
}
}
describe("生产管理员初始化 CLI", () => {
it("--check 是只读的,空数据目录不会被创建", () => {
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-"));
@@ -85,37 +124,14 @@ describe("生产管理员初始化 CLI", () => {
}
}, 15_000);
it("交互式输入正式密码后不会强制首次改密", () => {
ttyTest("交互式输入正式密码后不会强制首次改密", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
try {
const expectScript = [
"set timeout 15",
`spawn ${process.execPath} ${tsx} ${cli}`,
'expect "用户名: "',
'send "manual-admin\\r"',
'expect "显示名称: "',
'send "手动管理员\\r"',
'expect "密码(至少 12 个字符): "',
'send "Strong-password-2026!\\r"',
'expect "再次输入密码: "',
'send "Strong-password-2026!\\r"',
'expect eof',
].join("\n");
const result = spawnSync("expect", ["-c", expectScript], {
cwd: root,
env: {
...process.env,
NODE_ENV: "test",
TALLYNOTE_DATA_DIR: dataDir,
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
TALLYNOTE_COOKIE_SECURE: "false",
TALLYNOTE_UPDATE_STRATEGY: "disabled",
},
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain("已创建首位管理员");
expect(`${result.stdout}${result.stderr}`).toContain("Strong-password-2026!");
const result = runAdminTTY(dataDir, [], "manual-admin\n手动管理员\nStrong-password-2026!\nStrong-password-2026!\n");
expect(result.spawnError).toBeUndefined();
expect(result.exitCode).toBe(0);
expect(result.output).toContain("已创建首位管理员");
expect(result.output).toContain("Strong-password-2026!");
const database = new Database(path.join(dataDir, "tallynote.db"));
const admin = database.prepare("SELECT username, must_change_password FROM admins").get() as { username: string; must_change_password: number };
@@ -126,7 +142,7 @@ describe("生产管理员初始化 CLI", () => {
}
}, 30_000);
it("可以验证当前密码并清除旧版本遗留的首次改密标志", () => {
ttyTest("可以验证当前密码并清除旧版本遗留的首次改密标志", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
try {
const first = runAdmin(dataDir, ["--username", "legacy-admin", "--display-name", "旧版管理员", "--generate"]);
@@ -134,27 +150,10 @@ describe("生产管理员初始化 CLI", () => {
const generated = first.stdout.match(/一次性密码:([^\s]+)/)?.[1];
expect(generated).toBeTruthy();
const expectScript = [
"set timeout 15",
`spawn ${process.execPath} ${tsx} ${cli} --mark-password-configured --username legacy-admin`,
'expect "当前密码: "',
`send "${generated}\\r"`,
'expect eof',
].join("\n");
const result = spawnSync("expect", ["-c", expectScript], {
cwd: root,
env: {
...process.env,
NODE_ENV: "test",
TALLYNOTE_DATA_DIR: dataDir,
TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999",
TALLYNOTE_COOKIE_SECURE: "false",
TALLYNOTE_UPDATE_STRATEGY: "disabled",
},
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain("已确认当前密码为正式密码");
const result = runAdminTTY(dataDir, ["--mark-password-configured", "--username", "legacy-admin"], `${generated}\n`);
expect(result.spawnError).toBeUndefined();
expect(result.exitCode).toBe(0);
expect(result.output).toContain("已确认当前密码为正式密码");
const database = new Database(path.join(dataDir, "tallynote.db"));
const admin = database.prepare("SELECT must_change_password FROM admins WHERE username_norm='legacy-admin'").get() as { must_change_password: number };