mirror of
https://github.com/FeloDeck/obs-tools.git
synced 2026-11-01 17:33:29 +01:00
241 lines
12 KiB
JavaScript
241 lines
12 KiB
JavaScript
// End-to-end test for the everyday actions, stream health, meters and dials.
|
|
// Needs the profile from layout-new.json and the scenes from setup-obs.mjs.
|
|
// The stream test starts a local RTMP server with ffmpeg and streams to it for a few seconds.
|
|
import { spawn } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
import { ObsClient } from "../../de.felitendo.obs-tools.sdPlugin/plugin/lib/obs.js";
|
|
import { readLocalObsConfig } from "../../de.felitendo.obs-tools.sdPlugin/plugin/lib/connections.js";
|
|
|
|
const DECK = process.env.VIRTUAL_DECK_URL || "http://127.0.0.1:57190";
|
|
const filter = process.argv[2] ?? "";
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const post = (p) => fetch(DECK + p, { method: "POST" });
|
|
const press = (key, ms = 80) => post(`/api/key/${key}/press?ms=${ms}`);
|
|
const keyImage = async (key) => (await (await fetch(`${DECK}/api/state`)).json()).keys[key];
|
|
const dialImage = async (dial) => (await (await fetch(`${DECK}/api/state`)).json()).dials[dial];
|
|
|
|
const cfg = readLocalObsConfig();
|
|
const obs = new ObsClient({ port: cfg.port, password: cfg.password });
|
|
await new Promise((resolve) => (obs.once("connected", resolve), obs.start()));
|
|
const call = (t, d) => obs.call(t, d);
|
|
const pluginLog = path.join(os.homedir(), ".local/share/opendeck/logs/plugins/de.felitendo.obs-tools.sdPlugin.log");
|
|
const logSize = () => fs.statSync(pluginLog).size;
|
|
const logSince = (size) => fs.readFileSync(pluginLog, "utf8").slice(size);
|
|
|
|
async function waitFor(what, fn, timeout = 5000) {
|
|
const until = Date.now() + timeout;
|
|
let last;
|
|
while (Date.now() < until) {
|
|
if ((last = await fn())) return last;
|
|
await sleep(100);
|
|
}
|
|
throw new Error(`timeout waiting for ${what} (last: ${JSON.stringify(last)})`);
|
|
}
|
|
let failed = 0;
|
|
let count = 0;
|
|
async function test(name, fn) {
|
|
if (filter && !name.includes(filter)) return;
|
|
count++;
|
|
try {
|
|
await fn();
|
|
console.log(`ok ${name}`);
|
|
} catch (error) {
|
|
failed++;
|
|
console.log(`FAIL ${name}: ${error.message}`);
|
|
}
|
|
}
|
|
const text = async () => (await call("GetInputSettings", { inputName: "Title Text" })).inputSettings.text;
|
|
const item = async (scene, source) => (await call("GetSceneItemId", { sceneName: scene, sourceName: source })).sceneItemId;
|
|
|
|
await call("SetCurrentProgramScene", { sceneName: "Szene" });
|
|
await sleep(500);
|
|
|
|
await test("source visibility toggles", async () => {
|
|
const id = await item("Szene", "Title Text");
|
|
await call("SetSceneItemEnabled", { sceneName: "Szene", sceneItemId: id, sceneItemEnabled: true });
|
|
await sleep(300);
|
|
await press(0);
|
|
await waitFor("hidden", async () => !(await call("GetSceneItemEnabled", { sceneName: "Szene", sceneItemId: id })).sceneItemEnabled);
|
|
await press(0);
|
|
await waitFor("visible", async () => (await call("GetSceneItemEnabled", { sceneName: "Szene", sceneItemId: id })).sceneItemEnabled);
|
|
});
|
|
|
|
await test("hotkey by name with a source", async () => {
|
|
await call("SetInputMute", { inputName: "Desktop-Audio", inputMuted: false });
|
|
await press(1);
|
|
await waitFor("muted by hotkey", async () => (await call("GetInputMute", { inputName: "Desktop-Audio" })).inputMuted);
|
|
await call("SetInputMute", { inputName: "Desktop-Audio", inputMuted: false });
|
|
});
|
|
|
|
await test("screenshot saves a file", async () => {
|
|
const dir = path.join(os.homedir(), "Pictures/obstools-test");
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const before = new Set(fs.readdirSync(dir));
|
|
await press(2);
|
|
const file = await waitFor("new file", async () => fs.readdirSync(dir).find((f) => !before.has(f)));
|
|
console.log(` ${file}`);
|
|
});
|
|
|
|
await test("set text: fixed, counter, double press, hold to reset, list", async () => {
|
|
await press(9);
|
|
await waitFor("Live in 5", async () => (await text()) === "Live in 5");
|
|
// This counter counts down on a double press, so single presses need a pause between them.
|
|
await press(10, 1000);
|
|
await waitFor("Deaths: 0", async () => (await text()) === "Deaths: 0");
|
|
await press(10);
|
|
await sleep(500);
|
|
await press(10);
|
|
await waitFor("Deaths: 2", async () => (await text()) === "Deaths: 2");
|
|
await sleep(500);
|
|
await press(10);
|
|
await press(10);
|
|
await waitFor("Deaths: 1 (double press)", async () => (await text()) === "Deaths: 1");
|
|
await press(10, 1000);
|
|
await waitFor("Deaths: 0", async () => (await text()) === "Deaths: 0");
|
|
// The list key goes One, Two, Three, One... from wherever it is now.
|
|
const lines = ["One", "Two", "Three"];
|
|
await press(11);
|
|
const first = await waitFor("a line of the list", async () => {
|
|
const now = await text();
|
|
return lines.includes(now) && now;
|
|
});
|
|
await press(11);
|
|
const next = lines[(lines.indexOf(first) + 1) % lines.length];
|
|
await waitFor(next, async () => (await text()) === next);
|
|
});
|
|
|
|
await test("media control: play, pause, stop", async () => {
|
|
const id = await item("Szene", "Clip Player");
|
|
await call("SetSceneItemEnabled", { sceneName: "Szene", sceneItemId: id, sceneItemEnabled: true });
|
|
// Showing the source restarts it in OBS, so stop it only after that.
|
|
await sleep(800);
|
|
await call("TriggerMediaInputAction", { inputName: "Clip Player", mediaAction: "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP" });
|
|
await sleep(500);
|
|
const state = async () => (await call("GetMediaInputStatus", { inputName: "Clip Player" })).mediaState;
|
|
await press(5);
|
|
await waitFor("playing", async () => (await state()) === "OBS_MEDIA_STATE_PLAYING");
|
|
await sleep(700);
|
|
await press(5);
|
|
await waitFor("paused", async () => (await state()) === "OBS_MEDIA_STATE_PAUSED");
|
|
await press(6);
|
|
await waitFor("stopped", async () => ["OBS_MEDIA_STATE_STOPPED", "OBS_MEDIA_STATE_ENDED"].includes(await state()));
|
|
await call("SetSceneItemEnabled", { sceneName: "Szene", sceneItemId: id, sceneItemEnabled: false });
|
|
});
|
|
|
|
await test("profile switch and back", async () => {
|
|
await press(7);
|
|
await waitFor("profile Test 2", async () => (await call("GetProfileList")).currentProfileName === "Test 2", 8000);
|
|
await sleep(1500);
|
|
await press(8);
|
|
await waitFor("profile Unbenannt", async () => (await call("GetProfileList")).currentProfileName === "Unbenannt", 8000);
|
|
await sleep(1500);
|
|
});
|
|
|
|
await test("recording marker: chapter and split while recording", async () => {
|
|
const size = logSize();
|
|
await press(3);
|
|
await sleep(500);
|
|
if (!/RecordTools: error: not recording/.test(logSince(size))) throw new Error("chapter while not recording should fail");
|
|
await press(14);
|
|
await waitFor("recording", async () => (await call("GetRecordStatus")).outputActive, 6000);
|
|
await sleep(1500);
|
|
const before = logSize();
|
|
await press(3);
|
|
await sleep(800);
|
|
await press(4);
|
|
await sleep(1500);
|
|
const log = logSince(before);
|
|
// The chapter must work. Split only works with file splitting on in OBS, else OBS says so.
|
|
if (/chapter|hybrid/i.test(log)) throw new Error(`chapter failed: ${log.trim()}`);
|
|
console.log(` chapter ok; split: ${/file splitting/.test(log) ? "OBS says file splitting is off (expected here)" : "ok"}`);
|
|
await press(14);
|
|
await waitFor("stopped", async () => !(await call("GetRecordStatus")).outputActive, 8000);
|
|
});
|
|
|
|
await test("source volume key and dial show a live meter", async () => {
|
|
const id = await item("Szene", "Clip Player");
|
|
const quietKey = await keyImage(13);
|
|
const quietDial = await dialImage(0);
|
|
await call("SetInputSettings", { inputName: "Clip Player", inputSettings: { looping: true }, overlay: true });
|
|
await call("SetSceneItemEnabled", { sceneName: "Szene", sceneItemId: id, sceneItemEnabled: true });
|
|
await sleep(800);
|
|
await call("TriggerMediaInputAction", { inputName: "Clip Player", mediaAction: "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_RESTART" });
|
|
try {
|
|
await waitFor("key meter moves", async () => (await keyImage(13)) !== quietKey, 4000);
|
|
await waitFor("dial meter moves", async () => (await dialImage(0)) !== quietDial, 4000);
|
|
} finally {
|
|
await call("TriggerMediaInputAction", { inputName: "Clip Player", mediaAction: "OBS_WEBSOCKET_MEDIA_INPUT_ACTION_STOP" });
|
|
await call("SetInputSettings", { inputName: "Clip Player", inputSettings: { looping: false }, overlay: true });
|
|
await call("SetSceneItemEnabled", { sceneName: "Szene", sceneItemId: id, sceneItemEnabled: false });
|
|
}
|
|
});
|
|
|
|
await test("transition dial: time and next transition", async () => {
|
|
await call("SetCurrentSceneTransition", { transitionName: "Überblende" });
|
|
await call("SetCurrentSceneTransitionDuration", { transitionDuration: 300 });
|
|
await sleep(400);
|
|
await post("/api/dial/1/rotate?ticks=2");
|
|
await waitFor("400 ms", async () => (await call("GetCurrentSceneTransition")).transitionDuration === 400);
|
|
await post("/api/dial/1/press");
|
|
await waitFor("next transition", async () => (await call("GetCurrentSceneTransition")).transitionName !== "Überblende");
|
|
await call("SetCurrentSceneTransition", { transitionName: "Überblende" });
|
|
await call("SetCurrentSceneTransitionDuration", { transitionDuration: 300 });
|
|
});
|
|
|
|
await test("scene dial: turn picks, press switches", async () => {
|
|
await call("SetCurrentProgramScene", { sceneName: "Szene" });
|
|
await sleep(500);
|
|
const { scenes } = await call("GetSceneList");
|
|
const order = [...scenes].sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName);
|
|
const target = order[(order.indexOf("Szene") + 2) % order.length];
|
|
await post("/api/dial/2/rotate?ticks=2");
|
|
await sleep(600);
|
|
if ((await call("GetCurrentProgramScene")).sceneName !== "Szene") throw new Error("turning should not switch in pick mode");
|
|
await post("/api/dial/2/press");
|
|
await waitFor(`program = ${target}`, async () => (await call("GetCurrentProgramScene")).sceneName === target);
|
|
await call("SetCurrentProgramScene", { sceneName: "Szene" });
|
|
});
|
|
|
|
await test("filter dial: value, on and off", async () => {
|
|
const get = async () => await call("GetSourceFilter", { sourceName: "Box", filterName: "Look" });
|
|
await call("SetSourceFilterSettings", { sourceName: "Box", filterName: "Look", filterSettings: { opacity: 1 }, overlay: true });
|
|
await sleep(400);
|
|
await post("/api/dial/3/rotate?ticks=-5");
|
|
await waitFor("opacity 0.95", async () => Math.abs(((await get()).filterSettings.opacity ?? 1) - 0.95) < 0.001);
|
|
await post("/api/dial/3/press");
|
|
await waitFor("filter off", async () => !(await get()).filterEnabled);
|
|
await post("/api/dial/3/press");
|
|
await waitFor("filter on", async () => (await get()).filterEnabled);
|
|
await call("SetSourceFilterSettings", { sourceName: "Box", filterName: "Look", filterSettings: { opacity: 1 }, overlay: true });
|
|
});
|
|
|
|
await test("stream health while streaming to a local server", async () => {
|
|
const old = await call("GetStreamServiceSettings");
|
|
const server = spawn("ffmpeg", ["-hide_banner", "-loglevel", "error", "-listen", "1", "-i", "rtmp://127.0.0.1:19350/live/test", "-f", "null", "-"], { stdio: "ignore" });
|
|
try {
|
|
await sleep(800);
|
|
await call("SetStreamServiceSettings", { streamServiceType: "rtmp_custom", streamServiceSettings: { server: "rtmp://127.0.0.1:19350/live", key: "test" } });
|
|
const before = await keyImage(12);
|
|
await call("StartStream");
|
|
await waitFor("streaming", async () => (await call("GetStreamStatus")).outputActive, 10000);
|
|
await sleep(4000);
|
|
const live = await keyImage(12);
|
|
if (live === before) throw new Error("key did not change while live");
|
|
if (process.env.SHOTS_DIR) fs.writeFileSync(path.join(process.env.SHOTS_DIR, "stream-health.png"), Buffer.from(live.split(",")[1], "base64"));
|
|
const status = await call("GetStreamStatus");
|
|
console.log(` ${status.outputTimecode}, ${status.outputBytes} bytes, congestion ${status.outputCongestion}`);
|
|
await call("StopStream");
|
|
await waitFor("stopped", async () => !(await call("GetStreamStatus")).outputActive, 10000);
|
|
} finally {
|
|
server.kill();
|
|
await call("SetStreamServiceSettings", { streamServiceType: old.streamServiceType, streamServiceSettings: old.streamServiceSettings }).catch(() => {});
|
|
}
|
|
});
|
|
|
|
obs.stop();
|
|
console.log(failed ? `\n${failed} of ${count} failed` : `\nall ${count} passed`);
|
|
process.exit(failed ? 1 : 0);
|