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
71 changes: 71 additions & 0 deletions src/__tests__/client-unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,74 @@ describe('app-level typed errors', () => {
expect(err.message).toBe('test');
});
});

describe('takeScreenshot', () => {
const bigImage = Buffer.alloc(2000, 7);

// Roku's dev-mode screenshot format is device-dependent: HD (720p) TVs write
// a JPEG at /pkgs/dev.jpg, newer/4K models write a PNG at /pkgs/dev.png.
// `reportedPath` is what the /plugin_inspect POST response advertises;
// `servedExt` is the extension the device actually serves a 200 for.
function mockScreenshotFetch(reportedPath: string, servedExt: 'jpg' | 'png') {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL) => {
const u = url.toString();
if (u.includes('/plugin_inspect')) {
const body = reportedPath
? `<html><img src="${reportedPath}?time=123" /></html>`
: '<html>Screenshot ok</html>';
return new Response(body, { status: 200 });
}
if (u.includes(`dev.${servedExt}`)) {
return new Response(bigImage, { status: 200 });
}
// Any other extension does not exist on this device model.
return new Response('not found', { status: 404 });
}),
);
}

it('fetches the JPEG path an HD TV reports, never requesting the missing .png', async () => {
mockScreenshotFetch('pkgs/dev.jpg', 'jpg');
const client = new EcpClient('192.168.0.1');
const img = await client.takeScreenshot();
expect(img.length).toBe(2000);

const urls = (fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.map((c) =>
String(c[0]),
);
expect(urls.some((u) => u.includes('dev.jpg'))).toBe(true);
expect(urls.some((u) => u.includes('dev.png'))).toBe(false);
});

it('falls back to .jpg when the reported .png path 404s', async () => {
// Response advertises .png but the device only serves the .jpg.
mockScreenshotFetch('pkgs/dev.png', 'jpg');
const client = new EcpClient('192.168.0.1');
const img = await client.takeScreenshot();
expect(img.length).toBe(2000);
});

it('tries both extensions when the response advertises no path', async () => {
mockScreenshotFetch('', 'png');
const client = new EcpClient('192.168.0.1');
const img = await client.takeScreenshot();
expect(img.length).toBe(2000);
});

it('throws EcpScreenshotError when no candidate yields an image', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string | URL) => {
const u = url.toString();
if (u.includes('/plugin_inspect')) {
return new Response('<html></html>', { status: 200 });
}
return new Response('not found', { status: 404 });
}),
);
const client = new EcpClient('192.168.0.1');
await expect(client.takeScreenshot()).rejects.toBeInstanceOf(EcpScreenshotError);
});
});
37 changes: 29 additions & 8 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,25 +544,46 @@ export class EcpClient {
async takeScreenshot(): Promise<Buffer> {
const devUrl = `http://${this.deviceIp}`;

await digestUpload(
// The capture format is device-dependent: HD (720p) Roku TVs write the
// screenshot as a JPEG at /pkgs/dev.jpg, while newer/4K models write a PNG
// at /pkgs/dev.png. Fetching a hardcoded extension 404s on half of devices,
// so we read the actual path the device reports in the POST response HTML
// (e.g. src="pkgs/dev.jpg?time=..."), and fall back to trying both.
const html = await digestUpload(
`${devUrl}/plugin_inspect`,
'rokudev',
this.devPassword,
{ mysubmit: 'Screenshot' },
{},
);

const png = await digestGet(
`${devUrl}/pkgs/dev.png?time=${Date.now()}`,
'rokudev',
this.devPassword,
);
const reported = html.match(/pkgs\/dev\.(png|jpe?g)/i)?.[0];
const candidates = reported
? [reported, ...['pkgs/dev.jpg', 'pkgs/dev.png'].filter((p) => p !== reported)]
: ['pkgs/dev.jpg', 'pkgs/dev.png'];

let image: Buffer | undefined;
for (const path of candidates) {
try {
const data = await digestGet(
`${devUrl}/${path}?time=${Date.now()}`,
'rokudev',
this.devPassword,
);
if (data.length >= 1000) {
image = data;
break;
}
} catch {
// Wrong extension for this device model — try the next candidate.
}
}

if (png.length < 1000) {
if (!image) {
throw new EcpScreenshotError('Screenshot failed — is a dev channel sideloaded?');
}

return png;
return image;
}

/* ---- SSDP Discovery ---- */
Expand Down