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

258 lines
11 KiB
JavaScript

// End-to-end test: presses keys on the virtual deck (through OpenDeck) and checks OBS.
// Needs: OBS with the test scenes (test/e2e/setup-obs.mjs), OpenDeck with both plugins
// and the profile from layout-main.json.
//
// node test/e2e/run.mjs [test name filter]
import fs from "node:fs";
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 SHOTS = process.env.SHOTS_DIR || "";
const filter = process.argv[2] ?? "";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const post = (path) => fetch(DECK + path, { method: "POST" });
const press = (key, ms = 80) => post(`/api/key/${key}/press?ms=${ms}`);
const hold = (key, ms) => press(key, ms);
const deckState = async () => (await fetch(`${DECK}/api/state`)).json();
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 = (type, data) => obs.call(type, data);
const program = async () => (await call("GetCurrentProgramScene")).sceneName;
async function waitFor(what, fn, timeout = 5000) {
const until = Date.now() + timeout;
let last;
while (Date.now() < until) {
last = await fn();
if (last) return last;
await sleep(100);
}
throw new Error(`timeout waiting for ${what} (last: ${JSON.stringify(last)})`);
}
async function keyImage(key) {
const state = await deckState();
return state.keys[key];
}
async function shot(name) {
if (!SHOTS) return;
fs.mkdirSync(SHOTS, { recursive: true });
const state = await deckState();
fs.writeFileSync(`${SHOTS}/${name}.json`, JSON.stringify(state));
}
const results = [];
async function test(name, fn) {
if (filter && !name.includes(filter)) return;
const start = Date.now();
try {
await fn();
results.push({ name, ok: true });
console.log(`ok ${name} (${Date.now() - start} ms)`);
} catch (error) {
results.push({ name, ok: false, error: error.message });
console.log(`FAIL ${name}: ${error.message}`);
}
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
// ---------- tests ----------
await call("SetStudioModeEnabled", { studioModeEnabled: false });
await call("SetCurrentProgramScene", { sceneName: "Szene" });
await sleep(800);
await test("smart scene switches the live scene", async () => {
const before = await keyImage(1);
await press(1);
await waitFor("program = Scene 2", async () => (await program()) === "Scene 2");
await waitFor("key 1 image changes", async () => (await keyImage(1)) !== before);
await shot("smartscene-live");
});
await test("previous scene goes back", async () => {
await sleep(300);
await press(3);
await waitFor("program = Szene", async () => (await program()) === "Szene");
});
await test("next scene follows the scene list", async () => {
const { scenes } = await call("GetSceneList");
const order = [...scenes].sort((a, b) => b.sceneIndex - a.sceneIndex).map((s) => s.sceneName);
const expected = order[(order.indexOf("Szene") + 1) % order.length];
await press(4);
await waitFor(`program = ${expected}`, async () => (await program()) === expected);
await call("SetCurrentProgramScene", { sceneName: "Szene" });
await sleep(300);
});
await test("studio mode: preview first, then live", async () => {
await press(2);
await waitFor("studio mode on", async () => (await call("GetStudioModeEnabled")).studioModeEnabled);
await sleep(400);
await press(1);
await waitFor("preview = Scene 2", async () => (await call("GetCurrentPreviewScene")).sceneName === "Scene 2");
await sleep(400);
await shot("smartscene-preview");
assert((await program()) === "Szene", "program changed too early");
await press(1);
await waitFor("program = Scene 2", async () => (await program()) === "Scene 2", 6000);
await press(2);
await waitFor("studio mode off", async () => !(await call("GetStudioModeEnabled")).studioModeEnabled);
await call("SetCurrentProgramScene", { sceneName: "Szene" });
await sleep(300);
});
await test("source volume: press and hold", async () => {
await call("SetInputVolume", { inputName: "Desktop-Audio", inputVolumeDb: 0 });
await sleep(300);
await press(7);
await waitFor("-3 dB", async () => Math.abs((await call("GetInputVolume", { inputName: "Desktop-Audio" })).inputVolumeDb + 3) < 0.05);
await hold(7, 1100);
const db = (await call("GetInputVolume", { inputName: "Desktop-Audio" })).inputVolumeDb;
assert(db <= -12, `holding should repeat, volume is ${db} dB`);
});
await test("dial turns volume and mutes", async () => {
await call("SetInputVolume", { inputName: "Desktop-Audio", inputVolumeDb: -10 });
await sleep(300);
await post("/api/dial/0/rotate?ticks=3");
await waitFor("-7 dB", async () => Math.abs((await call("GetInputVolume", { inputName: "Desktop-Audio" })).inputVolumeDb + 7) < 0.05);
await post("/api/dial/0/press");
await waitFor("muted", async () => (await call("GetInputMute", { inputName: "Desktop-Audio" })).inputMuted);
await sleep(500);
await shot("dial-muted");
await post("/api/touch/0");
await waitFor("unmuted", async () => !(await call("GetInputMute", { inputName: "Desktop-Audio" })).inputMuted);
await call("SetInputVolume", { inputName: "Desktop-Audio", inputVolumeDb: 0 });
});
await test("mute toggle", async () => {
await press(8);
await waitFor("muted", async () => (await call("GetInputMute", { inputName: "Desktop-Audio" })).inputMuted);
await sleep(400);
await shot("muted");
await press(8);
await waitFor("unmuted", async () => !(await call("GetInputMute", { inputName: "Desktop-Audio" })).inputMuted);
});
await test("set transition", async () => {
await call("SetCurrentSceneTransition", { transitionName: "Überblende" });
await sleep(300);
await press(9);
await waitFor("transition = Schnitt", async () => (await call("GetCurrentSceneTransition")).transitionName === "Schnitt");
await call("SetCurrentSceneTransition", { transitionName: "Überblende" });
});
await test("filter toggle", async () => {
await call("SetSourceFilterEnabled", { sourceName: "Box", filterName: "Tint", filterEnabled: true });
await sleep(300);
await press(14);
await waitFor("filter off", async () => !(await call("GetSourceFilter", { sourceName: "Box", filterName: "Tint" })).filterEnabled);
await press(14);
await waitFor("filter on", async () => (await call("GetSourceFilter", { sourceName: "Box", filterName: "Tint" })).filterEnabled);
});
await test("source animation plays and resets", async () => {
const { sceneItemId } = await call("GetSceneItemId", { sceneName: "Szene", sourceName: "Box" });
const start = (await call("GetSceneItemTransform", { sceneName: "Szene", sceneItemId })).sceneItemTransform;
await press(13);
await sleep(700);
const mid = (await call("GetSceneItemTransform", { sceneName: "Szene", sceneItemId })).sceneItemTransform;
assert(mid.positionX > start.positionX + 50, `should be moving (x ${start.positionX} -> ${mid.positionX})`);
assert(mid.positionX < start.positionX + 600, `should not be done yet (x ${mid.positionX})`);
await sleep(1300);
const peak = (await call("GetSceneItemTransform", { sceneName: "Szene", sceneItemId })).sceneItemTransform;
await waitFor("back at start", async () => {
const t = (await call("GetSceneItemTransform", { sceneName: "Szene", sceneItemId })).sceneItemTransform;
return Math.abs(t.positionX - start.positionX) < 0.5 && Math.abs(t.rotation - start.rotation) < 0.5 && Math.abs(t.scaleX - start.scaleX) < 0.01;
}, 4000);
console.log(` x: ${start.positionX} -> ${mid.positionX.toFixed(1)} (0.7 s) -> back; peak sample scale ${peak.scaleX.toFixed(2)}`);
});
await test("video player plays, hides when done", async () => {
const { sceneItemId } = await call("GetSceneItemId", { sceneName: "Szene", sourceName: "Clip Player" });
await call("SetSceneItemEnabled", { sceneName: "Szene", sceneItemId, sceneItemEnabled: false });
await press(12);
await waitFor("visible", async () => (await call("GetSceneItemEnabled", { sceneName: "Szene", sceneItemId })).sceneItemEnabled);
await waitFor("playing", async () => (await call("GetMediaInputStatus", { inputName: "Clip Player" })).mediaState === "OBS_MEDIA_STATE_PLAYING");
await sleep(600);
await shot("video-playing");
await waitFor("hidden after the end", async () => !(await call("GetSceneItemEnabled", { sceneName: "Szene", sceneItemId })).sceneItemEnabled, 9000);
});
await test("video player: second press stops", async () => {
const { sceneItemId } = await call("GetSceneItemId", { sceneName: "Szene", sourceName: "Clip Player" });
await press(12);
await waitFor("visible", async () => (await call("GetSceneItemEnabled", { sceneName: "Szene", sceneItemId })).sceneItemEnabled);
await sleep(800);
await press(12);
await waitFor("hidden", async () => !(await call("GetSceneItemEnabled", { sceneName: "Szene", sceneItemId })).sceneItemEnabled, 3000);
});
await test("instant replay: hold starts buffer, press replays in slow motion", async () => {
if ((await call("GetReplayBufferStatus")).outputActive) await call("StopReplayBuffer");
await sleep(500);
await hold(11, 1000);
await waitFor("replay buffer on", async () => (await call("GetReplayBufferStatus")).outputActive, 6000);
await sleep(500);
await shot("replay-armed");
await sleep(3000);
await call("SetCurrentProgramScene", { sceneName: "Szene" });
await sleep(300);
await press(11);
await waitFor("switched to Replay", async () => (await program()) === "Replay", 10000);
const settings = await waitFor("replay file loaded", async () => {
const s = (await call("GetInputSettings", { inputName: "Replay Player" })).inputSettings;
return s.speed_percent === 50 && s;
});
assert(settings.speed_percent === 50, `speed should be 50, is ${settings.speed_percent}`);
assert(/Videos\/Replay/.test(settings.local_file ?? ""), `replay file not set: ${settings.local_file}`);
console.log(` playing ${settings.local_file} at ${settings.speed_percent}%`);
await waitFor("back to Szene after the replay", async () => (await program()) === "Szene", 20000);
await hold(11, 1000);
await waitFor("replay buffer off", async () => !(await call("GetReplayBufferStatus")).outputActive, 6000);
});
await test("record toggle shows a timer", async () => {
await press(10);
await waitFor("recording", async () => (await call("GetRecordStatus")).outputActive, 6000);
await sleep(2500);
await shot("recording");
await press(10);
const stopped = await waitFor("stopped", async () => {
const s = await call("GetRecordStatus");
return !s.outputActive && s;
}, 8000);
assert(stopped, "still recording");
});
await test("scene rename follows into the key settings", async () => {
await call("SetSceneName", { sceneName: "Scene 2", newSceneName: "Scene Two" });
await sleep(800);
await press(1);
try {
await waitFor("program = Scene Two", async () => (await program()) === "Scene Two");
} finally {
await call("SetSceneName", { sceneName: "Scene Two", newSceneName: "Scene 2" });
await sleep(500);
await call("SetCurrentProgramScene", { sceneName: "Szene" });
}
});
obs.stop();
const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} passed`);
process.exit(failed.length ? 1 : 0);