Skip to content

Commit

Permalink
refactor: namespaces and singletons (#32)
Browse files Browse the repository at this point in the history
* docs: align summary text
* refactor: switch to namespace exports
* test: remove spies
* test: profiles, system, and ui namespaces
* test: actions namespace
* test: Action, and settings namespace
* fix: export type of devices to be a true-singleton
* test: devices, and logger
* fix: file name
* test: connection, and registration parameters
* test: i18n
* build: fix jest-haste-map warning for duplicate mock names
* test: mock logging
* test: mock logging
* test: index
* test: improve export statement coverage
* refactor: improve encapsulation of ui connection

---------

Co-authored-by: Richard Herman <[email protected]>
  • Loading branch information
GeekyEggo and GeekyEggo authored Mar 20, 2024
1 parent b386372 commit e4567ec
Show file tree
Hide file tree
Showing 49 changed files with 3,184 additions and 3,764 deletions.
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const config = {
coverageReporters: ["json-summary", "text"],
globalSetup: "./tests/__setup__/global.ts",
maxWorkers: 1,
modulePathIgnorePatterns: ["<rootDir>/src/.+/__mocks__/.*"],
verbose: true,
roots: ["src"],
transform: {
Expand Down
3 changes: 3 additions & 0 deletions src/__mocks__/ws.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Enables usage of "jest-websocket-mock" when importing "ws".
// https://www.npmjs.com/package/jest-websocket-mock#using-jest-websocket-mock-to-interact-with-a-non-global-websocket-object
export { WebSocket as default } from "mock-socket";
18 changes: 9 additions & 9 deletions src/api/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import type { KeyDown, KeyUp } from "./keypad";
import type { ApplicationDidLaunch, ApplicationDidTerminate, DidReceiveDeepLink, DidReceiveGlobalSettings, SystemDidWakeUp } from "./system";
import type { DidReceivePluginMessage, DidReceivePropertyInspectorMessage, PropertyInspectorDidAppear, PropertyInspectorDidDisappear } from "./ui";

export { Controller } from "@elgato/schemas/streamdeck/plugins";
export { ActionIdentifier, State } from "./action";
export { DeviceIdentifier } from "./device";
export { type Controller } from "@elgato/schemas/streamdeck/plugins";
export { type ActionIdentifier, type State } from "./action";
export { type DeviceIdentifier } from "./device";

export { Coordinates, DidReceiveSettings, TitleParametersDidChange, WillAppear, WillDisappear } from "./action";
export { DeviceDidConnect, DeviceDidDisconnect } from "./device";
export { DialDown, DialRotate, DialUp, TouchTap } from "./encoder";
export { KeyDown, KeyUp } from "./keypad";
export { ApplicationDidLaunch, ApplicationDidTerminate, DidReceiveDeepLink, DidReceiveGlobalSettings, SystemDidWakeUp } from "./system";
export { DidReceivePluginMessage, DidReceivePropertyInspectorMessage, PropertyInspectorDidAppear, PropertyInspectorDidDisappear } from "./ui";
export { type Coordinates, type DidReceiveSettings, type TitleParametersDidChange, type WillAppear, type WillDisappear } from "./action";
export { type DeviceDidConnect, type DeviceDidDisconnect } from "./device";
export { type DialDown, type DialRotate, type DialUp, type TouchTap } from "./encoder";
export { type KeyDown, type KeyUp } from "./keypad";
export { type ApplicationDidLaunch, type ApplicationDidTerminate, type DidReceiveDeepLink, type DidReceiveGlobalSettings, type SystemDidWakeUp } from "./system";
export { type DidReceivePluginMessage, type DidReceivePropertyInspectorMessage, type PropertyInspectorDidAppear, type PropertyInspectorDidDisappear } from "./ui";

/**
* Represents an event that is emitted by Stream Deck.
Expand Down
6 changes: 3 additions & 3 deletions src/api/registration/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { RegistrationInfo } from "./info";
export { RegistrationParameter } from "./native";
export { ActionInfo, ConnectElgatoStreamDeckSocketFn } from "./ui";
export { type RegistrationInfo } from "./info";
export { RegistrationParameter } from "./parameters";
export { type ActionInfo, type ConnectElgatoStreamDeckSocketFn } from "./ui";
File renamed without changes.
15 changes: 15 additions & 0 deletions src/plugin/__mocks__/connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { registrationInfo } from "../../api/registration/__mocks__";

const { connection } = jest.requireActual<typeof import("../connection")>("../connection");

jest.spyOn(connection, "connect").mockReturnValue(Promise.resolve());
jest.spyOn(connection, "registrationParameters", "get").mockReturnValue({
info: registrationInfo,
pluginUUID: "abc123",
port: "12345",
registerEvent: "register"
});

jest.spyOn(connection, "send").mockReturnValue(Promise.resolve());

export { connection };
307 changes: 307 additions & 0 deletions src/plugin/__tests__/connection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
import { type WS as WebSocketServer } from "jest-websocket-mock";
import type { RegistrationInfo } from "..";
import type { DidReceiveGlobalSettings } from "../../api";
import type { Settings } from "../../api/__mocks__/events";
import { registrationInfo } from "../../api/registration/__mocks__";
import { type connection as Connection } from "../connection";
import { LogLevel } from "../logging/log-level";
import { Logger } from "../logging/logger";

jest.mock("ws");
jest.mock("../logging");

const port = ["-port", "12345"];
const pluginUUID = ["-pluginUUID", "abc123"];
const registerEvent = ["-registerEvent", "test_event"];
const info = ["-info", `{"plugin":{"uuid":"com.elgato.test","version":"0.1.0"}}`];

const originalArgv = process.argv;

describe("connection", () => {
let connection!: typeof Connection;
let logger!: Logger;

// Re-import the connection to ensure a fresh state.
beforeEach(async () => {
({ connection } = await require("../connection"));
({ logger } = await require("../logging"));

process.argv = [...port, ...pluginUUID, ...registerEvent, ...info];
});

// Reset modules to purge the state.
afterEach(() => {
process.argv = originalArgv;
jest.resetModules();
});

describe("WebSocket", () => {
let server: WebSocketServer;

// Setup the mock server.
beforeEach(async () => {
const { WS } = await require("jest-websocket-mock");
server = new WS(`ws://127.0.0.1:${port[1]}`, { jsonProtocol: true });
});

// Clean-up the mock server.
afterEach(() => server.close());

/**
* Asserts {@link Connection.connect} sends the registration message to the underlying web socket.
*/
it("connect", async () => {
// Arrange, act.
await connection.connect();

// Assert
await expect(server).toReceiveMessage({
event: registerEvent[1],
uuid: pluginUUID[1]
});
});

it("emits connected", async () => {
// Arrange.
const listener = jest.fn();

// Act.
connection.on("connected", listener);
await connection.connect();

// Assert
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toBeCalledWith<[RegistrationInfo]>(JSON.parse(info[1]));
});

/**
* Asserts {@link Connection.sends} forwards the message to the server.
*/
it("sends", async () => {
// Arrange.
await connection.connect();

// Act.
await connection.send({
event: "setSettings",
context: "abc123",
payload: {
message: "Hello world"
}
});

// Assert.
await expect(server).toReceiveMessage({
event: registerEvent[1],
uuid: pluginUUID[1]
});

await expect(server).toReceiveMessage({
event: "setSettings",
context: "abc123",
payload: {
message: "Hello world"
}
});
});

/**
* Asserts messages sent from the server are propagated by the {@link Connection}.
*/
it("propagates messages", async () => {
// Arrange.
const listener = jest.fn();
connection.on("didReceiveGlobalSettings", listener);

await connection.connect();

// Act.
server.send({
event: "didReceiveGlobalSettings",
payload: {
settings: {
name: "Elgato"
}
}
} satisfies DidReceiveGlobalSettings<Settings>);

// Assert.
expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith<[DidReceiveGlobalSettings<Settings>]>({
event: "didReceiveGlobalSettings",
payload: {
settings: {
name: "Elgato"
}
}
});
});
});

describe("registration parameters", () => {
/**
* Asserts the {@link Connection} is capable of parsing known arguments.
*/
it("parses valid arguments", () => {
// Arrange.
process.argv = [...port, ...pluginUUID, ...registerEvent, ...info];

// Act.
const parameters = connection.registrationParameters;

// Assert.
expect(parameters.port).toBe("12345");
expect(parameters.pluginUUID).toBe("abc123");
expect(parameters.registerEvent).toBe("test_event");
expect(parameters.info).not.toBeUndefined();
expect(parameters.info.plugin.uuid).toBe("com.elgato.test");
expect(parameters.info.plugin.version).toBe("0.1.0");
});

/**
* Asserts the {@link Connection} ignores unknown arguments.
*/
it("ignores unknown arguments", () => {
// Arrange
process.argv = [...port, "-other", "Hello world", ...pluginUUID, ...registerEvent, ...info];

// Act.
const parameters = connection.registrationParameters;

// Assert.
expect(parameters.port).toBe("12345");
expect(parameters.pluginUUID).toBe("abc123");
expect(parameters.registerEvent).toBe("test_event");
expect(parameters.info).not.toBeUndefined();
expect(parameters.info.plugin.uuid).toBe("com.elgato.test");
expect(parameters.info.plugin.version).toBe("0.1.0");
});

/**
* Asserts the {@link Connection} is able to handle an odd number of key-value arguments.
*/
it("handles uneven arguments", () => {
// Arrange.
process.argv = [...port, ...pluginUUID, "-bool", ...registerEvent, ...info];

// Act.
const parameters = connection.registrationParameters;

// Assert.
expect(parameters.port).toBe("12345");
expect(parameters.pluginUUID).toBe("abc123");
expect(parameters.registerEvent).toBe("test_event");
expect(parameters.info).not.toBeUndefined();
expect(parameters.info.plugin.uuid).toBe("com.elgato.test");
expect(parameters.info.plugin.version).toBe("0.1.0");
});

/**
* Asserts the {@link Connection} logs all arguments to the {@link Logger}.
*/
it("logs arguments", () => {
// Arrange
const scopedLogger = new Logger({
level: LogLevel.TRACE,
target: { write: jest.fn() }
});

jest.spyOn(logger, "createScope").mockReturnValueOnce(scopedLogger);
const spyOnLoggerDebug = jest.spyOn(scopedLogger, "debug");

// Act.
connection.registrationParameters;

// Assert.
expect(spyOnLoggerDebug).toHaveBeenCalledTimes(4);
expect(spyOnLoggerDebug).toBeCalledWith(`port=${[port[1]]}`);
expect(spyOnLoggerDebug).toBeCalledWith(`pluginUUID=${[pluginUUID[1]]}`);
expect(spyOnLoggerDebug).toBeCalledWith(`registerEvent=${[registerEvent[1]]}`);
expect(spyOnLoggerDebug).toBeCalledWith(`info=${info[1]}`);
});

/**
* Asserts the {@link Connection} creates a scoped {@link Logger}, when parsing registration parameters.
*/
it("creates a scoped logger", () => {
// Arrange, act.
const spyOnCreateScope = jest.spyOn(logger, "createScope");
connection.registrationParameters;

// Assert.
expect(spyOnCreateScope).toBeCalledWith("RegistrationParameters");
});

/**
* Asserts the {@link Connection} throws when there is a missing argument, and all missing arguments are included in the message, when parsing registration parameters.
*/
it("includes all missing arguments", () => {
// Arrange.
process.argv = [];

// Act, assert.
expect(() => connection.registrationParameters).toThrow(
"Unable to establish a connection with Stream Deck, missing command line arguments: -port, -pluginUUID, -registerEvent, -info"
);
});

/**
* Asserts the {@link Connection} throws when "-port" is missing, when parsing registration parameters.
*/
it("requires port", () => {
// Arrange.
process.argv = [...pluginUUID, ...registerEvent, ...info];

// Act, assert.
expect(() => connection.registrationParameters).toThrow("Unable to establish a connection with Stream Deck, missing command line arguments: -port");
});

/**
* Asserts the {@link Connection} throws when "-pluginUUID" is missing, when parsing registration parameters.
*/
it("requires pluginUUID", () => {
// Arrange.
process.argv = [...port, ...registerEvent, ...info];

// Act, assert.
expect(() => connection.registrationParameters).toThrow("Unable to establish a connection with Stream Deck, missing command line arguments: -pluginUUID");
});

/**
* Asserts the {@link Connection} throws when "-registerEvent" is missing, when parsing registration parameters.
*/
it("requires registerEvent", () => {
// Arrange.
process.argv = [...port, ...pluginUUID, ...info];

// Act, assert.
expect(() => connection.registrationParameters).toThrow("Unable to establish a connection with Stream Deck, missing command line arguments: -registerEvent");
});

/**
* Asserts the {@link Connection} throws when "-info" is missing, when parsing registration parameters.
*/
it("requires info", () => {
// Arrange.
process.argv = [...port, ...pluginUUID, ...registerEvent];

// Act, assert.
expect(() => connection.registrationParameters).toThrow("Unable to establish a connection with Stream Deck, missing command line arguments: -info");
});
});

/**
* Asserts {@link Connection.version} is parsed from the registration information.
*/
it("parses version from registration info", () => {
// Arrange.
process.argv = [...port, ...pluginUUID, ...registerEvent, "-info", JSON.stringify(registrationInfo)];

// Act, assert.
expect(connection.version).not.toBeUndefined();
expect(connection.version.major).toBe(99);
expect(connection.version.minor).toBe(8);
expect(connection.version.patch).toBe(6);
expect(connection.version.build).toBe(54321);
});
});
Loading

0 comments on commit e4567ec

Please sign in to comment.