From 8edca76bc9fbe31c4d95d1341923fbc89f52eded Mon Sep 17 00:00:00 2001 From: rokdock-sync Date: Thu, 23 Jul 2026 18:07:49 +0000 Subject: [PATCH] Standalone tool window fix and DMG layout update --- README.md | 18 ++-- electron-builder.json | 11 +-- resources/dmg-background.png | Bin 0 -> 9441 bytes resources/dmg-background@2x.png | Bin 0 -> 55971 bytes resources/dmgBackground.svg | 48 ++++++++++ scripts/launcherTemplates.cjs | 13 ++- src/main/ipc/handlers/capture.ts | 7 ++ src/main/ipc/toolWindow.ts | 13 +++ src/main/main.ts | 14 +-- src/preload/preload.ts | 2 +- src/renderer/capturePreview.ts | 49 ++++++---- .../components/devicePanel/deviceCard.tsx | 21 ++++- src/renderer/store/appStore.ts | 13 +++ tests/e2e/dockCloseIndependence.spec.ts | 84 ++++++++++++++++++ tests/e2e/secondInstanceToolLaunch.spec.ts | 81 +++++++++++++++++ tests/main/ipc/toolWindow.test.ts | 32 ++++++- tests/main/launch/launchRequest.test.ts | 17 ++++ tests/renderer/store/findReusableTab.test.ts | 50 +++++++++++ tests/scripts/launcherTemplates.test.ts | 10 +-- 19 files changed, 438 insertions(+), 45 deletions(-) create mode 100644 resources/dmg-background.png create mode 100644 resources/dmg-background@2x.png create mode 100644 resources/dmgBackground.svg create mode 100644 tests/e2e/dockCloseIndependence.spec.ts create mode 100644 tests/e2e/secondInstanceToolLaunch.spec.ts create mode 100644 tests/renderer/store/findReusableTab.test.ts diff --git a/README.md b/README.md index d65e255..d775d6b 100644 --- a/README.md +++ b/README.md @@ -33,15 +33,15 @@

RokDock is a cross-platform desktop control center for Roku development. It brings -the whole Roku workflow into one window: discover devices on your network, open -BrightScript debug terminals, drive an on-screen remote, sideload channels, capture -and compare device screenshots, automate device tests, fire deeplinks, build -SceneGraph image assets, and read the official Roku developer docs in-app. An opt-in -AI assistant is built in. - -It is for Roku channel developers who are tired of juggling a telnet client, the ECP -`curl` commands, the deeplink tester, an image editor, and a browser tab of docs. -RokDock is one tool for all of it. +the majority of your Roku development workflow into one window: discover devices on +your network, open BrightScript debug terminals, drive an on-screen remote, sideload +channels, capture and compare device screenshots, automate device tests, fire +deeplinks, build SceneGraph image assets, and read the official Roku developer docs +in-app. An opt-in AI assistant is built in. + +It is for Roku channel developers who are tired of juggling their remote control, a +telnet client, the deeplink tester, an image editor, and a dozen docs tabs. RokDock +is one tool for all of it.

diff --git a/electron-builder.json b/electron-builder.json index 9e78e66..f8bc8eb 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -117,16 +117,17 @@ }, "dmg": { "artifactName": "RokDock-${version}-mac-${arch}.dmg", + "background": "resources/dmg-background.png", "window": { "x": 130, "y": 220, - "width": 560, - "height": 420 + "width": 540, + "height": 400 }, "contents": [ - { "x": 140, "y": 140, "type": "file" }, - { "x": 420, "y": 140, "type": "link", "path": "/Applications" }, - { "x": 280, "y": 320, "type": "dir", "path": "build/RokDock Tools", "name": "RokDock Tools" } + { "x": 135, "y": 125, "type": "file" }, + { "x": 405, "y": 195, "type": "link", "path": "/Applications" }, + { "x": 135, "y": 265, "type": "dir", "path": "build/RokDock Tools", "name": "RokDock Tools" } ] }, "afterPack": "build/afterPack.cjs", diff --git a/resources/dmg-background.png b/resources/dmg-background.png new file mode 100644 index 0000000000000000000000000000000000000000..3ad0df0ee2caaff988e6aecb5121f66c317969c2 GIT binary patch literal 9441 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/launcherTemplates.cjs b/scripts/launcherTemplates.cjs index a2a9622..2d36ef3 100644 --- a/scripts/launcherTemplates.cjs +++ b/scripts/launcherTemplates.cjs @@ -2,6 +2,13 @@ * Pure string generators for the per-tool launcher artifacts. No I/O. The build * scripts require() this. launcherTemplates.test.ts unit-tests it. Each function * takes manifest entries shaped as { key, title, badge }. + * + * The tool is passed as the attached `--tool=` form, never `--tool `. + * When RokDock is already running, a launcher starts a second instance whose argv + * Electron forwards to the primary's second-instance handler after reordering it: + * bare positional args are moved to the end and Electron's own switches are spliced + * in, so a space-separated tool value no longer sits next to `--tool`. The attached + * `--tool=` is a single token that survives that reshuffle intact. */ /** The NSIS include written to build/tool-shortcuts.nsh and referenced by nsis.include. */ @@ -9,7 +16,7 @@ function nsisInclude(launchers) { const install = launchers .map( l => - ` CreateShortcut "$SMPROGRAMS\\RokDock\\${l.title}.lnk" "$INSTDIR\\RokDock.exe" "--tool ${l.key}" "$INSTDIR\\resources\\icons\\tools\\${l.key}.ico"` + ` CreateShortcut "$SMPROGRAMS\\RokDock\\${l.title}.lnk" "$INSTDIR\\RokDock.exe" "--tool=${l.key}" "$INSTDIR\\resources\\icons\\tools\\${l.key}.ico"` ) .join('\n') const uninstall = launchers @@ -35,7 +42,7 @@ function desktopEntry(launcher, execName) { return [ '[Desktop Entry]', `Name=RokDock ${launcher.title}`, - `Exec=${execName} --tool ${launcher.key} %U`, + `Exec=${execName} --tool=${launcher.key} %U`, `Icon=rokdock-${launcher.key}`, 'Type=Application', 'Terminal=false', @@ -75,7 +82,7 @@ function macLaunchStub(launcher) { '#!/bin/sh', "APP=$(mdfind \"kMDItemCFBundleIdentifier == 'com.rokdock.app'\" | head -n 1)", '[ -z "$APP" ] && APP="/Applications/RokDock.app"', - `exec "$APP/Contents/MacOS/RokDock" --tool ${launcher.key}`, + `exec "$APP/Contents/MacOS/RokDock" --tool=${launcher.key}`, '', ].join('\n') } diff --git a/src/main/ipc/handlers/capture.ts b/src/main/ipc/handlers/capture.ts index a7f173d..f592203 100644 --- a/src/main/ipc/handlers/capture.ts +++ b/src/main/ipc/handlers/capture.ts @@ -34,6 +34,11 @@ let capturePopoutWindow: BrowserWindow | null = null interface PopoutConfig { deviceId: string + // Stable device label, sent so the popout can re-resolve the correct capture + // device in its own origin. deviceId is salted per origin, so the dock's id does + // not resolve in the popout, but the label does. Without this the popout falls back + // to the default capture device, which is wrong when several cards are present. + deviceLabel: string | null muted: boolean idleTimeoutSec: number } @@ -73,6 +78,7 @@ export function registerCaptureHandlers(context: IpcContext): void { // the renderer calls capture:get-popout-config during its boot sequence. pendingPopoutConfig = { deviceId, + deviceLabel: preferences.captureDeviceLabel ?? null, muted, idleTimeoutSec: preferences.captureIdleTimeoutSec ?? 3600 } @@ -147,6 +153,7 @@ export function registerCaptureHandlers(context: IpcContext): void { ipcMain.handle('capture:get-popout-config', async (): Promise => { const config = pendingPopoutConfig ?? { deviceId: '', + deviceLabel: null, muted: true, idleTimeoutSec: 3600 } diff --git a/src/main/ipc/toolWindow.ts b/src/main/ipc/toolWindow.ts index a5138d7..9e101ab 100644 --- a/src/main/ipc/toolWindow.ts +++ b/src/main/ipc/toolWindow.ts @@ -38,6 +38,19 @@ export function getScopedToolWindow(toolKey: string, scope: ToolWindowScope): Br return win && !win.isDestroyed() ? win : null } +/** + * Every live tool window registered in a given scope. The dock uses the + * 'standalone' set to know which windows are independent of its own session + * (CLI / file-association launches) and must survive the dock closing. + */ +export function getToolWindowsInScope(scope: ToolWindowScope): BrowserWindow[] { + const windows: BrowserWindow[] = [] + for (const [key, win] of scopedToolWindows) { + if (key.endsWith(`:${scope}`) && !win.isDestroyed()) windows.push(win) + } + return windows +} + /** Record a window for a tool+scope and drop the entry (identity-guarded) when it closes. */ export function setScopedToolWindow(toolKey: string, scope: ToolWindowScope, win: BrowserWindow): void { const key = scopeKey(toolKey, scope) diff --git a/src/main/main.ts b/src/main/main.ts index 59d9a35..480aae3 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -61,7 +61,7 @@ import { TelnetSessionService } from './services/telnetSession' import { checkDevDependencies } from './utils/devDependencies' import { parseLaunchRequest, toolForFile } from './launch/launchRequest' import { openToolForLaunch } from './launch/openTool' -import { getScopedToolWindow } from './ipc/toolWindow' +import { getToolWindowsInScope } from './ipc/toolWindow' let mainWindow: BrowserWindow | null = null /** Fallback if renderer never calls showWindow() (e.g. IPC failure). Cleared when the window is destroyed. Keep short now that show-window resolves the real BrowserWindow. */ @@ -192,11 +192,15 @@ function createWindow(): void { // Persist restored bounds so normal-window launch size remains stable. const bounds = maximized ? mainWindow.getNormalBounds() : mainWindow.getBounds() storeService?.setWindowBounds(bounds) - // Close auxiliary windows (screenshot preview, JSON editor, etc.) so they don't outlive the main window. - // The standalone JSON editor is an independent persistent window. Spare it so its session survives. - const standaloneJson = getScopedToolWindow('json', 'standalone') + // Close the dock's own dependents (inDock tool windows plus device-gated aux + // windows like the screenshot preview and capture popout, which are only ever + // dock-opened) so they don't outlive the main window. Windows launched + // independently via --tool or a file association register in the 'standalone' + // scope and must survive the dock closing (issue #17: --tool windows are + // independent of the main RokDock window). + const independentWindows = new Set(getToolWindowsInScope('standalone')) for (const w of BrowserWindow.getAllWindows()) { - if (w !== mainWindow && w !== standaloneJson && !w.isDestroyed()) w.close() + if (w !== mainWindow && !independentWindows.has(w) && !w.isDestroyed()) w.close() } } }) diff --git a/src/preload/preload.ts b/src/preload/preload.ts index a6fab33..c2297e4 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -567,7 +567,7 @@ const api = { ipcRenderer.invoke('capture:get-device-id'), getMuted: (): Promise<{ ok: boolean; muted?: boolean }> => ipcRenderer.invoke('capture:get-muted'), - getPopoutConfig: (): Promise<{ deviceId: string; muted: boolean; idleTimeoutSec: number }> => + getPopoutConfig: (): Promise<{ deviceId: string; deviceLabel: string | null; muted: boolean; idleTimeoutSec: number }> => ipcRenderer.invoke('capture:get-popout-config'), setMode: (mode: string): Promise => ipcRenderer.invoke('capture:set-mode', mode), diff --git a/src/renderer/capturePreview.ts b/src/renderer/capturePreview.ts index 85b07c4..0f047c5 100644 --- a/src/renderer/capturePreview.ts +++ b/src/renderer/capturePreview.ts @@ -4,7 +4,7 @@ * Streams a capture device into a frameless floating window. Handles: * - Pull-model boot config via capture:get-popout-config IPC * - getUserMedia stream setup with audio auto-matching - * - The getSettings().deviceId pattern so popout-origin device IDs resolve correctly + * - Re-resolving the capture device by its stable label, since deviceIds are salted per origin * - Mute / volume sync (local slider + IPC events from the main window) * - Idle-timeout auto-pause and resume on user activity * - Aspect-ratio reporting so the main process can lock the window shape @@ -27,7 +27,7 @@ import { faXmark, } from '@fortawesome/free-solid-svg-icons' import { faSvg } from '@shared/icons' -import { findMatchingAudioDevice } from '@shared/captureDeviceMatch' +import { findMatchingAudioDevice, resolveCaptureDeviceId } from '@shared/captureDeviceMatch' import { videoFrameToPngDataUrl } from './utils/videoFrame' // Apply theme and await fonts before the body is revealed. @@ -83,6 +83,7 @@ closeBtn.innerHTML = svgClose // -- State --------------------------------------------------------------------- let requestedDeviceId = '' +let requestedDeviceLabel: string | null = null let muted = true let idleTimeoutSec = 3600 let volume = 80 @@ -340,26 +341,38 @@ function tryCapture(attempts: MediaStreamConstraints[], index = 0): void { }) } -function startCapture(): void { - const videoConstraints: MediaTrackConstraints = { - deviceId: { ideal: requestedDeviceId }, +function videoConstraintsFor(deviceId: string | null): MediaTrackConstraints { + return { + // exact when we have re-resolved the device in this origin, ideal only as a + // last-resort hint (the dock's id does not resolve here, so ideal degrades + // to the default device, which is the wrong card when several are present). + deviceId: deviceId ? { exact: deviceId } : { ideal: requestedDeviceId }, width: { ideal: 1920 }, height: { ideal: 1080 } } +} - // Acquire video-only first to resolve the actual device ID in this page's origin - // context. Stored preference deviceIds are origin-scoped to the main renderer page. - // enumerateDevices() in the popout returns different IDs for the same hardware. - // MediaStreamTrack.getSettings().deviceId is always consistent with enumerateDevices() - // in the same page, so we use it to find the matching audio endpoint. - navigator.mediaDevices.getUserMedia({ video: videoConstraints, audio: false }) +function startCapture(): void { + // Probe video-only first. This grants the media permission for this popout's + // origin, which is what makes enumerateDevices() return device labels. deviceIds + // are salted per origin, so the id sent from the dock does not resolve here, but + // the stable device label does. We re-resolve the intended card by its label in this + // origin's own enumeration, then acquire it with an exact constraint. + const probeConstraints = videoConstraintsFor(null) + navigator.mediaDevices.getUserMedia({ video: probeConstraints, audio: false }) .then((videoStream: MediaStream) => { - const videoTrack = videoStream.getVideoTracks()[0] - const actualDeviceId = videoTrack ? videoTrack.getSettings().deviceId ?? null : null + const probeDeviceId = videoStream.getVideoTracks()[0]?.getSettings().deviceId ?? null + videoStream.getTracks().forEach(track => track.stop()) return navigator.mediaDevices.enumerateDevices().then((allDevices: MediaDeviceInfo[]) => { - const audioDeviceId = findMatchingAudioDevice(actualDeviceId ?? requestedDeviceId, allDevices) - videoStream.getTracks().forEach(track => track.stop()) + const videoInputs = allDevices + .filter(device => device.kind === 'videoinput') + .map(device => ({ deviceId: device.deviceId, label: device.label })) + // Re-resolve by the stable label. Fall back to whatever the probe + // opened if the label is unknown (e.g. an older popout with no label). + const resolvedVideoId = resolveCaptureDeviceId(videoInputs, requestedDeviceId, requestedDeviceLabel) ?? probeDeviceId + const videoConstraints = videoConstraintsFor(resolvedVideoId) + const audioDeviceId = findMatchingAudioDevice(resolvedVideoId ?? requestedDeviceId, allDevices) const attempts: MediaStreamConstraints[] = [] if (audioDeviceId) { @@ -373,6 +386,11 @@ function startCapture(): void { }) } attempts.push({ video: videoConstraints, audio: false }) + // Last resort: the soft-hint probe constraints, so a device that could not + // be resolved by label still shows something rather than nothing. Skipped + // when resolvedVideoId is null, because videoConstraints already equals + // probeConstraints then and this would just duplicate the attempt above. + if (resolvedVideoId) attempts.push({ video: probeConstraints, audio: false }) tryCapture(attempts) }) }) @@ -494,6 +512,7 @@ window.rokdock.capture.onVolumeChanged((newVolume: number) => { async function init(): Promise { const config = await window.rokdock.capture.getPopoutConfig() requestedDeviceId = config.deviceId + requestedDeviceLabel = config.deviceLabel muted = config.muted idleTimeoutSec = config.idleTimeoutSec diff --git a/src/renderer/components/devicePanel/deviceCard.tsx b/src/renderer/components/devicePanel/deviceCard.tsx index e6e6d86..de78072 100644 --- a/src/renderer/components/devicePanel/deviceCard.tsx +++ b/src/renderer/components/devicePanel/deviceCard.tsx @@ -23,7 +23,7 @@ import React, { useEffect, useRef, useState } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faChevronRight, faLock, faUnlock } from '@fortawesome/free-solid-svg-icons' -import { useAppStore, createTabInfo, type Device } from '../../store/appStore' +import { useAppStore, createTabInfo, findReusableTab, type Device } from '../../store/appStore' import { resolveThemeMode } from '../../styles/theme' import ConfirmDialog from '../common/confirmDialog' import SideloadDialog from '../sideloadDialog' @@ -134,7 +134,12 @@ export default function DeviceCard({ const [dropError, setDropError] = useState(null) const [droppedFile, setDroppedFile] = useState<{ filePath: string; fileName: string } | null>(null) const dropErrorTimer = useRef(null) + // Ports with a createSession call in flight. The tab is added only after the async + // session is created, so this guards a rapid second click on the same port from + // opening a duplicate before the first tab exists to be found. + const connectingPortsRef = useRef>(new Set()) const addTab = useAppStore((state) => state.addTab) + const setActiveTab = useAppStore((state) => state.setActiveTab) const remoteTargetIp = useAppStore((state) => state.remoteTargetIp) const setRemoteTargetIp = useAppStore((state) => state.setRemoteTargetIp) const setRightPanel = useAppStore((state) => state.setRightPanel) @@ -227,6 +232,18 @@ export default function DeviceCard({ /** Opens a new terminal tab connected to this device on the given port. */ const handleConnect = async (port: number) => { + // A telnet port accepts only one connection, so a second tab for the same + // device and port could never connect. If a live tab already exists, focus + // it instead of opening a dead duplicate. + const existingTab = findReusableTab(useAppStore.getState().tabs, device.ip, port) + if (existingTab) { + setActiveTab(existingTab.id) + return + } + // Guard concurrent clicks: the tab is not added until createSession resolves, + // so without this a fast second click would start a second (doomed) session. + if (connectingPortsRef.current.has(port)) return + connectingPortsRef.current.add(port) try { const sessionId = await window.rokdock.terminal.createSession(device.ip, device.name, port) const tab = createTabInfo(sessionId, device.ip, displayName, port, { @@ -238,6 +255,8 @@ export default function DeviceCard({ setRemoteTargetIp(device.ip) } catch (e) { console.error('Failed to create session:', e) + } finally { + connectingPortsRef.current.delete(port) } } diff --git a/src/renderer/store/appStore.ts b/src/renderer/store/appStore.ts index a1249b0..ece8059 100644 --- a/src/renderer/store/appStore.ts +++ b/src/renderer/store/appStore.ts @@ -1357,3 +1357,16 @@ export function createTabInfo( paneId: 'a' } } + +/** + * Find a still-live terminal tab for a device and port. A telnet debug port accepts + * only one connection, so a second tab for the same target could never connect. The + * caller focuses the returned tab instead of opening a dead duplicate. Disconnected + * and errored tabs are ignored, so the user can still reconnect after a session drops. + */ +export function findReusableTab(tabs: TabInfo[], deviceIp: string, port: number): TabInfo | undefined { + return tabs.find( + (tab) => tab.deviceIp === deviceIp && tab.port === port && + (tab.status === 'connected' || tab.status === 'connecting') + ) +} diff --git a/tests/e2e/dockCloseIndependence.spec.ts b/tests/e2e/dockCloseIndependence.spec.ts new file mode 100644 index 0000000..ffa145d --- /dev/null +++ b/tests/e2e/dockCloseIndependence.spec.ts @@ -0,0 +1,84 @@ +/** + * E2E regression (issue #17 follow-up): closing the main RokDock dock must not + * take down tool windows launched independently via `--tool`. Those windows + * register in the 'standalone' scope and are independent of the dock session. + * + * The dock still closes its own dependents (tool windows opened from within the + * dock, in the 'inDock' scope). This spec asserts both halves so the cascade is + * scoped, not disabled: a standalone JSON window survives the dock closing while + * an inDock SVG window closes with it. + */ +/* eslint-disable @typescript-eslint/no-require-imports */ +import { test, expect } from '@playwright/test' +import type { ElectronApplication } from '@playwright/test' +import { spawn } from 'child_process' +import path from 'path' +import os from 'os' +import fs from 'fs' +import { launchRokDockWithArgsAndUserData } from './helpers' + +const electronExe: string = require( + path.join(__dirname, '..', '..', 'node_modules', 'electron') +) as string +const projectRoot = path.join(__dirname, '..', '..') + +async function windowTitles(app: ElectronApplication): Promise { + try { + return await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map(w => w.getTitle())) + } catch { + // The main-process evaluate context can be transiently torn down while a + // window is opening or closing. Return empty so a poll retries. + return [] + } +} + +/** Spawns a second RokDock process that forwards its argv to the primary, then exits. */ +function spawnSecondInstance(userDataDir: string, extraArgs: string[]): void { + const env = Object.fromEntries( + Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined) + ) + delete env.ELECTRON_RUN_AS_NODE + env.ROKDOCK_E2E = '1' + const child = spawn( + electronExe, + ['out/main/main.js', `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu', ...extraArgs], + { cwd: projectRoot, env, stdio: 'ignore' } + ) + child.unref() +} + +test('closing the dock spares standalone tool windows but closes inDock ones', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rokdock-dockclose-')) + const { app, mainWin } = await launchRokDockWithArgsAndUserData([], dir) + try { + expect(await windowTitles(app)).toEqual(['RokDock']) + + // Open a dependent (inDock) tool window from within the dock. + await mainWin.evaluate(() => window.rokdock.svgExporter.openEditor('dark')) + await expect.poll(() => windowTitles(app), { timeout: 15_000 }).toContain('SVG Converter') + + // Launch an independent (standalone) tool window via a forwarded --tool argv. + spawnSecondInstance(dir, ['--tool=json']) + await expect.poll(() => windowTitles(app), { timeout: 15_000 }).toContain('JSON Editor') + + // Close the dock through the main process so the BrowserWindow 'close' event + // fires, matching the real red-button / Cmd-W path. (Playwright's page.close() + // tears down the webContents without emitting 'close', so it would bypass the + // cascade handler entirely.) + await app.evaluate(({ BrowserWindow }) => { + const dock = BrowserWindow.getAllWindows().find(w => w.getTitle() === 'RokDock') + dock?.close() + }) + + // The standalone JSON window survives; the inDock SVG window and the dock are gone. + await expect.poll(() => windowTitles(app), { timeout: 15_000 }).toEqual(['JSON Editor']) + + // The app is still running because a window remains open (window-all-closed + // never fired). A negative control: had the cascade closed the standalone + // window too, the app would have quit and this evaluate would throw. + expect(await app.evaluate(({ app: electronApp }) => electronApp.isReady())).toBe(true) + } finally { + await app.close() + } +}) diff --git a/tests/e2e/secondInstanceToolLaunch.spec.ts b/tests/e2e/secondInstanceToolLaunch.spec.ts new file mode 100644 index 0000000..06c499b --- /dev/null +++ b/tests/e2e/secondInstanceToolLaunch.spec.ts @@ -0,0 +1,81 @@ +/** + * E2E regression: a second `--tool=` launch while RokDock is already running + * must open the tool window in the running instance (single-instance argv + * forwarding), not fall back to the dock. This reproduces the Windows per-tool + * Start Menu shortcut (`RokDock.exe --tool=json`) fired while the dock is open. + * + * The attached `--tool=` form is required: Electron reorders the argv it + * forwards to the second-instance handler, so a space-separated `--tool json` + * loses its value (covered at the unit level in launchRequest.test.ts). The + * launchers emit the attached form for exactly this reason. + */ +/* eslint-disable @typescript-eslint/no-require-imports */ +import { test, expect } from '@playwright/test' +import type { ElectronApplication } from '@playwright/test' +import { spawn } from 'child_process' +import path from 'path' +import os from 'os' +import fs from 'fs' +import { launchRokDockWithArgsAndUserData } from './helpers' + +const electronExe: string = require( + path.join(__dirname, '..', '..', 'node_modules', 'electron') +) as string +const projectRoot = path.join(__dirname, '..', '..') + +async function windowTitles(app: ElectronApplication): Promise { + try { + return await app.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows().map(w => w.getTitle())) + } catch { + // The main-process evaluate context can be transiently torn down while a + // window is opening. Return empty so a poll retries rather than throwing. + return [] + } +} + +/** + * Launches a second RokDock process against the given userData dir, so it loses the + * single-instance lock and forwards its argv to the primary, then exits. Mirrors + * what a per-tool launcher does when RokDock is already running. + */ +function spawnSecondInstance(userDataDir: string, extraArgs: string[]): void { + const env = Object.fromEntries( + Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined) + ) + delete env.ELECTRON_RUN_AS_NODE + env.ROKDOCK_E2E = '1' + const child = spawn( + electronExe, + ['out/main/main.js', `--user-data-dir=${userDataDir}`, '--no-sandbox', '--disable-gpu', ...extraArgs], + { cwd: projectRoot, env, stdio: 'ignore' } + ) + child.unref() +} + +test('a second --tool= launch opens the tool window in the running instance', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rokdock-2inst-')) + const { app } = await launchRokDockWithArgsAndUserData([], dir) + try { + expect(await windowTitles(app)).toEqual(['RokDock']) + spawnSecondInstance(dir, ['--tool=json']) + await expect.poll(() => windowTitles(app), { timeout: 15_000 }).toContain('JSON Editor') + } finally { + await app.close() + } +}) + +test('a bare second launch focuses the dock and opens no tool window (negative control)', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rokdock-2inst-bare-')) + const { app } = await launchRokDockWithArgsAndUserData([], dir) + try { + expect(await windowTitles(app)).toEqual(['RokDock']) + spawnSecondInstance(dir, []) + // Wait past when a tool window would appear (the positive case opens in ~1-2s), + // then confirm the bare launch added no window and only surfaced the dock. + await new Promise((resolve) => setTimeout(resolve, 4_000)) + expect(await windowTitles(app)).toEqual(['RokDock']) + } finally { + await app.close() + } +}) diff --git a/tests/main/ipc/toolWindow.test.ts b/tests/main/ipc/toolWindow.test.ts index d71cfd5..c5b1674 100644 --- a/tests/main/ipc/toolWindow.test.ts +++ b/tests/main/ipc/toolWindow.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from 'vitest' -import { getScopedToolWindow, setScopedToolWindow, resetScopedToolWindowsForTest } from '@main/ipc/toolWindow' +import { getScopedToolWindow, getToolWindowsInScope, setScopedToolWindow, resetScopedToolWindowsForTest } from '@main/ipc/toolWindow' // Minimal fake of the BrowserWindow surface the registry touches. function fakeWin() { @@ -48,4 +48,34 @@ describe('scoped tool-window registry', () => { expect(getScopedToolWindow('json', 'standalone')).toBe(windowA) expect(getScopedToolWindow('json', 'inDock')).toBe(windowB) }) + + it('collects every live window in a scope, across tools', () => { + const jsonStandalone = fakeWin() + const svgStandalone = fakeWin() + const svgInDock = fakeWin() + setScopedToolWindow('json', 'standalone', jsonStandalone as never) + setScopedToolWindow('svg', 'standalone', svgStandalone as never) + setScopedToolWindow('svg', 'inDock', svgInDock as never) + + const standalone = getToolWindowsInScope('standalone') + expect(standalone).toHaveLength(2) + expect(standalone).toContain(jsonStandalone) + expect(standalone).toContain(svgStandalone) + expect(standalone).not.toContain(svgInDock) + + expect(getToolWindowsInScope('inDock')).toEqual([svgInDock]) + }) + + it('omits closed and destroyed windows from a scope collection', () => { + const live = fakeWin() + const closed = fakeWin() + const destroyed = fakeWin() + setScopedToolWindow('json', 'standalone', live as never) + setScopedToolWindow('svg', 'standalone', closed as never) + setScopedToolWindow('script', 'standalone', destroyed as never) + closed.emitClosed() + destroyed.destroyed = true + + expect(getToolWindowsInScope('standalone')).toEqual([live]) + }) }) diff --git a/tests/main/launch/launchRequest.test.ts b/tests/main/launch/launchRequest.test.ts index 7645485..17e5e45 100644 --- a/tests/main/launch/launchRequest.test.ts +++ b/tests/main/launch/launchRequest.test.ts @@ -20,6 +20,23 @@ describe('parseLaunchRequest', () => { .toEqual({ tool: 'svg' }) }) + // Electron reorders the argv it forwards to the second-instance handler: bare + // positionals are moved to the end and its own switches are spliced in. The + // attached --tool= form survives that intact, which is why the launchers + // use it. This is the exact shape observed from a `RokDock --tool=json` relaunch. + it('parses --tool=value from a reordered second-instance argv', () => { + const reordered = ['electron', '--user-data-dir=/d', '--no-sandbox', '--tool=json', '--allow-file-access-from-files', 'out/main/main.js'] + expect(parseLaunchRequest(reordered, cwd)).toEqual({ tool: 'json' }) + }) + + // The space-separated form does NOT survive that reordering: a switch lands + // between --tool and its value, so the value is lost. This is the failure the + // launcher --tool= form exists to avoid; documented so it does not regress. + it('returns null when a reordered argv separates --tool from its space value', () => { + const reordered = ['electron', '--user-data-dir=/d', '--tool', '--allow-file-access-from-files', 'out/main/main.js', 'json'] + expect(parseLaunchRequest(reordered, cwd)).toBeNull() + }) + it('parses a following absolute file path', () => { expect(parseLaunchRequest(['electron', 'main.js', '--tool', 'json', '/abs/foo.json'], cwd)) .toEqual({ tool: 'json', filePath: '/abs/foo.json' }) diff --git a/tests/renderer/store/findReusableTab.test.ts b/tests/renderer/store/findReusableTab.test.ts new file mode 100644 index 0000000..fa63987 --- /dev/null +++ b/tests/renderer/store/findReusableTab.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest' +import { findReusableTab, createTabInfo, type TabInfo } from '@renderer/store/appStore' + +function tab(overrides: Partial = {}): TabInfo { + return { ...createTabInfo('s1', '10.0.0.5', 'Living Room', 8085), ...overrides } +} + +describe('findReusableTab', () => { + it('returns a connected tab for the same device and port', () => { + const tabs = [tab({ id: 's1', status: 'connected' })] + expect(findReusableTab(tabs, '10.0.0.5', 8085)?.id).toBe('s1') + }) + + it('returns a still-connecting tab (a second click during connect should focus it)', () => { + const tabs = [tab({ id: 's1', status: 'connecting' })] + expect(findReusableTab(tabs, '10.0.0.5', 8085)?.id).toBe('s1') + }) + + it('ignores a disconnected tab so the user can reconnect', () => { + const tabs = [tab({ id: 's1', status: 'disconnected' })] + expect(findReusableTab(tabs, '10.0.0.5', 8085)).toBeUndefined() + }) + + it('ignores an errored tab so the user can reconnect', () => { + const tabs = [tab({ id: 's1', status: 'error' })] + expect(findReusableTab(tabs, '10.0.0.5', 8085)).toBeUndefined() + }) + + it('does not match a different port on the same device', () => { + const tabs = [tab({ id: 's1', status: 'connected', port: 8085 })] + expect(findReusableTab(tabs, '10.0.0.5', 8089)).toBeUndefined() + }) + + it('does not match a different device on the same port', () => { + const tabs = [tab({ id: 's1', status: 'connected', deviceIp: '10.0.0.5' })] + expect(findReusableTab(tabs, '10.0.0.9', 8085)).toBeUndefined() + }) + + it('returns the live tab when a stale disconnected tab for the same target also exists', () => { + const tabs = [ + tab({ id: 'stale', status: 'disconnected' }), + tab({ id: 'live', status: 'connected' }) + ] + expect(findReusableTab(tabs, '10.0.0.5', 8085)?.id).toBe('live') + }) + + it('returns undefined for an empty tab list', () => { + expect(findReusableTab([], '10.0.0.5', 8085)).toBeUndefined() + }) +}) diff --git a/tests/scripts/launcherTemplates.test.ts b/tests/scripts/launcherTemplates.test.ts index efa739f..c2cd2ee 100644 --- a/tests/scripts/launcherTemplates.test.ts +++ b/tests/scripts/launcherTemplates.test.ts @@ -17,9 +17,9 @@ describe('nsisInclude', () => { it('creates one shortcut per launcher with its --tool arg and icon', () => { expect(nsh).toContain('"$SMPROGRAMS\\RokDock\\JSON Editor.lnk"') - expect(nsh).toContain('"--tool json"') + expect(nsh).toContain('"--tool=json"') expect(nsh).toContain('"$INSTDIR\\resources\\icons\\tools\\json.ico"') - expect(nsh).toContain('"--tool svg"') + expect(nsh).toContain('"--tool=svg"') }) it('deletes each shortcut on uninstall and removes the folder', () => { @@ -33,7 +33,7 @@ describe('desktopEntry', () => { const entry = templates.desktopEntry(LAUNCHERS[0], 'rokdock') expect(entry).toContain('[Desktop Entry]') expect(entry).toContain('Name=RokDock JSON Editor') - expect(entry).toContain('Exec=rokdock --tool json %U') + expect(entry).toContain('Exec=rokdock --tool=json %U') expect(entry).toContain('Icon=rokdock-json') expect(entry).toContain('Categories=Development;') expect(entry).toContain('Type=Application') @@ -58,7 +58,7 @@ describe('macLaunchStub', () => { expect(stub).toContain('#!/bin/sh') expect(stub).toContain("kMDItemCFBundleIdentifier == 'com.rokdock.app'") expect(stub).toContain('/Applications/RokDock.app') - expect(stub).toContain('--tool json') + expect(stub).toContain('--tool=json') }) }) @@ -67,7 +67,7 @@ describe('install/uninstall scripts', () => { const icons = { json: 'AAAA', svg: 'BBBB' } const script = templates.appImageInstallScript(LAUNCHERS, icons) expect(script).toContain('#!/bin/sh') - expect(script).toContain('--tool json') + expect(script).toContain('--tool=json') expect(script).toContain('rokdock-json.desktop') expect(script).toContain('AAAA') // Must use printf %b so the embedded \n escapes become real newlines in the .desktop file.