Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OBS end-to-end-testing #78

Closed
wants to merge 19 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
14 changes: 14 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ on:
branches: ["main"]

jobs:
yarn-duplicates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Use Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: "yarn"
cache-dependency-path: "yarn.lock"
- run: yarn install --immutable --inline-builds

- run: yarn dedupe --check

prettier:
runs-on: ubuntu-latest
steps:
Expand Down
File renamed without changes.
54 changes: 39 additions & 15 deletions desktop/e2e/example.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,48 @@
import { test, expect, _electron as electron } from "@playwright/test";
import {
test as base,
expect,
_electron as electron,
ElectronApplication,
Page,
} from "@playwright/test";

test("starts", async () => {
const app = await electron.launch({ args: [".vite/build/main.js"] });
const window = await app.firstWindow();
await window.waitForLoadState("domcontentloaded");
await expect(window.getByRole("heading", { name: "Bowser" })).toBeVisible();
const test = base.extend<{
app: [ElectronApplication, Page];
}>({
app: async ({}, use) => {
process.env.E2E_TEST = "true";
const app = await electron.launch({ args: [".vite/build/main.cjs"] });
const win = await app.firstWindow();

await win.waitForLoadState("domcontentloaded");

await use([app, win]);

await expect(
await app.evaluate(({ ipcMain }) => ipcMain.emit("resetTestSettings")),
).not.toBe(false);

await win.close();
await app.close();
},
});

test("starts", async ({ app: [app, win] }) => {
await expect(win.getByRole("heading", { name: "Bowser" })).toBeVisible();
});

test("can connect to server", async () => {
const app = await electron.launch({ args: [".vite/build/main.js"] });
const window = await app.firstWindow();
window.on("console", console.log);
await window.waitForLoadState("domcontentloaded");
test("can connect to server", async ({ app: [app, win] }) => {
await win.waitForLoadState("load");
if (await win.getByRole("heading", { name: "Select a show" }).isVisible()) {
return;
}

await window.getByLabel("Server address").fill("http://localhost:3000");
await window.getByLabel("Server Password").fill("aaa");
await win.getByLabel("Server address").fill("http://localhost:3000");
await win.getByLabel("Server Password").fill("aaa");

await window.getByRole("button", { name: "Connect" }).click();
await win.getByRole("button", { name: "Connect" }).click();

await expect(
window.getByRole("heading", { name: "Select a show" }),
win.getByRole("heading", { name: "Select a show" }),
).toBeVisible();
});
107 changes: 107 additions & 0 deletions desktop/e2e/obs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import {
ElectronApplication,
Page,
test as base,
_electron as electron,
expect,
} from "@playwright/test";
import { createTRPCProxyClient, httpBatchLink, loggerLink } from "@trpc/client";
import type { AppRouter } from "bowser-server/app/api/_router";
import MockOBSWebSocket from "@bowser/testing/MockOBSWebSocket.ts";
import SuperJSON from "superjson";
import { fetch } from "undici";

const api = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
url: "http://localhost:3000/api/trpc",
headers: () => ({
Authorization: "Bearer aaa",
}),
// @ts-expect-error the undici types don't match what TRPC is expecting, but they're close enough
fetch,
}),
],
transformer: SuperJSON,
});

const test = base.extend<{
app: [ElectronApplication, Page];
}>({
app: async ({}, use) => {
const app = await electron.launch({ args: [".vite/build/main.cjs"] });
const win = await app.firstWindow();

await win.waitForLoadState("domcontentloaded");

await win.getByLabel("Server address").fill("http://localhost:3000");
await win.getByLabel("Server Password").fill("aaa");

await win.getByRole("button", { name: "Connect" }).click();

await expect(
win.getByRole("heading", { name: "Select a show" }),
).toBeVisible();

await use([app, win]);

await expect(
app.evaluate(({ ipcMain }) => ipcMain.emit("resetTestSettings")),
).not.toBe(false);

await win.close();
await app.close();
},
});

test.beforeEach(async ({ request }) => {
await request.post(
"http://localhost:3000/api/resetDBInTestsDoNotUseOrYouWillBeFired",
);
await api.shows.create.mutate({
name: "Test Show",
start: new Date("2026-01-01T19:00:00Z"),
continuityItems: {
create: {
name: "Test Continuity",
durationSeconds: 0,
order: 0,
},
},
});
});

test("can connect to OBS", async ({ app: [app, win] }) => {
const obs = await MockOBSWebSocket.create(expect, async (obs) => {
obs.alwaysRespond("GetVersion", () => ({
success: true,
code: 100,
data: {
obsVersion: "1",
obsWebSocketVersion: "1",
availableRequests: [],
supportedImageFormats: [],
platform: "test",
platformDescription: "",
rpcVersion: 1,
},
}));
await obs.waitUntilClosed;
});

await win.getByRole("button", { name: "Select" }).click();

await expect(win.getByLabel("Settings")).toBeVisible();
await win.getByLabel("Settings").click();

await win.getByRole("tab", { name: "OBS" }).click();

await win.getByLabel("OBS Host").fill("localhost");
await win.getByLabel("OBS WebSocket Port").fill(obs.port.toString(10));
await win.getByLabel("OBS WebSocket Password").fill("there is no password");

await win.getByRole("button", { name: "Connect" }).click();
await obs.waitForConnection;
await expect(win.getByTestId("OBSSettings.error")).not.toBeVisible();
await expect(win.getByTestId("OBSSettings.success")).toBeVisible();
});
2 changes: 1 addition & 1 deletion desktop/forge.config.js → desktop/forge.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ module.exports = {
? "mac/icon"
: "png/64x64.png"
}`,
prune: true,
prune: false, // TODO
},
rebuildConfig: {},
makers: [
Expand Down
11 changes: 9 additions & 2 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"productName": "Bowser Desktop",
"version": "0.4.11",
"description": "My Electron application description",
"main": ".vite/build/main.js",
"main": ".vite/build/main.cjs",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
Expand All @@ -20,6 +20,7 @@
"email": "[email protected]"
},
"license": "MIT",
"type": "module",
"devDependencies": {
"@electron-forge/cli": "^6.4.1",
"@electron-forge/maker-deb": "^6.4.1",
Expand All @@ -44,23 +45,28 @@
"eslint": "^8.45.0",
"eslint-plugin-react": "latest",
"eslint-plugin-react-hooks": "^4.6.0",
"p-event": "^6.0.0",
"postcss": "^8.4.27",
"prettier": "^3.0.0",
"prettier-plugin-tailwindcss": "^0.4.1",
"tailwindcss": "^3.3.3",
"undici": "^5.23.0",
"vite": "^4.4.6",
"vitest": "^0.33.0"
},
"dependencies": {
"@bowser/components": "workspace:*",
"@bowser/prisma": "workspace:*",
"@bowser/testing": "workspace:*",
"@headlessui/react": "^1.7.15",
"@popperjs/core": "^2.11.8",
"@radix-ui/react-alert-dialog": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@sentry/electron": "^4.10.0",
"@sentry/react": "^7.64.0",
"@tailwindcss/forms": "^0.5.4",
"@tanstack/react-query": "^4.32.6",
"@trpc/client": "^10.35.0",
"@trpc/client": "^10.38.1",
"@trpc/react-query": "^10.35.0",
"@trpc/server": "^10.35.0",
"@types/progress-stream": "^2.0.2",
Expand All @@ -76,6 +82,7 @@
"electron-squirrel-startup": "^1.0.0",
"electron-trpc": "^0.5.2",
"fast-xml-parser": "^4.2.7",
"got": "^13.0.0",
"loglevel": "^1.8.1",
"loglevel-plugin-prefix": "^0.8.4",
"obs-websocket-js": "^5.0.3",
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow } from "electron";
import { app, BrowserWindow, ipcMain } from "electron";
import * as path from "path";
import { createIPCHandler } from "electron-trpc/main";
import { emitObservable, setSender } from "./ipcEventBus";
Expand Down
30 changes: 29 additions & 1 deletion desktop/src/main/settings.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import settings from "electron-settings";
import electronSettings from "electron-settings";
import { safeStorage } from "./safeStorage";
import { z } from "zod";
import * as fsp from "fs/promises";
import { AssetTypeSchema } from "@bowser/prisma/types";
import { IPCEvents } from "./ipcEventBus";
import { app, ipcMain } from "electron";

// All the functionality of `electron-settings` that we need
interface Settings {
get(key: string): Promise<unknown | undefined>;
set(key: string, value: unknown): Promise<void>;
}

// In E2E tests, to ensure that we start with a fresh instance (with no persisted data) on
// each test, we don't use electron-settings. Instead we store settings in-memory, so that
// they are lost when the application restarts between tests.
const testSettingsStore = new Map<string, unknown>();
const testSettings: Settings = {
async get(k) {
return testSettingsStore.get(k);
},
async set(k, v) {
testSettingsStore.set(k, v);
},
};
app.on("ready", () => {
ipcMain.on("resetTestSettings", () => {
testSettingsStore.clear();
});
});

const settings: Settings =
process.env.E2E_TEST === "true" ? testSettings : electronSettings;

/**
* Since settings are stored as JSON files on disk, we pass them through zod as a sanity check.
Expand Down
1 change: 1 addition & 0 deletions desktop/src/main/vmix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe("VMixConnection", () => {
});
describe("send/receive", () => {
test("request and response", async () => {
// vmix["send"] lets us access vmix.send even though it's private
const res = vmix["send"]("FUNCTION", "test");
expect(sock.write).toHaveBeenCalledWith(
"FUNCTION test\r\n",
Expand Down
37 changes: 29 additions & 8 deletions desktop/src/main/vmix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,8 @@ interface ReqQueueItem {
*/
export default class VMixConnection {
private sock!: Socket;
// When replying to a TCP request, vMix includes the name of the command
// in its response, but nothing else that would allow us to identify the sender
// (unlike e.g. the OBS WebSocket API, where requests can have an ID).
// Therefore, we only allow one request per command type to be in flight at
// a time. If a caller makes another request while one is in flight, we push it
// to the request queue and dispatch it after the first one comes back.
// This map is used to hang onto the response handler for the currently
// in-flight request - if the command has an entry, one is in flight.

// See the comment on doNextRequest for an explanation of this.
private replyAwaiting: Map<
VMixCommand,
{
Expand All @@ -61,6 +55,7 @@ export default class VMixConnection {
}
>;
private requestQueue: Array<ReqQueueItem> = [];

private buffer: string = "";
private xmlParser = new XMLParser({
ignoreAttributes: false,
Expand Down Expand Up @@ -263,6 +258,25 @@ export default class VMixConnection {
return reply;
}

// When replying to a TCP request, vMix includes the name of the command
// in its response, but nothing else that would allow us to identify the sender
// (unlike e.g. the OBS WebSocket API, where requests can have an ID).
// Therefore, we only allow one request per command type to be in flight at
// a time.
//
// The replyAwaiting map tracks whether we have sent a request for a given command,
// and therefore we can't send another until we've received a response to the first.
// If a request of type X is added to the queue when there is already one in flight,
// doNextRequest will skip processing it. Then, once a compelte response is received
// by onData(), it will call doNextRequest again to process the next request.
//
// This implementation is a bit simplistic - if the first request in the queue
// is blocked we won't process any others, even if they would not be blocked.
// However, this is unlikely to be a problem in practice.
//
// TODO: With that in mind, this could be simplified even further - instead of a
// replyAwaiting map, we could just have a single boolean flag indicating whether
// a request is in flight.
private async doNextRequest() {
const req = this.requestQueue[0];
if (!req) {
Expand All @@ -288,13 +302,18 @@ export default class VMixConnection {
private onData(data: Buffer) {
this.buffer += data.toString();
// Replies will be in one of the following forms:
//
//MYCOMMAND OK This is the response to MYCOMMAND\r\n
//
//MYCOMMAND ER This is an error message in response to MYCOMMAND\r\n
//
//MYCOMMAND 28\r\n
//This is optional binary data
//
//MYCOMMAND 28 This is a message in addition to the binary data\r\n
//This is optional binary data
//
// The 28 in the last two examples is the length of the binary data.
// NB: binary data doesn't necessarily end with \r\n!

if (!this.buffer.includes("\r\n")) {
Expand All @@ -317,8 +336,10 @@ export default class VMixConnection {
process.nextTick(this.doNextRequest.bind(this));
return;
}
// This is a binary response and "status" is actually its length
invariant(status.match(/^\d+$/), "Invalid status: " + status);
const payloadLength = parseInt(status, 10);
// +2 for the \r\n
if (this.buffer.length < payloadLength + firstLine.length + 2) {
// still need more data
return;
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion desktop/src/renderer/MainScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export default function MainScreen() {
open={isSettingsOpen}
onOpenChange={(v) => setIsSettingsOpen(v)}
>
<DialogTrigger>
<DialogTrigger aria-label="Settings">
<IoCog className="h-6 w-6" size={24} />
</DialogTrigger>
<DialogContent>
Expand Down
Loading