diff --git a/desktop/e2e/obs.spec.ts b/desktop/e2e/obs.spec.ts new file mode 100644 index 00000000..7caea7b7 --- /dev/null +++ b/desktop/e2e/obs.spec.ts @@ -0,0 +1,87 @@ +import { + ElectronApplication, + Page, + test as base, + _electron as electron, + expect, +} from "@playwright/test"; +import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; +import type { AppRouter } from "bowser-server/app/api/_router"; +import MockOBSWebSocket from "@bowser/testing/MockOBSWebSocket"; +import SuperJSON from "superjson"; + +const api = createTRPCProxyClient({ + links: [ + httpBatchLink({ + url: "http://localhost:3000/trpc", + headers: () => ({ + Authorization: "Bearer aaa", + }), + }), + ], + transformer: SuperJSON, +}); + +const test = base.extend<{ + app: [ElectronApplication, Page]; + obs: MockOBSWebSocket; +}>({ + app: async ({ request }, use) => { + const app = await electron.launch({ args: [".vite/build/main.js"] }); + 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 win.close(); + await app.close(); + }, + obs: async (_, use) => { + const mows = await MockOBSWebSocket.create(expect); + await use(mows); + await mows.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("continuity works", async ({ app: [app, win], obs }) => { + 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 expect(win.getByTestId("OBSSettings.error")).not.toBeVisible(); + await expect(win.getByTestId("OBSSettings.success")).toBeVisible(); +}); diff --git a/desktop/src/renderer/MainScreen.tsx b/desktop/src/renderer/MainScreen.tsx index 6c09a9e3..43db7b30 100644 --- a/desktop/src/renderer/MainScreen.tsx +++ b/desktop/src/renderer/MainScreen.tsx @@ -146,7 +146,7 @@ export default function MainScreen() { open={isSettingsOpen} onOpenChange={(v) => setIsSettingsOpen(v)} > - + diff --git a/desktop/src/renderer/screens/OBS.tsx b/desktop/src/renderer/screens/OBS.tsx index ab0b648a..eb48c7bb 100644 --- a/desktop/src/renderer/screens/OBS.tsx +++ b/desktop/src/renderer/screens/OBS.tsx @@ -91,12 +91,16 @@ export function OBSSettings() { Connect {state.data?.connected && ( - + Successfully connected to OBS version {state.data.version} on{" "} {state.data.platform} )} - {error && {error}} + {error && ( + + {error} + + )} ); diff --git a/server/app/api/_base.ts b/server/app/api/_base.ts index db2f6413..6f60c016 100644 --- a/server/app/api/_base.ts +++ b/server/app/api/_base.ts @@ -1,4 +1,4 @@ -import { initTRPC } from "@trpc/server"; +import { TRPCError, initTRPC } from "@trpc/server"; import superjson from "superjson"; const t = initTRPC.create({ @@ -6,4 +6,13 @@ const t = initTRPC.create({ }); export const publicProcedure = t.procedure; +export const testOnlyProcedure = publicProcedure.use(async ({ next }) => { + if (process.env.E2E_TEST === "true") { + return await next(); + } + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "This procedure is only available in end-to-end tests.", + }); +}); export const router = t.router; diff --git a/server/app/api/_router.ts b/server/app/api/_router.ts index 36155047..27e2c5da 100644 --- a/server/app/api/_router.ts +++ b/server/app/api/_router.ts @@ -1,4 +1,4 @@ -import { publicProcedure, router } from "./_base"; +import { publicProcedure, router, testOnlyProcedure } from "./_base"; import { z } from "zod"; import { db } from "@/lib/db"; import { @@ -8,7 +8,14 @@ import { PartialShowModel, } from "@bowser/prisma/utilityTypes"; import { getPresignedURL } from "@/lib/s3"; -import { ContinuityItemSchema, RundownItemSchema } from "@bowser/prisma/types"; +import { + ContinuityItemSchema, + MediaCreateInputSchema, + MediaFileSourceTypeSchema, + RundownItemSchema, + ShowCreateInputSchema, +} from "@bowser/prisma/types"; +import { dispatchJobForJobrunner } from "@/lib/jobs"; const ExtendedMediaModelWithDownloadURL = CompleteMediaModel.extend({ continuityItem: ContinuityItemSchema.nullable(), @@ -80,6 +87,35 @@ export const appRouter = router({ }); return obj; }), + create: testOnlyProcedure + .input(ShowCreateInputSchema) + .output(CompleteShowModel) + .mutation(async ({ input }) => { + return await db.show.create({ + data: input, + include: { + continuityItems: { + include: { + media: true, + }, + }, + rundowns: { + include: { + items: { + include: { + media: true, + }, + }, + assets: { + include: { + media: true, + }, + }, + }, + }, + }, + }); + }), }), media: router({ get: publicProcedure @@ -103,6 +139,45 @@ export const appRouter = router({ } return obj as z.infer; }), + create: testOnlyProcedure + .input( + z.object({ + media: MediaCreateInputSchema, + sourceType: MediaFileSourceTypeSchema, + source: z.string(), + }), + ) + .output(ExtendedMediaModelWithDownloadURL) + .mutation(async ({ input }) => { + const [media, job] = await db.$transaction(async ($db) => { + const media = await $db.media.create({ + data: input.media, + include: { + rundownItem: true, + continuityItem: true, + tasks: true, + }, + }); + const job = await $db.processMediaJob.create({ + data: { + sourceType: input.sourceType, + source: input.source, + media: { + connect: { + id: media.id, + }, + }, + base_job: { create: {} }, + }, + }); + return [media, job] as const; + }); + await dispatchJobForJobrunner(job.base_job_id); + return { + ...media, + downloadURL: null, + }; + }), }), rundowns: router({ get: publicProcedure diff --git a/utility/testing/MockOBSWebSocket.ts b/utility/testing/MockOBSWebSocket.ts index 8fc4d31a..73919e84 100644 --- a/utility/testing/MockOBSWebSocket.ts +++ b/utility/testing/MockOBSWebSocket.ts @@ -1,4 +1,3 @@ -import { type expect as vitestExpect } from "vitest"; import { EventSubscription, OBSEventTypes, @@ -8,6 +7,10 @@ import { import { AddressInfo, WebSocket, WebSocketServer } from "ws"; import { pEvent } from "p-event"; import * as msgpack from "@msgpack/msgpack"; +import type { ExpectStatic as VitestExpect } from "vitest"; +import type { Expect as PlaywrightExpect } from "@playwright/test"; + +type Expect = VitestExpect | PlaywrightExpect; type OBSRequestHandler< T extends keyof OBSRequestTypes = keyof OBSRequestTypes, @@ -27,7 +30,7 @@ export class MockOBSContext { ((h: OBSRequestHandler) => void)[] >(); constructor( - private readonly expect: typeof vitestExpect, + private readonly expect: Expect, private readonly socket: OBSSocket, public eventIntent: EventSubscription, ) {} @@ -258,7 +261,7 @@ class OBSSocket { export default class MockOBSWebSocket { private openConnections = 0; private constructor( - private readonly expect: typeof vitestExpect, + private readonly expect: Expect, private readonly server: WebSocketServer, public readonly port: number, ) {} @@ -284,7 +287,7 @@ export default class MockOBSWebSocket { }); public static async create( - expect: typeof vitestExpect, + expect: Expect, actor?: (obs: MockOBSContext) => Promise, ) { const server = new WebSocketServer({ @@ -305,7 +308,10 @@ export default class MockOBSWebSocket { socket = new OBSSocket(rawSocket, "msgpack"); break; default: - expect.fail("unknown protocol " + rawSocket.protocol); + throw new Error( + "MockOBSWebSocket: unrecognised obs-websocket protocol " + + rawSocket.protocol, + ); } socket.send({ op: 0, @@ -330,15 +336,15 @@ export default class MockOBSWebSocket { actorPromise = actor(ctx); } else { if (mows.openConnections > 0) { - expect.unreachable( - "only one connection supported without an actor function", + throw new Error( + "MockOBSWebSocket: test error: only one connection is supported without an actor function", ); } mows._ctx = ctx; } mows._ready(); socket.onMessage(async (payload: any) => { - expect(payload).toBeTypeOf("object"); + expect(typeof payload).toBe("object"); switch (payload.op) { case 3: // reidentify ctx.eventIntent = @@ -353,7 +359,7 @@ export default class MockOBSWebSocket { ); break; case 8: // request batch - expect.fail("request batch not handled yet"); + throw new Error("MockOBSWebSocket: request batch not implemented"); } }); socket.send({ op: 2, d: { negotiatedRpcVersion: 1 } });