diff --git a/package.json b/package.json index 050b1cf..d7c0802 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.2.9", + "version": "1.2.10", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/tests/admin-init.test.ts b/tests/admin-init.test.ts index 61f4dac..28ca599 100644 --- a/tests/admin-init.test.ts +++ b/tests/admin-init.test.ts @@ -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 }; diff --git a/tests/helpers/pty-run.py b/tests/helpers/pty-run.py new file mode 100755 index 0000000..7075de9 --- /dev/null +++ b/tests/helpers/pty-run.py @@ -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