|
1 | 1 | import { strict as assert } from "node:assert"; |
| 2 | +import { spawnSync } from "node:child_process"; |
2 | 3 | import { test } from "node:test"; |
3 | 4 | import { |
4 | 5 | classifyHeygenError, |
| 6 | + classifyHeygenErrorCode, |
| 7 | + flushHeygenFailureTracking, |
5 | 8 | HEYGEN_NOT_AUTHENTICATED_MESSAGE, |
6 | 9 | HEYGEN_NOT_FOUND_MESSAGE, |
7 | 10 | HEYGEN_OUTDATED_MESSAGE, |
| 11 | + reportHeygenFailure, |
8 | 12 | } from "./heygen-cli.mjs"; |
9 | 13 |
|
| 14 | +function captureFailureReport(err, context, trackEvent) { |
| 15 | + const originalError = console.error; |
| 16 | + const stderrCalls = []; |
| 17 | + console.error = (...args) => stderrCalls.push(args); |
| 18 | + try { |
| 19 | + if (trackEvent) { |
| 20 | + reportHeygenFailure(err, context, trackEvent); |
| 21 | + } else { |
| 22 | + reportHeygenFailure(err, context); |
| 23 | + } |
| 24 | + } finally { |
| 25 | + console.error = originalError; |
| 26 | + } |
| 27 | + return stderrCalls; |
| 28 | +} |
| 29 | + |
10 | 30 | test("classifies ENOENT-style missing heygen errors with install instructions", () => { |
11 | 31 | const message = classifyHeygenError({ code: "ENOENT", message: "spawn heygen ENOENT" }); |
12 | 32 |
|
@@ -64,3 +84,184 @@ test("passes through unrelated errors", () => { |
64 | 84 |
|
65 | 85 | assert.equal(message, "rate limit exceeded"); |
66 | 86 | }); |
| 87 | + |
| 88 | +test("classifies existing HeyGen failures with stable reason codes", () => { |
| 89 | + assert.equal(classifyHeygenErrorCode({ code: "ENOENT" }), "not_found"); |
| 90 | + assert.equal( |
| 91 | + classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 401 Unauthorized") }), |
| 92 | + "not_authenticated", |
| 93 | + ); |
| 94 | + assert.equal( |
| 95 | + classifyHeygenErrorCode({ stderr: Buffer.from("heygen v0.1.5 is unsupported") }), |
| 96 | + "outdated", |
| 97 | + ); |
| 98 | + assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from("provider unavailable") }), "other"); |
| 99 | +}); |
| 100 | + |
| 101 | +test("classifies rate-limit text case-insensitively", () => { |
| 102 | + assert.equal( |
| 103 | + classifyHeygenErrorCode({ stderr: Buffer.from("RATE LIMIT exceeded") }), |
| 104 | + "rate_limited", |
| 105 | + ); |
| 106 | +}); |
| 107 | + |
| 108 | +test("classifies quota and insufficient-credit errors as rate limited", () => { |
| 109 | + for (const detail of ["Quota exhausted", "INSUFFICIENT CREDIT remaining"]) { |
| 110 | + assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited"); |
| 111 | + } |
| 112 | +}); |
| 113 | + |
| 114 | +test("classifies the literal 429 reason phrase and throttling language as rate limited", () => { |
| 115 | + for (const detail of ["Too Many Requests", "Error: throttled by upstream, retry later"]) { |
| 116 | + assert.equal(classifyHeygenErrorCode({ stderr: Buffer.from(detail) }), "rate_limited"); |
| 117 | + } |
| 118 | +}); |
| 119 | + |
| 120 | +test("does not misclassify unrelated errors that share a word with the new phrasing", () => { |
| 121 | + // Shares "too many" with "too many requests" but is a distinct failure (fd |
| 122 | + // exhaustion, not a rate limit) — the match must require the full phrase. |
| 123 | + assert.equal( |
| 124 | + classifyHeygenErrorCode({ stderr: Buffer.from("Too many open file descriptors") }), |
| 125 | + "other", |
| 126 | + ); |
| 127 | +}); |
| 128 | + |
| 129 | +test("classifies a bare 429 as rate limited without matching request IDs", () => { |
| 130 | + assert.equal( |
| 131 | + classifyHeygenErrorCode({ stderr: Buffer.from("HTTP 429 Too Many Requests") }), |
| 132 | + "rate_limited", |
| 133 | + ); |
| 134 | + assert.equal( |
| 135 | + classifyHeygenErrorCode({ stderr: Buffer.from("request req-429abc failed") }), |
| 136 | + "other", |
| 137 | + ); |
| 138 | +}); |
| 139 | + |
| 140 | +test("tracks not-found failures without changing actionable output", () => { |
| 141 | + const trackingCalls = []; |
| 142 | + const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search", (...args) => |
| 143 | + trackingCalls.push(args), |
| 144 | + ); |
| 145 | + |
| 146 | + assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]); |
| 147 | + assert.deepEqual(trackingCalls, [ |
| 148 | + ["media_use_provider_error", { provider: "heygen", reason: "not_found" }], |
| 149 | + ]); |
| 150 | +}); |
| 151 | + |
| 152 | +test("tracks generic failures without including raw detail", () => { |
| 153 | + const trackingCalls = []; |
| 154 | + const stderrCalls = captureFailureReport( |
| 155 | + { stderr: Buffer.from("private provider detail") }, |
| 156 | + "heygen asset search", |
| 157 | + (...args) => trackingCalls.push(args), |
| 158 | + ); |
| 159 | + |
| 160 | + assert.deepEqual(stderrCalls, [ |
| 161 | + ["media-use: `heygen asset search` failed: private provider detail"], |
| 162 | + ]); |
| 163 | + assert.deepEqual(trackingCalls, [ |
| 164 | + ["media_use_provider_error", { provider: "heygen", reason: "other" }], |
| 165 | + ]); |
| 166 | +}); |
| 167 | + |
| 168 | +test("keeps failure output observable when telemetry is opted out", () => { |
| 169 | + const previousOptOut = process.env.HYPERFRAMES_NO_TELEMETRY; |
| 170 | + process.env.HYPERFRAMES_NO_TELEMETRY = "1"; |
| 171 | + try { |
| 172 | + const stderrCalls = captureFailureReport({ code: "ENOENT" }, "heygen asset search"); |
| 173 | + |
| 174 | + assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]); |
| 175 | + } finally { |
| 176 | + if (previousOptOut === undefined) { |
| 177 | + delete process.env.HYPERFRAMES_NO_TELEMETRY; |
| 178 | + } else { |
| 179 | + process.env.HYPERFRAMES_NO_TELEMETRY = previousOptOut; |
| 180 | + } |
| 181 | + } |
| 182 | +}); |
| 183 | + |
| 184 | +test("keeps failure output observable when tracking throws synchronously", () => { |
| 185 | + const originalError = console.error; |
| 186 | + const stderrCalls = []; |
| 187 | + let thrown; |
| 188 | + console.error = (...args) => stderrCalls.push(args); |
| 189 | + try { |
| 190 | + try { |
| 191 | + reportHeygenFailure({ code: "ENOENT" }, "heygen asset search", () => { |
| 192 | + throw new Error("tracking failed"); |
| 193 | + }); |
| 194 | + } catch (err) { |
| 195 | + thrown = err; |
| 196 | + } |
| 197 | + } finally { |
| 198 | + console.error = originalError; |
| 199 | + } |
| 200 | + |
| 201 | + assert.deepEqual(stderrCalls, [[HEYGEN_NOT_FOUND_MESSAGE]]); |
| 202 | + assert.equal(thrown, undefined); |
| 203 | +}); |
| 204 | + |
| 205 | +test("does not leave rejected tracking promises unhandled", () => { |
| 206 | + const moduleUrl = new URL("./heygen-cli.mjs", import.meta.url).href; |
| 207 | + const script = ` |
| 208 | + import { reportHeygenFailure } from ${JSON.stringify(moduleUrl)}; |
| 209 | + reportHeygenFailure( |
| 210 | + { stderr: "provider unavailable" }, |
| 211 | + "heygen asset search", |
| 212 | + () => Promise.reject(new Error("tracking failed")), |
| 213 | + ); |
| 214 | + await new Promise((resolve) => setImmediate(resolve)); |
| 215 | + `; |
| 216 | + const child = spawnSync( |
| 217 | + process.execPath, |
| 218 | + ["--unhandled-rejections=strict", "--input-type=module", "--eval", script], |
| 219 | + { encoding: "utf8", timeout: 5000 }, |
| 220 | + ); |
| 221 | + |
| 222 | + assert.equal(child.error, undefined); |
| 223 | + assert.equal(child.signal, null); |
| 224 | + assert.equal(child.status, 0, child.stderr); |
| 225 | + assert.equal(child.stderr, "media-use: `heygen asset search` failed: provider unavailable\n"); |
| 226 | +}); |
| 227 | + |
| 228 | +test("flushHeygenFailureTracking waits for a pending report before resolving", async () => { |
| 229 | + const events = []; |
| 230 | + let releaseTrack; |
| 231 | + const gate = new Promise((resolve) => { |
| 232 | + releaseTrack = resolve; |
| 233 | + }); |
| 234 | + |
| 235 | + // Mirrors the real call sites (voice-provider.mjs, heygen-search.mjs): |
| 236 | + // fire-and-forget, the return value is never awaited by the caller. |
| 237 | + reportHeygenFailure({ code: "ENOENT" }, "heygen voice speech", () => |
| 238 | + gate.then(() => { |
| 239 | + events.push("track-settled"); |
| 240 | + }), |
| 241 | + ); |
| 242 | + |
| 243 | + const flushed = flushHeygenFailureTracking().then(() => { |
| 244 | + events.push("flush-resolved"); |
| 245 | + }); |
| 246 | + |
| 247 | + // Let several pending microtasks drain before releasing the gate, so this |
| 248 | + // proves flush is genuinely still waiting on the tracked promise -- not |
| 249 | + // merely that it hasn't had a tick yet. |
| 250 | + await Promise.resolve(); |
| 251 | + await Promise.resolve(); |
| 252 | + await Promise.resolve(); |
| 253 | + assert.deepEqual(events, [], "flush must not resolve while the tracked promise is still pending"); |
| 254 | + |
| 255 | + releaseTrack(); |
| 256 | + await flushed; |
| 257 | + |
| 258 | + assert.deepEqual( |
| 259 | + events, |
| 260 | + ["track-settled", "flush-resolved"], |
| 261 | + "flush must resolve only after the pending track settles, in that order", |
| 262 | + ); |
| 263 | +}); |
| 264 | + |
| 265 | +test("flushHeygenFailureTracking resolves immediately when nothing is pending", async () => { |
| 266 | + await flushHeygenFailureTracking(); |
| 267 | +}); |
0 commit comments