339 lines
14 KiB
Bash
Executable File
339 lines
14 KiB
Bash
Executable File
#!/bin/sh
|
||
set -eu
|
||
|
||
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
|
||
DIRECT_COMPOSE="$ROOT_DIR/packages/reactive-resume-personal-direct/compose.yml"
|
||
DIRECT_ENV="$ROOT_DIR/packages/reactive-resume-personal-direct/.env"
|
||
QNAP_COMPOSE="$ROOT_DIR/packages/reactive-resume-personal-qnap-nas/compose-Nas.yml"
|
||
QNAP_PATCH_DIR="$ROOT_DIR/packages/reactive-resume-personal-qnap-nas/patches"
|
||
DIRECT_PATCH_DIR="$ROOT_DIR/packages/reactive-resume-personal-direct/patches"
|
||
QNAP_ZIP="$ROOT_DIR/dist/reactive-resume-personal-qnap-nas-20260520.zip"
|
||
DIRECT_ZIP="$ROOT_DIR/dist/reactive-resume-personal-direct-20260520.zip"
|
||
IMAGE_REPO="amruthpillai/reactive-resume"
|
||
IMAGE_INDEX="$IMAGE_REPO@sha256:b760446c4301af067e7d595537a877e378363aa6ce921b7349e62983621826aa"
|
||
PROJECT="reactive-resume-personal"
|
||
|
||
log() {
|
||
printf '\n[TEST] %s\n' "$*"
|
||
}
|
||
|
||
fail() {
|
||
printf '\n[FAIL] %s\n' "$*" >&2
|
||
exit 1
|
||
}
|
||
|
||
cleanup_direct() {
|
||
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" down -v >/dev/null 2>&1 || true
|
||
}
|
||
|
||
cleanup_tmp() {
|
||
if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then
|
||
rm -rf "$TMP_DIR"
|
||
fi
|
||
}
|
||
|
||
cleanup_all() {
|
||
cleanup_direct
|
||
cleanup_tmp
|
||
}
|
||
|
||
trap cleanup_all HUP INT TERM EXIT
|
||
|
||
cd "$ROOT_DIR"
|
||
|
||
log "检查补丁脚本语法"
|
||
sh -n "$QNAP_PATCH_DIR/reactive-resume-runtime-patch.sh"
|
||
sh -n "$QNAP_PATCH_DIR/reactive-resume-entrypoint.sh"
|
||
sh -n "$DIRECT_PATCH_DIR/reactive-resume-runtime-patch.sh"
|
||
sh -n "$DIRECT_PATCH_DIR/reactive-resume-entrypoint.sh"
|
||
|
||
log "检查 Compose 配置可解析"
|
||
docker compose -f "$QNAP_COMPOSE" config >/tmp/reactive-resume-qnap-compose-test.yml
|
||
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" config >/tmp/reactive-resume-direct-compose-test.yml
|
||
grep -q 'reactive-resume-entrypoint.sh' /tmp/reactive-resume-qnap-compose-test.yml
|
||
grep -q 'reactive-resume-entrypoint.sh' /tmp/reactive-resume-direct-compose-test.yml
|
||
|
||
log "检查 zip 安装包内容"
|
||
unzip -t "$QNAP_ZIP" >/dev/null
|
||
unzip -t "$DIRECT_ZIP" >/dev/null
|
||
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/compose-Nas.yml'
|
||
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/patches/reactive-resume-entrypoint.sh'
|
||
unzip -l "$QNAP_ZIP" | grep -q 'reactive_resume/patches/reactive-resume-runtime-patch.sh'
|
||
unzip -l "$DIRECT_ZIP" | grep -q 'reactive-resume-personal-direct/compose.yml'
|
||
unzip -l "$DIRECT_ZIP" | grep -q 'reactive-resume-personal-direct/patches/reactive-resume-entrypoint.sh'
|
||
|
||
if unzip -p "$QNAP_ZIP" 'reactive_resume/*' 2>/dev/null | grep -E 'isiseg|10004|Reactive_Resume_Personal|/share/Container/Reactive_Resume_Personal' >/dev/null; then
|
||
fail "QNAP zip 中仍有旧域名、旧端口或旧路径"
|
||
fi
|
||
if unzip -p "$QNAP_ZIP" 'reactive_resume/*' 2>/dev/null | grep -E 'amruthpillai/reactive-resume:latest|snowdreamtech/frpc:latest' >/dev/null; then
|
||
fail "QNAP zip 中仍有 latest 镜像"
|
||
fi
|
||
if unzip -p "$DIRECT_ZIP" 'reactive-resume-personal-direct/*' 2>/dev/null | grep -E 'amruthpillai/reactive-resume:latest|snowdreamtech/frpc:latest' >/dev/null; then
|
||
fail "direct zip 中仍有 latest 镜像"
|
||
fi
|
||
|
||
log "真实启动 direct 包并检查健康状态"
|
||
cleanup_direct
|
||
docker compose -f "$DIRECT_COMPOSE" --env-file "$DIRECT_ENV" up -d postgres reactive-resume seed
|
||
|
||
attempt=0
|
||
until curl -fsS "http://127.0.0.1:3004/api/health" >/tmp/reactive-resume-health.json 2>/dev/null; do
|
||
attempt=$((attempt + 1))
|
||
if [ "$attempt" -ge 60 ]; then
|
||
docker logs "$PROJECT-reactive-resume-1" --tail 200 >&2 || true
|
||
fail "direct 包启动后 /api/health 未在 60 秒内就绪"
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
docker wait "$PROJECT-seed-1" >/tmp/reactive-resume-seed-exit
|
||
seed_status="$(docker inspect "$PROJECT-seed-1" --format '{{.State.ExitCode}}' 2>/dev/null || printf 'missing')"
|
||
[ "$seed_status" = "0" ] || fail "seed 容器退出码不是 0:$seed_status"
|
||
|
||
attempt=0
|
||
until curl -fsS -I "http://127.0.0.1:3004/audience/resume" >/tmp/reactive-resume-audience.headers 2>/dev/null \
|
||
&& grep -q '200 OK' /tmp/reactive-resume-audience.headers; do
|
||
attempt=$((attempt + 1))
|
||
if [ "$attempt" -ge 30 ]; then
|
||
docker logs "$PROJECT-reactive-resume-1" --tail 200 >&2 || true
|
||
fail "seed 完成后 /audience/resume 未在 30 秒内返回 200"
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
docker logs "$PROJECT-reactive-resume-1" --tail 200 >/tmp/reactive-resume-direct.log 2>&1 || true
|
||
if grep -E 'Cannot find module|Buffer is not defined|Unexpected end of input' /tmp/reactive-resume-direct.log >/dev/null; then
|
||
cat /tmp/reactive-resume-direct.log >&2
|
||
fail "direct 包日志仍包含已知启动或前端错误"
|
||
fi
|
||
if docker exec "$PROJECT-reactive-resume-1" sh -lc 'APP_DIR=$(cat /tmp/reactive-resume-app-dir); grep -R -E "index-[A-Za-z0-9_-]+\\.js\\?v=rr-filename-title" "$APP_DIR/.output/public/assets" >/dev/null 2>&1'; then
|
||
fail "direct 包错误地给 index 主入口追加了 rr-filename-title 缓存标记"
|
||
fi
|
||
docker exec "$PROJECT-reactive-resume-1" sh -lc 'APP_DIR=$(cat /tmp/reactive-resume-app-dir); grep -R "String(t).trim().replace" "$APP_DIR/.output/public/assets"/file-*.js >/dev/null 2>&1' \
|
||
|| fail "direct 包未在 file-*.js 下载工具中应用文件名补丁"
|
||
curl -fsS "http://127.0.0.1:3004/" >/tmp/reactive-resume-home.html \
|
||
|| fail "direct 包首页无法访问"
|
||
grep -E '/assets/index-[A-Za-z0-9_-]+-rr[0-9a-z]+\.js' /tmp/reactive-resume-home.html >/dev/null \
|
||
|| fail "direct 包首页未改用防缓存的 index 主入口文件名"
|
||
curl -fsS "http://127.0.0.1:3004/sw.js" >/tmp/reactive-resume-sw.js \
|
||
|| fail "direct 包 sw.js 无法访问"
|
||
grep -q 'disable stale PWA caches' /tmp/reactive-resume-sw.js \
|
||
|| fail "direct 包 sw.js 未替换为清理旧缓存版本"
|
||
|
||
if command -v google-chrome >/dev/null 2>&1 && command -v node >/dev/null 2>&1; then
|
||
log "使用 Chrome 执行 direct 包首页 JS"
|
||
node <<'NODE'
|
||
const { spawn } = require("child_process");
|
||
const fs = require("fs");
|
||
const http = require("http");
|
||
|
||
const port = 9444;
|
||
const userData = "/tmp/reactive-resume-chrome-smoke";
|
||
fs.rmSync(userData, { recursive: true, force: true });
|
||
|
||
const chrome = spawn("google-chrome", [
|
||
"--headless=new",
|
||
"--disable-gpu",
|
||
"--no-sandbox",
|
||
`--remote-debugging-port=${port}`,
|
||
`--user-data-dir=${userData}`,
|
||
"about:blank",
|
||
], { stdio: ["ignore", "ignore", "ignore"] });
|
||
|
||
function request(path, method = "GET") {
|
||
return new Promise((resolve, reject) => {
|
||
const req = http.request({ host: "127.0.0.1", port, path, method }, (res) => {
|
||
let body = "";
|
||
res.on("data", (chunk) => body += chunk);
|
||
res.on("end", () => resolve({ status: res.statusCode, body }));
|
||
});
|
||
req.on("error", reject);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
async function waitForChrome() {
|
||
for (let i = 0; i < 60; i++) {
|
||
try {
|
||
const res = await request("/json/version");
|
||
if (res.status === 200) return;
|
||
} catch {}
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
}
|
||
throw new Error("Chrome remote debugging was not ready");
|
||
}
|
||
|
||
(async () => {
|
||
await waitForChrome();
|
||
const tab = JSON.parse((await request("/json/new?about:blank", "PUT")).body);
|
||
const ws = new WebSocket(tab.webSocketDebuggerUrl);
|
||
let id = 0;
|
||
const pending = new Map();
|
||
const errors = [];
|
||
const indexRequests = [];
|
||
|
||
ws.onmessage = (event) => {
|
||
const message = JSON.parse(event.data);
|
||
if (message.id && pending.has(message.id)) {
|
||
pending.get(message.id)(message);
|
||
pending.delete(message.id);
|
||
return;
|
||
}
|
||
if (message.method === "Runtime.exceptionThrown") {
|
||
errors.push(message.params.exceptionDetails);
|
||
}
|
||
if (message.method === "Network.requestWillBeSent" && message.params.request.url.includes("index-")) {
|
||
indexRequests.push(message.params.request.url);
|
||
}
|
||
};
|
||
await new Promise((resolve) => { ws.onopen = resolve; });
|
||
|
||
function send(method, params = {}) {
|
||
return new Promise((resolve) => {
|
||
pending.set(++id, resolve);
|
||
ws.send(JSON.stringify({ id, method, params }));
|
||
});
|
||
}
|
||
|
||
await send("Runtime.enable");
|
||
await send("Page.enable");
|
||
await send("Network.enable");
|
||
await send("Page.navigate", { url: "http://127.0.0.1:3004/" });
|
||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||
|
||
const result = await send("Runtime.evaluate", {
|
||
expression: "document.body.innerText.includes('A free and open-source resume builder')",
|
||
returnByValue: true,
|
||
});
|
||
ws.close();
|
||
chrome.kill("SIGKILL");
|
||
|
||
if (errors.length > 0) {
|
||
console.error(JSON.stringify(errors.map((error) => ({
|
||
text: error.text,
|
||
url: error.url,
|
||
line: error.lineNumber,
|
||
column: error.columnNumber,
|
||
exception: error.exception?.description,
|
||
})), null, 2));
|
||
process.exit(1);
|
||
}
|
||
if (!result.result?.result?.value) {
|
||
console.error("Chrome did not render the expected home page text");
|
||
process.exit(1);
|
||
}
|
||
if (indexRequests.some((url) => /\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(url) && !/-rr[0-9a-z]+\.js$/.test(url))) {
|
||
console.error(`Chrome still requested stale app shell: ${indexRequests.join(", ")}`);
|
||
process.exit(1);
|
||
}
|
||
})().catch((error) => {
|
||
chrome.kill("SIGKILL");
|
||
console.error(error);
|
||
process.exit(1);
|
||
});
|
||
NODE
|
||
else
|
||
log "未找到 google-chrome,跳过浏览器级首页 JS 测试"
|
||
fi
|
||
|
||
log "离线检查 arm64/QNAP 镜像布局"
|
||
ARM64_DIGEST="$(
|
||
docker manifest inspect "$IMAGE_INDEX" \
|
||
| node -e '
|
||
let source = "";
|
||
process.stdin.on("data", (chunk) => source += chunk);
|
||
process.stdin.on("end", () => {
|
||
const manifest = JSON.parse(source);
|
||
const arm = manifest.manifests.find((item) => item.platform?.os === "linux" && item.platform?.architecture === "arm64");
|
||
if (!arm) process.exit(2);
|
||
process.stdout.write(arm.digest);
|
||
});
|
||
'
|
||
)"
|
||
|
||
TMP_DIR="$(mktemp -d)"
|
||
CID="$(docker create --platform linux/arm64 "$IMAGE_INDEX" 2>/dev/null || true)"
|
||
[ -n "$CID" ] || fail "无法创建 arm64 镜像容器用于离线检查"
|
||
docker export "$CID" -o "$TMP_DIR/arm64-root.tar"
|
||
docker rm "$CID" >/dev/null
|
||
mkdir -p "$TMP_DIR/arm64-root"
|
||
tar -xf "$TMP_DIR/arm64-root.tar" -C "$TMP_DIR/arm64-root"
|
||
|
||
if [ -f "$TMP_DIR/arm64-root/app/apps/server/dist/index.mjs" ]; then
|
||
ARM64_SERVER_ENTRY="$TMP_DIR/arm64-root/app/apps/server/dist/index.mjs"
|
||
ARM64_FILENAME_ENTRY="$ARM64_SERVER_ENTRY"
|
||
ARM64_ASSETS_DIR="$TMP_DIR/arm64-root/app/apps/web/dist/assets"
|
||
EXPECTED_ENTRYPOINT_PWD="$TMP_DIR/arm64-root/app"
|
||
EXPECTED_ENTRYPOINT_ARGS="node apps/server/dist/index.mjs"
|
||
elif [ -f "$TMP_DIR/arm64-root/app/apps/web/.output/server/index.mjs" ]; then
|
||
ARM64_SERVER_ENTRY="$TMP_DIR/arm64-root/app/apps/web/.output/server/index.mjs"
|
||
ARM64_FILENAME_ENTRY="$(grep -Rsl 'function generateFilename' "$TMP_DIR/arm64-root/app/apps/web/.output/server/_ssr" 2>/dev/null | head -n 1 || true)"
|
||
[ -n "$ARM64_FILENAME_ENTRY" ] || fail "arm64 .output 布局中未找到 generateFilename SSR bundle"
|
||
ARM64_ASSETS_DIR="$TMP_DIR/arm64-root/app/apps/web/.output/public/assets"
|
||
EXPECTED_ENTRYPOINT_PWD="$TMP_DIR/arm64-root/app/apps/web"
|
||
EXPECTED_ENTRYPOINT_ARGS="node .output/server/index.mjs"
|
||
else
|
||
find "$TMP_DIR/arm64-root/app" -maxdepth 6 \( -name index.mjs -o -name server.js -o -name main.js \) 2>/dev/null >&2 || true
|
||
fail "arm64 镜像中未找到支持的服务入口"
|
||
fi
|
||
[ -d "$ARM64_ASSETS_DIR" ] || fail "arm64 镜像中未找到 assets 目录:$ARM64_ASSETS_DIR"
|
||
|
||
perl -0pe "
|
||
s#/app/apps#$TMP_DIR/arm64-root/app/apps#g;
|
||
s#for candidate in $TMP_DIR/arm64-root/app/apps/web /app#for candidate in $TMP_DIR/arm64-root/app/apps/web $TMP_DIR/arm64-root/app#g;
|
||
s#find /app#find $TMP_DIR/arm64-root/app#g;
|
||
s#APP_DIR=\"/app\"#APP_DIR=\"$TMP_DIR/arm64-root/app\"#g;
|
||
s#under /app#under $TMP_DIR/arm64-root/app#g;
|
||
" "$QNAP_PATCH_DIR/reactive-resume-runtime-patch.sh" > "$TMP_DIR/runtime-patch-arm64-test.sh"
|
||
sh "$TMP_DIR/runtime-patch-arm64-test.sh" >/tmp/reactive-resume-arm64-runtime.log 2>&1 || {
|
||
cat /tmp/reactive-resume-arm64-runtime.log >&2
|
||
fail "arm64 离线运行 runtime patch 失败"
|
||
}
|
||
|
||
grep -R 'rr-browser-buffer-polyfill' "$ARM64_ASSETS_DIR" >/dev/null \
|
||
|| fail "arm64 public PDF bundle 未注入 Buffer polyfill"
|
||
grep -R -F 'replace(/[\\/:*?"<>|]/g' "$ARM64_ASSETS_DIR" >/dev/null \
|
||
|| fail "arm64 文件名 bundle 未改为按标题下载"
|
||
if grep -R -E "index-[A-Za-z0-9_-]+\\.js\\?v=rr-filename-title" "$ARM64_ASSETS_DIR" >/dev/null 2>&1; then
|
||
fail "arm64 补丁错误地给 index 主入口追加了 rr-filename-title 缓存标记"
|
||
fi
|
||
find "$TMP_DIR/arm64-root/app" -type f -exec grep -qE 'index-[A-Za-z0-9_-]+-rr[0-9a-z]+\.js' {} \; -print -quit \
|
||
| grep -q . \
|
||
|| fail "arm64 补丁未改用防缓存的 index 主入口文件名"
|
||
find "$TMP_DIR/arm64-root/app" -path '*/sw.js' -type f -print0 | xargs -0 grep -l 'disable stale PWA caches' >/dev/null \
|
||
|| fail "arm64 sw.js 未替换为清理旧缓存版本"
|
||
grep -q 'function generateFilename(prefix, extension)' "$ARM64_FILENAME_ENTRY" \
|
||
|| fail "arm64 server entry 未包含 generateFilename"
|
||
grep -F 'filename.replace(/[\\/:*?"<>|]/g' "$ARM64_FILENAME_ENTRY" >/dev/null \
|
||
|| fail "arm64 server entry 未改为按标题生成下载文件名"
|
||
|
||
perl -0pe "
|
||
s#/app/apps#$TMP_DIR/arm64-root/app/apps#g;
|
||
s#cd /app#cd $TMP_DIR/arm64-root/app#g;
|
||
s#find /app#find $TMP_DIR/arm64-root/app#g;
|
||
s#under /app#under $TMP_DIR/arm64-root/app#g;
|
||
" "$QNAP_PATCH_DIR/reactive-resume-entrypoint.sh" > "$TMP_DIR/entrypoint-arm64-test.sh"
|
||
mkdir -p "$TMP_DIR/fakebin"
|
||
{
|
||
printf '#!/bin/sh\n'
|
||
printf 'printf "PWD=%%s\\n" "$PWD" > "%s/entrypoint-result.txt"\n' "$TMP_DIR"
|
||
printf 'printf "ARGS=%%s\\n" "$*" >> "%s/entrypoint-result.txt"\n' "$TMP_DIR"
|
||
} > "$TMP_DIR/fakebin/docker-entrypoint.sh"
|
||
chmod +x "$TMP_DIR/fakebin/docker-entrypoint.sh"
|
||
PATH="$TMP_DIR/fakebin:$PATH" sh "$TMP_DIR/entrypoint-arm64-test.sh" >/tmp/reactive-resume-arm64-entrypoint.log 2>&1 || {
|
||
cat /tmp/reactive-resume-arm64-entrypoint.log >&2
|
||
fail "arm64 entrypoint 选择测试失败"
|
||
}
|
||
|
||
grep -q "PWD=$EXPECTED_ENTRYPOINT_PWD" "$TMP_DIR/entrypoint-result.txt" \
|
||
|| fail "arm64 entrypoint 未切换到预期目录:$EXPECTED_ENTRYPOINT_PWD"
|
||
grep -q "ARGS=$EXPECTED_ENTRYPOINT_ARGS" "$TMP_DIR/entrypoint-result.txt" \
|
||
|| fail "arm64 entrypoint 未选择预期入口:$EXPECTED_ENTRYPOINT_ARGS"
|
||
|
||
log "清理 direct 测试容器"
|
||
cleanup_direct
|
||
|
||
log "全部测试通过"
|
||
printf 'direct health: %s\n' "$(cat /tmp/reactive-resume-health.json)"
|
||
printf 'arm64 digest: %s\n' "$ARM64_DIGEST"
|