Skip to content
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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
818 changes: 294 additions & 524 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@types/request": "^2.48.8",
"@types/semver": "^7.3.9",
"@types/sinon": "^10.0.6",
"@types/ws": "^8.18.1",
"@types/yargs": "^15.0.5",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
Expand Down Expand Up @@ -108,11 +109,14 @@
"eventemitter3": "^4.0.7",
"fast-glob": "^3.2.11",
"find-in-files": "^0.5.0",
"fs": "^0.0.1-security",
"fs-extra": "^10.0.0",
"glob": "^7.2.0",
"natural-orderby": "^2.0.3",
"path": "^0.12.7",
"portfinder": "^1.0.32",
"postman-request": "^2.88.1-postman.40",
"proxyquire": "^2.1.3",
"replace-in-file": "^6.3.2",
"replace-last": "^1.2.6",
"roku-deploy": "^3.16.1",
Expand All @@ -121,6 +125,7 @@
"smart-buffer": "^4.2.0",
"source-map": "^0.7.4",
"telnet-client": "^1.4.9",
"ws": "^8.18.3",
"xml2js": "^0.5.0",
"yargs": "^16.2.0"
}
Expand Down
18 changes: 18 additions & 0 deletions src/LaunchConfiguration.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { FileEntry } from 'roku-deploy';

Check failure on line 1 in src/LaunchConfiguration.ts

View workflow job for this annotation

GitHub Actions / ci (ubuntu-latest)

Cannot find module 'roku-deploy' or its corresponding type declarations.
import type { DebugProtocol } from '@vscode/debugprotocol';
import type { LogLevel } from './logging';

Expand Down Expand Up @@ -215,6 +215,24 @@
*/
injectRdbOnDeviceComponent: boolean;

/**
* Configuration for profiling functionality
*/
profiling?: {
/**
* Whether profiling is enabled
*/
enable?: boolean;
/**
* Directory where profile files should be stored
*/
dir?: string;
/**
* The name of the profile file. Can include variables like ${appTitle} and ${timestamp}
*/
filename?: string;
};

/**
* Base path to the folder containing RDB files for OnDeviceComponent
*/
Expand Down
108 changes: 108 additions & 0 deletions src/PerfettoClient.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { expect } from "chai";
import * as proxyquire from "proxyquire";
import { EventEmitter } from "events";
import * as sinonActual from "sinon";
let sinon = sinonActual.createSandbox();

describe("PerfettoClient", () => {
let fetchStub: sinon.SinonStub;
let mkdirSyncStub: sinon.SinonStub;
let createWriteStreamStub: sinon.SinonStub;
let wsConstructorStub: sinon.SinonStub;

let fakeWs: any;
let fakeStream: any;
let ECP: any;

beforeEach(() => {
// ---- fetch stub ----
fetchStub = sinon.stub();

// ---- fake write stream ----
fakeStream = new EventEmitter();
fakeStream.write = sinon.stub().returns(true);
fakeStream.end = sinon.stub();

mkdirSyncStub = sinon.stub();
createWriteStreamStub = sinon.stub().returns(fakeStream);

// ---- fake WebSocket ----
fakeWs = new EventEmitter();
fakeWs.readyState = 1; // OPEN
fakeWs.ping = sinon.stub();
fakeWs.close = sinon.stub();
fakeWs.pause = sinon.stub();
fakeWs.resume = sinon.stub();

wsConstructorStub = sinon.stub().returns(fakeWs);

// ---- load module with mocks ----
({ ECP } = proxyquire("../src/ECP", {
ws: { WebSocket: wsConstructorStub },
fs: {
mkdirSync: mkdirSyncStub,
createWriteStream: createWriteStreamStub,
},
path: {
dirname: sinon.stub().returns("/tmp"),
},
global: {
fetch: fetchStub,
},
}));
});

afterEach(() => {
sinon.restore();
});

describe("constructor", () => {
it("should initialize ip and baseUrl", () => {
const ecp = new ECP("192.168.1.10");
expect(ecp.ip).to.equal("192.168.1.10");
expect(ecp.baseUrl).to.equal("http://192.168.1.10:8060");
});
});

describe("wsSaveTrace", () => {
it("should create websocket and file stream", async () => {
const ecp = new ECP("10.0.0.1");

const shutdown = await ecp.wsSaveTrace("/trace", "/tmp/file.perfetto-trace");

expect(wsConstructorStub.calledOnce).to.be.true;
expect(mkdirSyncStub.calledOnce).to.be.true;
expect(createWriteStreamStub.calledOnce).to.be.true;
expect(shutdown).to.be.a("function");
});

it("should write binary websocket messages to file", async () => {
const ecp = new ECP("10.0.0.1");
await ecp.wsSaveTrace("/trace", "/tmp/file.perfetto-trace");

const buffer = Buffer.from([1, 2, 3]);
fakeWs.emit("message", buffer, true);

expect(fakeStream.write.calledWith(buffer)).to.be.true;
});

it("should ignore non-binary messages", async () => {
const ecp = new ECP("10.0.0.1");
await ecp.wsSaveTrace("/trace", "/tmp/file.perfetto-trace");

fakeWs.emit("message", "text", false);
expect(fakeStream.write.called).to.be.false;
});

it("shutdown should close websocket and file", async () => {
const exitStub = sinon.stub(process, "exit");
const ecp = new ECP("10.0.0.1");

const shutdown = await ecp.wsSaveTrace("/trace", "/tmp/file.perfetto-trace");
shutdown!();

expect(fakeStream.end.called).to.be.true;
exitStub.restore();
});
});
});
102 changes: 102 additions & 0 deletions src/PerfettoClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { WebSocket } from "ws";
import * as fs from "fs";
import * as pathModule from "path";

export class PerfettoClient {
/**
* PerfettoClient class for Roku devices
* This class provides methods to interact with Roku devices using the ECP API.
*/

private ip: string;

constructor(ip: string) {
this.ip = ip;
}

async wsSaveTrace(path: string, filename: string): Promise<(() => void) | null> {
const url = `ws://${this.ip}:8060${path}`;
const ws = new WebSocket(url);

// Ensure directory exists
fs.mkdirSync(pathModule.dirname(filename), { recursive: true });

// Create write stream in append mode
const out = fs.createWriteStream(filename, { flags: "a" });

out.on("error", (err) => {
console.error("File write error:", err);
process.exit(1);
});

// Ping configuration
const PING_INTERVAL_MS = 30000;
let pingTimer: NodeJS.Timeout | null = null;

ws.on("open", () => {
console.log("WebSocket connected:", url);

pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
try {
ws.ping();
} catch (error) {
// Silent catch for ping errors
}
}
}, PING_INTERVAL_MS);
});

ws.on("message", (data: Buffer, isBinary: boolean) => {
console.log("Message receiving, binary:", isBinary);

// Only process binary data
if (!isBinary) return;

// Handle backpressure when writing to file
if (!out.write(data)) {
ws.pause?.();
out.once("drain", () => {
ws.resume?.();
});
}
});

ws.on("error", (err: Error) => {
console.error("WebSocket error:", err);
});

ws.on("close", (code: number, reason: string) => {
if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}

console.log(`WebSocket closed. Code: ${code}, Reason: ${reason}`);
out.end();
});

// Graceful shutdown handler
const shutdown = (): string => {
console.log("Shutting down...");

if (pingTimer) {
clearInterval(pingTimer);
pingTimer = null;
}

try {
ws.close();
} catch (error) {
// Silent catch for close errors
}

out.write(Buffer.from([0x00]));
out.end(() => process.exit(0));

return filename;
};

return shutdown;
}
}
114 changes: 114 additions & 0 deletions src/PerfettoController.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { expect } from "chai";
import { PerfettoControls } from "./PerfettoController";
import * as sinonActual from "sinon";
let sinon = sinonActual.createSandbox();

describe("PerfettoControls", () => {
let fetchStub: sinon.SinonStub;
let perfetto: PerfettoControls;

beforeEach(() => {
fetchStub = sinon.stub(global as any, "fetch");
perfetto = new PerfettoControls("192.168.1.5");
});

afterEach(() => {
sinon.restore();
});

describe("constructor", () => {
it("should initialize host and default standardResponse", () => {
expect(perfetto.host).to.equal("192.168.1.5");
expect(perfetto.standardResponse).to.deep.equal({
message: "",
error: false,
});
});
});

describe("startTracing", () => {
it("should call enable and start endpoints and return success response", async () => {
fetchStub.resolves({ ok: true } as any);

const response = await perfetto.startTracing();

expect(fetchStub.calledTwice).to.be.true;

expect(fetchStub.firstCall.args[0]).to.equal(
"http://192.168.1.5:8060/perfetto/enable/dev"
);
expect(fetchStub.secondCall.args[0]).to.equal(
"http://192.168.1.5:8060/perfetto/start/dev"
);

expect(response).to.deep.equal({
message: "Traceing started successfully",
error: false,
});
});

it("should set error response when enable/start fails", async () => {
fetchStub.rejects(new Error("Network error"));

const response = await perfetto.startTracing();

expect(fetchStub.calledOnce).to.be.true;
expect(response.error).to.be.true;
expect(response.message).to.contain("Error fetching channels");
});
});

describe("stopTracing", () => {
it("should stop tracing and return success response when request succeeds", async () => {
fetchStub.resolves({ ok: true } as any);

const response = await perfetto.stopTracing();

expect(fetchStub.calledOnce).to.be.true;
expect(fetchStub.firstCall.args[0]).to.equal(
"http://192.168.1.5:8060/perfetto/stop/dev"
);

expect(response).to.deep.equal({
message: "Traceing stopped successfully",
error: false,
});
});

it("should return error response when stop request fails", async () => {
fetchStub.resolves(null as any);

const response = await perfetto.stopTracing();

expect(response).to.deep.equal({
message: "Error stopping tracing",
error: true,
});
});
});

describe("ecpGetPost (indirect)", () => {
it("should send POST request with correct headers and body", async () => {
fetchStub.resolves({ ok: true } as any);

await perfetto["ecpGetPost"]("/test", "post", "<xml />");

const [, options] = fetchStub.firstCall.args;

expect(options.method).to.equal("POST");
expect(options.body).to.equal("<xml />");
expect(options.headers["Content-Type"]).to.equal("text/xml");
});

it("should send GET request when method is get", async () => {
fetchStub.resolves({ ok: true } as any);

await perfetto["ecpGetPost"]("/test", "get", "");

const [, options] = fetchStub.firstCall.args;

expect(options.method).to.equal("GET");
expect(options.body).to.be.undefined;
});
});
});
Loading
Loading