diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs index fe115cc..2852e10 100644 --- a/scripts/postinstall.mjs +++ b/scripts/postinstall.mjs @@ -18,6 +18,8 @@ */ import { execSync } from "child_process"; import os from "os"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; function hasAdb() { try { @@ -28,36 +30,47 @@ function hasAdb() { } } -if (hasAdb()) { - console.log("ADB is installed and available in your PATH."); - process.exit(0); -} +/** + * Print whether adb is available, with install instructions when it isn't. + * @returns {void} + */ +export function main() { + if (hasAdb()) { + console.log("ADB is installed and available in your PATH."); + return; + } -console.log("\nADB is not installed or not in your PATH."); -console.log("Follow these instructions to install ADB:"); + console.log("\nADB is not installed or not in your PATH."); + console.log("Follow these instructions to install ADB:"); + + const platform = os.platform(); + if (platform === "win32") { + console.log("\nWindows:"); + console.log("1. Download the SDK Platform Tools for Windows: https://developer.android.com/studio/releases/platform-tools"); + console.log("2. Extract the ZIP file."); + console.log("3. Add the extracted folder to your system PATH."); + console.log('4. Test by running "adb version" in Command Prompt.'); + } else if (platform === "darwin") { + console.log("\nmacOS:"); + console.log("1. Download the SDK Platform Tools for Mac: https://developer.android.com/studio/releases/platform-tools"); + console.log("2. Extract the ZIP file."); + console.log("3. Move the extracted folder to /usr/local/bin or add it to your PATH."); + console.log('4. Test by running "adb version" in Terminal.'); + } else if (platform === "linux") { + console.log("\nLinux:"); + console.log("1. Download the SDK Platform Tools for Linux: https://developer.android.com/studio/releases/platform-tools"); + console.log("2. Extract the ZIP file."); + console.log("3. Move the extracted folder to /usr/local/bin or add it to your PATH."); + console.log("4. Or install via your package manager:"); + console.log(" - Ubuntu/Debian: sudo apt-get install android-tools-adb"); + console.log(" - Fedora/Red Hat: sudo dnf install android-tools"); + console.log(" - CentOS/yum: sudo yum install android-tools"); + console.log('5. Test by running "adb version" in your shell.'); + } + console.log("\nYou must accept the ADB authorization prompt on your Android TV device the first time you connect."); +} -const platform = os.platform(); -if (platform === "win32") { - console.log("\nWindows:"); - console.log("1. Download the SDK Platform Tools for Windows: https://developer.android.com/studio/releases/platform-tools"); - console.log("2. Extract the ZIP file."); - console.log("3. Add the extracted folder to your system PATH."); - console.log('4. Test by running "adb version" in Command Prompt.'); -} else if (platform === "darwin") { - console.log("\nmacOS:"); - console.log("1. Download the SDK Platform Tools for Mac: https://developer.android.com/studio/releases/platform-tools"); - console.log("2. Extract the ZIP file."); - console.log("3. Move the extracted folder to /usr/local/bin or add it to your PATH."); - console.log('4. Test by running "adb version" in Terminal.'); -} else if (platform === "linux") { - console.log("\nLinux:"); - console.log("1. Download the SDK Platform Tools for Linux: https://developer.android.com/studio/releases/platform-tools"); - console.log("2. Extract the ZIP file."); - console.log("3. Move the extracted folder to /usr/local/bin or add it to your PATH."); - console.log("4. Or install via your package manager:"); - console.log(" - Ubuntu/Debian: sudo apt-get install android-tools-adb"); - console.log(" - Fedora/Red Hat: sudo dnf install android-tools"); - console.log(" - CentOS/yum: sudo yum install android-tools"); - console.log('5. Test by running "adb version" in your shell.'); +// Run only when executed directly (npm postinstall), not when imported. +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(); } -console.log("\nYou must accept the ADB authorization prompt on your Android TV device the first time you connect."); diff --git a/scripts/setup-device.mjs b/scripts/setup-device.mjs index b601df0..301c1bc 100644 --- a/scripts/setup-device.mjs +++ b/scripts/setup-device.mjs @@ -13,12 +13,24 @@ * */ -import readline from "readline"; -import AndroidTVSetup from "../src/lib/adb/setup.mjs"; +import readline from "node:readline"; +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import createRemote from "../src/lib/android-tv-remote.mjs"; /** * Interactive CLI for setting up a new Android TV device. - * Prompts for IP and port, then runs setup. + * Prompts for IP and port, then applies the keep-awake settings, wakes the + * device and returns it to the home screen, using the v2 remote API. + * + * Usage: npm run setup-device + */ + +/** + * Ask a question on the terminal. + * @param {string} question - Prompt text. + * @param {string} [defaultValue] - Value used when the answer is empty. + * @returns {Promise} The answer, or the default. */ async function prompt(question, defaultValue) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); @@ -30,20 +42,48 @@ async function prompt(question, defaultValue) { }); } -async function main() { +/** + * Connect to a device and prepare it for remote control: apply the keep-awake + * settings, make sure it is awake, return to the home screen, then disconnect. + * @param {object} options - Setup options. + * @param {string} options.ip - Device IP address. + * @param {number} [options.port=5555] - ADB port. + * @param {boolean} [options.quiet=false] - Suppress progress logs. + * @param {Function} [options.create=createRemote] - Remote factory (injectable for tests). + * @param {Function} [options.log=console.log] - Log sink for progress and error events. + * @returns {Promise} Resolves once setup is complete; rejects on failure. + */ +export async function setupDevice({ ip, port = 5555, quiet = false, create = createRemote, log = console.log }) { + const remote = await create({ ip, port, quiet, autoConnect: false, maintainConnection: false }); + remote.on("log", (data) => log(`[${data.level}] ${data.message}`)); + remote.on("error", (data) => log(`[error] ${data.message}`)); + + const result = await remote.connect(); + if (!remote.isConnected) { + throw result instanceof Error ? result : new Error(`Could not connect to ${ip}:${port}`); + } + try { + await remote.setSettings(); + await remote.ensureAwake(); + await remote.press.home(); + } finally { + await remote.disconnect(); + } +} + +/** + * CLI entry: prompt for the device address and run the setup. + * @returns {Promise} + */ +export async function main() { const ip = await prompt("Enter device IP address"); if (!ip) { console.error("IP address is required."); process.exit(1); } const port = parseInt(await prompt("Enter device port", "5555"), 10) || 5555; - const quiet = false; - const setup = new AndroidTVSetup({ ip, port, quiet }); try { - await setup.connect(); - await setup.setSettings(); - await setup.ensureAwake(); - await setup.disconnect(); + await setupDevice({ ip, port }); console.log("\nSetup complete!"); } catch (err) { console.error("Error:", err.message || err); @@ -51,4 +91,7 @@ async function main() { } } -main(); +// Run only when executed directly (`npm run setup-device`), not when imported. +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); +} diff --git a/scripts/wake-tv.mjs b/scripts/wake-tv.mjs index 2449836..43a118a 100644 --- a/scripts/wake-tv.mjs +++ b/scripts/wake-tv.mjs @@ -18,36 +18,50 @@ * Usage: node wake-tv.mjs */ +import { realpathSync } from "node:fs"; +import { fileURLToPath } from "node:url"; import createRemote from "../src/lib/android-tv-remote.mjs"; -// Get IP address from command line arguments -const ip = process.argv[2]; +/** + * CLI entry: wake the device whose IP is given on the command line. + * @param {string[]} [argv=process.argv.slice(2)] - CLI arguments. + * @returns {Promise} + */ +export async function main(argv = process.argv.slice(2)) { + // Get IP address from command line arguments + const ip = argv[0]; -if (!ip) { - console.error("❌ Usage: node wake-tv.mjs "); - console.error(" Example: node wake-tv.mjs 10.6.0.133"); - process.exit(1); -} + if (!ip) { + console.error("❌ Usage: node wake-tv.mjs "); + console.error(" Example: node wake-tv.mjs 10.6.0.133"); + process.exit(1); + } -console.log(`🔌 Waking up TV at ${ip}...`); + console.log(`🔌 Waking up TV at ${ip}...`); -try { - const remote = await createRemote({ - ip: ip, - autoConnect: true, - quiet: false - }); + try { + const remote = await createRemote({ + ip: ip, + autoConnect: true, + quiet: false + }); - console.log("✅ Connected to device"); + console.log("✅ Connected to device"); - // Wake up the device - await remote.ensureAwake(); - console.log("🎉 TV should now be awake and ready!"); + // Wake up the device + await remote.ensureAwake(); + console.log("🎉 TV should now be awake and ready!"); + + // Disconnect + await remote.disconnect(); + console.log("✅ Disconnected from device"); + } catch (error) { + console.error("❌ Failed to wake up TV:", error.message); + process.exit(1); + } +} - // Disconnect - await remote.disconnect(); - console.log("✅ Disconnected from device"); -} catch (error) { - console.error("❌ Failed to wake up TV:", error.message); - process.exit(1); +// Run only when executed directly, not when imported. +if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) { + await main(); } diff --git a/tests/scripts.test.vitest.mjs b/tests/scripts.test.vitest.mjs new file mode 100644 index 0000000..2dbe317 --- /dev/null +++ b/tests/scripts.test.vitest.mjs @@ -0,0 +1,124 @@ +/** + * + * @Project: @cldmv/node-android-tv-remote + * @Filename: /tests/scripts.test.vitest.mjs + * @Date: 2026-10-03T17:30:24-07:00 (1791073824) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-10-03T17:34:47-07:00 (1791074087) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + * + */ + +/** + * Smoke test for everything under scripts/: every script must import cleanly, + * so a dangling import (like scripts/setup-device.mjs pointing at the removed + * src/lib/adb/setup.mjs, #42) fails CI instead of shipping. Scripts only run + * their CLI when executed directly, so importing them has no side effects. + * + * Also covers the setup-device flow against a fake remote — no ADB or network. + */ + +import { describe, test, expect, vi } from "vitest"; +import { readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const scriptsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "scripts"); +const scriptFiles = readdirSync(scriptsDir).filter((f) => /\.(mjs|js|cjs)$/.test(f)); + +describe("scripts/ smoke test", () => { + test("finds the scripts", () => { + expect(scriptFiles.length).toBeGreaterThan(0); + }); + + test.each(scriptFiles)("%s imports without errors and exports main()", async (file) => { + const mod = await import(pathToFileURL(path.join(scriptsDir, file)).href); + expect(typeof mod.main).toBe("function"); + }); +}); + +/** + * Build a fake remote that records the calls made on it. + * @param {object} [opts] + * @param {boolean} [opts.connects=true] - Whether connect() succeeds. + * @param {Error} [opts.settingsError] - Error thrown by setSettings(). + */ +function fakeRemote({ connects = true, settingsError } = {}) { + const calls = []; + const listeners = {}; + let connected = false; + const remote = { + on(event, fn) { + listeners[event] = fn; + return remote; + }, + listeners, + calls, + get isConnected() { + return connected; + }, + async connect() { + calls.push("connect"); + if (!connects) return new Error("connection refused"); + connected = true; + }, + async setSettings() { + calls.push("setSettings"); + if (settingsError) throw settingsError; + }, + async ensureAwake() { + calls.push("ensureAwake"); + return true; + }, + press: { + async home() { + calls.push("home"); + } + }, + async disconnect() { + calls.push("disconnect"); + connected = false; + } + }; + return remote; +} + +describe("setup-device", () => { + test("connects, applies settings, wakes, goes home and disconnects", async () => { + const { setupDevice } = await import("../scripts/setup-device.mjs"); + const remote = fakeRemote(); + const create = vi.fn(async () => remote); + const log = vi.fn(); + + await setupDevice({ ip: "10.0.0.5", port: 5556, create, log }); + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ ip: "10.0.0.5", port: 5556, autoConnect: false })); + expect(remote.calls).toEqual(["connect", "setSettings", "ensureAwake", "home", "disconnect"]); + + // log and error events are forwarded to the log sink + remote.listeners.log({ level: "info", message: "hello" }); + remote.listeners.error({ message: "boom" }); + expect(log).toHaveBeenCalledWith("[info] hello"); + expect(log).toHaveBeenCalledWith("[error] boom"); + }); + + test("rejects with the connection error when the device can't be reached", async () => { + const { setupDevice } = await import("../scripts/setup-device.mjs"); + const remote = fakeRemote({ connects: false }); + + await expect(setupDevice({ ip: "10.0.0.5", create: async () => remote, log: () => {} })).rejects.toThrow("connection refused"); + expect(remote.calls).toEqual(["connect"]); + }); + + test("still disconnects when a setup step fails", async () => { + const { setupDevice } = await import("../scripts/setup-device.mjs"); + const remote = fakeRemote({ settingsError: new Error("settings failed") }); + + await expect(setupDevice({ ip: "10.0.0.5", create: async () => remote, log: () => {} })).rejects.toThrow("settings failed"); + expect(remote.calls).toEqual(["connect", "setSettings", "disconnect"]); + }); +});