Skip to content
Draft
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
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@
"postman-request": "^2.88.1-postman.40",
"replace-in-file": "^6.3.2",
"replace-last": "^1.2.6",
"roku-deploy": "^3.17.2",
"roku-deploy": "^3.17.3",
"semver": "^7.5.4",
"serialize-error": "^8.1.0",
"smart-buffer": "^4.2.0",
Expand Down
59 changes: 59 additions & 0 deletions src/RokuECP.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,65 @@ describe('RokuECP', () => {
});
});

describe('launchApp', () => {
function stubDoRequest() {
return sinon.stub(rokuECP as any, 'doRequest').resolves({ body: '', statusCode: 200 });
}

it('posts to /launch/<channelId> with no query string when no params are supplied', async () => {
const stub = stubDoRequest();
await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: 'dev' });
expect(stub.getCall(0).args[0]).to.equal('launch/dev');
expect(stub.getCall(0).args[2]).to.equal('post');
});

it('honors a non-dev channelId', async () => {
const stub = stubDoRequest();
await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: '12345' });
expect(stub.getCall(0).args[0]).to.equal('launch/12345');
});

it('treats a raw key=value string as the query string', async () => {
const stub = stubDoRequest();
await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: 'dev', params: 'contentId=abc&mediaType=movie' });
expect(stub.getCall(0).args[0]).to.equal('launch/dev?contentId=abc&mediaType=movie');
});

it('passes a leading-? query string through unchanged', async () => {
const stub = stubDoRequest();
await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: 'dev', params: '?contentId=abc' });
expect(stub.getCall(0).args[0]).to.equal('launch/dev?contentId=abc');
});

it('extracts the query string from a full URL', async () => {
const stub = stubDoRequest();
await rokuECP.launchApp({
host: '1.1.1.1',
remotePort: 8080,
channelId: 'dev',
params: 'http://9.9.9.9:8060/launch/dev?contentId=abc&mediaType=movie'
});
expect(stub.getCall(0).args[0]).to.equal('launch/dev?contentId=abc&mediaType=movie');
});

it('returns no query string when a full URL has no query', async () => {
const stub = stubDoRequest();
await rokuECP.launchApp({
host: '1.1.1.1',
remotePort: 8080,
channelId: 'dev',
params: 'http://9.9.9.9:8060/launch/dev'
});
expect(stub.getCall(0).args[0]).to.equal('launch/dev');
});

it('treats empty params the same as omitted', async () => {
const stub = stubDoRequest();
await rokuECP.launchApp({ host: '1.1.1.1', remotePort: 8080, channelId: 'dev', params: '' });
expect(stub.getCall(0).args[0]).to.equal('launch/dev');
});
});

describe('captureHeapSnapshot', () => {
it('calls doRequest with correct route and options', async () => {
let options = {
Expand Down
27 changes: 27 additions & 0 deletions src/RokuECP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,33 @@ export class RokuECP {
return this.processExitApp(result);
}

/**
* Launches a channel via ECP. Pass `dev` to launch the currently sideloaded developer channel.
* Any `params` are appended as a query string, which Roku will surface to the channel as launch arguments (deep link).
* Accepts a full URL, a leading-`?` query string, or a raw `key=value&...` query string.
*/
public async launchApp(options: BaseOptions & { channelId: string; params?: string }): Promise<Response> {
const suffix = this.buildLaunchQueryString(options.params);
return this.doRequest(`launch/${options.channelId}${suffix}`, options, 'post');
}

private buildLaunchQueryString(params: string | undefined): string {
if (!params) {
return '';
}
if (params.startsWith('?')) {
return params;
}
if (/^https?:\/\//i.test(params)) {
try {
return new URL(params).search;
} catch {
//fall through to raw handling
}
}
return `?${params}`;
}

private async processExitApp(response: Response): Promise<EcpExitAppData> {
return this.parseResponse(response, 'exit-app', (parsed: ExitAppAsJson, status): EcpExitAppData => {
return { status: status };
Expand Down
40 changes: 40 additions & 0 deletions src/debugSession/BrightScriptDebugSession.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { EventEmitter } from 'eventemitter3';
import type { EvaluateContainer } from '../adapters/DebugProtocolAdapter';
import { VariableType } from '../debugProtocol/events/responses/VariablesResponse';
import { PerfettoManager } from '../PerfettoManager';
import { rokuECP } from '../RokuECP';

//DebugSession.shutdown() calls process.exit() after a sleep, so we need to prevent that during tests. This should not be a mock, it needs to be permanent for this flow
DebugSession.prototype.shutdown = () => { };
Expand Down Expand Up @@ -2627,6 +2628,7 @@ describe('BrightScriptDebugSession', () => {
const shutdownStub = sinon.stub(session, 'shutdown').resolves() as unknown as SinonStub;
rokuAdapter.connected = false;
sinon.stub(session.rokuDeploy, 'publish').resolves();
sinon.stub(rokuECP, 'launchApp').resolves({} as any);

const publishPromise = (session as any).publish();

Expand All @@ -2639,6 +2641,44 @@ describe('BrightScriptDebugSession', () => {
expect(shutdownStub.calledOnceWithExactly('Debug session cancelled: failed to connect to debug protocol control port.')).to.be.true;
clock.restore();
});

it('sideloads with autoLaunch disabled and starts the channel via ECP launch after publish', async () => {
rokuAdapter.connected = true;
const publishStub = sinon.stub(session.rokuDeploy, 'publish').resolves();
const launchAppStub = sinon.stub(rokuECP, 'launchApp').resolves({} as any);
sinon.stub(rokuAdapter as any, 'once').resolves();

await (session as any).publish();

expect(publishStub.calledOnce).to.be.true;
expect(publishStub.firstCall.args[0]).to.include({ autoLaunch: false });
expect(launchAppStub.calledOnce).to.be.true;
expect(launchAppStub.firstCall.args[0]).to.include({ channelId: 'dev' });
});

it('forwards deepLinkUrl as the params for the ECP launch', async () => {
rokuAdapter.connected = true;
launchConfiguration.deepLinkUrl = 'http://1.2.3.4:8060/launch/dev?contentId=movie-42&mediaType=movie';
sinon.stub(session.rokuDeploy, 'publish').resolves();
const launchAppStub = sinon.stub(rokuECP, 'launchApp').resolves({} as any);
sinon.stub(rokuAdapter as any, 'once').resolves();

await (session as any).publish();

expect(launchAppStub.calledOnce).to.be.true;
expect(launchAppStub.firstCall.args[0].params).to.equal(launchConfiguration.deepLinkUrl);
});

it('skips the ECP launch when publish soft-fails', async () => {
rokuAdapter.connected = true;
sinon.stub(session.rokuDeploy, 'publish').rejects(new Error('non-fatal publish error'));
const launchAppStub = sinon.stub(rokuECP, 'launchApp').resolves({} as any);
sinon.stub(rokuAdapter as any, 'once').resolves();

await (session as any).publish();

expect(launchAppStub.called).to.be.false;
});
});

describe('threadsRequest', () => {
Expand Down
63 changes: 48 additions & 15 deletions src/debugSession/BrightScriptDebugSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -773,20 +773,6 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
throw error;
}

//at this point, the project has been deployed. If we need to use a deep link, launch it now.
if (this.launchConfiguration.deepLinkUrl && !this.enableDebugProtocol) {
//wait until the first entry breakpoint has been hit
await this.firstRunDeferred.promise;
//if we are at a breakpoint, continue
await this.rokuAdapter.continue();
//kill the app on the roku
// await this.rokuDeploy.pressHomeButton(this.launchConfiguration.host, this.launchConfiguration.remotePort);
//convert a hostname to an ip address
const deepLinkUrl = await util.resolveUrl(this.launchConfiguration.deepLinkUrl);
//send the deep link http request
await util.httpPost(deepLinkUrl);
}

} catch (e) {
//if the message is anything other than compile errors, we want to display the error
if (!(e instanceof CompileError)) {
Expand Down Expand Up @@ -990,6 +976,8 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
remoteDebugConnectEarly: false,
//we don't want to fail if there were compile errors...we'll let our compile error processor handle that
failOnCompileError: true,
//prevent the device from auto-launching the channel — we'll start it ourselves via ECP below
autoLaunch: false,
//pass any upload form overrides the client may have configured
packageUploadOverrides: this.launchConfiguration.packageUploadOverrides
};
Expand All @@ -1016,6 +1004,24 @@ export class BrightScriptDebugSession extends LoggingDebugSession {

uploadingEnd();

//the channel was sideloaded with autoLaunch disabled — start it now via ECP, folding in deep link params when provided
if (packageIsPublished) {
//wait until the device reports the installed-but-not-running state before firing the launch.
//firing immediately after publish can race the device's install-settle step, which can drop
//the remotedebug=1 flag on the floor and break the debug protocol attach.
await this.waitForDevAppInstalled();
try {
await rokuECP.launchApp({
host: this.launchConfiguration.host,
remotePort: this.launchConfiguration.remotePort,
channelId: 'dev',
params: this.launchConfiguration.deepLinkUrl
});
} catch (e) {
this.logger.error('Failed to launch sideloaded channel via ECP', e);
}
}

//the channel has been deployed. Wait for the adapter to finish connecting.
//if it hasn't connected after 60 seconds, abort the launch.
let didTimeOut = false;
Expand All @@ -1035,6 +1041,33 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
}
}

/**
* After a `dev_autolaunch=0` sideload, poll `query/app-state` until the device reports the
* channel as `inactive` or `background` (installed but not running). This makes sure the device
* has finished settling the install before we fire the ECP launch.
*/
private async waitForDevAppInstalled(timeoutMs = 10_000, pollIntervalMs = 200) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const result = await rokuECP.getAppState({
host: this.launchConfiguration.host,
remotePort: this.launchConfiguration.remotePort,
appId: 'dev',
requestOptions: { timeout: 500 }
});
if (result.state === AppState.inactive || result.state === AppState.background) {
return;
}
} catch (e) {
//the device may briefly refuse the query mid-install — keep polling
this.logger.warn('app-state poll failed; retrying', e);
}
await util.sleep(pollIntervalMs);
}
this.logger.warn(`waitForDevAppInstalled: timed out after ${timeoutMs}ms; firing launch anyway`);
}

private pendingSendLogPromise = Promise.resolve();

/**
Expand Down Expand Up @@ -2905,7 +2938,7 @@ export class BrightScriptDebugSession extends LoggingDebugSession {
public async handleEntryBreakpoint() {
if (!this.enableDebugProtocol) {
this.entryBreakpointWasHandled = true;
if (this.launchConfiguration.stopOnEntry || this.launchConfiguration.deepLinkUrl) {
if (this.launchConfiguration.stopOnEntry) {
await this.projectManager.registerEntryBreakpoint(this.projectManager.mainProject.stagingDir);
}
}
Expand Down
Loading