@web-auto/camo 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@web-auto/camo",
3
- "version": "0.1.0002",
3
+ "version": "0.1.0003",
4
4
  "description": "Camoufox Browser CLI - Cross-platform browser automation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,8 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "build": "node scripts/build.mjs",
18
- "test": "node --test tests/*.test.mjs",
18
+ "test": "node --test 'tests/**/*.test.mjs'",
19
+ "test:coverage": "c8 --reporter=text --reporter=lcov node --test 'tests/**/*.test.mjs'",
19
20
  "version:bump": "node scripts/bump-version.mjs",
20
21
  "install:global": "npm run build && npm install -g .",
21
22
  "uninstall:global": "npm uninstall -g @web-auto/camo",
@@ -32,8 +33,10 @@
32
33
  "engines": {
33
34
  "node": ">=20.0.0"
34
35
  },
35
- "dependencies": {},
36
36
  "peerDependencies": {
37
37
  "camoufox": "^0.1.0"
38
+ },
39
+ "devDependencies": {
40
+ "c8": "^10.1.3"
38
41
  }
39
42
  }
package/src/cli.mjs CHANGED
@@ -11,6 +11,7 @@ import { handleCookiesCommand } from './commands/cookies.mjs';
11
11
  import { handleWindowCommand } from './commands/window.mjs';
12
12
  import { handleMouseCommand } from './commands/mouse.mjs';
13
13
  import { handleSystemCommand } from './commands/system.mjs';
14
+ import { handleContainerCommand } from './commands/container.mjs';
14
15
  import {
15
16
  handleStartCommand, handleStopCommand, handleStatusCommand,
16
17
  handleGotoCommand, handleBackCommand, handleScreenshotCommand,
@@ -112,11 +113,16 @@ async function main() {
112
113
  return;
113
114
  }
114
115
 
116
+ if (cmd === 'container') {
117
+ await handleContainerCommand(args);
118
+ return;
119
+ }
120
+
115
121
  const serviceCommands = new Set([
116
122
  'start', 'stop', 'close', 'status', 'list', 'goto', 'navigate', 'back', 'screenshot',
117
123
  'new-page', 'close-page', 'switch-page', 'list-pages', 'shutdown',
118
124
  'scroll', 'click', 'type', 'highlight', 'clear-highlight', 'viewport',
119
- 'cookies', 'window', 'mouse', 'system',
125
+ 'cookies', 'window', 'mouse', 'system', 'container',
120
126
  ]);
121
127
 
122
128
  if (!serviceCommands.has(cmd)) {
@@ -0,0 +1,178 @@
1
+ // Container commands - filter and watch elements
2
+ import { resolveProfileId, getPositionals } from '../utils/args.mjs';
3
+ import { callAPI, getSessionByProfile } from '../utils/browser-service.mjs';
4
+ import { getDefaultProfile } from '../utils/config.mjs';
5
+ import { getChangeNotifier } from '../container/change-notifier.mjs';
6
+ import { createElementFilter } from '../container/element-filter.mjs';
7
+
8
+ const notifier = getChangeNotifier();
9
+ const elementFilter = createElementFilter();
10
+
11
+ export async function handleContainerFilterCommand(args) {
12
+ const profileId = resolveProfileId(args);
13
+ const session = await getSessionByProfile(profileId);
14
+ if (!session) {
15
+ throw new Error(`No active session for profile: ${profileId || 'default'}`);
16
+ }
17
+
18
+ const selectors = [];
19
+ for (let i = 1; i < args.length; i++) {
20
+ const arg = args[i];
21
+ if (arg === '--profile' || arg === '-p') { i++; continue; }
22
+ if (arg.startsWith('--')) continue;
23
+ selectors.push(arg);
24
+ }
25
+
26
+ if (selectors.length === 0) {
27
+ throw new Error('Usage: camo container filter <selector> [--profile <id>]');
28
+ }
29
+
30
+ // Get DOM snapshot from browser service
31
+ const result = await callAPI(`/session/${session.session_id}/dom-tree`, { method: 'POST' });
32
+ const snapshot = result.dom_tree || result;
33
+
34
+ // Filter elements
35
+ const matched = [];
36
+ for (const selector of selectors) {
37
+ const elements = notifier.findElements(snapshot, { css: selector });
38
+ matched.push(...elements.map(e => ({
39
+ path: e.path,
40
+ tag: e.tag,
41
+ id: e.id,
42
+ classes: e.classes,
43
+ text: (e.textSnippet || e.text || '').slice(0, 50),
44
+ })));
45
+ }
46
+
47
+ console.log(JSON.stringify({ ok: true, count: matched.length, elements: matched }, null, 2));
48
+ }
49
+
50
+ export async function handleContainerWatchCommand(args) {
51
+ const profileId = resolveProfileId(args);
52
+ const session = await getSessionByProfile(profileId);
53
+ if (!session) {
54
+ throw new Error(`No active session for profile: ${profileId || 'default'}`);
55
+ }
56
+
57
+ const positionalArgs = getPositionals(args, ['--profile', '-p', '--selector', '-s', '--throttle', '-t']);
58
+
59
+ const selectorIdx = args.indexOf('--selector') !== -1 ? args.indexOf('--selector') : args.indexOf('-s');
60
+ const throttleIdx = args.indexOf('--throttle') !== -1 ? args.indexOf('--throttle') : args.indexOf('-t');
61
+
62
+ const selector = selectorIdx >= 0 ? args[selectorIdx + 1] : positionalArgs[0];
63
+ const throttle = throttleIdx >= 0 ? parseInt(args[throttleIdx + 1], 10) : 500;
64
+
65
+ if (!selector) {
66
+ throw new Error('Usage: camo container watch --selector <css> [--throttle ms] [--profile <id>]');
67
+ }
68
+
69
+ console.log(JSON.stringify({ ok: true, message: `Watching selector: ${selector}`, throttle }));
70
+
71
+ // Setup WebSocket connection for DOM updates
72
+ // For now, poll the browser service
73
+ const interval = setInterval(async () => {
74
+ try {
75
+ const result = await callAPI(`/session/${session.session_id}/dom-tree`, { method: 'POST' });
76
+ const snapshot = result.dom_tree || result;
77
+ notifier.processSnapshot(snapshot);
78
+ } catch (err) {
79
+ console.error(JSON.stringify({ ok: false, error: err.message }));
80
+ }
81
+ }, throttle);
82
+
83
+ // Watch the selector
84
+ notifier.watch({ css: selector }, {
85
+ onAppear: (elements) => {
86
+ console.log(JSON.stringify({ event: 'appear', selector, count: elements.length, elements }));
87
+ },
88
+ onDisappear: (elements) => {
89
+ console.log(JSON.stringify({ event: 'disappear', selector, count: elements.length }));
90
+ },
91
+ onChange: ({ appeared, disappeared }) => {
92
+ console.log(JSON.stringify({ event: 'change', selector, appeared: appeared.length, disappeared: disappeared.length }));
93
+ },
94
+ throttle,
95
+ });
96
+
97
+ // Keep process alive
98
+ process.on('SIGINT', () => {
99
+ clearInterval(interval);
100
+ notifier.destroy();
101
+ console.log(JSON.stringify({ ok: true, message: 'Watch stopped' }));
102
+ process.exit(0);
103
+ });
104
+ }
105
+
106
+ export async function handleContainerListCommand(args) {
107
+ const profileId = resolveProfileId(args);
108
+ const session = await getSessionByProfile(profileId);
109
+ if (!session) {
110
+ throw new Error(`No active session for profile: ${profileId || 'default'}`);
111
+ }
112
+
113
+ const result = await callAPI(`/session/${session.session_id}/dom-tree`, { method: 'POST' });
114
+ const snapshot = result.dom_tree || result;
115
+
116
+ // Get viewport info
117
+ const viewportResult = await callAPI(`/session/${session.session_id}/viewport`);
118
+ const viewport = viewportResult.viewport || { width: 1280, height: 720 };
119
+
120
+ // Collect all visible elements
121
+ const collectElements = (node, path = 'root') => {
122
+ const elements = [];
123
+ if (!node) return elements;
124
+
125
+ const rect = node.rect || node.bbox;
126
+ if (rect && viewport) {
127
+ const inViewport = elementFilter.isInViewport(rect, viewport);
128
+ const visibilityRatio = elementFilter.getVisibilityRatio(rect, viewport);
129
+
130
+ if (inViewport && visibilityRatio > 0.1) {
131
+ elements.push({
132
+ path,
133
+ tag: node.tag,
134
+ id: node.id,
135
+ classes: node.classes?.slice(0, 3),
136
+ visibilityRatio: Math.round(visibilityRatio * 100) / 100,
137
+ rect: { x: rect.left || rect.x, y: rect.top || rect.y, w: rect.width, h: rect.height },
138
+ });
139
+ }
140
+ }
141
+
142
+ if (node.children) {
143
+ for (let i = 0; i < node.children.length; i++) {
144
+ elements.push(...collectElements(node.children[i], `${path}/${i}`));
145
+ }
146
+ }
147
+
148
+ return elements;
149
+ };
150
+
151
+ const elements = collectElements(snapshot);
152
+ console.log(JSON.stringify({ ok: true, viewport, count: elements.length, elements: elements.slice(0, 50) }, null, 2));
153
+ }
154
+
155
+ export async function handleContainerCommand(args) {
156
+ const sub = args[1];
157
+
158
+ switch (sub) {
159
+ case 'filter':
160
+ return handleContainerFilterCommand(args.slice(1));
161
+ case 'watch':
162
+ return handleContainerWatchCommand(args.slice(1));
163
+ case 'list':
164
+ return handleContainerListCommand(args.slice(1));
165
+ default:
166
+ console.log(`Usage: camo container <filter|watch|list> [options]
167
+
168
+ Commands:
169
+ filter <selector> - Filter DOM elements by CSS selector
170
+ watch --selector <css> - Watch for element changes (outputs JSON events)
171
+ list - List all visible elements in viewport
172
+
173
+ Options:
174
+ --profile, -p <id> - Profile to use
175
+ --throttle, -t <ms> - Throttle interval for watch (default: 500)
176
+ `);
177
+ }
178
+ }
@@ -0,0 +1,170 @@
1
+ // Change Notifier - Subscribe to DOM changes and element events
2
+
3
+ export class ChangeNotifier {
4
+ constructor() {
5
+ this.subscriptions = new Map(); // topic -> Set<callback>
6
+ this.elementWatchers = new Map(); // selector -> { lastState, callbacks }
7
+ this.lastSnapshot = null;
8
+ }
9
+
10
+ // Subscribe to a topic
11
+ subscribe(topic, callback) {
12
+ if (!this.subscriptions.has(topic)) {
13
+ this.subscriptions.set(topic, new Set());
14
+ }
15
+ this.subscriptions.get(topic).add(callback);
16
+ return () => {
17
+ this.subscriptions.get(topic)?.delete(callback);
18
+ };
19
+ }
20
+
21
+ // Watch specific elements by selector
22
+ watch(selector, options = {}) {
23
+ const { onAppear, onDisappear, onChange, throttle = 200 } = options;
24
+
25
+ if (!this.elementWatchers.has(selector)) {
26
+ this.elementWatchers.set(selector, {
27
+ lastState: null,
28
+ lastNotifyTime: 0,
29
+ callbacks: { onAppear, onDisappear, onChange },
30
+ });
31
+ } else {
32
+ const watcher = this.elementWatchers.get(selector);
33
+ if (onAppear) watcher.callbacks.onAppear = onAppear;
34
+ if (onDisappear) watcher.callbacks.onDisappear = onDisappear;
35
+ if (onChange) watcher.callbacks.onChange = onChange;
36
+ }
37
+
38
+ return () => {
39
+ this.elementWatchers.delete(selector);
40
+ };
41
+ }
42
+
43
+ // Notify all subscribers of a topic
44
+ notify(topic, data) {
45
+ const callbacks = this.subscriptions.get(topic);
46
+ if (!callbacks) return;
47
+ for (const callback of callbacks) {
48
+ try {
49
+ callback(data);
50
+ } catch (err) {
51
+ console.error(`[ChangeNotifier] callback error for ${topic}:`, err);
52
+ }
53
+ }
54
+ }
55
+
56
+ // Process new DOM snapshot and trigger notifications
57
+ processSnapshot(snapshot) {
58
+ const now = Date.now();
59
+ const prevSnapshot = this.lastSnapshot;
60
+ this.lastSnapshot = snapshot;
61
+
62
+ // Notify general DOM change
63
+ this.notify('dom:changed', { snapshot, prevSnapshot });
64
+
65
+ // Process element watchers
66
+ for (const [selector, watcher] of this.elementWatchers) {
67
+ const { lastState, callbacks, lastNotifyTime, throttle } = watcher;
68
+
69
+ // Throttle notifications
70
+ if (now - lastNotifyTime < throttle) continue;
71
+
72
+ const currentElements = this.findElements(snapshot, selector);
73
+ const currentState = currentElements.map(e => e.path).sort().join(',');
74
+
75
+ if (lastState !== null && currentState !== lastState) {
76
+ // Something changed
77
+ const prevElements = watcher.prevElements || [];
78
+ const appeared = currentElements.filter(e => !prevElements.find(p => p.path === e.path));
79
+ const disappeared = prevElements.filter(e => !currentElements.find(c => c.path === e.path));
80
+
81
+ if (appeared.length > 0 && callbacks.onAppear) {
82
+ callbacks.onAppear(appeared);
83
+ watcher.lastNotifyTime = now;
84
+ }
85
+ if (disappeared.length > 0 && callbacks.onDisappear) {
86
+ callbacks.onDisappear(disappeared);
87
+ watcher.lastNotifyTime = now;
88
+ }
89
+ if (callbacks.onChange) {
90
+ callbacks.onChange({ current: currentElements, previous: prevElements, appeared, disappeared });
91
+ watcher.lastNotifyTime = now;
92
+ }
93
+ }
94
+
95
+ watcher.lastState = currentState;
96
+ watcher.prevElements = currentElements;
97
+ }
98
+ }
99
+
100
+ // Find elements matching selector in DOM tree
101
+ findElements(node, selector, path = 'root') {
102
+ const results = [];
103
+ if (!node) return results;
104
+
105
+ // Check if current node matches
106
+ if (this.nodeMatchesSelector(node, selector)) {
107
+ results.push({ ...node, path });
108
+ }
109
+
110
+ // Recurse into children
111
+ if (node.children) {
112
+ for (let i = 0; i < node.children.length; i++) {
113
+ const childResults = this.findElements(node.children[i], selector, `${path}/${i}`);
114
+ results.push(...childResults);
115
+ }
116
+ }
117
+
118
+ return results;
119
+ }
120
+
121
+ // Check if node matches selector
122
+ nodeMatchesSelector(node, selector) {
123
+ if (!node) return false;
124
+
125
+ // CSS selector match
126
+ if (selector.css && node.selector === selector.css) return true;
127
+ if (selector.css && node.classes) {
128
+ const selClasses = selector.css.match(/\.[\w-]+/g);
129
+ if (selClasses) {
130
+ const nodeClasses = new Set(node.classes || []);
131
+ if (selClasses.map(s => s.slice(1)).every(c => nodeClasses.has(c))) return true;
132
+ }
133
+ }
134
+
135
+ // ID match
136
+ if (selector.id && node.id === selector.id) return true;
137
+
138
+ // Class match
139
+ if (selector.classes) {
140
+ const nodeClasses = new Set(node.classes || []);
141
+ if (selector.classes.every(c => nodeClasses.has(c))) return true;
142
+ }
143
+
144
+ return false;
145
+ }
146
+
147
+ // Cleanup
148
+ destroy() {
149
+ this.subscriptions.clear();
150
+ this.elementWatchers.clear();
151
+ this.lastSnapshot = null;
152
+ }
153
+ }
154
+
155
+ // Global instance
156
+ let globalNotifier = null;
157
+
158
+ export function getChangeNotifier() {
159
+ if (!globalNotifier) {
160
+ globalNotifier = new ChangeNotifier();
161
+ }
162
+ return globalNotifier;
163
+ }
164
+
165
+ export function destroyChangeNotifier() {
166
+ if (globalNotifier) {
167
+ globalNotifier.destroy();
168
+ globalNotifier = null;
169
+ }
170
+ }
@@ -0,0 +1,97 @@
1
+ // Container Element Filter - Filter DOM elements by visibility, container definitions
2
+
3
+ export class ElementFilter {
4
+ constructor(options = {}) {
5
+ this.viewportMargin = options.viewportMargin || 0;
6
+ this.minVisibleRatio = options.minVisibleRatio || 0.5;
7
+ }
8
+
9
+ // Check if element is in viewport
10
+ isInViewport(rect, viewport) {
11
+ return (
12
+ rect.left < viewport.width + this.viewportMargin &&
13
+ rect.right > -this.viewportMargin &&
14
+ rect.top < viewport.height + this.viewportMargin &&
15
+ rect.bottom > -this.viewportMargin
16
+ );
17
+ }
18
+
19
+ // Calculate visibility ratio
20
+ getVisibilityRatio(rect, viewport) {
21
+ const visibleLeft = Math.max(0, rect.left);
22
+ const visibleTop = Math.max(0, rect.top);
23
+ const visibleRight = Math.min(viewport.width, rect.right);
24
+ const visibleBottom = Math.min(viewport.height, rect.bottom);
25
+
26
+ const visibleArea = Math.max(0, visibleRight - visibleLeft) * Math.max(0, visibleBottom - visibleTop);
27
+ const totalArea = rect.width * rect.height;
28
+
29
+ return totalArea > 0 ? visibleArea / totalArea : 0;
30
+ }
31
+
32
+ // Filter elements by container definition
33
+ filterByContainer(elements, containerDef) {
34
+ const selectors = containerDef.selectors || [];
35
+ const results = [];
36
+
37
+ for (const element of elements) {
38
+ for (const selector of selectors) {
39
+ if (this.matchesSelector(element, selector)) {
40
+ results.push({
41
+ element,
42
+ container: containerDef,
43
+ matchedSelector: selector,
44
+ });
45
+ break;
46
+ }
47
+ }
48
+ }
49
+
50
+ return results;
51
+ }
52
+
53
+ // Check if element matches selector definition
54
+ matchesSelector(element, selector) {
55
+ if (selector.css && element.selector === selector.css) return true;
56
+ if (selector.id && element.id === selector.id) return true;
57
+ if (selector.classes) {
58
+ const elementClasses = new Set(element.classes || []);
59
+ if (selector.classes.every(c => elementClasses.has(c))) return true;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ // Main filter method
65
+ filter(elements, options = {}) {
66
+ const {
67
+ container,
68
+ viewport,
69
+ requireVisible = true,
70
+ minVisibleRatio = this.minVisibleRatio,
71
+ } = options;
72
+
73
+ let results = [...elements];
74
+
75
+ // Filter by container definition
76
+ if (container) {
77
+ results = this.filterByContainer(results, container);
78
+ }
79
+
80
+ // Filter by viewport visibility
81
+ if (requireVisible && viewport) {
82
+ results = results.filter(item => {
83
+ const rect = item.element?.rect || item.rect;
84
+ if (!rect) return false;
85
+ const ratio = this.getVisibilityRatio(rect, viewport);
86
+ item.visibilityRatio = ratio;
87
+ return ratio >= minVisibleRatio;
88
+ });
89
+ }
90
+
91
+ return results;
92
+ }
93
+ }
94
+
95
+ export function createElementFilter(options) {
96
+ return new ElementFilter(options);
97
+ }
@@ -0,0 +1,3 @@
1
+ // Container module - element filtering and change notification
2
+ export { ElementFilter, createElementFilter } from './element-filter.mjs';
3
+ export { ChangeNotifier, getChangeNotifier, destroyChangeNotifier } from './change-notifier.mjs';
@@ -0,0 +1,197 @@
1
+ // Camo Container Client - High-level API for container subscription
2
+
3
+ import { callAPI, getSessionByProfile, checkBrowserService } from '../utils/browser-service.mjs';
4
+ import { getDefaultProfile } from '../utils/config.mjs';
5
+ import { getChangeNotifier } from '../container/change-notifier.mjs';
6
+ import { createElementFilter } from '../container/element-filter.mjs';
7
+
8
+ export class CamoContainerClient {
9
+ constructor(options = {}) {
10
+ this.profileId = options.profileId || getDefaultProfile();
11
+ this.serviceUrl = options.serviceUrl || 'http://127.0.0.1:7704';
12
+ this.notifier = getChangeNotifier();
13
+ this.filter = createElementFilter(options.filterOptions || {});
14
+ this.session = null;
15
+ this.pollInterval = null;
16
+ this.subscriptions = new Map(); // containerId -> callback
17
+ this.lastSnapshot = null;
18
+ this.viewport = { width: 1280, height: 720 };
19
+ }
20
+
21
+ async ensureSession() {
22
+ if (this.session) return this.session;
23
+
24
+ if (!await checkBrowserService()) {
25
+ throw new Error('Browser service not running. Run: camo init');
26
+ }
27
+
28
+ this.session = await getSessionByProfile(this.profileId);
29
+ if (!this.session) {
30
+ throw new Error(`No active session for profile: ${this.profileId}`);
31
+ }
32
+
33
+ return this.session;
34
+ }
35
+
36
+ async getSnapshot() {
37
+ await this.ensureSession();
38
+
39
+ const result = await callAPI(`/session/${this.session.session_id}/dom-tree`, { method: 'POST' });
40
+ this.lastSnapshot = result.dom_tree || result;
41
+ return this.lastSnapshot;
42
+ }
43
+
44
+ async getViewport() {
45
+ await this.ensureSession();
46
+
47
+ try {
48
+ const result = await callAPI(`/session/${this.session.session_id}/viewport`);
49
+ this.viewport = result.viewport || this.viewport;
50
+ } catch {}
51
+
52
+ return this.viewport;
53
+ }
54
+
55
+ // Subscribe to container changes
56
+ async subscribe(containers, options = {}) {
57
+ const { throttle = 500 } = options;
58
+ await this.ensureSession();
59
+
60
+ for (const container of containers) {
61
+ const { containerId, selector, onAppear, onDisappear, onChange } = container;
62
+
63
+ this.notifier.watch(
64
+ typeof selector === 'string' ? { css: selector } : selector,
65
+ {
66
+ onAppear: (elements) => {
67
+ this.subscriptions.get(containerId)?.onAppear?.(elements);
68
+ },
69
+ onDisappear: (elements) => {
70
+ this.subscriptions.get(containerId)?.onDisappear?.(elements);
71
+ },
72
+ onChange: (data) => {
73
+ this.subscriptions.get(containerId)?.onChange?.(data);
74
+ },
75
+ throttle,
76
+ }
77
+ );
78
+
79
+ this.subscriptions.set(containerId, {
80
+ selector,
81
+ onAppear,
82
+ onDisappear,
83
+ onChange,
84
+ });
85
+ }
86
+
87
+ // Start polling
88
+ if (!this.pollInterval) {
89
+ this.pollInterval = setInterval(async () => {
90
+ try {
91
+ const snapshot = await this.getSnapshot();
92
+ this.notifier.processSnapshot(snapshot);
93
+ } catch (err) {
94
+ // Ignore errors during polling
95
+ }
96
+ }, throttle);
97
+ }
98
+
99
+ return {
100
+ unsubscribe: () => {
101
+ for (const container of containers) {
102
+ this.subscriptions.delete(container.containerId);
103
+ }
104
+ },
105
+ };
106
+ }
107
+
108
+ // Checkpoint detection helper
109
+ async detectCheckpoint(checkpointRules) {
110
+ const snapshot = await this.getSnapshot();
111
+ const viewport = await this.getViewport();
112
+
113
+ const matched = new Set();
114
+
115
+ for (const [checkpointId, rule] of Object.entries(checkpointRules)) {
116
+ const { selectors, requireAll = false } = rule;
117
+ let matchCount = 0;
118
+
119
+ for (const selector of selectors) {
120
+ const elements = this.notifier.findElements(snapshot, { css: selector });
121
+ const visible = elements.filter(e => {
122
+ const rect = e.rect || e.bbox;
123
+ if (!rect) return false;
124
+ return this.filter.isInViewport(rect, viewport);
125
+ });
126
+
127
+ if (visible.length > 0) {
128
+ matchCount++;
129
+ }
130
+ }
131
+
132
+ if (requireAll) {
133
+ if (matchCount === selectors.length) {
134
+ matched.add(checkpointId);
135
+ }
136
+ } else {
137
+ if (matchCount > 0) {
138
+ matched.add(checkpointId);
139
+ }
140
+ }
141
+ }
142
+
143
+ return Array.from(matched);
144
+ }
145
+
146
+ // List visible elements
147
+ async listVisibleElements(options = {}) {
148
+ const { minVisibility = 0.1, maxResults = 50 } = options;
149
+ const snapshot = await this.getSnapshot();
150
+ const viewport = await this.getViewport();
151
+
152
+ const collect = (node, path = 'root') => {
153
+ const elements = [];
154
+ if (!node) return elements;
155
+
156
+ const rect = node.rect || node.bbox;
157
+ if (rect) {
158
+ const ratio = this.filter.getVisibilityRatio(rect, viewport);
159
+ if (ratio >= minVisibility) {
160
+ elements.push({
161
+ path,
162
+ tag: node.tag,
163
+ id: node.id,
164
+ classes: node.classes?.slice(0, 3),
165
+ visibilityRatio: Math.round(ratio * 100) / 100,
166
+ rect: { x: rect.left || rect.x, y: rect.top || rect.y, w: rect.width, h: rect.height },
167
+ });
168
+ }
169
+ }
170
+
171
+ if (node.children) {
172
+ for (let i = 0; i < node.children.length; i++) {
173
+ elements.push(...collect(node.children[i], `${path}/${i}`));
174
+ }
175
+ }
176
+
177
+ return elements;
178
+ };
179
+
180
+ const all = collect(snapshot);
181
+ return all.slice(0, maxResults);
182
+ }
183
+
184
+ // Cleanup
185
+ destroy() {
186
+ if (this.pollInterval) {
187
+ clearInterval(this.pollInterval);
188
+ this.pollInterval = null;
189
+ }
190
+ this.subscriptions.clear();
191
+ this.notifier.destroy();
192
+ }
193
+ }
194
+
195
+ export function createCamoClient(options) {
196
+ return new CamoContainerClient(options);
197
+ }
@@ -114,6 +114,11 @@ EXAMPLES:
114
114
  camo unlock myprofile
115
115
  camo stop
116
116
 
117
+ CONTAINER FILTER & SUBSCRIPTION:
118
+ container filter <selector> [--profile <id>] Filter DOM elements by CSS selector
119
+ container watch --selector <css> Watch for element changes
120
+ container list List visible elements in viewport
121
+
117
122
  ENV:
118
123
  WEBAUTO_BROWSER_URL Default: http://127.0.0.1:7704
119
124
  WEBAUTO_REPO_ROOT Optional explicit webauto repo root