Files
live-app-translator/packaging/windows_smoke.py
EJClaw 4af97a68f2
Some checks failed
Windows / verify (push) Has been cancelled
fix: Windows 점검기 SIGABRT 수정 + 러너 안내 정확하게
리뷰 지적을 확인해보니 두 건은 실제 결함이었다.

1) 점검기가 화면 없는 환경에서 그냥 죽음 (SIGABRT, 종료코드 134)
   QT_QPA_PLATFORM 을 "windows" 로 하드코딩해 둔 탓에, 표시할 화면이 없으면
   첫 QApplication 생성에서 플러그인 로드에 실패하며 아무 결과도 못 남기고
   abort 했다. 점검기가 그렇게 죽으면 존재 이유가 없다.
   - 화면이 없으면 offscreen 으로 떨어지게 하고, 이 판단을 Qt import 전에 한다
   - QApplication 생성을 _qt_app() 한 곳으로 모음
   - 실행 플랫폼을 출력해 리눅스에서 돌아간 경우가 바로 보이게 함
   이제 환경변수 없이 돌려도 항목별 판정과 종료코드가 정상으로 나온다.

2) 보안 설명이 과장돼 있었음 — 가장 중요한 수정
   "커밋된 워크플로만 실행하니 임의 명령은 불가"라고 썼는데, 나는 이 저장소
   main 에 push 할 수 있으므로 워크플로를 고쳐 커밋하면 그 PC에서 원하는
   명령을 돌릴 수 있다. 개인 PC를 내주는 판단에 쓰일 설명을 틀리게 준 것이라
   그대로 바로잡았다. 실제로 보장되는 것(숨길 수 없음·언제든 끌 수 있음·
   계정 권한으로만 동작)만 적고, VM 또는 전용 계정을 권하도록 바꿨다.
   서비스 등록(SYSTEM 권한) 안내도 로그온 작업(계정 권한)으로 교체 —
   "관리자로 띄우지 말라"는 권고와 모순됐다.

3) 라벨 오타 안전장치 (공식 문서 확인 중 발견)
   Gitea 는 runs-on 이 어떤 라벨과도 안 맞으면 실패시키지 않고 기본 우분투
   컨테이너에서 돌린다. Windows 검증이 조용히 리눅스에서 돌아가 "통과"로
   보일 수 있어, 워크플로 첫 단계에서 RUNNER_OS 를 확인해 막는다.
   문서의 등록 라벨도 windows:host 로 명시 (생략 시 기본값이 host 이지만
   명시가 낫다 — docs.gitea.com/runner/labels).

4) 트리거 설명 정정: "아무거나 push" -> main push 또는 수동 실행.

검증: pytest 189개 통과, ruff clean, 워크플로 트리거/스텝 파싱 확인,
      QT_QPA_PLATFORM 없이 점검기 실행해 abort 없이 종료코드 1 확인

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-23 03:17:11 +09:00

227 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Windows 전용 경로 점검.
리눅스 개발 환경에서는 확인이 불가능한 것들만 모아놨다. 여기서 실패하면
"리눅스에서 테스트 다 통과했는데 윈도우에서 안 되더라" 가 되는 지점이다.
python packaging/windows_smoke.py # 소스에서 점검
python packaging/windows_smoke.py --exe dist\\LiveSub.exe # 빌드된 exe 점검
결과는 artifacts/ 에 로그와 스크린샷으로 남는다. CI 가 그걸 업로드하므로
원격에서도 실제 화면을 눈으로 확인할 수 있다.
"""
from __future__ import annotations
import argparse
import os
import subprocess
import sys
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ARTIFACTS = ROOT / "artifacts"
sys.path.insert(0, str(ROOT / "src"))
# Qt 플랫폼 플러그인은 QApplication 을 만들기 *전에* 정해야 한다.
# 표시할 화면이 없는데 그냥 두면 플러그인 로드 실패로 SIGABRT 가 나면서
# 아무 결과도 못 남기고 죽는다. 점검기가 그렇게 죽으면 쓸모가 없다.
if sys.platform != "win32" and not (
os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
):
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
_results: list[tuple[str, bool, str]] = []
def _qt_app():
"""QApplication 을 한 번만 만들어 재사용한다."""
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
def check(name: str):
"""점검 하나. 예외가 나도 나머지는 계속 돈다."""
def wrapper(fn):
try:
detail = fn() or ""
ok = True
except Exception as exc: # noqa: BLE001 - 점검 결과로 기록하는 게 목적
detail, ok = f"{type(exc).__name__}: {exc}", False
_results.append((name, ok, str(detail)))
print(f" [{'OK ' if ok else '실패'}] {name} {detail}")
return fn
return wrapper
# --- 소스 기준 점검 ---------------------------------------------------------
def run_source_checks() -> None:
print(f"\n=== Windows 전용 경로 (실행 플랫폼: {sys.platform}) ===")
if sys.platform != "win32":
print(" 주의: Windows 가 아니라 Windows 전용 항목은 당연히 실패합니다.")
@check("WASAPI 루프백 장치 열거")
def _devices():
from livesub.audio.wasapi_loopback import WasapiLoopbackCapture
if not WasapiLoopbackCapture.available():
raise RuntimeError("PyAudioWPatch 없음 — 오디오 캡처 불가")
sources = WasapiLoopbackCapture.list_sources()
if not sources:
raise RuntimeError("루프백 장치가 하나도 없음")
return f"{len(sources)}개: {sources[0].label}"
@check("소리 내는 프로그램 열거 (pycaw)")
def _processes():
from livesub.audio.process_loopback import ProcessLoopbackCapture
sources = ProcessLoopbackCapture.list_sources()
return f"{len(sources)}개" + (f" (예: {sources[0].label})" if sources else " (지금 소리 내는 앱 없음)")
@check("프로그램별 캡처 보조 프로그램")
def _helper():
from livesub.audio.process_loopback import helper_path
path = helper_path()
if path is None:
raise RuntimeError("livesub_capture.exe 없음 — 장치 전체 캡처로 폴백됨")
return str(path)
@check("전역 단축키 등록/해제")
def _hotkey():
from livesub.ui.hotkeys import GlobalHotkeys
_qt_app()
keys = GlobalHotkeys()
if not keys.supported:
raise RuntimeError("이 플랫폼은 전역 단축키 미지원")
fired = []
if not keys.register("Ctrl+Alt+F12", lambda: fired.append(1)):
raise RuntimeError(keys.last_error)
keys.unregister_all()
return "Ctrl+Alt+F12 등록·해제 성공"
@check("모니터 열거")
def _screens():
from livesub.ui.placement import list_screens
_qt_app()
screens = list_screens()
if not screens:
raise RuntimeError("모니터를 찾지 못함")
return " / ".join(s.label for s in screens)
@check("GPU 인식")
def _gpu():
from livesub.models import detect_gpu
gpu = detect_gpu()
if not gpu.available:
raise RuntimeError(gpu.reason)
return f"{gpu.name} · {gpu.total_vram_mb / 1024:.1f}GB"
@check("사용 가능한 모델 티어")
def _tiers():
from livesub.models.manager import has_torch, tier_availability
from livesub.models.tiers import ordered_tiers
usable = [t.name for t in ordered_tiers() if tier_availability(t)[0]]
return f"torch={'있음' if has_torch() else '없음'} → {', '.join(usable)}"
@check("자막 오버레이 실제 렌더링")
def _overlay():
from livesub.config import AppConfig
from livesub.ui.main_window import MainWindow
app = _qt_app()
cfg = AppConfig()
cfg.models.preload_on_start = False
win = MainWindow(cfg)
try:
win.show()
win.overlay.set_subtitle_visible(True)
win.overlay.set_line("적이 왼쪽에서 들어온다, 지금 바로 빠져!", "Enemy from the left")
for _ in range(10):
app.processEvents()
time.sleep(0.05)
ARTIFACTS.mkdir(exist_ok=True)
win.grab().save(str(ARTIFACTS / "windows-main.png"))
win.overlay.grab().save(str(ARTIFACTS / "windows-overlay.png"))
finally:
win.engine.shutdown()
win.overlay.close()
win.close()
return "스크린샷 2장 저장 (artifacts/)"
# --- 빌드된 exe 점검 --------------------------------------------------------
def run_exe_check(exe: Path) -> None:
print(f"\n=== 빌드된 exe 점검: {exe} ===")
@check("exe 존재와 크기")
def _size():
if not exe.is_file():
raise RuntimeError("파일 없음")
mb = exe.stat().st_size / 1024 / 1024
if mb > 1500:
raise RuntimeError(f"{mb:.0f}MB — 너무 큼. torch 가 섞여 들어갔는지 확인")
return f"{mb:.1f} MB"
@check("exe 실행 후 8초 생존")
def _launch():
# 창을 띄우고 잠깐 살아 있는지 본다. 바로 죽으면 의존성 누락이다.
proc = subprocess.Popen([str(exe)], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
time.sleep(8)
alive = proc.poll() is None
if not alive:
err = (proc.stderr.read() or b"").decode("utf-8", "replace")[-800:]
raise RuntimeError(f"즉시 종료 (코드 {proc.returncode}): {err}")
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
return "정상 기동"
def main() -> int:
parser = argparse.ArgumentParser(description="Windows 전용 점검")
parser.add_argument("--exe", type=Path, help="빌드된 exe 를 점검")
args = parser.parse_args()
if args.exe:
run_exe_check(args.exe)
else:
run_source_checks()
ARTIFACTS.mkdir(exist_ok=True)
report = "\n".join(
f"[{'OK' if ok else 'FAIL'}] {name}: {detail}" for name, ok, detail in _results
)
(ARTIFACTS / "windows-smoke.txt").write_text(report, encoding="utf-8")
failed = [n for n, ok, _ in _results if not ok]
print(f"\n{len(_results) - len(failed)}/{len(_results)} 통과")
if failed:
print("실패:", ", ".join(failed))
# 오디오 장치·보조 프로그램은 러너 환경에 따라 없을 수 있어 경고로만 둔다.
fatal = [n for n in failed if n not in {
"프로그램별 캡처 보조 프로그램",
"소리 내는 프로그램 열거 (pycaw)",
"WASAPI 루프백 장치 열거",
"GPU 인식",
}]
return 1 if fatal else 0
if __name__ == "__main__":
raise SystemExit(main())