first commit
This commit is contained in:
58
scripts/check-web-bundle-size.mjs
Normal file
58
scripts/check-web-bundle-size.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const assetsDir = join(__dirname, "..", "apps", "web", "dist", "assets");
|
||||
|
||||
const thresholds = {
|
||||
indexGzipBytes: 85 * 1024,
|
||||
terminalPanelGzipBytes: 100 * 1024
|
||||
};
|
||||
|
||||
function findLatestChunk(prefix) {
|
||||
const files = readdirSync(assetsDir).filter((file) => file.startsWith(prefix) && file.endsWith(".js"));
|
||||
if (files.length === 0) {
|
||||
throw new Error(`未找到 ${prefix}*.js`);
|
||||
}
|
||||
files.sort();
|
||||
return files.at(-1);
|
||||
}
|
||||
|
||||
function gzipSizeOf(fileName) {
|
||||
const absPath = join(assetsDir, fileName);
|
||||
const raw = readFileSync(absPath);
|
||||
return gzipSync(raw).byteLength;
|
||||
}
|
||||
|
||||
function formatKiB(bytes) {
|
||||
return `${(bytes / 1024).toFixed(2)} KiB`;
|
||||
}
|
||||
|
||||
function check(label, fileName, limitBytes) {
|
||||
const size = gzipSizeOf(fileName);
|
||||
const pass = size <= limitBytes;
|
||||
const status = pass ? "PASS" : "FAIL";
|
||||
console.log(`[bundle-size] ${status} ${label}: ${fileName} gzip=${formatKiB(size)} threshold=${formatKiB(limitBytes)}`);
|
||||
return pass;
|
||||
}
|
||||
|
||||
let ok = true;
|
||||
|
||||
try {
|
||||
const indexChunk = findLatestChunk("index-");
|
||||
const terminalPanelChunk = findLatestChunk("TerminalPanel-");
|
||||
|
||||
ok = check("index", indexChunk, thresholds.indexGzipBytes) && ok;
|
||||
ok = check("TerminalPanel", terminalPanelChunk, thresholds.terminalPanelGzipBytes) && ok;
|
||||
} catch (error) {
|
||||
console.error(`[bundle-size] FAIL ${(error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
process.exit(1);
|
||||
}
|
||||
489
scripts/gatewayctl.sh
Executable file
489
scripts/gatewayctl.sh
Executable file
@@ -0,0 +1,489 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 统一常量:systemd 与 launchd 各自的服务标识与路径。
|
||||
SYSTEMD_SERVICE_NAME="remoteconn-gateway.service"
|
||||
LAUNCHD_LABEL="com.remoteconn.gateway"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
GATEWAY_ENV_FILE="${REPO_ROOT}/apps/gateway/.env"
|
||||
DEFAULT_HEALTH_PORT="$(awk -F= '/^PORT=/{gsub(/[[:space:]\r\"]/, "", $2); print $2; exit}' "${GATEWAY_ENV_FILE}" 2>/dev/null || true)"
|
||||
if [[ -z "${DEFAULT_HEALTH_PORT}" ]]; then
|
||||
DEFAULT_HEALTH_PORT="8787"
|
||||
fi
|
||||
DEFAULT_HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:${DEFAULT_HEALTH_PORT}/health}"
|
||||
LAUNCHD_PLIST="${HOME}/Library/LaunchAgents/${LAUNCHD_LABEL}.plist"
|
||||
LAUNCHD_STDOUT_LOG="${HOME}/Library/Logs/${LAUNCHD_LABEL}.out.log"
|
||||
LAUNCHD_STDERR_LOG="${HOME}/Library/Logs/${LAUNCHD_LABEL}.err.log"
|
||||
TSX_BIN="${REPO_ROOT}/node_modules/.bin/tsx"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
用法:
|
||||
scripts/gatewayctl.sh <命令> [参数]
|
||||
|
||||
命令:
|
||||
ensure-env 若缺失 apps/gateway/.env,则按 .env.example 生成
|
||||
build 构建 gateway 产物(dist)
|
||||
install-service [user] 安装/更新服务(Linux=systemd,macOS=launchd)
|
||||
uninstall-service 卸载服务
|
||||
start 启动服务
|
||||
stop 停止服务
|
||||
restart 重启服务
|
||||
status 查看服务状态
|
||||
logs [lines] 查看日志(默认 200 行)
|
||||
logs-clear 清空当前服务日志文件(仅清理文件,不影响进程)
|
||||
health [url] 健康检查(默认 http://127.0.0.1:8787/health)
|
||||
deploy [url] 一键发布(ensure-env + build + restart + health)
|
||||
run-local 本地前台运行(不依赖守护服务)
|
||||
|
||||
示例:
|
||||
scripts/gatewayctl.sh deploy
|
||||
scripts/gatewayctl.sh install-service gavin
|
||||
scripts/gatewayctl.sh health http://127.0.0.1:8787/health
|
||||
USAGE
|
||||
}
|
||||
|
||||
need_cmd() {
|
||||
local cmd="$1"
|
||||
if ! command -v "${cmd}" >/dev/null 2>&1; then
|
||||
echo "[错误] 缺少命令: ${cmd}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_with_sudo() {
|
||||
if [[ "${EUID}" -eq 0 ]]; then
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
sudo "$@"
|
||||
}
|
||||
|
||||
# 判定当前机器的服务管理器:Linux 优先 systemd,macOS 使用 launchd。
|
||||
detect_service_manager() {
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
echo "systemd"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]] && command -v launchctl >/dev/null 2>&1; then
|
||||
echo "launchd"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "none"
|
||||
}
|
||||
|
||||
ensure_service_manager() {
|
||||
local manager
|
||||
manager="$(detect_service_manager)"
|
||||
if [[ "${manager}" == "none" ]]; then
|
||||
echo "[错误] 当前环境不支持 systemd/launchd,无法托管服务" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
escape_single_quote() {
|
||||
printf "%s" "$1" | sed "s/'/'\"'\"'/g"
|
||||
}
|
||||
|
||||
xml_escape() {
|
||||
printf "%s" "$1" \
|
||||
| sed -e 's/&/\&/g' \
|
||||
-e 's/</\</g' \
|
||||
-e 's/>/\>/g' \
|
||||
-e 's/"/\"/g' \
|
||||
-e "s/'/\'/g"
|
||||
}
|
||||
|
||||
ensure_tsx_bin() {
|
||||
if [[ ! -x "${TSX_BIN}" ]]; then
|
||||
echo "[错误] 未找到 tsx 可执行文件: ${TSX_BIN}" >&2
|
||||
echo "[提示] 请先在仓库根目录执行 npm install" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
bootstrap_launchd_with_retry() {
|
||||
local domain plist retries i
|
||||
domain="gui/$(id -u)"
|
||||
plist="${LAUNCHD_PLIST}"
|
||||
retries=5
|
||||
i=1
|
||||
|
||||
while (( i <= retries )); do
|
||||
if launchctl bootstrap "${domain}" "${plist}" >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
sleep 0.3
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo "[错误] launchd 加载失败: ${plist}" >&2
|
||||
launchctl bootstrap "${domain}" "${plist}"
|
||||
}
|
||||
|
||||
ensure_env() {
|
||||
local env_file="${REPO_ROOT}/apps/gateway/.env"
|
||||
if [[ -f "${env_file}" ]]; then
|
||||
echo "[信息] 已存在: ${env_file}"
|
||||
return
|
||||
fi
|
||||
cp "${REPO_ROOT}/apps/gateway/.env.example" "${env_file}"
|
||||
echo "[信息] 已创建: ${env_file}"
|
||||
}
|
||||
|
||||
build_gateway() {
|
||||
need_cmd npm
|
||||
echo "[信息] 开始构建 gateway..."
|
||||
(
|
||||
cd "${REPO_ROOT}"
|
||||
npm --workspace @remoteconn/gateway run build
|
||||
)
|
||||
echo "[信息] 构建完成"
|
||||
}
|
||||
|
||||
install_systemd_service() {
|
||||
ensure_tsx_bin
|
||||
|
||||
local run_user="${1:-${SUDO_USER:-${USER}}}"
|
||||
local unit_path="/etc/systemd/system/${SYSTEMD_SERVICE_NAME}"
|
||||
local tmp_file
|
||||
tmp_file="$(mktemp)"
|
||||
|
||||
# 统一通过 tsx 直接运行源码入口,规避 dist ESM 扩展名解析差异与 npm 包装噪声。
|
||||
cat >"${tmp_file}" <<UNIT
|
||||
[Unit]
|
||||
Description=RemoteConn Gateway Service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${run_user}
|
||||
WorkingDirectory=${REPO_ROOT}
|
||||
EnvironmentFile=${REPO_ROOT}/apps/gateway/.env
|
||||
ExecStart=${TSX_BIN} ${REPO_ROOT}/apps/gateway/src/index.ts
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
TimeoutStopSec=15
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
LimitNOFILE=65535
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
UNIT
|
||||
|
||||
run_with_sudo cp "${tmp_file}" "${unit_path}"
|
||||
rm -f "${tmp_file}"
|
||||
run_with_sudo systemctl daemon-reload
|
||||
run_with_sudo systemctl enable "${SYSTEMD_SERVICE_NAME}"
|
||||
echo "[信息] systemd 服务已安装: ${unit_path}"
|
||||
}
|
||||
|
||||
install_launchd_service() {
|
||||
ensure_tsx_bin
|
||||
|
||||
local run_user="${1:-${USER}}"
|
||||
if [[ "${run_user}" != "${USER}" ]]; then
|
||||
echo "[告警] launchd 仅管理当前用户会话,忽略 user=${run_user}"
|
||||
fi
|
||||
|
||||
mkdir -p "${HOME}/Library/LaunchAgents" "${HOME}/Library/Logs"
|
||||
|
||||
local env_file shell_cmd shell_cmd_xml
|
||||
env_file="${REPO_ROOT}/apps/gateway/.env"
|
||||
shell_cmd="cd '$(escape_single_quote "${REPO_ROOT}")' && set -a && source '$(escape_single_quote "${env_file}")' && exec '$(escape_single_quote "${TSX_BIN}")' '$(escape_single_quote "${REPO_ROOT}/apps/gateway/src/index.ts")'"
|
||||
shell_cmd_xml="$(xml_escape "${shell_cmd}")"
|
||||
|
||||
cat >"${LAUNCHD_PLIST}" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>${LAUNCHD_LABEL}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/zsh</string>
|
||||
<string>-lc</string>
|
||||
<string>${shell_cmd_xml}</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${REPO_ROOT}</string>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${LAUNCHD_STDOUT_LOG}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${LAUNCHD_STDERR_LOG}</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
launchctl bootout "gui/$(id -u)/${LAUNCHD_LABEL}" >/dev/null 2>&1 || true
|
||||
bootstrap_launchd_with_retry
|
||||
echo "[信息] launchd 服务已安装: ${LAUNCHD_PLIST}"
|
||||
}
|
||||
|
||||
install_service() {
|
||||
ensure_service_manager
|
||||
local manager
|
||||
manager="$(detect_service_manager)"
|
||||
case "${manager}" in
|
||||
systemd)
|
||||
install_systemd_service "${1:-}"
|
||||
;;
|
||||
launchd)
|
||||
install_launchd_service "${1:-}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
uninstall_service() {
|
||||
ensure_service_manager
|
||||
local manager
|
||||
manager="$(detect_service_manager)"
|
||||
case "${manager}" in
|
||||
systemd)
|
||||
run_with_sudo systemctl disable --now "${SYSTEMD_SERVICE_NAME}" || true
|
||||
run_with_sudo rm -f "/etc/systemd/system/${SYSTEMD_SERVICE_NAME}"
|
||||
run_with_sudo systemctl daemon-reload
|
||||
echo "[信息] systemd 服务已卸载: ${SYSTEMD_SERVICE_NAME}"
|
||||
;;
|
||||
launchd)
|
||||
launchctl bootout "gui/$(id -u)/${LAUNCHD_LABEL}" >/dev/null 2>&1 || true
|
||||
rm -f "${LAUNCHD_PLIST}"
|
||||
echo "[信息] launchd 服务已卸载: ${LAUNCHD_LABEL}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
start_service() {
|
||||
ensure_service_manager
|
||||
local manager
|
||||
manager="$(detect_service_manager)"
|
||||
case "${manager}" in
|
||||
systemd)
|
||||
run_with_sudo systemctl start "${SYSTEMD_SERVICE_NAME}"
|
||||
echo "[信息] 已启动: ${SYSTEMD_SERVICE_NAME}"
|
||||
;;
|
||||
launchd)
|
||||
if [[ ! -f "${LAUNCHD_PLIST}" ]]; then
|
||||
echo "[错误] 未找到 ${LAUNCHD_PLIST},请先执行 install-service" >&2
|
||||
exit 1
|
||||
fi
|
||||
if launchctl print "gui/$(id -u)/${LAUNCHD_LABEL}" >/dev/null 2>&1; then
|
||||
launchctl kickstart -k "gui/$(id -u)/${LAUNCHD_LABEL}"
|
||||
else
|
||||
bootstrap_launchd_with_retry
|
||||
fi
|
||||
echo "[信息] 已启动: ${LAUNCHD_LABEL}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
ensure_service_manager
|
||||
local manager
|
||||
manager="$(detect_service_manager)"
|
||||
case "${manager}" in
|
||||
systemd)
|
||||
run_with_sudo systemctl stop "${SYSTEMD_SERVICE_NAME}"
|
||||
echo "[信息] 已停止: ${SYSTEMD_SERVICE_NAME}"
|
||||
;;
|
||||
launchd)
|
||||
launchctl bootout "gui/$(id -u)/${LAUNCHD_LABEL}" >/dev/null 2>&1 || true
|
||||
echo "[信息] 已停止: ${LAUNCHD_LABEL}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
restart_service() {
|
||||
ensure_service_manager
|
||||
local manager
|
||||
manager="$(detect_service_manager)"
|
||||
case "${manager}" in
|
||||
systemd)
|
||||
run_with_sudo systemctl restart "${SYSTEMD_SERVICE_NAME}"
|
||||
echo "[信息] 已重启: ${SYSTEMD_SERVICE_NAME}"
|
||||
;;
|
||||
launchd)
|
||||
if [[ ! -f "${LAUNCHD_PLIST}" ]]; then
|
||||
echo "[错误] 未找到 ${LAUNCHD_PLIST},请先执行 install-service" >&2
|
||||
exit 1
|
||||
fi
|
||||
if launchctl print "gui/$(id -u)/${LAUNCHD_LABEL}" >/dev/null 2>&1; then
|
||||
launchctl kickstart -k "gui/$(id -u)/${LAUNCHD_LABEL}"
|
||||
else
|
||||
bootstrap_launchd_with_retry
|
||||
fi
|
||||
echo "[信息] 已重启: ${LAUNCHD_LABEL}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
status_service() {
|
||||
ensure_service_manager
|
||||
local manager
|
||||
manager="$(detect_service_manager)"
|
||||
case "${manager}" in
|
||||
systemd)
|
||||
run_with_sudo systemctl status "${SYSTEMD_SERVICE_NAME}" --no-pager
|
||||
;;
|
||||
launchd)
|
||||
local status_tmp
|
||||
status_tmp="/tmp/remoteconn-launchd-status.log"
|
||||
if launchctl print "gui/$(id -u)/${LAUNCHD_LABEL}" >"${status_tmp}" 2>&1; then
|
||||
cat "${status_tmp}"
|
||||
else
|
||||
if grep -q "Could not find service" "${status_tmp}"; then
|
||||
echo "[信息] 服务未加载: ${LAUNCHD_LABEL}"
|
||||
else
|
||||
cat "${status_tmp}"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
logs_service() {
|
||||
ensure_service_manager
|
||||
local manager lines
|
||||
manager="$(detect_service_manager)"
|
||||
lines="${1:-200}"
|
||||
case "${manager}" in
|
||||
systemd)
|
||||
run_with_sudo journalctl -u "${SYSTEMD_SERVICE_NAME}" -n "${lines}" --no-pager
|
||||
;;
|
||||
launchd)
|
||||
echo "[信息] stdout: ${LAUNCHD_STDOUT_LOG}"
|
||||
if [[ -f "${LAUNCHD_STDOUT_LOG}" ]]; then
|
||||
tail -n "${lines}" "${LAUNCHD_STDOUT_LOG}"
|
||||
else
|
||||
echo "[信息] 尚无 stdout 日志"
|
||||
fi
|
||||
echo "[信息] stderr: ${LAUNCHD_STDERR_LOG}"
|
||||
if [[ -f "${LAUNCHD_STDERR_LOG}" ]]; then
|
||||
tail -n "${lines}" "${LAUNCHD_STDERR_LOG}"
|
||||
else
|
||||
echo "[信息] 尚无 stderr 日志"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
logs_clear() {
|
||||
mkdir -p "$(dirname "${LAUNCHD_STDOUT_LOG}")"
|
||||
: >"${LAUNCHD_STDOUT_LOG}"
|
||||
: >"${LAUNCHD_STDERR_LOG}"
|
||||
echo "[信息] 已清空日志:"
|
||||
echo " - ${LAUNCHD_STDOUT_LOG}"
|
||||
echo " - ${LAUNCHD_STDERR_LOG}"
|
||||
}
|
||||
|
||||
health_check() {
|
||||
need_cmd curl
|
||||
local url retries interval index output last_error
|
||||
url="${1:-${DEFAULT_HEALTH_URL}}"
|
||||
retries="${HEALTH_RETRIES:-5}"
|
||||
interval="${HEALTH_INTERVAL_SEC:-1}"
|
||||
index=1
|
||||
last_error=""
|
||||
|
||||
while (( index <= retries )); do
|
||||
if output="$(curl -fsS --max-time 2 "${url}" 2>/dev/null)"; then
|
||||
echo "[信息] 健康检查(${index}/${retries})通过: ${url}"
|
||||
echo "${output}"
|
||||
return
|
||||
fi
|
||||
last_error="$(curl -fsS --max-time 2 "${url}" 2>&1 || true)"
|
||||
echo "[告警] 健康检查失败,第 ${index}/${retries} 次重试"
|
||||
sleep "${interval}"
|
||||
index=$((index + 1))
|
||||
done
|
||||
|
||||
echo "[错误] 健康检查失败: ${url}" >&2
|
||||
if [[ -n "${last_error}" ]]; then
|
||||
echo "${last_error}" >&2
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
deploy() {
|
||||
local health_url
|
||||
health_url="${1:-${DEFAULT_HEALTH_URL}}"
|
||||
ensure_env
|
||||
build_gateway
|
||||
restart_service
|
||||
health_check "${health_url}"
|
||||
}
|
||||
|
||||
run_local() {
|
||||
ensure_env
|
||||
ensure_tsx_bin
|
||||
echo "[信息] 前台运行 gateway(Ctrl+C 退出)"
|
||||
"${TSX_BIN}" "${REPO_ROOT}/apps/gateway/src/index.ts"
|
||||
}
|
||||
|
||||
main() {
|
||||
local cmd
|
||||
cmd="${1:-}"
|
||||
case "${cmd}" in
|
||||
ensure-env)
|
||||
ensure_env
|
||||
;;
|
||||
build)
|
||||
build_gateway
|
||||
;;
|
||||
install-service)
|
||||
install_service "${2:-}"
|
||||
;;
|
||||
uninstall-service)
|
||||
uninstall_service
|
||||
;;
|
||||
start)
|
||||
start_service
|
||||
;;
|
||||
stop)
|
||||
stop_service
|
||||
;;
|
||||
restart)
|
||||
restart_service
|
||||
;;
|
||||
status)
|
||||
status_service
|
||||
;;
|
||||
logs)
|
||||
logs_service "${2:-}"
|
||||
;;
|
||||
logs-clear)
|
||||
logs_clear
|
||||
;;
|
||||
health)
|
||||
health_check "${2:-}"
|
||||
;;
|
||||
deploy)
|
||||
deploy "${2:-}"
|
||||
;;
|
||||
run-local)
|
||||
run_local
|
||||
;;
|
||||
-h|--help|help|"")
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
echo "[错误] 未知命令: ${cmd}" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
33
scripts/lint.mjs
Normal file
33
scripts/lint.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const files = [
|
||||
"prototype/liquid-console.html",
|
||||
"prototype/liquid-console.css",
|
||||
"prototype/liquid-console.js",
|
||||
"prototype/README.md"
|
||||
];
|
||||
|
||||
let failed = false;
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
const lines = content.split("\n");
|
||||
|
||||
lines.forEach((line, idx) => {
|
||||
if (/\s+$/.test(line)) {
|
||||
console.error(`${file}:${idx + 1} 存在行尾空格`);
|
||||
failed = true;
|
||||
}
|
||||
|
||||
if (/\t/.test(line)) {
|
||||
console.error(`${file}:${idx + 1} 存在 tab,请改为空格`);
|
||||
failed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("lint passed");
|
||||
147
scripts/mini-preview.mjs
Normal file
147
scripts/mini-preview.mjs
Normal file
@@ -0,0 +1,147 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const APP_ID = "wxa0e7e5a27599cf6c";
|
||||
const PROJECT_PATH = path.join(ROOT, "apps/miniprogram");
|
||||
const PRIVATE_KEY_PATH = path.join(ROOT, "private.wxa0e7e5a27599cf6c.key");
|
||||
const DEFAULT_REDRAW_RETRY_COUNT = 1;
|
||||
const DEFAULT_REDRAW_WAIT_MS = 300;
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => {
|
||||
globalThis.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeRetryCount(raw, fallback = DEFAULT_REDRAW_RETRY_COUNT) {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
if (parsed < 0) return 0;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
export async function renderTerminalQrcodeWithRetry({
|
||||
qrcodeBase64,
|
||||
generateTerminalQrcode,
|
||||
maxRetries = DEFAULT_REDRAW_RETRY_COUNT,
|
||||
waitMs = DEFAULT_REDRAW_WAIT_MS,
|
||||
onRetry,
|
||||
onRecovered
|
||||
}) {
|
||||
let lastError = null;
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
|
||||
try {
|
||||
const terminalQrcode = await generateTerminalQrcode(qrcodeBase64);
|
||||
if (attempt > 0 && typeof onRecovered === "function") {
|
||||
onRecovered({ attempt, maxRetries });
|
||||
}
|
||||
return terminalQrcode;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt >= maxRetries) break;
|
||||
if (typeof onRetry === "function") {
|
||||
onRetry({ attempt: attempt + 1, maxRetries, error });
|
||||
}
|
||||
if (waitMs > 0) {
|
||||
await wait(waitMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function runNodeScript(scriptPath) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [scriptPath], {
|
||||
cwd: ROOT,
|
||||
stdio: "inherit"
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`脚本执行失败:${scriptPath}(退出码 ${code ?? "unknown"})`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function loadMiniProgramCiModules() {
|
||||
const require = createRequire(import.meta.url);
|
||||
return {
|
||||
ci: require("miniprogram-ci"),
|
||||
terminalQrcodeModule: require("miniprogram-ci/dist/ci/utils/terminalQrcode"),
|
||||
miniprogramLog: require("miniprogram-ci/dist/utils/log")
|
||||
};
|
||||
}
|
||||
|
||||
function patchTerminalQrcodeRedraw({ terminalQrcodeModule, miniprogramLog, maxRetries, waitMs }) {
|
||||
const originalGenerate = terminalQrcodeModule.generateTerminalQrcode;
|
||||
terminalQrcodeModule.generateTerminalQrcode = async (qrcodeBase64) =>
|
||||
renderTerminalQrcodeWithRetry({
|
||||
qrcodeBase64,
|
||||
maxRetries,
|
||||
waitMs,
|
||||
generateTerminalQrcode: originalGenerate,
|
||||
onRetry: ({ attempt, maxRetries: totalRetries }) => {
|
||||
miniprogramLog.warn(`终端二维码首次渲染失败,正在自动重画(${attempt}/${totalRetries})...`);
|
||||
},
|
||||
onRecovered: ({ attempt, maxRetries: totalRetries }) => {
|
||||
miniprogramLog.log(`终端二维码已自动重画成功(第 ${attempt} 次重画,共 ${totalRetries} 次机会)。`);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
terminalQrcodeModule.generateTerminalQrcode = originalGenerate;
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
process.env.COLUMNS = process.env.COLUMNS || "120";
|
||||
await runNodeScript(path.join(ROOT, "scripts/sync-miniprogram-env.mjs"));
|
||||
|
||||
const redrawRetries = normalizeRetryCount(process.env.MINI_TERMINAL_QRCODE_RETRIES);
|
||||
const redrawWaitMs = normalizeRetryCount(process.env.MINI_TERMINAL_QRCODE_WAIT_MS, DEFAULT_REDRAW_WAIT_MS);
|
||||
const { ci, terminalQrcodeModule, miniprogramLog } = loadMiniProgramCiModules();
|
||||
const restorePatchedQrcode = patchTerminalQrcodeRedraw({
|
||||
terminalQrcodeModule,
|
||||
miniprogramLog,
|
||||
maxRetries: redrawRetries,
|
||||
waitMs: redrawWaitMs
|
||||
});
|
||||
|
||||
try {
|
||||
const project = new ci.Project({
|
||||
appid: APP_ID,
|
||||
type: "miniProgram",
|
||||
projectPath: PROJECT_PATH,
|
||||
privateKeyPath: PRIVATE_KEY_PATH
|
||||
});
|
||||
|
||||
await ci.preview({
|
||||
project,
|
||||
robot: 1,
|
||||
setting: {
|
||||
useProjectConfig: true
|
||||
},
|
||||
qrcodeFormat: "terminal",
|
||||
onProgressUpdate: (progress) => {
|
||||
miniprogramLog.log(progress);
|
||||
}
|
||||
});
|
||||
miniprogramLog.log("done");
|
||||
} finally {
|
||||
restorePatchedQrcode();
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
globalThis.console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
50
scripts/mini-preview.test.ts
Normal file
50
scripts/mini-preview.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("mini preview terminal qrcode redraw", async () => {
|
||||
const { normalizeRetryCount, renderTerminalQrcodeWithRetry } = await import("./mini-preview.mjs");
|
||||
|
||||
it("在第一次失败后会自动重画一次", async () => {
|
||||
const generateTerminalQrcode = vi
|
||||
.fn<(_: string) => Promise<string>>()
|
||||
.mockRejectedValueOnce(new Error("decode failed"))
|
||||
.mockResolvedValueOnce("terminal-qrcode");
|
||||
const onRetry = vi.fn();
|
||||
const onRecovered = vi.fn();
|
||||
|
||||
const result = await renderTerminalQrcodeWithRetry({
|
||||
qrcodeBase64: "demo",
|
||||
generateTerminalQrcode,
|
||||
maxRetries: 1,
|
||||
waitMs: 0,
|
||||
onRetry,
|
||||
onRecovered
|
||||
});
|
||||
|
||||
expect(result).toBe("terminal-qrcode");
|
||||
expect(generateTerminalQrcode).toHaveBeenCalledTimes(2);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
expect(onRecovered).toHaveBeenCalledWith({ attempt: 1, maxRetries: 1 });
|
||||
});
|
||||
|
||||
it("超过重画次数后会抛出最后一次错误", async () => {
|
||||
const finalError = new Error("still failed");
|
||||
const generateTerminalQrcode = vi.fn<(_: string) => Promise<string>>().mockRejectedValue(finalError);
|
||||
|
||||
await expect(
|
||||
renderTerminalQrcodeWithRetry({
|
||||
qrcodeBase64: "demo",
|
||||
generateTerminalQrcode,
|
||||
maxRetries: 1,
|
||||
waitMs: 0
|
||||
})
|
||||
).rejects.toThrow("still failed");
|
||||
expect(generateTerminalQrcode).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("重画次数会被归一化为非负整数", () => {
|
||||
expect(normalizeRetryCount(undefined)).toBe(1);
|
||||
expect(normalizeRetryCount("3.9")).toBe(3);
|
||||
expect(normalizeRetryCount("-1")).toBe(0);
|
||||
expect(normalizeRetryCount("bad", 2)).toBe(2);
|
||||
});
|
||||
});
|
||||
87
scripts/sync-miniprogram-env.mjs
Normal file
87
scripts/sync-miniprogram-env.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const MINI_ROOT = path.join(ROOT, "apps/miniprogram");
|
||||
const ENV_PATH = path.join(MINI_ROOT, ".env");
|
||||
const OUTPUT_PATH = path.join(MINI_ROOT, "utils/opsEnv.js");
|
||||
|
||||
function stripQuotes(raw) {
|
||||
const value = String(raw || "").trim();
|
||||
if (!value) return "";
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseEnv(text) {
|
||||
const out = {};
|
||||
const lines = String(text || "").split(/\r?\n/);
|
||||
for (const lineRaw of lines) {
|
||||
const line = lineRaw.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const normalized = line.startsWith("export ") ? line.slice(7).trim() : line;
|
||||
const eq = normalized.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = normalized.slice(0, eq).trim();
|
||||
if (!key) continue;
|
||||
out[key] = stripQuotes(normalized.slice(eq + 1));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getValue(env, key, fallback = "") {
|
||||
return Object.prototype.hasOwnProperty.call(env, key) ? String(env[key]) : fallback;
|
||||
}
|
||||
|
||||
function toNumber(raw, fallback) {
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
function toBool(raw, fallback) {
|
||||
const v = String(raw || "").trim().toLowerCase();
|
||||
if (!v) return fallback;
|
||||
if (["1", "true", "yes", "on"].includes(v)) return true;
|
||||
if (["0", "false", "no", "off"].includes(v)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildOps(env) {
|
||||
return {
|
||||
gatewayUrl: getValue(env, "GATEWAY_URL"),
|
||||
gatewayToken: getValue(env, "GATEWAY_TOKEN"),
|
||||
hostKeyPolicy: getValue(env, "HOST_KEY_POLICY", "strict"),
|
||||
credentialMemoryPolicy: getValue(env, "CREDENTIAL_MEMORY_POLICY", "remember"),
|
||||
gatewayConnectTimeoutMs: toNumber(getValue(env, "GATEWAY_CONNECT_TIMEOUT_MS", "12000"), 12000),
|
||||
waitForConnectedTimeoutMs: toNumber(getValue(env, "WAIT_FOR_CONNECTED_TIMEOUT_MS", "15000"), 15000),
|
||||
terminalBufferMaxEntries: toNumber(getValue(env, "TERMINAL_BUFFER_MAX_ENTRIES", "5000"), 5000),
|
||||
terminalBufferMaxBytes: toNumber(getValue(env, "TERMINAL_BUFFER_MAX_BYTES", String(4 * 1024 * 1024)), 4 * 1024 * 1024),
|
||||
maskSecrets: toBool(getValue(env, "MASK_SECRETS", "true"), true)
|
||||
};
|
||||
}
|
||||
|
||||
function writeOpsModule(ops) {
|
||||
const content = `/**\n * Auto-generated from apps/miniprogram/.env\n * Do not edit manually. Run: node scripts/sync-miniprogram-env.mjs\n */\nmodule.exports = ${JSON.stringify(
|
||||
ops,
|
||||
null,
|
||||
2
|
||||
)};\n`;
|
||||
fs.writeFileSync(OUTPUT_PATH, content, "utf8");
|
||||
}
|
||||
|
||||
function main() {
|
||||
if (!fs.existsSync(ENV_PATH)) {
|
||||
throw new Error(
|
||||
`缺少 .env 文件: ${ENV_PATH}\n请先从 apps/miniprogram/.env.example 复制一份,并至少填写 GATEWAY_URL 与 GATEWAY_TOKEN。`
|
||||
);
|
||||
}
|
||||
const raw = fs.readFileSync(ENV_PATH, "utf8");
|
||||
const env = parseEnv(raw);
|
||||
const ops = buildOps(env);
|
||||
writeOpsModule(ops);
|
||||
console.log("[sync-miniprogram-env] 已生成 apps/miniprogram/utils/opsEnv.js");
|
||||
}
|
||||
|
||||
main();
|
||||
541
scripts/terminal-perf-replay.mjs
Normal file
541
scripts/terminal-perf-replay.mjs
Normal file
@@ -0,0 +1,541 @@
|
||||
import { createRequire } from "node:module";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const ROOT = process.cwd();
|
||||
const TERMINAL_PAGE_PATH = path.join(ROOT, "apps/miniprogram/pages/terminal/index.js");
|
||||
const TERMINAL_FIXTURE_PATH = path.join(ROOT, "apps/miniprogram/pages/terminal/codexCaptureFixture.js");
|
||||
const TERMINAL_BUFFER_STATE_PATH = path.join(
|
||||
ROOT,
|
||||
"apps/miniprogram/pages/terminal/terminalBufferState.js"
|
||||
);
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 97;
|
||||
const DEFAULT_CADENCE_MS = 8;
|
||||
const DEFAULT_REPEAT = 4;
|
||||
const DEFAULT_SETTLE_MS = 1600;
|
||||
const DEFAULT_CAPTURE_SPEED = 1;
|
||||
const DEFAULT_RECT = Object.freeze({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 375,
|
||||
bottom: 520,
|
||||
width: 375,
|
||||
height: 520
|
||||
});
|
||||
|
||||
function parsePositiveInteger(raw, fallback) {
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = Array.isArray(argv) ? argv : [];
|
||||
const parsed = {
|
||||
chunkSize: DEFAULT_CHUNK_SIZE,
|
||||
cadenceMs: DEFAULT_CADENCE_MS,
|
||||
repeat: DEFAULT_REPEAT,
|
||||
settleMs: DEFAULT_SETTLE_MS,
|
||||
fixture: "20260311",
|
||||
captureFile: "",
|
||||
captureSpeed: DEFAULT_CAPTURE_SPEED,
|
||||
quiet: false
|
||||
};
|
||||
args.forEach((item) => {
|
||||
const source = String(item || "");
|
||||
if (!source.startsWith("--")) {
|
||||
return;
|
||||
}
|
||||
const [key, rawValue = ""] = source.slice(2).split("=");
|
||||
if (key === "chunk-size") {
|
||||
parsed.chunkSize = parsePositiveInteger(rawValue, DEFAULT_CHUNK_SIZE);
|
||||
return;
|
||||
}
|
||||
if (key === "cadence-ms") {
|
||||
parsed.cadenceMs = Math.max(0, parsePositiveInteger(rawValue, DEFAULT_CADENCE_MS));
|
||||
return;
|
||||
}
|
||||
if (key === "repeat") {
|
||||
parsed.repeat = parsePositiveInteger(rawValue, DEFAULT_REPEAT);
|
||||
return;
|
||||
}
|
||||
if (key === "settle-ms") {
|
||||
parsed.settleMs = Math.max(0, parsePositiveInteger(rawValue, DEFAULT_SETTLE_MS));
|
||||
return;
|
||||
}
|
||||
if (key === "fixture" && rawValue) {
|
||||
parsed.fixture = rawValue;
|
||||
return;
|
||||
}
|
||||
if (key === "capture-file" && rawValue) {
|
||||
parsed.captureFile = path.isAbsolute(rawValue) ? rawValue : path.join(ROOT, rawValue);
|
||||
return;
|
||||
}
|
||||
if (key === "capture-speed") {
|
||||
const value = Number(rawValue);
|
||||
parsed.captureSpeed = Number.isFinite(value) && value > 0 ? value : DEFAULT_CAPTURE_SPEED;
|
||||
return;
|
||||
}
|
||||
if (key === "quiet") {
|
||||
parsed.quiet = rawValue !== "false";
|
||||
}
|
||||
});
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function splitTextIntoChunks(text, chunkSize) {
|
||||
const chunks = [];
|
||||
const size = Math.max(1, parsePositiveInteger(chunkSize, DEFAULT_CHUNK_SIZE));
|
||||
for (let index = 0; index < text.length; index += size) {
|
||||
chunks.push(text.slice(index, index + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
const delay = Math.max(0, Number(ms) || 0);
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
|
||||
function loadCaptureRecording(filePath) {
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.join(ROOT, filePath);
|
||||
const lines = fs
|
||||
.readFileSync(resolvedPath, "utf8")
|
||||
.split(/\r?\n/)
|
||||
.filter(Boolean);
|
||||
const events = [];
|
||||
let meta = null;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
throw new Error(`录制文件解析失败,第 ${index + 1} 行不是合法 JSON: ${(error && error.message) || error}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
return;
|
||||
}
|
||||
if (parsed.kind === "meta") {
|
||||
meta = parsed;
|
||||
return;
|
||||
}
|
||||
if (parsed.kind === "frame" && typeof parsed.type === "string" && typeof parsed.data === "string") {
|
||||
events.push({
|
||||
offsetMs: Math.max(0, Number(parsed.offsetMs) || 0),
|
||||
type: parsed.type,
|
||||
data: parsed.data
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
filePath: resolvedPath,
|
||||
meta,
|
||||
events
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRect(rect) {
|
||||
const source = rect && typeof rect === "object" ? rect : DEFAULT_RECT;
|
||||
return {
|
||||
left: Number(source.left) || 0,
|
||||
top: Number(source.top) || 0,
|
||||
right: Number(source.right) || 0,
|
||||
bottom: Number(source.bottom) || 0,
|
||||
width: Number(source.width) || DEFAULT_RECT.width,
|
||||
height: Number(source.height) || DEFAULT_RECT.height
|
||||
};
|
||||
}
|
||||
|
||||
function createSelectorQueryStub() {
|
||||
const queue = [];
|
||||
const api = {
|
||||
in() {
|
||||
return api;
|
||||
},
|
||||
select() {
|
||||
return {
|
||||
boundingClientRect(callback) {
|
||||
queue.push({
|
||||
type: "rect",
|
||||
callback: typeof callback === "function" ? callback : null
|
||||
});
|
||||
return api;
|
||||
},
|
||||
scrollOffset(callback) {
|
||||
queue.push({
|
||||
type: "scroll",
|
||||
callback: typeof callback === "function" ? callback : null
|
||||
});
|
||||
return api;
|
||||
}
|
||||
};
|
||||
},
|
||||
exec(callback) {
|
||||
const results = queue.map((item) =>
|
||||
item.type === "rect"
|
||||
? cloneRect(DEFAULT_RECT)
|
||||
: {
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0
|
||||
}
|
||||
);
|
||||
queue.forEach((item, index) => {
|
||||
if (item.callback) {
|
||||
item.callback(results[index]);
|
||||
}
|
||||
});
|
||||
if (typeof callback === "function") {
|
||||
callback(results);
|
||||
}
|
||||
}
|
||||
};
|
||||
return api;
|
||||
}
|
||||
|
||||
function installMiniprogramGlobals() {
|
||||
const globalState = globalThis;
|
||||
const previousPage = globalState.Page;
|
||||
const previousWx = globalState.wx;
|
||||
let capturedPageOptions = null;
|
||||
const noop = () => {};
|
||||
// 回放脚本只需要一个轻量的内存存储桩,避免触发真实小程序存储分支。
|
||||
const storage = new Map();
|
||||
|
||||
globalState.Page = (options) => {
|
||||
capturedPageOptions = options;
|
||||
};
|
||||
globalState.wx = {
|
||||
env: {
|
||||
USER_DATA_PATH: "/tmp"
|
||||
},
|
||||
getRecorderManager: () => ({
|
||||
onStart: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
onFrameRecorded: noop,
|
||||
start: noop,
|
||||
stop: noop
|
||||
}),
|
||||
createInnerAudioContext: () => ({
|
||||
onCanplay: noop,
|
||||
onPlay: noop,
|
||||
onEnded: noop,
|
||||
onStop: noop,
|
||||
onError: noop,
|
||||
stop: noop,
|
||||
destroy: noop
|
||||
}),
|
||||
setInnerAudioOption: noop,
|
||||
createSelectorQuery: () => createSelectorQueryStub(),
|
||||
nextTick: (callback) => {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
},
|
||||
getSystemInfoSync: () => ({
|
||||
windowWidth: DEFAULT_RECT.width,
|
||||
windowHeight: 667,
|
||||
screenWidth: DEFAULT_RECT.width,
|
||||
screenHeight: 667,
|
||||
pixelRatio: 2
|
||||
}),
|
||||
canIUse: () => false,
|
||||
getStorageSync: (key) => storage.get(String(key)),
|
||||
setStorageSync: (key, value) => {
|
||||
storage.set(String(key), value);
|
||||
},
|
||||
removeStorageSync: (key) => {
|
||||
storage.delete(String(key));
|
||||
},
|
||||
clearStorageSync: () => {
|
||||
storage.clear();
|
||||
},
|
||||
getStorageInfoSync: () => ({
|
||||
keys: Array.from(storage.keys()),
|
||||
currentSize: storage.size,
|
||||
limitSize: 10240
|
||||
}),
|
||||
setNavigationBarTitle: noop,
|
||||
showToast: noop,
|
||||
showModal: noop,
|
||||
hideKeyboard: noop
|
||||
};
|
||||
|
||||
delete require.cache[require.resolve(TERMINAL_PAGE_PATH)];
|
||||
require(TERMINAL_PAGE_PATH);
|
||||
|
||||
return {
|
||||
capturedPageOptions,
|
||||
restore() {
|
||||
if (previousPage === undefined) {
|
||||
delete globalState.Page;
|
||||
} else {
|
||||
globalState.Page = previousPage;
|
||||
}
|
||||
if (previousWx === undefined) {
|
||||
delete globalState.wx;
|
||||
} else {
|
||||
globalState.wx = previousWx;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createTerminalPageHarness() {
|
||||
const { createEmptyTerminalBufferState, cloneAnsiState, ANSI_RESET_STATE } = require(
|
||||
TERMINAL_BUFFER_STATE_PATH
|
||||
);
|
||||
const { capturedPageOptions, restore } = installMiniprogramGlobals();
|
||||
if (!capturedPageOptions) {
|
||||
restore();
|
||||
throw new Error("terminal page not captured");
|
||||
}
|
||||
|
||||
const captured = capturedPageOptions;
|
||||
const page = {
|
||||
...captured,
|
||||
data: JSON.parse(JSON.stringify(captured.data || {})),
|
||||
setData(patch, callback) {
|
||||
Object.assign(this.data, patch || {});
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
page.initTerminalPerfState();
|
||||
page.initTerminalRenderScheduler();
|
||||
page.connectionDiagnosticNetworkProbeTimer = null;
|
||||
page.connectionDiagnosticNetworkProbePending = false;
|
||||
page.connectionDiagnosticKeepSamplesOnNextConnect = false;
|
||||
page.outputCursorRow = 0;
|
||||
page.outputCursorCol = 0;
|
||||
page.outputCells = [[]];
|
||||
page.outputReplayText = "";
|
||||
page.outputReplayBytes = 0;
|
||||
page.outputAnsiState = cloneAnsiState(ANSI_RESET_STATE);
|
||||
page.outputRectWidth = DEFAULT_RECT.width;
|
||||
page.outputRectHeight = DEFAULT_RECT.height;
|
||||
page.stdoutReplayCarryText = "";
|
||||
page.terminalSyncUpdateState = page.terminalSyncUpdateState || { depth: 0, carryText: "", bufferedText: "" };
|
||||
page.terminalStdoutUserInputPending = false;
|
||||
page.terminalCols = 80;
|
||||
page.terminalRows = 24;
|
||||
page.outputTerminalState = createEmptyTerminalBufferState({
|
||||
bufferCols: page.terminalCols,
|
||||
bufferRows: page.terminalRows
|
||||
});
|
||||
page.applyTerminalBufferState(page.outputTerminalState);
|
||||
page.currentOutputScrollTop = 0;
|
||||
page.outputRectSnapshot = cloneRect(DEFAULT_RECT);
|
||||
page.outputViewportWindow = null;
|
||||
page.outputViewportScrollRefreshPending = false;
|
||||
page.terminalScrollOverlayTimer = null;
|
||||
page.terminalScrollIdleTimer = null;
|
||||
page.terminalScrollViewportPrefetchTimer = null;
|
||||
page.terminalScrollLastOverlayAt = 0;
|
||||
page.terminalScrollLastViewportRefreshAt = 0;
|
||||
page.terminalScrollDirection = 0;
|
||||
page.shellFontSizePx = 15;
|
||||
page.shellLineHeightRatio = 1.4;
|
||||
page.shellLineHeightPx = 21;
|
||||
page.shellCharWidthPx = 9;
|
||||
page.outputHorizontalPaddingPx = 8;
|
||||
page.outputRightPaddingPx = 8;
|
||||
page.windowWidth = DEFAULT_RECT.width;
|
||||
page.windowHeight = 667;
|
||||
page.keyboardVisibleHeightPx = 0;
|
||||
page.keyboardRestoreScrollTop = null;
|
||||
page.keyboardSessionActive = false;
|
||||
page.sessionSuspended = false;
|
||||
page.codexBootstrapGuard = null;
|
||||
page.activeAiProvider = "codex";
|
||||
page.activeCodexSandboxMode = "";
|
||||
page.aiSessionShellReady = true;
|
||||
page.aiRuntimeExitCarry = "";
|
||||
page.pendingCodexResumeAfterReconnect = false;
|
||||
page.client = null;
|
||||
page.data.statusText = "connected";
|
||||
page.data.statusClass = "connected";
|
||||
page.data.serverId = "terminal-replay";
|
||||
page.data.sessionId = "terminal-replay-session";
|
||||
page.data.serverLabel = "terminal-replay";
|
||||
|
||||
page.queryOutputRect = function queryOutputRect(callback) {
|
||||
this.currentOutputScrollTop = Math.max(0, Number(this.currentOutputScrollTop) || 0);
|
||||
this.outputRectSnapshot = cloneRect(DEFAULT_RECT);
|
||||
if (typeof callback === "function") {
|
||||
callback(cloneRect(DEFAULT_RECT));
|
||||
}
|
||||
};
|
||||
page.runAfterTerminalLayout = function runAfterTerminalLayout(callback) {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
page.measureShellMetrics = function measureShellMetrics(callback) {
|
||||
if (typeof callback === "function") {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
page.setStatus = function setStatus(status) {
|
||||
const nextStatus = String(status || "");
|
||||
this.data.statusText = nextStatus;
|
||||
this.data.statusClass = nextStatus === "connected" ? "connected" : "idle";
|
||||
};
|
||||
page.syncConnectionAction = () => {};
|
||||
page.persistTerminalSessionStatus = () => {};
|
||||
page.persistConnectionDiagnosticSamples = () => {};
|
||||
page.resetTtsRoundState = () => {};
|
||||
page.appendTtsRoundOutput = () => {};
|
||||
page.handleError = (error) => {
|
||||
throw error instanceof Error ? error : new Error(String(error || "terminal replay error"));
|
||||
};
|
||||
|
||||
return {
|
||||
page,
|
||||
restore
|
||||
};
|
||||
}
|
||||
|
||||
function loadFixture(name) {
|
||||
const fixtureModule = require(TERMINAL_FIXTURE_PATH);
|
||||
if (name === "20260311" && typeof fixtureModule.decodeCodexTtyCapture20260311 === "function") {
|
||||
return fixtureModule.decodeCodexTtyCapture20260311();
|
||||
}
|
||||
throw new Error(`未知 fixture:${name}`);
|
||||
}
|
||||
|
||||
function parsePerfLine(line) {
|
||||
const source = String(line || "");
|
||||
const marker = "[terminal.perf] ";
|
||||
const index = source.indexOf(marker);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
const jsonText = source.slice(index + marker.length).trim();
|
||||
try {
|
||||
return JSON.parse(jsonText);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function runReplay(options) {
|
||||
const config = options && typeof options === "object" ? options : {};
|
||||
const recording = config.captureFile ? loadCaptureRecording(config.captureFile) : null;
|
||||
const capture = recording ? "" : loadFixture(config.fixture || "20260311");
|
||||
const chunks = recording ? [] : splitTextIntoChunks(capture, config.chunkSize);
|
||||
const { page, restore } = createTerminalPageHarness();
|
||||
const originalConsoleInfo = console.info;
|
||||
const perfRecords = [];
|
||||
|
||||
console.info = (...args) => {
|
||||
const line = args.map((item) => String(item)).join(" ");
|
||||
const parsed = parsePerfLine(line);
|
||||
if (parsed) {
|
||||
perfRecords.push(parsed);
|
||||
}
|
||||
if (!config.quiet) {
|
||||
originalConsoleInfo(...args);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
for (let round = 0; round < config.repeat; round += 1) {
|
||||
if (recording) {
|
||||
let previousOffsetMs = 0;
|
||||
for (let index = 0; index < recording.events.length; index += 1) {
|
||||
const event = recording.events[index];
|
||||
const deltaMs = Math.max(0, event.offsetMs - previousOffsetMs);
|
||||
previousOffsetMs = event.offsetMs;
|
||||
if (deltaMs > 0) {
|
||||
await wait(deltaMs / config.captureSpeed);
|
||||
}
|
||||
if (event.type === "stdout" || event.type === "stderr") {
|
||||
page.handleFrame({
|
||||
type: event.type,
|
||||
payload: {
|
||||
data: event.data
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let index = 0; index < chunks.length; index += 1) {
|
||||
page.handleFrame({
|
||||
type: "stdout",
|
||||
payload: {
|
||||
data: chunks[index]
|
||||
}
|
||||
});
|
||||
if (config.cadenceMs > 0) {
|
||||
await wait(config.cadenceMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (config.settleMs > 0) {
|
||||
await wait(config.settleMs);
|
||||
}
|
||||
const flushedSummary = page.flushTerminalPerfLogs("script_end");
|
||||
const scheduler = page.getTerminalRenderSchedulerSnapshot();
|
||||
const activeTask = page.getActiveStdoutTaskSnapshot();
|
||||
const summaries = perfRecords.filter((item) => item && item.event === "perf.summary");
|
||||
const snapshots = perfRecords.filter((item) => item && item.event === "perf.snapshot");
|
||||
return {
|
||||
captureFile: recording ? recording.filePath : "",
|
||||
captureMeta: recording ? recording.meta : null,
|
||||
fixture: config.fixture,
|
||||
chunkSize: config.chunkSize,
|
||||
cadenceMs: config.cadenceMs,
|
||||
captureSpeed: config.captureSpeed,
|
||||
repeat: config.repeat,
|
||||
settleMs: config.settleMs,
|
||||
chunkCount: recording ? recording.events.length : chunks.length,
|
||||
captureChars: capture.length,
|
||||
perfSummaryCount: summaries.length,
|
||||
perfSnapshotCount: snapshots.length,
|
||||
lastPerfSummary: flushedSummary || summaries.at(-1) || null,
|
||||
lastPerfSnapshot: snapshots.at(-1) || null,
|
||||
scheduler,
|
||||
activeTask,
|
||||
outputLineCount: Array.isArray(page.data.outputRenderLines) ? page.data.outputRenderLines.length : 0,
|
||||
outputTopSpacerPx: Number(page.data.outputTopSpacerPx) || 0,
|
||||
outputBottomSpacerPx: Number(page.data.outputBottomSpacerPx) || 0
|
||||
};
|
||||
} finally {
|
||||
console.info = originalConsoleInfo;
|
||||
restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const result = await runReplay(options);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
|
||||
export {
|
||||
createTerminalPageHarness,
|
||||
parseArgs,
|
||||
runReplay,
|
||||
splitTextIntoChunks
|
||||
};
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
9
scripts/typecheck.mjs
Normal file
9
scripts/typecheck.mjs
Normal file
@@ -0,0 +1,9 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const targets = ["prototype/liquid-console.js"];
|
||||
|
||||
for (const file of targets) {
|
||||
execSync(`node --check ${file}`, { stdio: "inherit" });
|
||||
}
|
||||
|
||||
console.log("typecheck passed");
|
||||
Reference in New Issue
Block a user