diff --git a/n8n-nodes-browser-cli/README.md b/n8n-nodes-browser-cli/README.md index 2fcb46a..25c3138 100644 --- a/n8n-nodes-browser-cli/README.md +++ b/n8n-nodes-browser-cli/README.md @@ -53,18 +53,27 @@ policy tier noted below. | Resource | Operation | Command | Server flag needed | |----------|-----------|---------|--------------------| -| Tab | List | `tabs.list` | safe (default) | -| Tab | Open | `navigate.open` | `--allow-control` | -| Tab | Close | `tabs.close` (ids / inactive / duplicates) | `--allow-control` | +| Tab | List / Query / Get / Count / Filter / Active in Window | `tabs.list` / `tabs.query` / `tabs.status` / `tabs.count` / `tabs.filter` / `tabs.active_in_window` | safe (default) | | Tab | Get HTML | `tabs.html` | `--allow-read-page` | +| Tab | Open / Close / Activate / Move / Navigate To / Reload / Hard Reload / Back / Forward | `navigate.open` / `tabs.close` / `tabs.active` / `tabs.move` / `navigate.to` / `navigate.reload` / `navigate.hard_reload` / `navigate.back` / `navigate.forward` | `--allow-control` | +| Tab | Mute / Unmute / Pin / Unpin / Dedupe / Sort / Merge Windows | `tabs.mute` / `tabs.unmute` / `tabs.pin` / `tabs.unpin` / `tabs.dedupe` / `tabs.sort` / `tabs.merge_windows` | `--allow-control` | +| Tab | Screenshot | `tabs.screenshot` | `--allow-dangerous` | | Page | Get Info | `page.info` | safe (default) | -| Page | Extract Text / Links / Images / HTML / Markdown | `extract.*` | `--allow-read-page` | -| DOM | Query | `dom.query` | `--allow-read-page` | -| DOM | Click / Type | `dom.click` / `dom.type` | `--allow-control` | +| Page | Extract Text / Links / Images / HTML / Markdown / JSON | `extract.*` | `--allow-read-page` | +| DOM | Query / Text / Attribute / Exists | `dom.query` / `dom.text` / `dom.attr` / `dom.exists` | `--allow-read-page` | +| DOM | Click / Type / Select / Hover / Focus / Check / Uncheck / Clear / Submit / Scroll / Key | `dom.*` | `--allow-control` | | DOM | Eval | `dom.eval` | `--allow-dangerous` | +| Group | List / Query / Tabs | `group.list` / `group.query` / `group.tabs` | safe (default) | +| Group | Count / Create / Add Tab / Move / Close | `group.count` / `group.open` / `group.add_tab` / `group.move` / `group.close` | `--allow-control` | +| Window | List | `windows.list` | safe (default) | +| Window | Open / Close / Rename | `windows.open` / `windows.close` / `windows.rename` | `--allow-control` | +| Session | List / Save / Load / Remove / Export / Diff / Auto Save | `session.*` | `--allow-control` | +| Storage | Get / Set | `storage.get` / `storage.set` | `--allow-dangerous` | +| Performance | Status | `perf.status` | safe (default) | +| Extension | Info / Capabilities | `extension.info` / `extension.capabilities` | safe (default) | +| Extension | Reload | `extension.reload` | `--allow-control` | | Client | List | `clients.list` | safe (default) | | Command | Execute | any command name + JSON args | per command | -| Gateway | Health | pings with `tabs.list` | safe (default) | **Command → Execute** is the escape hatch: any command string the server policy allows (`tabs.query`, `session.save`, `windows.list`, …) with a JSON args object. diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts b/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts index c3ab748..9af529e 100644 --- a/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/BrowserCli.node.ts @@ -26,7 +26,7 @@ export class BrowserCli implements INodeType { icon: 'file:browserCli.svg', group: ['transform'], version: 1, - subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', + subtitle: '={{({ tab: "Tab", page: "Page", dom: "DOM", group: "Group", window: "Window", session: "Session", storage: "Storage", perf: "Perf", extension: "Extension", client: "Client", command: "Command" }[$parameter["resource"]] || $parameter["resource"]) + ": " + $parameter["operation"]}}', description: 'Control a remote browser by talking directly to a browser-cli serve endpoint', defaults: { name: 'Browser CLI' }, inputs: [NodeConnectionTypes.Main], @@ -43,9 +43,14 @@ export class BrowserCli implements INodeType { { name: 'Tab', value: 'tab' }, { name: 'Page', value: 'page' }, { name: 'DOM', value: 'dom' }, + { name: 'Group', value: 'group' }, + { name: 'Window', value: 'window' }, + { name: 'Session', value: 'session' }, + { name: 'Storage', value: 'storage' }, + { name: 'Performance', value: 'perf' }, + { name: 'Extension', value: 'extension' }, { name: 'Client', value: 'client' }, { name: 'Command', value: 'command' }, - { name: 'Gateway', value: 'gateway' }, ], default: 'tab', }, @@ -59,9 +64,29 @@ export class BrowserCli implements INodeType { displayOptions: { show: { resource: ['tab'] } }, options: [ { name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' }, + { name: 'Query', value: 'query', action: 'Search tabs by text', description: 'tabs.query (safe)' }, + { name: 'Get', value: 'get', action: 'Get a tab status', description: 'tabs.status (safe)' }, + { name: 'Count', value: 'count', action: 'Count open tabs', description: 'tabs.count (safe)' }, + { name: 'Filter', value: 'filter', action: 'Filter tabs by URL pattern', description: 'tabs.filter (safe)' }, + { name: 'Active in Window', value: 'activeInWindow', action: 'Get active tab in a window', description: 'tabs.active_in_window (safe)' }, { name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' }, { name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' }, { name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' }, + { name: 'Activate', value: 'activate', action: 'Switch focus to a tab', description: 'tabs.active (needs --allow-control)' }, + { name: 'Move', value: 'move', action: 'Move a tab', description: 'tabs.move (needs --allow-control)' }, + { name: 'Navigate To', value: 'navigateTo', action: 'Navigate a tab to a URL', description: 'navigate.to (needs --allow-control)' }, + { name: 'Reload', value: 'reload', action: 'Reload a tab', description: 'navigate.reload (needs --allow-control)' }, + { name: 'Hard Reload', value: 'hardReload', action: 'Hard reload a tab', description: 'navigate.hard_reload (needs --allow-control)' }, + { name: 'Back', value: 'back', action: 'Go back in history', description: 'navigate.back (needs --allow-control)' }, + { name: 'Forward', value: 'forward', action: 'Go forward in history', description: 'navigate.forward (needs --allow-control)' }, + { name: 'Mute', value: 'mute', action: 'Mute a tab', description: 'tabs.mute (needs --allow-control)' }, + { name: 'Unmute', value: 'unmute', action: 'Unmute a tab', description: 'tabs.unmute (needs --allow-control)' }, + { name: 'Pin', value: 'pin', action: 'Pin a tab', description: 'tabs.pin (needs --allow-control)' }, + { name: 'Unpin', value: 'unpin', action: 'Unpin a tab', description: 'tabs.unpin (needs --allow-control)' }, + { name: 'Dedupe', value: 'dedupe', action: 'Close duplicate tabs', description: 'tabs.dedupe (needs --allow-control)' }, + { name: 'Sort', value: 'sort', action: 'Sort tabs within windows', description: 'tabs.sort (needs --allow-control)' }, + { name: 'Merge Windows', value: 'mergeWindows', action: 'Merge all tabs into one window', description: 'tabs.merge_windows (needs --allow-control)' }, + { name: 'Screenshot', value: 'screenshot', action: 'Capture a tab screenshot', description: 'tabs.screenshot (needs --allow-dangerous)' }, ], default: 'list', }, @@ -80,6 +105,7 @@ export class BrowserCli implements INodeType { { name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' }, { name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' }, { name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' }, + { name: 'Extract JSON', value: 'extractJson', action: 'Extract JSON-LD / structured data', description: 'extract.json (needs --allow-read-page)' }, ], default: 'extractText', }, @@ -93,13 +119,122 @@ export class BrowserCli implements INodeType { displayOptions: { show: { resource: ['dom'] } }, options: [ { name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' }, + { name: 'Text', value: 'text', action: 'Get element text', description: 'dom.text (needs --allow-read-page)' }, + { name: 'Attribute', value: 'attr', action: 'Get an element attribute', description: 'dom.attr (needs --allow-read-page)' }, + { name: 'Exists', value: 'exists', action: 'Check if an element exists', description: 'dom.exists (needs --allow-read-page)' }, { name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' }, { name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' }, + { name: 'Select', value: 'select', action: 'Select a dropdown option', description: 'dom.select (needs --allow-control)' }, + { name: 'Hover', value: 'hover', action: 'Hover over an element', description: 'dom.hover (needs --allow-control)' }, + { name: 'Focus', value: 'focus', action: 'Focus an element', description: 'dom.focus (needs --allow-control)' }, + { name: 'Check', value: 'check', action: 'Check a checkbox', description: 'dom.check (needs --allow-control)' }, + { name: 'Uncheck', value: 'uncheck', action: 'Uncheck a checkbox', description: 'dom.uncheck (needs --allow-control)' }, + { name: 'Clear', value: 'clear', action: 'Clear an input', description: 'dom.clear (needs --allow-control)' }, + { name: 'Submit', value: 'submit', action: 'Submit a form', description: 'dom.submit (needs --allow-control)' }, + { name: 'Scroll', value: 'scroll', action: 'Scroll to an element or position', description: 'dom.scroll (needs --allow-control)' }, + { name: 'Key', value: 'key', action: 'Send a keyboard key', description: 'dom.key (needs --allow-control)' }, { name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' }, ], default: 'query', }, + // --- Group operations ------------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['group'] } }, + options: [ + { name: 'List', value: 'list', action: 'List tab groups', description: 'group.list (safe)' }, + { name: 'Query', value: 'query', action: 'Search groups by name', description: 'group.query (safe)' }, + { name: 'Tabs', value: 'tabs', action: 'List tabs in a group', description: 'group.tabs (safe)' }, + { name: 'Count', value: 'count', action: 'Count tab groups', description: 'group.count (needs --allow-control)' }, + { name: 'Create', value: 'create', action: 'Create a tab group', description: 'group.open (needs --allow-control)' }, + { name: 'Add Tab', value: 'addTab', action: 'Add a tab to a group', description: 'group.add_tab (needs --allow-control)' }, + { name: 'Move', value: 'move', action: 'Move a group forward/backward', description: 'group.move (needs --allow-control)' }, + { name: 'Close', value: 'close', action: 'Close a tab group', description: 'group.close (needs --allow-control)' }, + ], + default: 'list', + }, + + // --- Window operations ------------------------------------------------ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['window'] } }, + options: [ + { name: 'List', value: 'list', action: 'List browser windows', description: 'windows.list (safe)' }, + { name: 'Open', value: 'open', action: 'Open a new window', description: 'windows.open (needs --allow-control)' }, + { name: 'Close', value: 'close', action: 'Close a window', description: 'windows.close (needs --allow-control)' }, + { name: 'Rename', value: 'rename', action: 'Rename a window', description: 'windows.rename (needs --allow-control)' }, + ], + default: 'list', + }, + + // --- Session operations ----------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['session'] } }, + options: [ + { name: 'List', value: 'list', action: 'List saved sessions', description: 'session.list (needs --allow-control)' }, + { name: 'Save', value: 'save', action: 'Save the current session', description: 'session.save (needs --allow-control)' }, + { name: 'Load', value: 'load', action: 'Load a saved session', description: 'session.load (needs --allow-control)' }, + { name: 'Remove', value: 'remove', action: 'Delete a saved session', description: 'session.remove (needs --allow-control)' }, + { name: 'Export', value: 'export', action: 'Export a session as JSON', description: 'session.export (needs --allow-control)' }, + { name: 'Diff', value: 'diff', action: 'Diff two sessions', description: 'session.diff (needs --allow-control)' }, + { name: 'Auto Save', value: 'autoSave', action: 'Toggle session auto-save', description: 'session.auto_save (needs --allow-control)' }, + ], + default: 'list', + }, + + // --- Storage operations ----------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['storage'] } }, + options: [ + { name: 'Get', value: 'get', action: 'Read localStorage / sessionStorage', description: 'storage.get (needs --allow-dangerous)' }, + { name: 'Set', value: 'set', action: 'Write localStorage / sessionStorage', description: 'storage.set (needs --allow-dangerous)' }, + ], + default: 'get', + }, + + // --- Performance operations ------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['perf'] } }, + options: [ + { name: 'Status', value: 'status', action: 'Get performance status', description: 'perf.status (safe)' }, + ], + default: 'status', + }, + + // --- Extension operations --------------------------------------------- + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + displayOptions: { show: { resource: ['extension'] } }, + options: [ + { name: 'Info', value: 'info', action: 'Get extension info', description: 'extension.info (safe)' }, + { name: 'Capabilities', value: 'capabilities', action: 'List extension capabilities', description: 'extension.capabilities (safe)' }, + { name: 'Reload', value: 'reload', action: 'Reload the extension', description: 'extension.reload (needs --allow-control)' }, + ], + default: 'info', + }, + // --- Client operations ------------------------------------------------ { displayName: 'Operation', @@ -126,18 +261,6 @@ export class BrowserCli implements INodeType { default: 'execute', }, - // --- Gateway operations ----------------------------------------------- - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - displayOptions: { show: { resource: ['gateway'] } }, - options: [ - { name: 'Health', value: 'health', action: 'Check serve connectivity', description: 'Pings the endpoint with tabs.list (safe)' }, - ], - default: 'health', - }, // --- Shared parameter fields ----------------------------------------- { @@ -147,7 +270,16 @@ export class BrowserCli implements INodeType { default: '', required: true, placeholder: 'https://example.com', - displayOptions: { show: showFor('tab', ['open']) }, + displayOptions: { show: showFor('tab', ['open', 'navigateTo']) }, + }, + { + displayName: 'URL', + name: 'url', + type: 'string', + default: '', + placeholder: 'https://example.com', + description: 'Optional URL to open. Leave empty for a blank window/tab.', + displayOptions: { show: { resource: ['window', 'group'], operation: ['open', 'addTab'] } }, }, { displayName: 'Focus Tab', @@ -184,7 +316,136 @@ export class BrowserCli implements INodeType { type: 'number', default: 0, description: 'Target tab ID. Leave 0 for the active tab.', - displayOptions: { show: { resource: ['tab'], operation: ['getHtml'] } }, + displayOptions: { + show: { + resource: ['tab', 'dom'], + operation: ['getHtml', 'get', 'reload', 'hardReload', 'back', 'forward', 'mute', 'unmute', 'pin', 'unpin', 'screenshot', 'eval'], + }, + }, + }, + { + displayName: 'Tab ID', + name: 'tabId', + type: 'number', + default: 0, + required: true, + description: 'Target tab ID (required — no active-tab fallback)', + displayOptions: { show: showFor('tab', ['activate', 'move', 'navigateTo']) }, + }, + { + displayName: 'Tab ID', + name: 'tabId', + type: 'number', + default: 0, + description: 'Target tab ID. Leave 0 for the active tab.', + displayOptions: { show: showFor('storage', ['get', 'set']) }, + }, + { + displayName: 'Search', + name: 'search', + type: 'string', + default: '', + required: true, + placeholder: 'github', + description: 'Substring to match against tab/group titles and URLs', + displayOptions: { show: { resource: ['tab', 'group'], operation: ['query'] } }, + }, + { + displayName: 'URL Pattern', + name: 'pattern', + type: 'string', + default: '', + placeholder: '*.github.com/*', + description: 'URL glob pattern. Required for Filter; optional for Count (omit to count all).', + displayOptions: { show: showFor('tab', ['filter', 'count']) }, + }, + { + displayName: 'Window ID', + name: 'windowId', + type: 'number', + default: 0, + required: true, + displayOptions: { show: showFor('tab', ['activeInWindow']) }, + }, + { + displayName: 'Window ID', + name: 'windowId', + type: 'number', + default: 0, + required: true, + displayOptions: { show: showFor('window', ['close', 'rename']) }, + }, + { + displayName: 'Window ID', + name: 'windowId', + type: 'number', + default: 0, + description: 'Move the tab to this window. Leave 0 to keep it in the current window.', + displayOptions: { show: showFor('tab', ['move']) }, + }, + { + displayName: 'Index', + name: 'index', + type: 'number', + default: 0, + description: 'Target position within the window (0-based)', + displayOptions: { show: showFor('tab', ['move']) }, + }, + { + displayName: 'Format', + name: 'format', + type: 'options', + default: 'png', + options: [ + { name: 'PNG', value: 'png' }, + { name: 'JPEG', value: 'jpeg' }, + ], + displayOptions: { show: showFor('tab', ['screenshot']) }, + }, + { + displayName: 'Quality', + name: 'quality', + type: 'number', + default: 80, + description: 'JPEG quality 0-100 (ignored for PNG)', + displayOptions: { show: { resource: ['tab'], operation: ['screenshot'], format: ['jpeg'] } }, + }, + { + displayName: 'Gentle Mode', + name: 'gentleMode', + type: 'options', + default: 'auto', + description: 'How aggressively to rearrange tabs', + options: [ + { name: 'Auto', value: 'auto' }, + { name: 'On', value: 'on' }, + { name: 'Off', value: 'off' }, + ], + displayOptions: { show: showFor('tab', ['dedupe', 'sort', 'mergeWindows']) }, + }, + { + displayName: 'Gentle Mode', + name: 'gentleMode', + type: 'options', + default: 'auto', + options: [ + { name: 'Auto', value: 'auto' }, + { name: 'On', value: 'on' }, + { name: 'Off', value: 'off' }, + ], + displayOptions: { show: showFor('group', ['close']) }, + }, + { + displayName: 'Sort By', + name: 'by', + type: 'options', + default: 'domain', + options: [ + { name: 'Domain', value: 'domain' }, + { name: 'Title', value: 'title' }, + { name: 'Time', value: 'time' }, + ], + displayOptions: { show: showFor('tab', ['sort']) }, }, { displayName: 'Selector', @@ -196,7 +457,11 @@ export class BrowserCli implements INodeType { displayOptions: { show: { resource: ['dom', 'page'], - operation: ['query', 'click', 'type', 'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown'], + operation: [ + 'query', 'text', 'attr', 'exists', 'click', 'type', 'select', 'hover', 'focus', + 'check', 'uncheck', 'clear', 'submit', 'scroll', 'key', + 'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown', 'extractJson', + ], }, }, }, @@ -208,6 +473,51 @@ export class BrowserCli implements INodeType { required: true, displayOptions: { show: showFor('dom', ['type']) }, }, + { + displayName: 'Attribute', + name: 'attr', + type: 'string', + default: '', + required: true, + placeholder: 'href', + description: 'Attribute name to read from the matched element', + displayOptions: { show: showFor('dom', ['attr']) }, + }, + { + displayName: 'Value', + name: 'value', + type: 'string', + default: '', + required: true, + description: 'Option value to select in the dropdown', + displayOptions: { show: showFor('dom', ['select']) }, + }, + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + required: true, + placeholder: 'Enter', + description: 'Keyboard key to send, e.g. Enter, Escape, ArrowDown', + displayOptions: { show: showFor('dom', ['key']) }, + }, + { + displayName: 'X', + name: 'x', + type: 'number', + default: 0, + description: 'Horizontal scroll position (used when no selector is given)', + displayOptions: { show: showFor('dom', ['scroll']) }, + }, + { + displayName: 'Y', + name: 'y', + type: 'number', + default: 0, + description: 'Vertical scroll position (used when no selector is given)', + displayOptions: { show: showFor('dom', ['scroll']) }, + }, { displayName: 'JavaScript', name: 'code', @@ -220,12 +530,97 @@ export class BrowserCli implements INodeType { displayOptions: { show: showFor('dom', ['eval']) }, }, { - displayName: 'Tab ID', - name: 'tabId', + displayName: 'Group ID', + name: 'groupId', type: 'number', default: 0, - description: 'Target tab ID. Leave 0 for the active tab.', - displayOptions: { show: { resource: ['dom'], operation: ['eval'] } }, + required: true, + displayOptions: { show: showFor('group', ['tabs', 'close']) }, + }, + { + displayName: 'Group', + name: 'group', + type: 'string', + default: '', + required: true, + placeholder: 'Research or 12', + description: 'Target group by name or numeric ID', + displayOptions: { show: showFor('group', ['addTab', 'move']) }, + }, + { + displayName: 'Direction', + name: 'direction', + type: 'options', + default: 'forward', + options: [ + { name: 'Forward', value: 'forward' }, + { name: 'Backward', value: 'backward' }, + ], + displayOptions: { show: showFor('group', ['move']) }, + }, + { + displayName: 'Name', + name: 'name', + type: 'string', + default: '', + description: 'Name for the group/window/session. Required for all but Export (which dumps the active session when empty).', + displayOptions: { + show: { + resource: ['group', 'window', 'session'], + operation: ['create', 'rename', 'save', 'load', 'remove', 'export'], + }, + }, + }, + { + displayName: 'Session A', + name: 'nameA', + type: 'string', + default: '', + required: true, + displayOptions: { show: showFor('session', ['diff']) }, + }, + { + displayName: 'Session B', + name: 'nameB', + type: 'string', + default: '', + required: true, + displayOptions: { show: showFor('session', ['diff']) }, + }, + { + displayName: 'Enabled', + name: 'enabled', + type: 'boolean', + default: true, + description: 'Whether to turn session auto-save on', + displayOptions: { show: showFor('session', ['autoSave']) }, + }, + { + displayName: 'Storage Key', + name: 'key', + type: 'string', + default: '', + description: 'Storage key. Required for Set; on Get, omit to dump all keys.', + displayOptions: { show: showFor('storage', ['get', 'set']) }, + }, + { + displayName: 'Storage Value', + name: 'value', + type: 'string', + default: '', + required: true, + displayOptions: { show: showFor('storage', ['set']) }, + }, + { + displayName: 'Storage Type', + name: 'storeType', + type: 'options', + default: 'local', + options: [ + { name: 'localStorage', value: 'local' }, + { name: 'sessionStorage', value: 'session' }, + ], + displayOptions: { show: showFor('storage', ['get', 'set']) }, }, { displayName: 'Command', @@ -357,25 +752,116 @@ function collectParams( } return { command: get('command'), args }; } + // --- Tabs ------------------------------------------------------------- case 'tab:open': return { url: get('url'), focus: get('focus', false) }; + case 'tab:navigateTo': + return { tabId: get('tabId', 0), url: get('url') }; case 'tab:close': return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') }; - case 'tab:getHtml': + case 'tab:query': + return { search: get('search', '') }; + case 'tab:filter': + case 'tab:count': + return { pattern: get('pattern', '') }; + case 'tab:activeInWindow': + return { windowId: get('windowId', 0) }; + case 'tab:activate': return { tabId: get('tabId', 0) }; + case 'tab:move': + return { tabId: get('tabId', 0), windowId: get('windowId', 0), index: get('index', '') }; + case 'tab:get': + case 'tab:getHtml': + case 'tab:reload': + case 'tab:hardReload': + case 'tab:back': + case 'tab:forward': + case 'tab:mute': + case 'tab:unmute': + case 'tab:pin': + case 'tab:unpin': + return { tabId: get('tabId', 0) }; + case 'tab:dedupe': + case 'tab:mergeWindows': + return { gentleMode: get('gentleMode', 'auto') }; + case 'tab:sort': + return { by: get('by', 'domain'), gentleMode: get('gentleMode', 'auto') }; + case 'tab:screenshot': + return { tabId: get('tabId', 0), format: get('format', 'png'), quality: get('quality', '') }; + + // --- DOM -------------------------------------------------------------- case 'dom:query': + case 'dom:text': + case 'dom:exists': case 'dom:click': + case 'dom:hover': + case 'dom:focus': + case 'dom:check': + case 'dom:uncheck': + case 'dom:clear': + case 'dom:submit': return { selector: get('selector', '') }; case 'dom:type': return { selector: get('selector', ''), text: get('text', '') }; + case 'dom:attr': + return { selector: get('selector', ''), attr: get('attr', '') }; + case 'dom:select': + return { selector: get('selector', ''), value: get('value', '') }; + case 'dom:key': + return { selector: get('selector', ''), key: get('key', '') }; + case 'dom:scroll': + return { selector: get('selector', ''), x: get('x', ''), y: get('y', '') }; case 'dom:eval': return { code: get('code', ''), tabId: get('tabId', 0) }; + + // --- Page / extraction ------------------------------------------------ case 'page:extractText': case 'page:extractLinks': case 'page:extractImages': case 'page:extractHtml': case 'page:extractMarkdown': + case 'page:extractJson': return { selector: get('selector', '') }; + + // --- Groups ----------------------------------------------------------- + case 'group:query': + return { search: get('search', '') }; + case 'group:tabs': + return { groupId: get('groupId', 0) }; + case 'group:close': + return { groupId: get('groupId', 0), gentleMode: get('gentleMode', 'auto') }; + case 'group:create': + return { name: get('name', '') }; + case 'group:addTab': + return { group: get('group', ''), url: get('url', '') }; + case 'group:move': + return { group: get('group', ''), direction: get('direction', 'forward') }; + + // --- Windows ---------------------------------------------------------- + case 'window:open': + return { url: get('url', '') }; + case 'window:close': + return { windowId: get('windowId', 0) }; + case 'window:rename': + return { windowId: get('windowId', 0), name: get('name', '') }; + + // --- Sessions --------------------------------------------------------- + case 'session:save': + case 'session:load': + case 'session:remove': + case 'session:export': + return { name: get('name', '') }; + case 'session:diff': + return { nameA: get('nameA', ''), nameB: get('nameB', '') }; + case 'session:autoSave': + return { enabled: get('enabled', true) }; + + // --- Storage ---------------------------------------------------------- + case 'storage:get': + return { key: get('key', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) }; + case 'storage:set': + return { key: get('key', ''), value: get('value', ''), storeType: get('storeType', 'local'), tabId: get('tabId', 0) }; + default: return {}; } diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg b/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg index 76705f9..e74fd05 100644 --- a/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/browserCli.svg @@ -1,4 +1,4 @@ - + browser-cli icon diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts b/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts index 94323fc..5dbf0c9 100644 --- a/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts @@ -7,6 +7,10 @@ * (see `serveClient.ts`). Every operation maps to one raw extension command; * what the server returns is the *raw* command result (no SDK-side rendering), * still subject to the server's --allow-* policy noted per operation. + * + * Command names and argument shapes mirror the Python SDK (browser_cli/sdk/*) + * and the server-side policy in browser_cli/command_security.py. Gating per + * operation is documented in BrowserCli.node.ts next to each operation. */ export type CommandParams = Record; @@ -51,6 +55,16 @@ export function buildCommand( // --- Tabs ------------------------------------------------------------- case 'tab:list': return { command: 'tabs.list', args: {} }; + case 'tab:query': + return { command: 'tabs.query', args: { search: str(params, 'search') } }; + case 'tab:get': + return { command: 'tabs.status', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:count': + return { command: 'tabs.count', args: compact({ pattern: str(params, 'pattern') }) }; + case 'tab:filter': + return { command: 'tabs.filter', args: { pattern: str(params, 'pattern') } }; + case 'tab:activeInWindow': + return { command: 'tabs.active_in_window', args: { windowId: numArg(params.windowId) } }; case 'tab:open': { const focus = Boolean(params.focus); return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) }; @@ -63,6 +77,50 @@ export function buildCommand( } case 'tab:getHtml': return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:activate': + return { command: 'tabs.active', args: { tabId: numArg(params.tabId) } }; + case 'tab:move': + return { + command: 'tabs.move', + args: compact({ + tabId: numArg(params.tabId), + windowId: tabIdArg(params.windowId), + index: indexArg(params.index), + }), + }; + case 'tab:reload': + return { command: 'navigate.reload', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:hardReload': + return { command: 'navigate.hard_reload', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:back': + return { command: 'navigate.back', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:forward': + return { command: 'navigate.forward', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:navigateTo': + return { command: 'navigate.to', args: { tabId: numArg(params.tabId), url: str(params, 'url') } }; + case 'tab:mute': + return { command: 'tabs.mute', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:unmute': + return { command: 'tabs.unmute', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:pin': + return { command: 'tabs.pin', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:unpin': + return { command: 'tabs.unpin', args: compact({ tabId: tabIdArg(params.tabId) }) }; + case 'tab:dedupe': + return { command: 'tabs.dedupe', args: { gentleMode: str(params, 'gentleMode') || 'auto' } }; + case 'tab:sort': + return { command: 'tabs.sort', args: { by: str(params, 'by') || 'domain', gentleMode: str(params, 'gentleMode') || 'auto' } }; + case 'tab:mergeWindows': + return { command: 'tabs.merge_windows', args: { gentleMode: str(params, 'gentleMode') || 'auto' } }; + case 'tab:screenshot': + return { + command: 'tabs.screenshot', + args: compact({ + tabId: tabIdArg(params.tabId), + format: str(params, 'format') || 'png', + quality: indexArg(params.quality), + }), + }; // --- Page / extraction ------------------------------------------------ case 'page:info': @@ -77,6 +135,8 @@ export function buildCommand( return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractMarkdown': return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) }; + case 'page:extractJson': + return { command: 'extract.json', args: { selector: str(params, 'selector') } }; // --- DOM -------------------------------------------------------------- case 'dom:query': @@ -85,17 +145,118 @@ export function buildCommand( return { command: 'dom.click', args: { selector: str(params, 'selector') } }; case 'dom:type': return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } }; + case 'dom:attr': + return { command: 'dom.attr', args: { selector: str(params, 'selector'), attr: str(params, 'attr') } }; + case 'dom:text': + return { command: 'dom.text', args: { selector: str(params, 'selector') } }; + case 'dom:exists': + return { command: 'dom.exists', args: { selector: str(params, 'selector') } }; + case 'dom:scroll': + return { + command: 'dom.scroll', + args: compact({ selector: str(params, 'selector'), x: indexArg(params.x), y: indexArg(params.y) }), + }; + case 'dom:select': + return { command: 'dom.select', args: { selector: str(params, 'selector'), value: str(params, 'value') } }; + case 'dom:hover': + return { command: 'dom.hover', args: { selector: str(params, 'selector') } }; + case 'dom:check': + return { command: 'dom.check', args: { selector: str(params, 'selector') } }; + case 'dom:uncheck': + return { command: 'dom.uncheck', args: { selector: str(params, 'selector') } }; + case 'dom:clear': + return { command: 'dom.clear', args: { selector: str(params, 'selector') } }; + case 'dom:focus': + return { command: 'dom.focus', args: { selector: str(params, 'selector') } }; + case 'dom:submit': + return { command: 'dom.submit', args: { selector: str(params, 'selector') } }; + case 'dom:key': + return { command: 'dom.key', args: compact({ key: str(params, 'key'), selector: str(params, 'selector') }) }; case 'dom:eval': return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) }; + // --- Groups ----------------------------------------------------------- + case 'group:list': + return { command: 'group.list', args: {} }; + case 'group:query': + return { command: 'group.query', args: { search: str(params, 'search') } }; + case 'group:tabs': + return { command: 'group.tabs', args: { groupId: numArg(params.groupId) } }; + case 'group:count': + return { command: 'group.count', args: {} }; + case 'group:create': + return { command: 'group.open', args: { name: str(params, 'name') } }; + case 'group:addTab': + return { command: 'group.add_tab', args: compact({ group: str(params, 'group'), url: str(params, 'url') }) }; + case 'group:move': { + const direction = str(params, 'direction'); + return { + command: 'group.move', + args: { group: str(params, 'group'), forward: direction === 'forward', backward: direction === 'backward' }, + }; + } + case 'group:close': + return { command: 'group.close', args: { groupId: numArg(params.groupId), gentleMode: str(params, 'gentleMode') || 'auto' } }; + + // --- Windows ---------------------------------------------------------- + case 'window:list': + return { command: 'windows.list', args: {} }; + case 'window:open': + return { command: 'windows.open', args: compact({ url: str(params, 'url') }) }; + case 'window:close': + return { command: 'windows.close', args: { windowId: numArg(params.windowId) } }; + case 'window:rename': + return { command: 'windows.rename', args: { windowId: numArg(params.windowId), name: str(params, 'name') } }; + + // --- Sessions --------------------------------------------------------- + case 'session:list': + return { command: 'session.list', args: {} }; + case 'session:save': + return { command: 'session.save', args: { name: str(params, 'name') } }; + case 'session:load': + return { command: 'session.load', args: { name: str(params, 'name') } }; + case 'session:remove': + return { command: 'session.remove', args: { name: str(params, 'name') } }; + case 'session:export': + return { command: 'session.export', args: compact({ name: str(params, 'name') }) }; + case 'session:diff': + return { command: 'session.diff', args: { nameA: str(params, 'nameA'), nameB: str(params, 'nameB') } }; + case 'session:autoSave': + return { command: 'session.auto_save', args: { enabled: Boolean(params.enabled) } }; + + // --- Storage ---------------------------------------------------------- + case 'storage:get': + return { + command: 'storage.get', + args: compact({ key: str(params, 'key'), type: str(params, 'storeType') || 'local', tabId: tabIdArg(params.tabId) }), + }; + case 'storage:set': + return { + command: 'storage.set', + args: compact({ + key: str(params, 'key'), + value: str(params, 'value'), + type: str(params, 'storeType') || 'local', + tabId: tabIdArg(params.tabId), + }), + }; + + // --- Performance ------------------------------------------------------ + case 'perf:status': + return { command: 'perf.status', args: {} }; + + // --- Extension -------------------------------------------------------- + case 'extension:info': + return { command: 'extension.info', args: {} }; + case 'extension:capabilities': + return { command: 'extension.capabilities', args: {} }; + case 'extension:reload': + return { command: 'extension.reload', args: {} }; + // --- Clients ---------------------------------------------------------- case 'client:list': return { command: 'clients.list', args: {} }; - // --- Gateway: serve has no health route, so ping with a safe command -- - case 'gateway:health': - return { command: 'tabs.list', args: {} }; - default: throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`); } @@ -108,6 +269,19 @@ function tabIdArg(value: unknown): number | undefined { return Number.isFinite(n) && n > 0 ? n : undefined; } +/** A required numeric arg; non-finite values fall through as 0. */ +function numArg(value: unknown): number { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +} + +/** An optional numeric arg that keeps 0 (a meaningful index/coordinate). */ +function indexArg(value: unknown): number | undefined { + if (value === undefined || value === null || value === '') return undefined; + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + /** Accept an array, a JSON array string, or a comma/space separated list. */ export function parseTabIds(value: unknown): number[] { if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite); diff --git a/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts b/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts index 9f5989f..c78eb5e 100644 --- a/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts +++ b/n8n-nodes-browser-cli/nodes/BrowserCli/serveClient.ts @@ -15,7 +15,7 @@ import { buildAuthPayload, decodeResponse, frame, type Challenge } from './proto /** Version advertised to the server. Must be >= the server's PROTOCOL_MIN_CLIENT * (0.9.0) and >= 0.9.5 so the server enforces the post-quantum handshake this * client implements. */ -const CLIENT_VERSION = '0.15.4'; +const CLIENT_VERSION = '0.16.0'; const USER_AGENT = `browser-cli/${CLIENT_VERSION}`; // Force a plain-JSON, uncompressed response so no msgpack/zstd decoder is needed. const ACCEPT_ENCODING = { ser: ['json'], comp: [] as string[] }; diff --git a/n8n-nodes-browser-cli/package-lock.json b/n8n-nodes-browser-cli/package-lock.json index 6d80206..0792905 100644 --- a/n8n-nodes-browser-cli/package-lock.json +++ b/n8n-nodes-browser-cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "n8n-nodes-browser-cli", - "version": "0.2.4", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "n8n-nodes-browser-cli", - "version": "0.2.4", + "version": "0.3.0", "license": "PolyForm-Noncommercial-1.0.0", "dependencies": { "@noble/post-quantum": "^0.6.1" diff --git a/n8n-nodes-browser-cli/package.json b/n8n-nodes-browser-cli/package.json index 95f8db5..d3c20e6 100644 --- a/n8n-nodes-browser-cli/package.json +++ b/n8n-nodes-browser-cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-browser-cli", - "version": "0.2.4", + "version": "0.3.0", "description": "n8n community node that controls a remote browser by talking directly to a browser-cli serve endpoint (Ed25519 + post-quantum encrypted)", "keywords": [ "n8n-community-node-package", diff --git a/n8n-nodes-browser-cli/test/request.test.ts b/n8n-nodes-browser-cli/test/request.test.ts index 92f1fd0..aa32a24 100644 --- a/n8n-nodes-browser-cli/test/request.test.ts +++ b/n8n-nodes-browser-cli/test/request.test.ts @@ -11,10 +11,6 @@ test('client:list maps to clients.list', () => { assert.deepEqual(buildCommand('client', 'list', {}), { command: 'clients.list', args: {} }); }); -test('gateway:health pings with tabs.list (serve has no health route)', () => { - assert.deepEqual(buildCommand('gateway', 'health', {}), { command: 'tabs.list', args: {} }); -}); - test('tab:open sends navigate.open with background derived from focus', () => { const bg = buildCommand('tab', 'open', { url: 'https://example.com', focus: false }); assert.deepEqual(bg, { @@ -60,8 +56,128 @@ test('command:execute passes command and args through', () => { }); }); +test('tab read ops map to safe commands', () => { + assert.deepEqual(buildCommand('tab', 'query', { search: 'docs' }), { command: 'tabs.query', args: { search: 'docs' } }); + assert.deepEqual(buildCommand('tab', 'filter', { pattern: '*.dev/*' }), { command: 'tabs.filter', args: { pattern: '*.dev/*' } }); + assert.deepEqual(buildCommand('tab', 'count', { pattern: '' }).args, {}, 'empty pattern is dropped'); + assert.deepEqual(buildCommand('tab', 'get', { tabId: 0 }).args, {}, 'tabId 0 means active tab'); + assert.deepEqual(buildCommand('tab', 'get', { tabId: 5 }), { command: 'tabs.status', args: { tabId: 5 } }); + assert.deepEqual(buildCommand('tab', 'activeInWindow', { windowId: 3 }), { + command: 'tabs.active_in_window', + args: { windowId: 3 }, + }); +}); + +test('tab control ops map to navigate/tabs commands', () => { + assert.deepEqual(buildCommand('tab', 'activate', { tabId: 7 }), { command: 'tabs.active', args: { tabId: 7 } }); + assert.deepEqual(buildCommand('tab', 'navigateTo', { tabId: 7, url: 'https://x.dev' }), { + command: 'navigate.to', + args: { tabId: 7, url: 'https://x.dev' }, + }); + assert.deepEqual(buildCommand('tab', 'reload', { tabId: 0 }), { command: 'navigate.reload', args: {} }); + assert.deepEqual(buildCommand('tab', 'back', { tabId: 0 }).command, 'navigate.back'); + assert.deepEqual(buildCommand('tab', 'mute', { tabId: 2 }), { command: 'tabs.mute', args: { tabId: 2 } }); + assert.deepEqual(buildCommand('tab', 'pin', { tabId: 0 }), { command: 'tabs.pin', args: {} }); +}); + +test('tab move keeps index 0 but drops active tabId fallback for window', () => { + assert.deepEqual(buildCommand('tab', 'move', { tabId: 4, windowId: 0, index: 0 }), { + command: 'tabs.move', + args: { tabId: 4, index: 0 }, + }); + assert.deepEqual(buildCommand('tab', 'move', { tabId: 4, windowId: 9, index: '' }).args, { tabId: 4, windowId: 9 }); +}); + +test('tab rearrange ops default gentleMode and sort key', () => { + assert.deepEqual(buildCommand('tab', 'dedupe', {}), { command: 'tabs.dedupe', args: { gentleMode: 'auto' } }); + assert.deepEqual(buildCommand('tab', 'sort', { by: 'title' }), { + command: 'tabs.sort', + args: { by: 'title', gentleMode: 'auto' }, + }); + assert.deepEqual(buildCommand('tab', 'mergeWindows', {}).command, 'tabs.merge_windows'); +}); + +test('tab screenshot includes quality only when set', () => { + assert.deepEqual(buildCommand('tab', 'screenshot', { tabId: 0, format: 'png', quality: '' }).args, { format: 'png' }); + assert.deepEqual(buildCommand('tab', 'screenshot', { tabId: 1, format: 'jpeg', quality: 80 }).args, { + tabId: 1, + format: 'jpeg', + quality: 80, + }); +}); + +test('dom interaction ops map to dom.* commands', () => { + assert.deepEqual(buildCommand('dom', 'attr', { selector: 'a', attr: 'href' }), { + command: 'dom.attr', + args: { selector: 'a', attr: 'href' }, + }); + assert.deepEqual(buildCommand('dom', 'select', { selector: '#s', value: 'v' }), { + command: 'dom.select', + args: { selector: '#s', value: 'v' }, + }); + assert.deepEqual(buildCommand('dom', 'key', { key: 'Enter', selector: '' }).args, { key: 'Enter' }); + assert.deepEqual(buildCommand('dom', 'scroll', { selector: '', x: '', y: 500 }).args, { y: 500 }); + assert.deepEqual(buildCommand('dom', 'exists', { selector: '#x' }), { command: 'dom.exists', args: { selector: '#x' } }); +}); + +test('page extractJson sends selector', () => { + assert.deepEqual(buildCommand('page', 'extractJson', { selector: 'script' }), { + command: 'extract.json', + args: { selector: 'script' }, + }); +}); + +test('group ops map to group.* commands', () => { + assert.deepEqual(buildCommand('group', 'create', { name: 'Research' }), { command: 'group.open', args: { name: 'Research' } }); + assert.deepEqual(buildCommand('group', 'tabs', { groupId: 3 }), { command: 'group.tabs', args: { groupId: 3 } }); + assert.deepEqual(buildCommand('group', 'addTab', { group: 'Research', url: '' }).args, { group: 'Research' }); + assert.deepEqual(buildCommand('group', 'move', { group: '5', direction: 'backward' }), { + command: 'group.move', + args: { group: '5', forward: false, backward: true }, + }); + assert.deepEqual(buildCommand('group', 'close', { groupId: 2, gentleMode: 'off' }).args, { groupId: 2, gentleMode: 'off' }); +}); + +test('window ops map to windows.* commands', () => { + assert.deepEqual(buildCommand('window', 'open', { url: '' }), { command: 'windows.open', args: {} }); + assert.deepEqual(buildCommand('window', 'rename', { windowId: 1, name: 'Work' }), { + command: 'windows.rename', + args: { windowId: 1, name: 'Work' }, + }); +}); + +test('session ops map to session.* commands', () => { + assert.deepEqual(buildCommand('session', 'save', { name: 'morning' }), { command: 'session.save', args: { name: 'morning' } }); + assert.deepEqual(buildCommand('session', 'export', { name: '' }), { command: 'session.export', args: {} }); + assert.deepEqual(buildCommand('session', 'diff', { nameA: 'a', nameB: 'b' }).args, { nameA: 'a', nameB: 'b' }); + assert.deepEqual(buildCommand('session', 'autoSave', { enabled: false }), { + command: 'session.auto_save', + args: { enabled: false }, + }); +}); + +test('storage ops default to local and drop active tabId', () => { + assert.deepEqual(buildCommand('storage', 'get', { key: 'token', storeType: 'local', tabId: 0 }), { + command: 'storage.get', + args: { key: 'token', type: 'local' }, + }); + assert.deepEqual(buildCommand('storage', 'set', { key: 'k', value: 'v', storeType: 'session', tabId: 4 }), { + command: 'storage.set', + args: { key: 'k', value: 'v', type: 'session', tabId: 4 }, + }); + assert.deepEqual(buildCommand('storage', 'get', { key: '', storeType: 'local', tabId: 0 }).args, { type: 'local' }); +}); + +test('perf and extension ops map to safe/control commands', () => { + assert.deepEqual(buildCommand('perf', 'status', {}), { command: 'perf.status', args: {} }); + assert.deepEqual(buildCommand('extension', 'capabilities', {}), { command: 'extension.capabilities', args: {} }); + assert.deepEqual(buildCommand('extension', 'reload', {}), { command: 'extension.reload', args: {} }); +}); + test('unknown operation throws', () => { assert.throws(() => buildCommand('tab', 'nope', {}), /Unsupported operation/); + assert.throws(() => buildCommand('connection', 'health', {}), /Unsupported operation/); + assert.throws(() => buildCommand('gateway', 'health', {}), /Unsupported operation/); }); test('parseTabIds accepts array, json, and delimited strings', () => {