Skip to content
Open
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/curly-poets-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@elgato/streamdeck": minor
---

Add connection bridge to enable communication with the VS Code extension.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ dist/
# Shared package files
packages/plugin/README.md
packages/plugin/LICENSE

# OS
.DS_Store
4 changes: 4 additions & 0 deletions packages/plugin/src/plugin/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { describe, expect, it, vi } from "vitest";
import { BarSubType, DeviceType, Target } from "../../api/index.js";
import { SingletonAction } from "../actions/singleton-action.js";
import { connection } from "../connection.js";
import { bridge } from "../bridge/adapter.js";
import streamDeckAsDefaultExport, { streamDeck } from "../index.js";
import { logger } from "../logging/index.js";

vi.mock("../../common/i18n.js");
vi.mock("../logging/index.js");
vi.mock("../manifest.js");
vi.mock("../connection.js");
vi.mock("../bridge/adapter.js");

describe("index", () => {
/**
Expand Down Expand Up @@ -47,10 +49,12 @@ describe("index", () => {
it("connects", async () => {
// Arrange.
const spyOnConnect = vi.spyOn(connection, "connect");
const spyOnStart = vi.spyOn(bridge, "start");

// Act, assert.
await streamDeck.connect();
expect(spyOnConnect).toHaveBeenCalledTimes(1);
expect(spyOnStart).toHaveBeenCalledTimes(1);
});

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/plugin/src/plugin/bridge/__mocks__/adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { vi } from "vitest";

export const bridge = {
start: vi.fn().mockResolvedValue(undefined),
};
342 changes: 342 additions & 0 deletions packages/plugin/src/plugin/bridge/__tests__/adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,342 @@
import type { JsonRpcRequest, JsonRpcResponse } from "@elgato/utils/rpc";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { DeviceType, type DeviceDidChange, type DidReceiveSettings, type WillAppear, type WillDisappear } from "../../../api/index.js";

vi.mock("../../connection.js");
vi.mock("../../manifest.js");

describe("debug adapter", () => {
let adapter: Awaited<typeof import("../adapter.js")>["bridge"];
let connection: Awaited<typeof import("../../connection.js")>["connection"];
let sent: Array<JsonRpcRequest | JsonRpcResponse>;

beforeEach(async () => {
vi.resetModules();
sent = [];

({ connection } = await import("../../connection.js"));
({ bridge: adapter } = await import("../adapter.js"));
adapter.attachRpc(async (value) => {
sent.push(value);
});
});

afterEach(() => {
vi.clearAllMocks();
});

it("builds plugin state from manifest actions and visible instances", () => {
connection.emit("willAppear", createKeyWillAppear());

expect(adapter.getPluginState()).toEqual({
actions: [
{
instances: [
{
context: "ctx_001",
controller: "Keypad",
coordinates: {
column: 2,
row: 3,
},
device: "Device One",
isInMultiAction: false,
resources: {
thumbnail: "imgs/key.png",
},
settings: {
count: 42,
},
},
],
name: "Action One",
uuid: "com.elgato.test.key",
},
{
instances: [],
name: "Action Two",
uuid: "com.elgato.test.dial",
},
],
plugin: {
name: "Test Plugin",
uuid: "com.elgato.test",
version: "1.0.0",
},
});
});

it("publishes plugin state changes for visible instances", async () => {
connection.emit("willAppear", createKeyWillAppear());
await waitForMessages(sent, 1);

expect(sent).toEqual([
{
jsonrpc: "2.0",
method: "streamDeck.bridge.pluginStateChanged",
params: adapter.getPluginState(),
},
]);
});

it("preserves coordinates and multi-action state", () => {
connection.emit("willAppear", createDialWillAppear());

expect(adapter.getPluginState().actions[1].instances[0]?.coordinates).toEqual({
column: 1,
row: 0,
});

connection.emit("willAppear", createMultiActionWillAppear());

const instance = adapter.getPluginState().actions[0].instances[0];
expect(instance?.isInMultiAction).toBe(true);
expect(instance?.coordinates).toEqual({
column: 4,
row: 2,
});
});

it("updates settings, device details, and removals from connection events", async () => {
connection.emit("willAppear", createKeyWillAppear());
await waitForMessages(sent, 1);
clearMessages(sent);

connection.emit("didReceiveSettings", createDidReceiveSettings());
await waitForMessages(sent, 1);
expect(adapter.getPluginState().actions[0].instances[0]?.settings).toEqual({
count: 7,
});
expect(adapter.getPluginState().actions[0].instances[0]?.resources).toEqual({
thumbnail: "imgs/updated.png",
});
expect(sent).toHaveLength(1);

clearMessages(sent);
connection.emit("deviceDidChange", createDeviceDidChange());
await waitForMessages(sent, 1);
expect(adapter.getPluginState().actions[0].instances[0]?.device).toBe("Renamed Device");
expect(sent).toHaveLength(1);

clearMessages(sent);
connection.emit("willDisappear", createWillDisappear());
await waitForMessages(sent, 1);
expect(adapter.getPluginState().actions[0].instances).toEqual([]);
expect(sent).toHaveLength(1);
});

it("responds to getPluginState RPC requests", async () => {
connection.emit("willAppear", createKeyWillAppear());
await waitForMessages(sent, 1);
clearMessages(sent);

const handled = await adapter.receive({
id: "request-1",
jsonrpc: "2.0",
method: "streamDeck.bridge.getPluginState",
});

expect(handled).toBe(true);
expect(sent).toEqual([
{
id: "request-1",
jsonrpc: "2.0",
result: adapter.getPluginState(),
},
]);
});

it("does not start the socket transport outside debug mode", async () => {
vi.resetModules();
vi.doMock("../../common/utils.js", async () => {
const actual = await vi.importActual<typeof import("../../common/utils.js")>("../../common/utils.js");

return {
...actual,
isDebugMode: vi.fn().mockReturnValue(false),
};
});
vi.doMock("../socket.js", () => ({
socket: {
start: vi.fn().mockResolvedValue(undefined),
},
}));

const { bridge } = await import("../adapter.js");
const { socket } = await import("../socket.js");

await bridge.start();

expect(socket.start).not.toHaveBeenCalled();
});
});

/**
* Clears recorded JSON-RPC messages between assertions.
* @param messages Recorded messages.
*/
function clearMessages(messages: Array<JsonRpcRequest | JsonRpcResponse>): void {
messages.splice(0, messages.length);
}

/**
* Waits for the adapter's queued JSON-RPC messages to be sent.
* @param messages Recorded messages.
* @param length Expected message count.
*/
async function waitForMessages(messages: Array<JsonRpcRequest | JsonRpcResponse>, length: number): Promise<void> {
await vi.waitFor(() => {
expect(messages).toHaveLength(length);
});
}

/**
* Creates a device-change event for the seeded mock device.
* @returns Device-change event.
*/
function createDeviceDidChange(): DeviceDidChange {
return {
device: "DEV1",
deviceInfo: {
name: "Renamed Device",
size: {
columns: 5,
rows: 3,
},
type: DeviceType.StreamDeckXL,
},
event: "deviceDidChange",
};
}

/**
* Creates a settings update for the visible test key.
* @returns Did-receive-settings event.
*/
function createDidReceiveSettings(): DidReceiveSettings<{ count: number }> {
return {
action: "com.elgato.test.key",
context: "ctx_001",
device: "DEV1",
event: "didReceiveSettings",
payload: {
controller: "Keypad",
coordinates: {
column: 2,
row: 3,
},
isInMultiAction: false,
resources: {
thumbnail: "imgs/updated.png",
},
settings: {
count: 7,
},
},
};
}

/**
* Creates a visible dial instance event.
* @returns Will-appear event for a dial.
*/
function createDialWillAppear(): WillAppear<{ target: string }> {
return {
action: "com.elgato.test.dial",
context: "ctx_002",
device: "DEV1",
event: "willAppear",
payload: {
controller: "Encoder",
coordinates: {
column: 1,
row: 0,
},
isInMultiAction: false,
resources: {
thumbnail: "imgs/dial.png",
},
settings: {
target: "master",
},
},
};
}

/**
* Creates a visible key instance event.
* @returns Will-appear event for a key.
*/
function createKeyWillAppear(): WillAppear<{ count: number }> {
return {
action: "com.elgato.test.key",
context: "ctx_001",
device: "DEV1",
event: "willAppear",
payload: {
controller: "Keypad",
coordinates: {
column: 2,
row: 3,
},
isInMultiAction: false,
resources: {
thumbnail: "imgs/key.png",
},
settings: {
count: 42,
},
},
};
}

/**
* Creates a visible multi-action key instance event.
* @returns Will-appear event for a multi-action key.
*/
function createMultiActionWillAppear(): WillAppear<{ count: number }> {
return {
action: "com.elgato.test.key",
context: "ctx_003",
device: "DEV1",
event: "willAppear",
payload: {
controller: "Keypad",
coordinates: {
column: 4,
row: 2,
},
isInMultiAction: true,
resources: {},
settings: {
count: 99,
},
},
};
}

/**
* Creates a disappearance event for the visible test key.
* @returns Will-disappear event for a key.
*/
function createWillDisappear(): WillDisappear<{ count: number }> {
return {
action: "com.elgato.test.key",
context: "ctx_001",
device: "DEV1",
event: "willDisappear",
payload: {
controller: "Keypad",
coordinates: {
column: 2,
row: 3,
},
isInMultiAction: false,
resources: {},
settings: {
count: 7,
},
},
};
}
Loading
Loading