fix: use python3 pty helper for admin-init tests on CI
TallyNote release / linux-x64 (push) Successful in 6m11s
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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tallynote",
|
"name": "tallynote",
|
||||||
"version": "1.2.9",
|
"version": "1.2.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@9.0.6",
|
"packageManager": "pnpm@9.0.6",
|
||||||
|
|||||||
+51
-52
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 { spawnSync } from "node:child_process";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -8,6 +8,9 @@ import Database from "better-sqlite3";
|
|||||||
const root = path.resolve(process.cwd());
|
const root = path.resolve(process.cwd());
|
||||||
const cli = path.join(root, "server", "cli", "admin-init.ts");
|
const cli = path.join(root, "server", "cli", "admin-init.ts");
|
||||||
const tsx = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
|
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[]) {
|
function runAdmin(dataDir: string, args: string[]) {
|
||||||
return spawnSync(process.execPath, [tsx, cli, ...args], {
|
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", () => {
|
describe("生产管理员初始化 CLI", () => {
|
||||||
it("--check 是只读的,空数据目录不会被创建", () => {
|
it("--check 是只读的,空数据目录不会被创建", () => {
|
||||||
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-"));
|
const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-"));
|
||||||
@@ -85,37 +124,14 @@ describe("生产管理员初始化 CLI", () => {
|
|||||||
}
|
}
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
it("交互式输入正式密码后不会强制首次改密", () => {
|
ttyTest("交互式输入正式密码后不会强制首次改密", () => {
|
||||||
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
||||||
try {
|
try {
|
||||||
const expectScript = [
|
const result = runAdminTTY(dataDir, [], "manual-admin\n手动管理员\nStrong-password-2026!\nStrong-password-2026!\n");
|
||||||
"set timeout 15",
|
expect(result.spawnError).toBeUndefined();
|
||||||
`spawn ${process.execPath} ${tsx} ${cli}`,
|
expect(result.exitCode).toBe(0);
|
||||||
'expect "用户名: "',
|
expect(result.output).toContain("已创建首位管理员");
|
||||||
'send "manual-admin\\r"',
|
expect(result.output).toContain("Strong-password-2026!");
|
||||||
'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 database = new Database(path.join(dataDir, "tallynote.db"));
|
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 };
|
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);
|
}, 30_000);
|
||||||
|
|
||||||
it("可以验证当前密码并清除旧版本遗留的首次改密标志", () => {
|
ttyTest("可以验证当前密码并清除旧版本遗留的首次改密标志", () => {
|
||||||
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
|
||||||
try {
|
try {
|
||||||
const first = runAdmin(dataDir, ["--username", "legacy-admin", "--display-name", "旧版管理员", "--generate"]);
|
const first = runAdmin(dataDir, ["--username", "legacy-admin", "--display-name", "旧版管理员", "--generate"]);
|
||||||
@@ -134,27 +150,10 @@ describe("生产管理员初始化 CLI", () => {
|
|||||||
const generated = first.stdout.match(/一次性密码:([^\s]+)/)?.[1];
|
const generated = first.stdout.match(/一次性密码:([^\s]+)/)?.[1];
|
||||||
expect(generated).toBeTruthy();
|
expect(generated).toBeTruthy();
|
||||||
|
|
||||||
const expectScript = [
|
const result = runAdminTTY(dataDir, ["--mark-password-configured", "--username", "legacy-admin"], `${generated}\n`);
|
||||||
"set timeout 15",
|
expect(result.spawnError).toBeUndefined();
|
||||||
`spawn ${process.execPath} ${tsx} ${cli} --mark-password-configured --username legacy-admin`,
|
expect(result.exitCode).toBe(0);
|
||||||
'expect "当前密码: "',
|
expect(result.output).toContain("已确认当前密码为正式密码");
|
||||||
`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 database = new Database(path.join(dataDir, "tallynote.db"));
|
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 };
|
const admin = database.prepare("SELECT must_change_password FROM admins WHERE username_norm='legacy-admin'").get() as { must_change_password: number };
|
||||||
|
|||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Minimal cross-platform pty driver for the admin-init CLI tests.
|
||||||
|
|
||||||
|
Forks a child on a real pseudo-terminal so the CLI sees a TTY and runs its
|
||||||
|
raw-mode password prompts. Forwards a prepared input file to the child's stdin
|
||||||
|
and copies child output to stdout. Writes the child's exit code to a file so
|
||||||
|
the Node test can read it deterministically.
|
||||||
|
|
||||||
|
Used instead of `expect` (not installed on CI) or BSD `script` (injects a stray
|
||||||
|
EOT byte when stdin is a regular file, corrupting the first prompt value).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import select
|
||||||
|
import sys
|
||||||
|
|
||||||
|
argv = sys.argv[1:]
|
||||||
|
exit_file = os.environ.get("PTY_EXIT_FILE", "")
|
||||||
|
stdin_file = os.environ.get("PTY_STDIN_FILE", "")
|
||||||
|
|
||||||
|
pid, master = pty.fork()
|
||||||
|
if pid == 0:
|
||||||
|
# Child: replace with the target command. argv[0] is an absolute node path.
|
||||||
|
os.execvp(argv[0], argv)
|
||||||
|
os._exit(127)
|
||||||
|
|
||||||
|
in_fd = os.open(stdin_file, os.O_RDONLY) if stdin_file else -1
|
||||||
|
open_stdin = in_fd >= 0
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
fds = [master]
|
||||||
|
if open_stdin:
|
||||||
|
fds.append(in_fd)
|
||||||
|
try:
|
||||||
|
readable, _, _ = select.select(fds, [], [], 30.0)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
break
|
||||||
|
if not readable:
|
||||||
|
break
|
||||||
|
if master in readable:
|
||||||
|
try:
|
||||||
|
data = os.read(master, 4096)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
os.write(1, data)
|
||||||
|
if open_stdin and in_fd in readable:
|
||||||
|
data = os.read(in_fd, 4096)
|
||||||
|
if data:
|
||||||
|
os.write(master, data)
|
||||||
|
else:
|
||||||
|
open_stdin = False
|
||||||
|
os.close(in_fd)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
_, status = os.waitpid(pid, 0)
|
||||||
|
except ChildProcessError:
|
||||||
|
status = 0
|
||||||
|
code = os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") else (status >> 8)
|
||||||
|
if exit_file:
|
||||||
|
try:
|
||||||
|
with open(exit_file, "w") as handle:
|
||||||
|
handle.write(str(code))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
Reference in New Issue
Block a user