feat: harden isolated Tornado test workflow

This commit is contained in:
2026-07-10 11:27:40 +09:00
parent 5a8c7028dc
commit fc932b27f6
36 changed files with 3919 additions and 226 deletions

View File

@@ -1,6 +1,9 @@
(() => {
"use strict";
const playoutSafety = globalThis.MbnPlayoutSafety;
if (!playoutSafety) throw new Error("Playout safety module is unavailable.");
const catalog = [
{ code: "5001", title: "1열판 · 기본", detail: "종목 현재가 / 등락", category: "plate", market: "kospi" },
{ code: "5006", title: "1열판 · 시가", detail: "시가 정보", category: "plate", market: "kosdaq" },
@@ -39,9 +42,14 @@
playout: {
mode: "unknown",
engineState: "IDLE",
connectionState: "unavailable",
processDetected: false,
connected: false,
commandAvailable: false,
liveTakeInAllowed: false,
nativeOutcomeUnknown: false,
operationTimeoutMs: 5000,
responseTimeoutMs: 10000,
message: "송출 어댑터 상태를 확인하고 있습니다.",
preparedCode: null,
onAirCode: null,
@@ -352,7 +360,9 @@
}
function playoutDisplayState() {
return isDryRunMode() ? "DRY RUN" : state.playout.engineState;
if (isDryRunMode()) return "DRY RUN";
if (isTestMode() && state.playout.engineState === "PROGRAM") return "TEST ON AIR";
return state.playout.engineState;
}
function setStatusDot(element, status) {
@@ -379,22 +389,40 @@
const liveMode = isLiveMode();
const testMode = isTestMode();
const bridgeReady = Boolean(nativeBridge);
const outcomeUnknown = state.playout.error?.outcomeUnknown === true;
const commandReady = bridgeReady && !pending && !outcomeUnknown && (state.playout.connected || dryRun);
const outcomeUnknown = state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true;
const commandReady = bridgeReady && !pending && !outcomeUnknown && state.playout.commandAvailable;
const currentCue = cueFromItem(state.playlist[state.currentIndex]);
const prepared = state.playout.engineState === "PREPARED" || Boolean(state.playout.preparedCode);
const onAir = state.playout.engineState === "PROGRAM" || Boolean(state.playout.onAirCode);
const takeInSafe = dryRun || ((testMode || liveMode) && state.playout.liveTakeInAllowed);
elements.playoutState.textContent = displayState;
elements.playoutState.classList.remove("neutral", "live", "dry-run", "error");
elements.playoutState.classList.remove("neutral", "live", "test-on-air", "dry-run", "error");
if (displayState === "IDLE") elements.playoutState.classList.add("neutral");
if (displayState === "PROGRAM") elements.playoutState.classList.add("live");
if (displayState === "TEST ON AIR") elements.playoutState.classList.add("test-on-air");
if (displayState === "DRY RUN") elements.playoutState.classList.add("dry-run");
elements.tornadoProcessState.textContent = state.playout.processDetected ? "감지됨" : "미감지";
setStatusDot(elements.tornadoProcessDot, state.playout.processDetected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
elements.playoutConnectionState.textContent = state.playout.connected ? "연결됨" : "미연결";
setStatusDot(elements.playoutConnectionDot, state.playout.connected ? "healthy" : (bridgeReady ? "error" : "unconfigured"));
const transitionalConnection = ["connecting", "reconnecting", "disconnecting"].includes(state.playout.connectionState);
const connectionLabels = {
"disabled": "Disabled",
"dry-run-ready": "DryRunReady",
"disconnected": "Disconnected",
"connecting": "Connecting",
"connected": "Connected",
"reconnecting": "Reconnecting",
"disconnecting": "Disconnecting",
"faulted": "Faulted",
"outcome-unknown": "OutcomeUnknown",
"disposed": "Disposed",
"unavailable": "Unavailable"
};
elements.playoutConnectionState.textContent = connectionLabels[state.playout.connectionState] || "Unavailable";
setStatusDot(elements.playoutConnectionDot, state.playout.connected
? "healthy"
: (transitionalConnection ? "warn" : (bridgeReady ? "error" : "unconfigured")));
const safetyContainer = elements.playoutSafetyMode.closest(".playout-health-item");
safetyContainer.classList.remove("live-allowed", "live-locked");
@@ -423,10 +451,14 @@
? `${pending.command.toUpperCase()} 요청 처리 중 · ${pending.cue?.code || "송출 엔진"}`
: `${state.playout.message || "송출 상태를 확인할 수 없습니다."}${changedLabel}`;
elements.playoutStatusMessage.classList.toggle("pending", Boolean(pending));
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error));
elements.playoutStatusMessage.classList.toggle("error", Boolean(state.playout.error) || state.playout.nativeOutcomeUnknown);
elements.playoutSummary.classList.remove("ready", "dry-run", "error");
if (dryRun) {
if (state.playout.nativeOutcomeUnknown) {
elements.playoutSummary.classList.add("error");
elements.playoutSummaryBadge.textContent = "UNKNOWN";
elements.playoutSummaryDetail.textContent = "출력 상태 확인 후 앱 재시작 필요";
} else if (dryRun) {
elements.playoutSummary.classList.add("dry-run");
elements.playoutSummaryBadge.textContent = "DRY RUN";
elements.playoutSummaryDetail.textContent = bridgeReady ? "안전 모드 · PROGRAM 출력 차단" : "브라우저 미리보기 전용";
@@ -446,7 +478,7 @@
elements.prepareButton.disabled = !commandReady || !currentCue;
elements.takeInButton.disabled = !commandReady || !prepared || !takeInSafe;
elements.nextButton.disabled = !commandReady || state.playlist.length === 0;
elements.nextButton.disabled = !commandReady || !onAir || state.playlist.length === 0;
elements.takeOutButton.disabled = !commandReady || (state.playout.engineState === "IDLE" && !state.playout.preparedCode && !state.playout.onAirCode);
for (const button of [elements.prepareButton, elements.takeInButton, elements.nextButton, elements.takeOutButton]) {
button.setAttribute("aria-busy", String(Boolean(pending)));
@@ -454,7 +486,9 @@
elements.playoutError.hidden = !state.playout.error;
elements.playoutErrorMessage.textContent = state.playout.error?.message || "";
document.querySelector("#clearPlayoutErrorButton").textContent = outcomeUnknown ? "확인 후 잠금 해제" : "닫기";
document.querySelector("#clearPlayoutErrorButton").textContent = state.playout.nativeOutcomeUnknown
? "닫기 · 잠금 유지"
: (outcomeUnknown ? "확인 후 잠금 해제" : "닫기");
}
function clearPlayoutError(render = true) {
@@ -496,6 +530,25 @@
return;
}
if (state.playout.pending) return;
const blockReason = playoutSafety.commandBlockReason({
pending: state.playout.pending,
outcomeUnknown: state.playout.nativeOutcomeUnknown || state.playout.error?.outcomeUnknown === true,
commandAvailable: state.playout.commandAvailable,
engineState: state.playout.engineState,
preparedCode: state.playout.preparedCode,
onAirCode: state.playout.onAirCode
}, command);
if (blockReason) {
const message = {
"outcome-unknown": "출력 상태가 불명확합니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
"unavailable": "Tornado 송출 명령을 사용할 수 없는 상태입니다.",
"next-requires-on-air": "TAKE IN 이후에만 NEXT를 실행할 수 있습니다.",
"take-in-requires-prepare": "먼저 PREPARE를 완료하세요.",
"nothing-to-take-out": "종료할 송출 장면이 없습니다."
}[blockReason] || "다른 송출 명령을 처리하고 있습니다.";
showToast(message);
return;
}
const resolved = resolveCommand(command);
if (!resolved) {
@@ -518,7 +571,9 @@
addLog(`${resolved.cue?.code || "ENGINE"} ${command.toUpperCase()} 요청`);
const payload = { requestId, command };
if (resolved.cue) payload.cue = resolved.cue;
if (resolved.cue && (command === "prepare" || command === "next")) {
payload.cue = { code: resolved.cue.code };
}
postNative("playout-command", payload);
pending.timeoutId = setTimeout(() => {
@@ -527,12 +582,12 @@
showPlayoutError({
code: "WEB_TIMEOUT",
message: "송출 엔진 응답 시간이 초과되었습니다.",
retryable: true,
retryable: false,
outcomeUnknown: true
});
addLog(`${command.toUpperCase()} 응답 시간 초과 · 결과 미확인`);
requestPlayoutStatus();
}, 15000);
}, state.playout.responseTimeoutMs);
}
function requestPlayoutStatus() {
@@ -556,9 +611,17 @@
state.playout.mode = normalizePlayoutMode(payload.mode);
state.playout.engineState = normalizePlayoutState(payload.state);
state.playout.connectionState = playoutSafety.normalizeConnectionState(payload.connectionState);
state.playout.processDetected = payload.processDetected === true;
state.playout.connected = payload.connected === true;
state.playout.commandAvailable = payload.commandAvailable === true;
state.playout.liveTakeInAllowed = payload.liveTakeInAllowed === true;
state.playout.nativeOutcomeUnknown = payload.outcomeUnknown === true ||
state.playout.connectionState === "outcome-unknown";
state.playout.operationTimeoutMs = Number.isFinite(Number(payload.operationTimeoutMilliseconds))
? Number(payload.operationTimeoutMilliseconds)
: state.playout.operationTimeoutMs;
state.playout.responseTimeoutMs = playoutSafety.responseTimeoutFromNative(payload.operationTimeoutMilliseconds);
state.playout.message = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "Tornado 송출 상태를 수신했습니다.";
@@ -569,8 +632,17 @@
state.playout.changedAtMs = changedAtMs || state.playout.changedAtMs;
if (payload.requestId === state.playout.latestStatusRequestId) state.playout.latestStatusRequestId = null;
if (state.playout.nativeOutcomeUnknown && state.playout.error?.outcomeUnknown !== true) {
state.playout.error = {
code: "OUTCOME_UNKNOWN",
message: "송출 결과를 확인할 수 없습니다. 실제 출력 확인 후 앱을 다시 시작하세요.",
retryable: false,
outcomeUnknown: true
};
}
renderPlayout();
const signature = [state.playout.mode, state.playout.engineState, state.playout.processDetected, state.playout.connected, state.playout.liveTakeInAllowed].join("|");
const signature = [state.playout.mode, state.playout.engineState, state.playout.connectionState, state.playout.processDetected, state.playout.connected, state.playout.commandAvailable, state.playout.liveTakeInAllowed, state.playout.nativeOutcomeUnknown].join("|");
if (signature !== state.playout.lastSignature) {
addLog(`Tornado 상태 · ${playoutDisplayState()} · ${state.playout.connected ? "연결" : "미연결"}`);
state.playout.lastSignature = signature;
@@ -579,7 +651,7 @@
function finishPendingCommand(payload) {
const pending = state.playout.pending;
if (!pending || payload?.requestId !== pending.requestId || payload?.command !== pending.command) return null;
if (!playoutSafety.matchesPendingCommand(pending, payload)) return null;
clearTimeout(pending.timeoutId);
state.playout.pending = null;
return pending;
@@ -625,6 +697,7 @@
function handlePlayoutCommandError(payload) {
const pending = finishPendingCommand(payload);
if (!pending) return;
if (payload.outcomeUnknown === true) state.playout.nativeOutcomeUnknown = true;
showPlayoutError({
code: payload.code,
message: payload.message,