mirror of
https://github.com/ilyhalight/voice-over-translation.git
synced 2026-10-15 13:57:49 +00:00
573 lines
19 KiB
TypeScript
573 lines
19 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import { VideoTranslationStatus } from "@vot.js/core/types/providers/yandex";
|
|
|
|
(globalThis as unknown as { DEBUG_MODE: boolean }).DEBUG_MODE = false;
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
configurable: true,
|
|
value: {
|
|
getItem: () => null,
|
|
setItem: () => undefined,
|
|
removeItem: () => undefined,
|
|
},
|
|
});
|
|
|
|
const { VOTTranslationHandler } = await import(
|
|
"../src/core/translationHandler"
|
|
);
|
|
const { strategies, WEB_ABR_STRATEGY, WEB_MSE_PROXY_STRATEGY } = await import(
|
|
"../src/audioDownloader/strategies/index"
|
|
);
|
|
|
|
const tick = (ms = 10) =>
|
|
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
describe("fatal audio upload failure keeps preparing source", () => {
|
|
test("no later chunk is uploaded but preparation completes and caches", async () => {
|
|
const handlerCtor = VOTTranslationHandler as unknown as {
|
|
AUDIO_UPLOAD_RETRY_DELAY_MS: number;
|
|
};
|
|
const prevDelay = handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS;
|
|
handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS = 0;
|
|
|
|
type Strategy = (typeof strategies)[keyof typeof strategies];
|
|
const table = strategies as unknown as Record<string, Strategy>;
|
|
const prevAbr = table[WEB_ABR_STRATEGY];
|
|
const prevMse = table[WEB_MSE_PROXY_STRATEGY];
|
|
|
|
let uploadCalls = 0;
|
|
const errorArgs: unknown[] = [];
|
|
let yieldedChunks = 0;
|
|
let producerSignal: AbortSignal | null = null;
|
|
const producerDone = deferred<void>();
|
|
|
|
table[WEB_ABR_STRATEGY] = async (options: {
|
|
videoId: string;
|
|
signal: AbortSignal;
|
|
}) => {
|
|
producerSignal = options.signal;
|
|
return {
|
|
fileId: "test-file",
|
|
mediaPartsLength: null,
|
|
getMediaBuffers: async function* () {
|
|
try {
|
|
const chunks = [
|
|
{ size: 10, last: false },
|
|
{ size: 10, last: false },
|
|
{ size: 10, last: true },
|
|
];
|
|
for (const chunk of chunks) {
|
|
yieldedChunks += 1;
|
|
yield {
|
|
buffer: new Uint8Array(chunk.size).fill(1),
|
|
isLastChunk: chunk.last,
|
|
};
|
|
}
|
|
} finally {
|
|
producerDone.resolve();
|
|
}
|
|
},
|
|
};
|
|
};
|
|
table[WEB_MSE_PROXY_STRATEGY] = async () => {
|
|
throw new Error("fallback should not run");
|
|
};
|
|
|
|
const videoHandler: any = {
|
|
data: { useAudioDownload: true, useLivelyVoice: false },
|
|
site: { host: "youtube" },
|
|
votClient: {
|
|
translateVideo: async () => ({
|
|
status: VideoTranslationStatus.AUDIO_REQUESTED,
|
|
translated: false,
|
|
remainingTime: 0,
|
|
translationId: "tr1",
|
|
}),
|
|
provider: {
|
|
requestVtransAudio: async () => {
|
|
uploadCalls += 1;
|
|
throw new Error("PUT failed");
|
|
},
|
|
requestVtransFailAudio: async () => undefined,
|
|
},
|
|
},
|
|
getRequestLangForTranslation: () => "en",
|
|
isLivelyVoiceAllowed: () => false,
|
|
isYouTubeHosts: () => true,
|
|
updateTranslationErrorMsg: async (message: unknown) => {
|
|
errorArgs.push(message);
|
|
},
|
|
hadAsyncWait: false,
|
|
notifier: { translationFailed: () => undefined },
|
|
actionsAbortController: { signal: { aborted: false } },
|
|
};
|
|
|
|
try {
|
|
const handler = new VOTTranslationHandler(videoHandler);
|
|
const external = new AbortController();
|
|
const result = await handler.translateVideoImpl(
|
|
{ videoId: "vid-fatal", duration: 10 } as never,
|
|
"en" as never,
|
|
"ru" as never,
|
|
null,
|
|
false,
|
|
external.signal,
|
|
);
|
|
|
|
expect(result).toBeNull();
|
|
expect((handler as any).downloading).toBe(false);
|
|
// The PUT failure must reach the normal failure UI path as a real Error,
|
|
// not be misclassified as a user cancellation by an AbortError race.
|
|
const uiError = errorArgs.find(
|
|
(arg): arg is Error => arg instanceof Error,
|
|
);
|
|
expect(uiError).toBeDefined();
|
|
expect(uiError?.name).not.toBe("AbortError");
|
|
// First chunk: initial attempt + 5 retries (= 6 attempts), then stop.
|
|
expect(uploadCalls).toBe(6);
|
|
|
|
// The producer is not aborted: it finishes collecting every chunk and
|
|
// populates the single-entry cache for a same-video retry.
|
|
await producerDone.promise;
|
|
await tick();
|
|
expect(producerSignal).not.toBeNull();
|
|
expect((producerSignal as unknown as AbortSignal).aborted).toBe(false);
|
|
expect(yieldedChunks).toBe(3);
|
|
const cached = (handler as any).audioDownloader.completedAudioCache;
|
|
expect(cached?.videoId).toBe("vid-fatal");
|
|
expect(cached?.fileId).toBe("test-file");
|
|
expect(cached?.chunks.length).toBe(3);
|
|
// Caller's external signal is untouched.
|
|
expect(external.signal.aborted).toBe(false);
|
|
} finally {
|
|
handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS = prevDelay;
|
|
table[WEB_ABR_STRATEGY] = prevAbr;
|
|
table[WEB_MSE_PROXY_STRATEGY] = prevMse;
|
|
}
|
|
});
|
|
|
|
test("stale run failure cannot affect a newer run", async () => {
|
|
const videoHandler: any = {
|
|
data: {},
|
|
site: { host: "youtube" },
|
|
votClient: { provider: {} },
|
|
updateTranslationErrorMsg: async () => undefined,
|
|
};
|
|
const handler = new VOTTranslationHandler(videoHandler) as any;
|
|
const external = new AbortController();
|
|
|
|
const first = handler.startAudioRun(external.signal, "tr-old", "v-old");
|
|
expect(first.signal.aborted).toBe(false);
|
|
const second = handler.startAudioRun(external.signal, "tr-new", "v-new");
|
|
expect(first.signal.aborted).toBe(true);
|
|
expect(second.signal.aborted).toBe(false);
|
|
|
|
handler.downloading = true;
|
|
handler.finishDownloadFailure(new Error("stale"), first.runId);
|
|
expect(second.signal.aborted).toBe(false);
|
|
expect(handler.downloading).toBe(true);
|
|
|
|
// A current-run failure reports the error without killing the source
|
|
// preparation; only an external/user abort stops the shared signal.
|
|
handler.finishDownloadFailure(new Error("current"), second.runId);
|
|
expect(handler.downloading).toBe(false);
|
|
expect(second.signal.aborted).toBe(false);
|
|
external.abort();
|
|
expect(second.signal.aborted).toBe(true);
|
|
});
|
|
|
|
test("stale translation events are ignored by the active run", async () => {
|
|
let uploadCalls = 0;
|
|
const failAudioCalls: string[] = [];
|
|
const videoHandler: any = {
|
|
data: { useAudioDownload: false },
|
|
site: { host: "youtube" },
|
|
votClient: {
|
|
provider: {
|
|
requestVtransAudio: async () => {
|
|
uploadCalls += 1;
|
|
},
|
|
requestVtransFailAudio: async (url: string) => {
|
|
failAudioCalls.push(url);
|
|
},
|
|
},
|
|
},
|
|
updateTranslationErrorMsg: async () => undefined,
|
|
};
|
|
const handler = new VOTTranslationHandler(videoHandler) as any;
|
|
const external = new AbortController().signal;
|
|
|
|
const stale = handler.startAudioRun(external, "tr-old", "v-old");
|
|
const active = handler.startAudioRun(external, "tr-new", "v-new");
|
|
expect(stale.signal.aborted).toBe(true);
|
|
handler.downloading = true;
|
|
|
|
await handler.onDownloadedAudio("tr-old", {
|
|
videoId: "v",
|
|
fileId: "f",
|
|
audioData: new Uint8Array([1]),
|
|
});
|
|
await handler.onDownloadedPartialAudio("tr-old", {
|
|
videoId: "v",
|
|
fileId: "f",
|
|
audioData: new Uint8Array([1]),
|
|
version: 1,
|
|
index: 0,
|
|
amount: 1,
|
|
});
|
|
await handler.onDownloadAudioError("tr-old", "v");
|
|
|
|
expect(uploadCalls).toBe(0);
|
|
expect(failAudioCalls).toEqual([]);
|
|
expect(handler.downloading).toBe(true);
|
|
expect(active.signal.aborted).toBe(false);
|
|
|
|
// The newer run state survived the stale events and still accepts its own.
|
|
await handler.onDownloadedAudio("tr-new", {
|
|
videoId: "v",
|
|
fileId: "f",
|
|
audioData: new Uint8Array([1]),
|
|
});
|
|
expect(uploadCalls).toBe(1);
|
|
});
|
|
|
|
test("external abort clears download waiters and resets the run", async () => {
|
|
type Strategy = (typeof strategies)[keyof typeof strategies];
|
|
const table = strategies as unknown as Record<string, Strategy>;
|
|
const prevAbr = table[WEB_ABR_STRATEGY];
|
|
let producerSignal: AbortSignal | null = null;
|
|
|
|
table[WEB_ABR_STRATEGY] = async (options: {
|
|
videoId: string;
|
|
signal: AbortSignal;
|
|
}) => {
|
|
producerSignal = options.signal;
|
|
return {
|
|
fileId: "abort-file",
|
|
mediaPartsLength: null,
|
|
getMediaBuffers: async function* () {
|
|
await new Promise<void>((resolve) => {
|
|
if (options.signal.aborted) return resolve();
|
|
options.signal.addEventListener("abort", () => resolve(), {
|
|
once: true,
|
|
});
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
const videoHandler: any = {
|
|
data: { useAudioDownload: true, useLivelyVoice: false },
|
|
site: { host: "youtube" },
|
|
votClient: {
|
|
translateVideo: async () => ({
|
|
status: VideoTranslationStatus.AUDIO_REQUESTED,
|
|
translated: false,
|
|
remainingTime: 0,
|
|
translationId: "tr-abort",
|
|
}),
|
|
provider: {
|
|
requestVtransAudio: async () => undefined,
|
|
requestVtransFailAudio: async () => undefined,
|
|
},
|
|
},
|
|
getRequestLangForTranslation: () => "en",
|
|
isLivelyVoiceAllowed: () => false,
|
|
isYouTubeHosts: () => true,
|
|
updateTranslationErrorMsg: async () => undefined,
|
|
hadAsyncWait: false,
|
|
notifier: { translationFailed: () => undefined },
|
|
actionsAbortController: { signal: { aborted: false } },
|
|
};
|
|
|
|
try {
|
|
const handler = new VOTTranslationHandler(videoHandler) as any;
|
|
const external = new AbortController();
|
|
const operation = handler.translateVideoImpl(
|
|
{ videoId: "vid-abort", duration: 10 },
|
|
"en",
|
|
"ru",
|
|
null,
|
|
false,
|
|
external.signal,
|
|
);
|
|
|
|
while (!producerSignal) await tick(0);
|
|
external.abort();
|
|
|
|
expect(await operation).toBeNull();
|
|
expect((producerSignal as unknown as AbortSignal).aborted).toBe(true);
|
|
expect(handler.downloading).toBe(false);
|
|
expect(handler.downloadSettlers.size).toBe(0);
|
|
} finally {
|
|
table[WEB_ABR_STRATEGY] = prevAbr;
|
|
}
|
|
});
|
|
|
|
test("immediate same-video retry waits for preparation and replays cache", async () => {
|
|
const handlerCtor = VOTTranslationHandler as unknown as {
|
|
AUDIO_UPLOAD_RETRY_DELAY_MS: number;
|
|
};
|
|
const prevDelay = handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS;
|
|
handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS = 0;
|
|
|
|
type Strategy = (typeof strategies)[keyof typeof strategies];
|
|
const table = strategies as unknown as Record<string, Strategy>;
|
|
const prevAbr = table[WEB_ABR_STRATEGY];
|
|
const prevMse = table[WEB_MSE_PROXY_STRATEGY];
|
|
|
|
const release = deferred<void>();
|
|
const blocked = deferred<void>();
|
|
const producerFinished = deferred<void>();
|
|
let strategyCalls = 0;
|
|
let index5Attempts = 0;
|
|
let translateCalls = 0;
|
|
const uploads: {
|
|
translationId: string;
|
|
chunkId: number;
|
|
fileId: string;
|
|
}[] = [];
|
|
|
|
table[WEB_ABR_STRATEGY] = async () => {
|
|
strategyCalls += 1;
|
|
return {
|
|
fileId: "orig-file",
|
|
mediaPartsLength: null,
|
|
getMediaBuffers: async function* () {
|
|
try {
|
|
for (let index = 0; index < 12; index++) {
|
|
if (index === 7) {
|
|
blocked.resolve();
|
|
await release.promise;
|
|
}
|
|
yield {
|
|
buffer: new Uint8Array(10).fill(1),
|
|
isLastChunk: index === 11,
|
|
};
|
|
}
|
|
} finally {
|
|
producerFinished.resolve();
|
|
}
|
|
},
|
|
};
|
|
};
|
|
table[WEB_MSE_PROXY_STRATEGY] = async () => {
|
|
throw new Error("fallback should not run");
|
|
};
|
|
|
|
const videoHandler: any = {
|
|
data: { useAudioDownload: true, useLivelyVoice: false },
|
|
site: { host: "youtube" },
|
|
votClient: {
|
|
translateVideo: async () => {
|
|
translateCalls += 1;
|
|
if (translateCalls <= 2) {
|
|
return {
|
|
status: VideoTranslationStatus.AUDIO_REQUESTED,
|
|
translated: false,
|
|
remainingTime: 0,
|
|
translationId: translateCalls === 1 ? "tr-1" : "tr-2",
|
|
};
|
|
}
|
|
return {
|
|
status: VideoTranslationStatus.TRANSLATED,
|
|
translated: true,
|
|
remainingTime: 0,
|
|
translationId: "tr-2",
|
|
url: "https://example.com/audio.mp3",
|
|
};
|
|
},
|
|
provider: {
|
|
requestVtransAudio: async (
|
|
_url: string,
|
|
translationId: string,
|
|
data: { chunkId?: number },
|
|
opts: { fileId?: string },
|
|
) => {
|
|
const chunkId = data.chunkId ?? -1;
|
|
uploads.push({
|
|
translationId,
|
|
chunkId,
|
|
fileId: opts?.fileId ?? "",
|
|
});
|
|
if (chunkId === 5 && index5Attempts < 6) {
|
|
index5Attempts += 1;
|
|
throw new Error("PUT failed");
|
|
}
|
|
},
|
|
requestVtransFailAudio: async () => undefined,
|
|
},
|
|
},
|
|
getRequestLangForTranslation: () => "en",
|
|
isLivelyVoiceAllowed: () => false,
|
|
isYouTubeHosts: () => true,
|
|
updateTranslationErrorMsg: async () => undefined,
|
|
hadAsyncWait: false,
|
|
notifier: { translationFailed: () => undefined },
|
|
actionsAbortController: { signal: { aborted: false } },
|
|
};
|
|
|
|
try {
|
|
const handler = new VOTTranslationHandler(videoHandler);
|
|
const external = new AbortController();
|
|
const videoData = { videoId: "vid-retry", duration: 10 } as never;
|
|
|
|
const first = handler.translateVideoImpl(
|
|
videoData,
|
|
"en" as never,
|
|
"ru" as never,
|
|
null,
|
|
false,
|
|
external.signal,
|
|
);
|
|
await blocked.promise;
|
|
expect(await first).toBeNull();
|
|
|
|
// Retry while the source producer is still blocked: it must queue behind
|
|
// the in-flight preparation, not start a second download.
|
|
const second = handler.translateVideoImpl(
|
|
videoData,
|
|
"en" as never,
|
|
"ru" as never,
|
|
null,
|
|
false,
|
|
external.signal,
|
|
);
|
|
await tick();
|
|
expect(strategyCalls).toBe(1);
|
|
|
|
release.resolve();
|
|
await producerFinished.promise;
|
|
await second;
|
|
|
|
expect(strategyCalls).toBe(1);
|
|
expect(translateCalls).toBe(3);
|
|
|
|
const firstRunUploads = uploads.filter((u) => u.translationId === "tr-1");
|
|
const retryUploads = uploads.filter((u) => u.translationId === "tr-2");
|
|
// First run: chunks 0-4 succeeded and chunk 5 exhausted its 6 attempts.
|
|
expect(firstRunUploads.map((u) => u.chunkId)).toEqual([
|
|
0, 1, 2, 3, 4, 5, 5, 5, 5, 5, 5,
|
|
]);
|
|
// No first-run upload past the failed chunk.
|
|
expect(firstRunUploads.some((u) => u.chunkId > 5)).toBe(false);
|
|
// Retry replays the cache with the original fileId, skipping 0-4.
|
|
expect(retryUploads.map((u) => u.chunkId)).toEqual([
|
|
5, 6, 7, 8, 9, 10, 11,
|
|
]);
|
|
expect(retryUploads.every((u) => u.fileId === "orig-file")).toBe(true);
|
|
expect((handler as any).audioDownloader.completedAudioCache).toBeNull();
|
|
} finally {
|
|
handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS = prevDelay;
|
|
table[WEB_ABR_STRATEGY] = prevAbr;
|
|
table[WEB_MSE_PROXY_STRATEGY] = prevMse;
|
|
}
|
|
});
|
|
|
|
test("same-video reuse links each external signal and unlinks on cleanup", () => {
|
|
const videoHandler: any = {
|
|
data: {},
|
|
site: { host: "youtube" },
|
|
votClient: { provider: {} },
|
|
updateTranslationErrorMsg: async () => undefined,
|
|
};
|
|
const handler = new VOTTranslationHandler(videoHandler) as any;
|
|
const first = new AbortController();
|
|
const second = new AbortController();
|
|
|
|
const run = handler.startAudioRun(first.signal, "tr-1", "v");
|
|
handler.startAudioRun(second.signal, "tr-2", "v");
|
|
expect(handler.audioRunExternalUnlinks.size).toBe(2);
|
|
|
|
handler.cleanupAudioRun();
|
|
expect(handler.audioRunExternalUnlinks.size).toBe(0);
|
|
expect(run.signal.aborted).toBe(false);
|
|
|
|
first.abort();
|
|
second.abort();
|
|
// Listeners were removed, so post-cleanup aborts don't touch the run.
|
|
expect(run.signal.aborted).toBe(false);
|
|
});
|
|
|
|
test("reuse with an already-aborted external signal aborts preparation", () => {
|
|
const videoHandler: any = {
|
|
data: {},
|
|
site: { host: "youtube" },
|
|
votClient: { provider: {} },
|
|
updateTranslationErrorMsg: async () => undefined,
|
|
};
|
|
const handler = new VOTTranslationHandler(videoHandler) as any;
|
|
const first = new AbortController();
|
|
const run = handler.startAudioRun(first.signal, "tr-1", "v");
|
|
expect(run.signal.aborted).toBe(false);
|
|
|
|
const second = new AbortController();
|
|
second.abort();
|
|
handler.startAudioRun(second.signal, "tr-2", "v");
|
|
expect(run.signal.aborted).toBe(true);
|
|
});
|
|
|
|
test("external abort during retry delay stops further uploads without failure state", async () => {
|
|
const handlerCtor = VOTTranslationHandler as unknown as {
|
|
AUDIO_UPLOAD_RETRY_DELAY_MS: number;
|
|
};
|
|
const prevDelay = handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS;
|
|
handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS = 1000;
|
|
|
|
let uploadCalls = 0;
|
|
const videoHandler: any = {
|
|
data: {},
|
|
site: { host: "youtube" },
|
|
votClient: {
|
|
provider: {
|
|
requestVtransAudio: async () => {
|
|
uploadCalls += 1;
|
|
throw new Error("PUT failed");
|
|
},
|
|
},
|
|
},
|
|
updateTranslationErrorMsg: async () => undefined,
|
|
};
|
|
|
|
try {
|
|
const handler = new VOTTranslationHandler(videoHandler) as any;
|
|
const external = new AbortController();
|
|
const run = handler.startAudioRun(
|
|
external.signal,
|
|
"tr-abort-retry",
|
|
"v-abort-retry",
|
|
);
|
|
handler.downloading = true;
|
|
|
|
const pending = handler.onDownloadedPartialAudio("tr-abort-retry", {
|
|
videoId: "v-abort-retry",
|
|
fileId: "f",
|
|
audioData: new Uint8Array([1]),
|
|
version: 1,
|
|
index: 0,
|
|
amount: 2,
|
|
});
|
|
await tick();
|
|
expect(uploadCalls).toBe(1);
|
|
external.abort();
|
|
await pending;
|
|
await tick(20);
|
|
|
|
expect(uploadCalls).toBe(1);
|
|
expect(run.signal.aborted).toBe(true);
|
|
expect(handler.uploadResumeState).toBeNull();
|
|
expect(handler.downloading).toBe(true);
|
|
} finally {
|
|
handlerCtor.AUDIO_UPLOAD_RETRY_DELAY_MS = prevDelay;
|
|
}
|
|
});
|
|
});
|