diff --git a/tests/ffmpeg-core.test.js b/tests/ffmpeg-core.test.js index 0852eca2746..dbb21b47353 100644 --- a/tests/ffmpeg-core.test.js +++ b/tests/ffmpeg-core.test.js @@ -301,3 +301,109 @@ describe(genName("state between calls"), () => { core.FS.unlink("probe.json"); }); }); + +describe(genName("exec() repeated calls"), () => { + beforeEach(reset); + + it("should produce identical output across runs", () => { + const outputs = []; + for (let i = 0; i < 5; i++) { + expect( + core.exec( + "-f", "lavfi", "-i", "testsrc=d=0.3:s=32x24:r=10", + "-pix_fmt", "yuv420p", "-f", "yuv4mpegpipe", "repeat.y4m" + ) + ).to.equal(0); + outputs.push(core.FS.readFile("repeat.y4m")); + core.FS.unlink("repeat.y4m"); + } + const [first, ...rest] = outputs; + expect(first.length).to.not.equal(0); + rest.forEach((out) => expect(out).to.deep.equal(first)); + }); + + it("should not carry inputs and outputs over to the next run", () => { + expect(core.exec("-f", "lavfi", "-i", "sine=d=0.2", "carry.wav")).to.equal(0); + expect(core.exec("-f", "lavfi", "-i", "testsrc=d=0.2:s=16x16:r=5", "carry.mp4")).to.equal(0); + expect(probe("carry.mp4").streams.map((s) => s.codec_type)).to.deep.equal(["video"]); + core.FS.unlink("carry.wav"); + core.FS.unlink("carry.mp4"); + }); +}); + +describe(genName("ffprobe() repeated calls"), () => { + beforeEach(reset); + + const probeStreams = (...args) => { + core.ffprobe("-v", "error", "-print_format", "json", ...args, "video.mp4", "-o", "probe.json"); + const json = JSON.parse(new TextDecoder().decode(core.FS.readFile("probe.json"))); + core.FS.unlink("probe.json"); + return json; + }; + + it("should not keep -select_streams in a later call", () => { + expect(probeStreams("-show_streams", "-select_streams", "a").streams).to.have.lengthOf(0); + expect(probeStreams("-show_streams").streams).to.have.lengthOf(1); + }); + + it("should keep working when calls alternate with exec()", () => { + for (let i = 0; i < 3; i++) { + expect(probeStreams("-show_format").format.nb_streams).to.equal(1); + expect(core.exec("-f", "lavfi", "-i", "sine=d=0.1", "alt.wav")).to.equal(0); + core.FS.unlink("alt.wav"); + } + }); +}); + +describe(genName("exec() exit codes"), () => { + beforeEach(reset); + + const run = (...args) => { + const logs = []; + core.setLogger(({ message }) => logs.push(message)); + const ret = core.exec(...args); + return { ret, log: logs.join("\n") }; + }; + + it("should return non-zero for an unknown option", () => { + const { ret, log } = run("-definitely-not-an-option"); + expect(ret).to.not.equal(0); + expect(log).to.match(/Unrecognized option/); + }); + + it("should return non-zero when the input is missing", () => { + const { ret, log } = run("-i", "missing.mp4", "missing.avi"); + expect(ret).to.not.equal(0); + expect(log).to.match(/No such file or directory/); + }); + + it("should return non-zero for an unknown encoder", () => { + const { ret } = run("-f", "lavfi", "-i", "nullsrc=s=16x16:d=0.1", "-c:v", "no-such-encoder", "bad.mp4"); + expect(ret).to.not.equal(0); + }); + + it("should return non-zero when no output is given", () => { + expect(run("-f", "lavfi", "-i", "nullsrc=s=16x16:d=0.1").ret).to.not.equal(0); + }); + + it("should return the value left in core.ret", () => { + expect(core.exec("-definitely-not-an-option")).to.equal(core.ret); + }); + + it("should not throw when an option is missing its argument", () => { + expect(() => core.exec("-i")).to.not.throw(); + }); +}); + +describe(genName("reset() handlers"), () => { + it("should keep the logger and progress handlers", () => { + const logger = () => {}; + const progress = () => {}; + core.setLogger(logger); + core.setProgress(progress); + core.reset(); + expect(core.logger).to.equal(logger); + expect(core.progress).to.equal(progress); + reset(); + }); +}); diff --git a/tests/ffmpeg-node.test.mjs b/tests/ffmpeg-node.test.mjs index 3b9ce8926b6..7ff416f26de 100644 --- a/tests/ffmpeg-node.test.mjs +++ b/tests/ffmpeg-node.test.mjs @@ -43,6 +43,9 @@ const corePath = fileURLToPath( const load = (ffmpeg) => ffmpeg.load(coreURL ? { coreURL } : {}); +// Endless input, so a command cannot finish before the call is settled. +const ENDLESS = ["-f", "lavfi", "-i", "testsrc=d=600:s=16x16", "-f", "null", "-"]; + // fetchFile()'s local-path and file: URL branches only run under Node.js, // so the browser test suite can't cover them; both core-variant commands // run this same suite, but fetchFile() itself doesn't touch the core. @@ -372,4 +375,136 @@ describe(genName("FFmpeg.terminate()"), function () { ffmpeg.terminate(); } }); + + const message = async (promise) => { + try { + await promise; + } catch (err) { + return String(err.message ?? err); + } + return undefined; + }; + + it("rejects pending calls with the terminate error", async () => { + const ffmpeg = new FFmpeg(); + await load(ffmpeg); + const pending = ffmpeg.exec(ENDLESS); + ffmpeg.terminate(); + expect(await message(pending)).to.match(/terminate/); + }); + + it("rejects calls made after terminate()", async () => { + const ffmpeg = new FFmpeg(); + await load(ffmpeg); + ffmpeg.terminate(); + expect(await message(ffmpeg.listDir("/"))).to.match(/not loaded/); + }); + + it("loads again with a fresh file system", async () => { + const ffmpeg = new FFmpeg(); + await load(ffmpeg); + await ffmpeg.writeFile("/before", "x"); + ffmpeg.terminate(); + try { + expect(await load(ffmpeg)).to.be.true; + const names = (await ffmpeg.listDir("/")).map(({ name }) => name); + expect(names).to.not.include("before"); + } finally { + ffmpeg.terminate(); + } + }); + + it("does nothing when never loaded", () => { + expect(() => new FFmpeg().terminate()).to.not.throw(); + }); +}); + +describe(genName("abort signal"), function () { + this.timeout(60000); + + const abortName = async (call) => { + const controller = new AbortController(); + const pending = call(controller.signal); + controller.abort(); + try { + await pending; + } catch (err) { + return err.name; + } + return undefined; + }; + + it("rejects exec() and file calls with an AbortError", async () => { + const ffmpeg = new FFmpeg(); + await load(ffmpeg); + try { + expect( + await abortName((signal) => ffmpeg.exec(ENDLESS, -1, { signal })) + ).to.equal("AbortError"); + expect( + await abortName((signal) => ffmpeg.listDir("/", { signal })) + ).to.equal("AbortError"); + } finally { + ffmpeg.terminate(); + } + }); +}); + +describe(genName("repeated calls and exit codes"), function () { + this.timeout(60000); + + let ffmpeg; + + before(async () => { + ffmpeg = new FFmpeg(); + await load(ffmpeg); + await ffmpeg.writeFile("video.mp4", b64ToUint8Array(VIDEO_1S_MP4)); + }); + + after(() => { + ffmpeg.terminate(); + }); + + it("produces identical output across exec() runs", async () => { + const outputs = []; + for (let i = 0; i < 3; i++) { + const ret = await ffmpeg.exec([ + "-f", "lavfi", "-i", "testsrc=d=0.3:s=32x24:r=10", + "-pix_fmt", "yuv420p", "-f", "yuv4mpegpipe", "repeat.y4m", + ]); + expect(ret).to.equal(0); + outputs.push(Buffer.from(await ffmpeg.readFile("repeat.y4m"))); + await ffmpeg.deleteFile("repeat.y4m"); + } + expect(outputs[0].length).to.not.equal(0); + for (const out of outputs) expect(out).to.deep.equal(outputs[0]); + }); + + it("does not keep ffprobe options between calls", async () => { + const streams = async (...args) => { + await ffmpeg.ffprobe([ + "-v", "error", "-print_format", "json", ...args, "video.mp4", "-o", "p.json", + ]); + const json = JSON.parse(await ffmpeg.readFile("p.json", "utf8")); + await ffmpeg.deleteFile("p.json"); + return json.streams; + }; + expect(await streams("-show_streams", "-select_streams", "a")).to.have.lengthOf(0); + expect(await streams("-show_streams")).to.have.lengthOf(1); + }); + + it("returns non-zero for bad arguments, a missing input and an unknown encoder", async () => { + expect(await ffmpeg.exec(["-definitely-not-an-option"])).to.not.equal(0); + expect(await ffmpeg.exec(["-i", "missing.mp4", "missing.avi"])).to.not.equal(0); + expect( + await ffmpeg.exec([ + "-f", "lavfi", "-i", "nullsrc=s=16x16:d=0.1", "-c:v", "no-such-encoder", "bad.mp4", + ]) + ).to.not.equal(0); + }); + + it("runs again after a failure", async () => { + expect(await ffmpeg.exec(["-definitely-not-an-option"])).to.not.equal(0); + expect(await ffmpeg.exec(["-i", "video.mp4", "-f", "null", "-"])).to.equal(0); + }); }); diff --git a/tests/ffmpeg.test.js b/tests/ffmpeg.test.js index 353502261ba..2a988de07ca 100644 --- a/tests/ffmpeg.test.js +++ b/tests/ffmpeg.test.js @@ -208,3 +208,125 @@ describe(genName("helpers (probe(), transcode(), extractFrames())"), function () expect(names.filter((n) => n.startsWith("ffmpeg-wasm-job-"))).to.deep.equal([]); }); }); + +// Endless input, so the command cannot finish before the call is settled. +const ENDLESS = ["-f", "lavfi", "-i", "testsrc=d=600:s=16x16", "-f", "null", "-"]; + +describe(genName("abort signal"), function () { + it("should reject exec() with an AbortError", async () => { + const ffmpeg = await createFFmpeg(); + try { + const controller = new AbortController(); + const pending = ffmpeg.exec(ENDLESS, -1, { signal: controller.signal }); + controller.abort(); + let name; + try { + await pending; + } catch (err) { + name = err.name; + } + expect(name).to.equal("AbortError"); + } finally { + ffmpeg.terminate(); + } + }); + + it("should reject file calls with an AbortError", async () => { + const ffmpeg = await createFFmpeg(); + try { + const controller = new AbortController(); + const pending = ffmpeg.listDir("/", { signal: controller.signal }); + controller.abort(); + let name; + try { + await pending; + } catch (err) { + name = err.name; + } + expect(name).to.equal("AbortError"); + } finally { + ffmpeg.terminate(); + } + }); +}); + +describe(genName("FFmpeg.terminate()"), function () { + const rejection = async (promise) => { + try { + await promise; + } catch (err) { + return String(err.message ?? err); + } + return undefined; + }; + + it("should reject pending calls with the terminate error", async () => { + const ffmpeg = await createFFmpeg(); + const pending = ffmpeg.exec(ENDLESS); + ffmpeg.terminate(); + expect(await rejection(pending)).to.match(/terminate/); + }); + + it("should reject calls made after terminate()", async () => { + const ffmpeg = await createFFmpeg(); + ffmpeg.terminate(); + expect(await rejection(ffmpeg.listDir("/"))).to.match(/not loaded/); + }); + + it("should load again with a fresh file system", async () => { + const ffmpeg = await createFFmpeg(); + await ffmpeg.writeFile("/before", "x"); + ffmpeg.terminate(); + const config = { coreURL: CORE_URL, thread: FFMPEG_TYPE === "mt" }; + try { + expect(await ffmpeg.load(config)).to.equal(true); + const names = (await ffmpeg.listDir("/")).map(({ name }) => name); + expect(names).to.not.include("before"); + } finally { + ffmpeg.terminate(); + } + }); + + it("should do nothing when never loaded", () => { + expect(() => new FFmpeg().terminate()).to.not.throw(); + }); +}); + +describe(genName("load() on a loaded instance"), function () { + it("should keep the loaded core and its files", async () => { + const ffmpeg = await createFFmpeg(); + try { + await ffmpeg.writeFile("/kept.txt", "hello"); + const config = { coreURL: CORE_URL, thread: FFMPEG_TYPE === "mt" }; + expect(await ffmpeg.load(config)).to.equal(false); + expect(await ffmpeg.readFile("/kept.txt", "utf8")).to.equal("hello"); + } finally { + ffmpeg.terminate(); + } + }); +}); + +describe(genName("FFmpeg.exec() exit codes"), function () { + let ffmpeg; + + before(async () => { + ffmpeg = await createFFmpeg(); + }); + + after(() => { + ffmpeg.terminate(); + }); + + it("should return non-zero for bad arguments, a missing input and an unknown encoder", async () => { + expect(await ffmpeg.exec(["-definitely-not-an-option"])).to.not.equal(0); + expect(await ffmpeg.exec(["-i", "missing.mp4", "missing.avi"])).to.not.equal(0); + expect( + await ffmpeg.exec(["-f", "lavfi", "-i", "nullsrc=s=16x16:d=0.1", "-c:v", "no-such-encoder", "bad.mp4"]) + ).to.not.equal(0); + }); + + it("should run again after a failure", async () => { + expect(await ffmpeg.exec(["-definitely-not-an-option"])).to.not.equal(0); + expect(await ffmpeg.exec(["-f", "lavfi", "-i", "nullsrc=s=16x16:d=0.1", "-f", "null", "-"])).to.equal(0); + }); +});