Files
obs-tools/test/e2e/run-new.mjs
T
2026-10-14 07:00:33 +02:00

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);