@web-auto/camo 0.2.0 → 0.2.1

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.
Files changed (114) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +586 -586
  3. package/bin/browser-service.mjs +11 -11
  4. package/bin/camo.mjs +22 -22
  5. package/package.json +48 -48
  6. package/scripts/build.mjs +19 -19
  7. package/scripts/bump-version.mjs +34 -34
  8. package/scripts/check-file-size.mjs +80 -80
  9. package/scripts/file-size-policy.json +12 -2
  10. package/scripts/install.mjs +76 -76
  11. package/scripts/release.sh +54 -54
  12. package/src/autoscript/action-providers/index.mjs +6 -6
  13. package/src/autoscript/impact-engine.mjs +78 -78
  14. package/src/autoscript/runtime.mjs +1017 -1017
  15. package/src/autoscript/schema.mjs +376 -376
  16. package/src/cli.mjs +405 -405
  17. package/src/commands/attach.mjs +141 -141
  18. package/src/commands/autoscript.mjs +1011 -1011
  19. package/src/commands/browser.mjs +1255 -1255
  20. package/src/commands/container.mjs +401 -401
  21. package/src/commands/cookies.mjs +69 -69
  22. package/src/commands/create.mjs +98 -98
  23. package/src/commands/devtools.mjs +349 -349
  24. package/src/commands/events.mjs +152 -152
  25. package/src/commands/highlight-mode.mjs +24 -24
  26. package/src/commands/init.mjs +68 -68
  27. package/src/commands/lifecycle.mjs +275 -275
  28. package/src/commands/mouse.mjs +45 -45
  29. package/src/commands/profile.mjs +46 -46
  30. package/src/commands/record.mjs +115 -115
  31. package/src/commands/system.mjs +14 -14
  32. package/src/commands/window.mjs +123 -123
  33. package/src/container/change-notifier.mjs +362 -362
  34. package/src/container/element-filter.mjs +143 -143
  35. package/src/container/index.mjs +3 -3
  36. package/src/container/runtime-core/checkpoint.mjs +209 -209
  37. package/src/container/runtime-core/index.mjs +21 -21
  38. package/src/container/runtime-core/operations/index.mjs +774 -774
  39. package/src/container/runtime-core/operations/selector-scripts.mjs +277 -277
  40. package/src/container/runtime-core/operations/tab-pool.mjs +746 -746
  41. package/src/container/runtime-core/operations/viewport.mjs +189 -189
  42. package/src/container/runtime-core/search.mjs +190 -190
  43. package/src/container/runtime-core/subscription.mjs +224 -224
  44. package/src/container/runtime-core/utils.mjs +94 -94
  45. package/src/container/runtime-core/validation.mjs +127 -127
  46. package/src/container/runtime-core.mjs +1 -1
  47. package/src/container/subscription-registry.mjs +459 -459
  48. package/src/core/actions.mjs +561 -561
  49. package/src/core/browser.mjs +266 -266
  50. package/src/core/index.mjs +52 -52
  51. package/src/core/utils.mjs +91 -91
  52. package/src/events/daemon-entry.mjs +33 -33
  53. package/src/events/daemon.mjs +80 -80
  54. package/src/events/progress-log.mjs +109 -109
  55. package/src/events/ws-server.mjs +239 -239
  56. package/src/lib/client.mjs +200 -200
  57. package/src/lifecycle/cleanup.mjs +83 -83
  58. package/src/lifecycle/lock.mjs +126 -126
  59. package/src/lifecycle/session-registry.mjs +279 -279
  60. package/src/lifecycle/session-view.mjs +76 -76
  61. package/src/lifecycle/session-watchdog.mjs +281 -281
  62. package/src/services/browser-service/index.js +671 -671
  63. package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
  64. package/src/services/browser-service/internal/BrowserSession.js +325 -304
  65. package/src/services/browser-service/internal/ElementRegistry.js +60 -60
  66. package/src/services/browser-service/internal/ProfileLock.js +84 -84
  67. package/src/services/browser-service/internal/SessionManager.js +184 -184
  68. package/src/services/browser-service/internal/SessionManager.test.js +39 -39
  69. package/src/services/browser-service/internal/browser-session/cookies.js +144 -144
  70. package/src/services/browser-service/internal/browser-session/input-ops.js +222 -222
  71. package/src/services/browser-service/internal/browser-session/input-pipeline.js +144 -144
  72. package/src/services/browser-service/internal/browser-session/logging.js +46 -46
  73. package/src/services/browser-service/internal/browser-session/navigation.js +38 -38
  74. package/src/services/browser-service/internal/browser-session/page-hooks.js +442 -442
  75. package/src/services/browser-service/internal/browser-session/page-management.js +302 -302
  76. package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -148
  77. package/src/services/browser-service/internal/browser-session/recording.js +198 -198
  78. package/src/services/browser-service/internal/browser-session/runtime-events.js +61 -61
  79. package/src/services/browser-service/internal/browser-session/session-core.js +84 -84
  80. package/src/services/browser-service/internal/browser-session/session-state.js +38 -38
  81. package/src/services/browser-service/internal/browser-session/types.js +14 -14
  82. package/src/services/browser-service/internal/browser-session/utils.js +95 -95
  83. package/src/services/browser-service/internal/browser-session/viewport-manager.js +46 -46
  84. package/src/services/browser-service/internal/browser-session/viewport.js +215 -215
  85. package/src/services/browser-service/internal/container-matcher.js +851 -851
  86. package/src/services/browser-service/internal/container-registry.js +182 -182
  87. package/src/services/browser-service/internal/engine-manager.js +259 -259
  88. package/src/services/browser-service/internal/fingerprint.js +203 -203
  89. package/src/services/browser-service/internal/heartbeat.js +137 -137
  90. package/src/services/browser-service/internal/logging.js +46 -46
  91. package/src/services/browser-service/internal/page-runtime/runtime.js +1317 -1317
  92. package/src/services/browser-service/internal/pageRuntime.js +28 -28
  93. package/src/services/browser-service/internal/runtimeInjector.js +31 -31
  94. package/src/services/browser-service/internal/service-process-logger.js +140 -140
  95. package/src/services/browser-service/internal/state-bus.js +45 -45
  96. package/src/services/browser-service/internal/storage-paths.js +42 -42
  97. package/src/services/browser-service/internal/ws-server.js +1194 -1194
  98. package/src/services/browser-service/internal/ws-server.test.js +58 -58
  99. package/src/services/browser-service/server.mjs +6 -6
  100. package/src/services/controller/cli-bridge.js +93 -93
  101. package/src/services/controller/container-index.js +50 -50
  102. package/src/services/controller/container-storage.js +36 -36
  103. package/src/services/controller/controller-actions.js +207 -207
  104. package/src/services/controller/controller.js +1138 -1138
  105. package/src/services/controller/selectors.js +54 -54
  106. package/src/services/controller/transport.js +125 -125
  107. package/src/utils/args.mjs +26 -26
  108. package/src/utils/browser-service.mjs +544 -544
  109. package/src/utils/command-log.mjs +64 -64
  110. package/src/utils/config.mjs +214 -214
  111. package/src/utils/fingerprint.mjs +181 -181
  112. package/src/utils/help.mjs +216 -216
  113. package/src/utils/js-policy.mjs +13 -13
  114. package/src/utils/ws-client.mjs +30 -30
@@ -1,362 +1,362 @@
1
- // Change Notifier - Subscribe to DOM changes and element events
2
-
3
- function normalizeSelector(selector) {
4
- if (!selector) return { visible: true };
5
- if (typeof selector === 'string') return { css: selector, visible: true };
6
- if (typeof selector !== 'object') return { visible: true };
7
- return {
8
- ...selector,
9
- visible: selector.visible !== false,
10
- };
11
- }
12
-
13
- function selectorKey(selector) {
14
- const normalized = normalizeSelector(selector);
15
- const stable = {
16
- css: normalized.css || null,
17
- tag: normalized.tag || null,
18
- id: normalized.id || null,
19
- classes: Array.isArray(normalized.classes) ? [...normalized.classes].sort() : [],
20
- visible: normalized.visible !== false,
21
- };
22
- return JSON.stringify(stable);
23
- }
24
-
25
- function parseCssSelector(css) {
26
- const raw = typeof css === 'string' ? css.trim() : '';
27
- if (!raw) return [];
28
- const attrRegex = /\[\s*([^\s~|^$*=\]]+)\s*(\*=|\^=|\$=|=)?\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))?\s*\]/g;
29
- const parseSegment = (item) => {
30
- const tagMatch = item.match(/^[a-zA-Z][\w-]*/);
31
- const idMatch = item.match(/#([\w-]+)/);
32
- const classMatches = item.match(/\.([\w-]+)/g) || [];
33
- const attrs = [];
34
- let attrMatch = attrRegex.exec(item);
35
- while (attrMatch) {
36
- attrs.push({
37
- name: String(attrMatch[1] || '').toLowerCase(),
38
- op: attrMatch[2] || 'exists',
39
- value: attrMatch[3] ?? attrMatch[4] ?? attrMatch[5] ?? '',
40
- });
41
- attrMatch = attrRegex.exec(item);
42
- }
43
- attrRegex.lastIndex = 0;
44
- return {
45
- raw: item,
46
- tag: tagMatch ? tagMatch[0].toLowerCase() : null,
47
- id: idMatch ? idMatch[1] : null,
48
- classes: classMatches.map((token) => token.slice(1)),
49
- attrs,
50
- };
51
- };
52
- return raw
53
- .split(',')
54
- .map((item) => item.trim())
55
- .filter(Boolean)
56
- .map((item) => {
57
- const segments = item
58
- .split(/\s+/)
59
- .map((segment) => segment.trim())
60
- .filter(Boolean)
61
- .map((segment) => parseSegment(segment));
62
- return {
63
- raw: item,
64
- segments,
65
- ...parseSegment(item),
66
- };
67
- });
68
- }
69
-
70
- function nodeAttribute(node, name, nodeId, nodeClasses) {
71
- const key = String(name || '').toLowerCase();
72
- if (!key) return null;
73
- if (key === 'id') return nodeId || null;
74
- if (key === 'class') return Array.from(nodeClasses).join(' ');
75
-
76
- const attrs = node?.attrs && typeof node.attrs === 'object' ? node.attrs : null;
77
- if (attrs && attrs[key] !== undefined && attrs[key] !== null) return String(attrs[key]);
78
-
79
- const direct = node?.[key];
80
- if (typeof direct === 'string' || typeof direct === 'number' || typeof direct === 'boolean') {
81
- return String(direct);
82
- }
83
- return null;
84
- }
85
-
86
- function matchAttribute(node, attrSpec, nodeId, nodeClasses) {
87
- const value = nodeAttribute(node, attrSpec.name, nodeId, nodeClasses);
88
- if (attrSpec.op === 'exists') return value !== null && value !== '';
89
- if (value === null) return false;
90
- const expected = String(attrSpec.value || '');
91
- if (attrSpec.op === '=') return value === expected;
92
- if (attrSpec.op === '*=') return value.includes(expected);
93
- if (attrSpec.op === '^=') return value.startsWith(expected);
94
- if (attrSpec.op === '$=') return value.endsWith(expected);
95
- return false;
96
- }
97
-
98
- function nodeMatchesCssSegment(node, cssSegment) {
99
- const nodeTag = typeof node?.tag === 'string' ? node.tag.toLowerCase() : null;
100
- const nodeId = typeof node?.id === 'string' ? node.id : null;
101
- const nodeClasses = new Set(Array.isArray(node?.classes) ? node.classes : []);
102
-
103
- const hasConstraints = Boolean(
104
- cssSegment?.tag
105
- || cssSegment?.id
106
- || (cssSegment?.classes && cssSegment.classes.length > 0)
107
- || (cssSegment?.attrs && cssSegment.attrs.length > 0),
108
- );
109
- if (!hasConstraints) return false;
110
-
111
- let matched = true;
112
- if (cssSegment.tag && nodeTag !== cssSegment.tag) matched = false;
113
- if (cssSegment.id && nodeId !== cssSegment.id) matched = false;
114
- if (matched && cssSegment.classes.length > 0) {
115
- matched = cssSegment.classes.every((className) => nodeClasses.has(className));
116
- }
117
- if (matched && cssSegment.attrs.length > 0) {
118
- matched = cssSegment.attrs.every((attrSpec) => matchAttribute(node, attrSpec, nodeId, nodeClasses));
119
- }
120
- return matched;
121
- }
122
-
123
- function matchesAncestorChain(ancestors, segments) {
124
- if (!Array.isArray(segments) || segments.length === 0) return true;
125
- if (!Array.isArray(ancestors) || ancestors.length === 0) return false;
126
- let ancestorIndex = ancestors.length - 1;
127
- for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) {
128
- let found = false;
129
- while (ancestorIndex >= 0) {
130
- if (nodeMatchesCssSegment(ancestors[ancestorIndex], segments[segmentIndex])) {
131
- found = true;
132
- ancestorIndex -= 1;
133
- break;
134
- }
135
- ancestorIndex -= 1;
136
- }
137
- if (!found) return false;
138
- }
139
- return true;
140
- }
141
-
142
- export class ChangeNotifier {
143
- constructor() {
144
- this.subscriptions = new Map(); // topic -> Set<callback>
145
- this.elementWatchers = new Map(); // selector -> { lastState, callbacks }
146
- this.lastSnapshot = null;
147
- }
148
-
149
- nodePassesVisibility(node, selector, viewport) {
150
- const normalized = normalizeSelector(selector);
151
- if (normalized.visible === false) return true;
152
- if (!node || typeof node !== 'object') return false;
153
- if (typeof node.visible === 'boolean') return node.visible;
154
-
155
- const rect = node.rect || null;
156
- if (!rect) return true;
157
- const width = Number(rect.width || 0);
158
- const height = Number(rect.height || 0);
159
- if (width <= 0 || height <= 0) return false;
160
-
161
- const vw = Number(viewport?.width || 0);
162
- const vh = Number(viewport?.height || 0);
163
- if (vw <= 0 || vh <= 0) return true;
164
- const left = Number(rect.left ?? rect.x ?? 0);
165
- const top = Number(rect.top ?? rect.y ?? 0);
166
- const right = Number(rect.right ?? (left + width));
167
- const bottom = Number(rect.bottom ?? (top + height));
168
- return right > 0 && bottom > 0 && left < vw && top < vh;
169
- }
170
-
171
- // Subscribe to a topic
172
- subscribe(topic, callback) {
173
- if (!this.subscriptions.has(topic)) {
174
- this.subscriptions.set(topic, new Set());
175
- }
176
- this.subscriptions.get(topic).add(callback);
177
- return () => {
178
- this.subscriptions.get(topic)?.delete(callback);
179
- };
180
- }
181
-
182
- // Watch specific elements by selector
183
- watch(selector, options = {}) {
184
- const { onAppear, onDisappear, onChange, throttle = 200 } = options;
185
- const resolvedSelector = normalizeSelector(selector);
186
- const key = selectorKey(resolvedSelector);
187
- const resolvedThrottle = Math.max(50, Number(throttle) || 200);
188
-
189
- if (!this.elementWatchers.has(key)) {
190
- this.elementWatchers.set(key, {
191
- selector: resolvedSelector,
192
- lastState: null,
193
- lastNotifyTime: 0,
194
- throttle: resolvedThrottle,
195
- callbacks: { onAppear, onDisappear, onChange },
196
- });
197
- } else {
198
- const watcher = this.elementWatchers.get(key);
199
- if (onAppear) watcher.callbacks.onAppear = onAppear;
200
- if (onDisappear) watcher.callbacks.onDisappear = onDisappear;
201
- if (onChange) watcher.callbacks.onChange = onChange;
202
- watcher.throttle = resolvedThrottle;
203
- }
204
-
205
- return () => {
206
- this.elementWatchers.delete(key);
207
- };
208
- }
209
-
210
- // Notify all subscribers of a topic
211
- notify(topic, data) {
212
- const callbacks = this.subscriptions.get(topic);
213
- if (!callbacks) return;
214
- for (const callback of callbacks) {
215
- try {
216
- callback(data);
217
- } catch (err) {
218
- console.error(`[ChangeNotifier] callback error for ${topic}:`, err);
219
- }
220
- }
221
- }
222
-
223
- // Process new DOM snapshot and trigger notifications
224
- processSnapshot(snapshot) {
225
- const now = Date.now();
226
- const prevSnapshot = this.lastSnapshot;
227
- this.lastSnapshot = snapshot;
228
-
229
- // Notify general DOM change
230
- this.notify('dom:changed', { snapshot, prevSnapshot });
231
-
232
- // Process element watchers
233
- for (const [, watcher] of this.elementWatchers) {
234
- const { lastState, callbacks, lastNotifyTime, throttle } = watcher;
235
-
236
- // Throttle notifications
237
- if (now - lastNotifyTime < throttle) continue;
238
-
239
- const currentElements = this.findElements(snapshot, watcher.selector);
240
- const currentState = currentElements.map(e => e.path).sort().join(',');
241
-
242
- if (lastState !== null && currentState !== lastState) {
243
- // Something changed
244
- const prevElements = watcher.prevElements || [];
245
- const appeared = currentElements.filter(e => !prevElements.find(p => p.path === e.path));
246
- const disappeared = prevElements.filter(e => !currentElements.find(c => c.path === e.path));
247
-
248
- if (appeared.length > 0 && callbacks.onAppear) {
249
- callbacks.onAppear(appeared);
250
- watcher.lastNotifyTime = now;
251
- }
252
- if (disappeared.length > 0 && callbacks.onDisappear) {
253
- callbacks.onDisappear(disappeared);
254
- watcher.lastNotifyTime = now;
255
- }
256
- if (callbacks.onChange) {
257
- callbacks.onChange({ current: currentElements, previous: prevElements, appeared, disappeared });
258
- watcher.lastNotifyTime = now;
259
- }
260
- }
261
-
262
- watcher.lastState = currentState;
263
- watcher.prevElements = currentElements;
264
- }
265
- }
266
-
267
- // Find elements matching selector in DOM tree
268
- findElements(node, selector, path = 'root', context = null) {
269
- const results = [];
270
- if (!node) return results;
271
- const normalized = normalizeSelector(selector);
272
- const runtimeContext = context || {
273
- viewport: node?.__viewport || null,
274
- ancestors: [],
275
- };
276
-
277
- // Check if current node matches
278
- if (this.nodeMatchesSelector(node, normalized, runtimeContext.ancestors) && this.nodePassesVisibility(node, normalized, runtimeContext.viewport)) {
279
- results.push({ ...node, path });
280
- }
281
-
282
- // Recurse into children
283
- if (node.children) {
284
- const childContext = {
285
- ...runtimeContext,
286
- ancestors: [...runtimeContext.ancestors, node],
287
- };
288
- for (let i = 0; i < node.children.length; i++) {
289
- const childResults = this.findElements(node.children[i], normalized, `${path}/${i}`, childContext);
290
- results.push(...childResults);
291
- }
292
- }
293
-
294
- return results;
295
- }
296
-
297
- // Check if node matches selector
298
- nodeMatchesSelector(node, selector, ancestors = []) {
299
- if (!node) return false;
300
- const normalized = normalizeSelector(selector);
301
- if (!normalized || typeof normalized !== 'object') return false;
302
-
303
- const nodeTag = typeof node.tag === 'string' ? node.tag.toLowerCase() : null;
304
- const nodeId = typeof node.id === 'string' ? node.id : null;
305
- const nodeClasses = new Set(Array.isArray(node.classes) ? node.classes : []);
306
-
307
- // Exact selector string (fast path).
308
- if (normalized.css && node.selector === normalized.css) return true;
309
-
310
- const cssVariants = parseCssSelector(normalized.css);
311
- if (cssVariants.length > 0) {
312
- for (const cssVariant of cssVariants) {
313
- const segments = Array.isArray(cssVariant.segments) && cssVariant.segments.length > 0
314
- ? cssVariant.segments
315
- : [cssVariant];
316
- const targetSegment = segments[segments.length - 1];
317
- if (!nodeMatchesCssSegment(node, targetSegment)) continue;
318
- if (segments.length === 1) return true;
319
- if (matchesAncestorChain(ancestors, segments.slice(0, -1))) return true;
320
- }
321
- }
322
-
323
- const requiredTag = normalized.tag ? String(normalized.tag).toLowerCase() : null;
324
- const requiredId = normalized.id ? String(normalized.id) : null;
325
- const requiredClasses = Array.isArray(normalized.classes)
326
- ? normalized.classes.filter(Boolean).map((className) => String(className))
327
- : [];
328
-
329
- const hasStructuredSelector = Boolean(requiredTag || requiredId || requiredClasses.length > 0);
330
- if (!hasStructuredSelector) return false;
331
- if (requiredTag && nodeTag !== requiredTag) return false;
332
- if (requiredId && nodeId !== requiredId) return false;
333
- if (requiredClasses.length > 0 && !requiredClasses.every((className) => nodeClasses.has(className))) {
334
- return false;
335
- }
336
- return true;
337
- }
338
-
339
- // Cleanup
340
- destroy() {
341
- this.subscriptions.clear();
342
- this.elementWatchers.clear();
343
- this.lastSnapshot = null;
344
- }
345
- }
346
-
347
- // Global instance
348
- let globalNotifier = null;
349
-
350
- export function getChangeNotifier() {
351
- if (!globalNotifier) {
352
- globalNotifier = new ChangeNotifier();
353
- }
354
- return globalNotifier;
355
- }
356
-
357
- export function destroyChangeNotifier() {
358
- if (globalNotifier) {
359
- globalNotifier.destroy();
360
- globalNotifier = null;
361
- }
362
- }
1
+ // Change Notifier - Subscribe to DOM changes and element events
2
+
3
+ function normalizeSelector(selector) {
4
+ if (!selector) return { visible: true };
5
+ if (typeof selector === 'string') return { css: selector, visible: true };
6
+ if (typeof selector !== 'object') return { visible: true };
7
+ return {
8
+ ...selector,
9
+ visible: selector.visible !== false,
10
+ };
11
+ }
12
+
13
+ function selectorKey(selector) {
14
+ const normalized = normalizeSelector(selector);
15
+ const stable = {
16
+ css: normalized.css || null,
17
+ tag: normalized.tag || null,
18
+ id: normalized.id || null,
19
+ classes: Array.isArray(normalized.classes) ? [...normalized.classes].sort() : [],
20
+ visible: normalized.visible !== false,
21
+ };
22
+ return JSON.stringify(stable);
23
+ }
24
+
25
+ function parseCssSelector(css) {
26
+ const raw = typeof css === 'string' ? css.trim() : '';
27
+ if (!raw) return [];
28
+ const attrRegex = /\[\s*([^\s~|^$*=\]]+)\s*(\*=|\^=|\$=|=)?\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))?\s*\]/g;
29
+ const parseSegment = (item) => {
30
+ const tagMatch = item.match(/^[a-zA-Z][\w-]*/);
31
+ const idMatch = item.match(/#([\w-]+)/);
32
+ const classMatches = item.match(/\.([\w-]+)/g) || [];
33
+ const attrs = [];
34
+ let attrMatch = attrRegex.exec(item);
35
+ while (attrMatch) {
36
+ attrs.push({
37
+ name: String(attrMatch[1] || '').toLowerCase(),
38
+ op: attrMatch[2] || 'exists',
39
+ value: attrMatch[3] ?? attrMatch[4] ?? attrMatch[5] ?? '',
40
+ });
41
+ attrMatch = attrRegex.exec(item);
42
+ }
43
+ attrRegex.lastIndex = 0;
44
+ return {
45
+ raw: item,
46
+ tag: tagMatch ? tagMatch[0].toLowerCase() : null,
47
+ id: idMatch ? idMatch[1] : null,
48
+ classes: classMatches.map((token) => token.slice(1)),
49
+ attrs,
50
+ };
51
+ };
52
+ return raw
53
+ .split(',')
54
+ .map((item) => item.trim())
55
+ .filter(Boolean)
56
+ .map((item) => {
57
+ const segments = item
58
+ .split(/\s+/)
59
+ .map((segment) => segment.trim())
60
+ .filter(Boolean)
61
+ .map((segment) => parseSegment(segment));
62
+ return {
63
+ raw: item,
64
+ segments,
65
+ ...parseSegment(item),
66
+ };
67
+ });
68
+ }
69
+
70
+ function nodeAttribute(node, name, nodeId, nodeClasses) {
71
+ const key = String(name || '').toLowerCase();
72
+ if (!key) return null;
73
+ if (key === 'id') return nodeId || null;
74
+ if (key === 'class') return Array.from(nodeClasses).join(' ');
75
+
76
+ const attrs = node?.attrs && typeof node.attrs === 'object' ? node.attrs : null;
77
+ if (attrs && attrs[key] !== undefined && attrs[key] !== null) return String(attrs[key]);
78
+
79
+ const direct = node?.[key];
80
+ if (typeof direct === 'string' || typeof direct === 'number' || typeof direct === 'boolean') {
81
+ return String(direct);
82
+ }
83
+ return null;
84
+ }
85
+
86
+ function matchAttribute(node, attrSpec, nodeId, nodeClasses) {
87
+ const value = nodeAttribute(node, attrSpec.name, nodeId, nodeClasses);
88
+ if (attrSpec.op === 'exists') return value !== null && value !== '';
89
+ if (value === null) return false;
90
+ const expected = String(attrSpec.value || '');
91
+ if (attrSpec.op === '=') return value === expected;
92
+ if (attrSpec.op === '*=') return value.includes(expected);
93
+ if (attrSpec.op === '^=') return value.startsWith(expected);
94
+ if (attrSpec.op === '$=') return value.endsWith(expected);
95
+ return false;
96
+ }
97
+
98
+ function nodeMatchesCssSegment(node, cssSegment) {
99
+ const nodeTag = typeof node?.tag === 'string' ? node.tag.toLowerCase() : null;
100
+ const nodeId = typeof node?.id === 'string' ? node.id : null;
101
+ const nodeClasses = new Set(Array.isArray(node?.classes) ? node.classes : []);
102
+
103
+ const hasConstraints = Boolean(
104
+ cssSegment?.tag
105
+ || cssSegment?.id
106
+ || (cssSegment?.classes && cssSegment.classes.length > 0)
107
+ || (cssSegment?.attrs && cssSegment.attrs.length > 0),
108
+ );
109
+ if (!hasConstraints) return false;
110
+
111
+ let matched = true;
112
+ if (cssSegment.tag && nodeTag !== cssSegment.tag) matched = false;
113
+ if (cssSegment.id && nodeId !== cssSegment.id) matched = false;
114
+ if (matched && cssSegment.classes.length > 0) {
115
+ matched = cssSegment.classes.every((className) => nodeClasses.has(className));
116
+ }
117
+ if (matched && cssSegment.attrs.length > 0) {
118
+ matched = cssSegment.attrs.every((attrSpec) => matchAttribute(node, attrSpec, nodeId, nodeClasses));
119
+ }
120
+ return matched;
121
+ }
122
+
123
+ function matchesAncestorChain(ancestors, segments) {
124
+ if (!Array.isArray(segments) || segments.length === 0) return true;
125
+ if (!Array.isArray(ancestors) || ancestors.length === 0) return false;
126
+ let ancestorIndex = ancestors.length - 1;
127
+ for (let segmentIndex = segments.length - 1; segmentIndex >= 0; segmentIndex -= 1) {
128
+ let found = false;
129
+ while (ancestorIndex >= 0) {
130
+ if (nodeMatchesCssSegment(ancestors[ancestorIndex], segments[segmentIndex])) {
131
+ found = true;
132
+ ancestorIndex -= 1;
133
+ break;
134
+ }
135
+ ancestorIndex -= 1;
136
+ }
137
+ if (!found) return false;
138
+ }
139
+ return true;
140
+ }
141
+
142
+ export class ChangeNotifier {
143
+ constructor() {
144
+ this.subscriptions = new Map(); // topic -> Set<callback>
145
+ this.elementWatchers = new Map(); // selector -> { lastState, callbacks }
146
+ this.lastSnapshot = null;
147
+ }
148
+
149
+ nodePassesVisibility(node, selector, viewport) {
150
+ const normalized = normalizeSelector(selector);
151
+ if (normalized.visible === false) return true;
152
+ if (!node || typeof node !== 'object') return false;
153
+ if (typeof node.visible === 'boolean') return node.visible;
154
+
155
+ const rect = node.rect || null;
156
+ if (!rect) return true;
157
+ const width = Number(rect.width || 0);
158
+ const height = Number(rect.height || 0);
159
+ if (width <= 0 || height <= 0) return false;
160
+
161
+ const vw = Number(viewport?.width || 0);
162
+ const vh = Number(viewport?.height || 0);
163
+ if (vw <= 0 || vh <= 0) return true;
164
+ const left = Number(rect.left ?? rect.x ?? 0);
165
+ const top = Number(rect.top ?? rect.y ?? 0);
166
+ const right = Number(rect.right ?? (left + width));
167
+ const bottom = Number(rect.bottom ?? (top + height));
168
+ return right > 0 && bottom > 0 && left < vw && top < vh;
169
+ }
170
+
171
+ // Subscribe to a topic
172
+ subscribe(topic, callback) {
173
+ if (!this.subscriptions.has(topic)) {
174
+ this.subscriptions.set(topic, new Set());
175
+ }
176
+ this.subscriptions.get(topic).add(callback);
177
+ return () => {
178
+ this.subscriptions.get(topic)?.delete(callback);
179
+ };
180
+ }
181
+
182
+ // Watch specific elements by selector
183
+ watch(selector, options = {}) {
184
+ const { onAppear, onDisappear, onChange, throttle = 200 } = options;
185
+ const resolvedSelector = normalizeSelector(selector);
186
+ const key = selectorKey(resolvedSelector);
187
+ const resolvedThrottle = Math.max(50, Number(throttle) || 200);
188
+
189
+ if (!this.elementWatchers.has(key)) {
190
+ this.elementWatchers.set(key, {
191
+ selector: resolvedSelector,
192
+ lastState: null,
193
+ lastNotifyTime: 0,
194
+ throttle: resolvedThrottle,
195
+ callbacks: { onAppear, onDisappear, onChange },
196
+ });
197
+ } else {
198
+ const watcher = this.elementWatchers.get(key);
199
+ if (onAppear) watcher.callbacks.onAppear = onAppear;
200
+ if (onDisappear) watcher.callbacks.onDisappear = onDisappear;
201
+ if (onChange) watcher.callbacks.onChange = onChange;
202
+ watcher.throttle = resolvedThrottle;
203
+ }
204
+
205
+ return () => {
206
+ this.elementWatchers.delete(key);
207
+ };
208
+ }
209
+
210
+ // Notify all subscribers of a topic
211
+ notify(topic, data) {
212
+ const callbacks = this.subscriptions.get(topic);
213
+ if (!callbacks) return;
214
+ for (const callback of callbacks) {
215
+ try {
216
+ callback(data);
217
+ } catch (err) {
218
+ console.error(`[ChangeNotifier] callback error for ${topic}:`, err);
219
+ }
220
+ }
221
+ }
222
+
223
+ // Process new DOM snapshot and trigger notifications
224
+ processSnapshot(snapshot) {
225
+ const now = Date.now();
226
+ const prevSnapshot = this.lastSnapshot;
227
+ this.lastSnapshot = snapshot;
228
+
229
+ // Notify general DOM change
230
+ this.notify('dom:changed', { snapshot, prevSnapshot });
231
+
232
+ // Process element watchers
233
+ for (const [, watcher] of this.elementWatchers) {
234
+ const { lastState, callbacks, lastNotifyTime, throttle } = watcher;
235
+
236
+ // Throttle notifications
237
+ if (now - lastNotifyTime < throttle) continue;
238
+
239
+ const currentElements = this.findElements(snapshot, watcher.selector);
240
+ const currentState = currentElements.map(e => e.path).sort().join(',');
241
+
242
+ if (lastState !== null && currentState !== lastState) {
243
+ // Something changed
244
+ const prevElements = watcher.prevElements || [];
245
+ const appeared = currentElements.filter(e => !prevElements.find(p => p.path === e.path));
246
+ const disappeared = prevElements.filter(e => !currentElements.find(c => c.path === e.path));
247
+
248
+ if (appeared.length > 0 && callbacks.onAppear) {
249
+ callbacks.onAppear(appeared);
250
+ watcher.lastNotifyTime = now;
251
+ }
252
+ if (disappeared.length > 0 && callbacks.onDisappear) {
253
+ callbacks.onDisappear(disappeared);
254
+ watcher.lastNotifyTime = now;
255
+ }
256
+ if (callbacks.onChange) {
257
+ callbacks.onChange({ current: currentElements, previous: prevElements, appeared, disappeared });
258
+ watcher.lastNotifyTime = now;
259
+ }
260
+ }
261
+
262
+ watcher.lastState = currentState;
263
+ watcher.prevElements = currentElements;
264
+ }
265
+ }
266
+
267
+ // Find elements matching selector in DOM tree
268
+ findElements(node, selector, path = 'root', context = null) {
269
+ const results = [];
270
+ if (!node) return results;
271
+ const normalized = normalizeSelector(selector);
272
+ const runtimeContext = context || {
273
+ viewport: node?.__viewport || null,
274
+ ancestors: [],
275
+ };
276
+
277
+ // Check if current node matches
278
+ if (this.nodeMatchesSelector(node, normalized, runtimeContext.ancestors) && this.nodePassesVisibility(node, normalized, runtimeContext.viewport)) {
279
+ results.push({ ...node, path });
280
+ }
281
+
282
+ // Recurse into children
283
+ if (node.children) {
284
+ const childContext = {
285
+ ...runtimeContext,
286
+ ancestors: [...runtimeContext.ancestors, node],
287
+ };
288
+ for (let i = 0; i < node.children.length; i++) {
289
+ const childResults = this.findElements(node.children[i], normalized, `${path}/${i}`, childContext);
290
+ results.push(...childResults);
291
+ }
292
+ }
293
+
294
+ return results;
295
+ }
296
+
297
+ // Check if node matches selector
298
+ nodeMatchesSelector(node, selector, ancestors = []) {
299
+ if (!node) return false;
300
+ const normalized = normalizeSelector(selector);
301
+ if (!normalized || typeof normalized !== 'object') return false;
302
+
303
+ const nodeTag = typeof node.tag === 'string' ? node.tag.toLowerCase() : null;
304
+ const nodeId = typeof node.id === 'string' ? node.id : null;
305
+ const nodeClasses = new Set(Array.isArray(node.classes) ? node.classes : []);
306
+
307
+ // Exact selector string (fast path).
308
+ if (normalized.css && node.selector === normalized.css) return true;
309
+
310
+ const cssVariants = parseCssSelector(normalized.css);
311
+ if (cssVariants.length > 0) {
312
+ for (const cssVariant of cssVariants) {
313
+ const segments = Array.isArray(cssVariant.segments) && cssVariant.segments.length > 0
314
+ ? cssVariant.segments
315
+ : [cssVariant];
316
+ const targetSegment = segments[segments.length - 1];
317
+ if (!nodeMatchesCssSegment(node, targetSegment)) continue;
318
+ if (segments.length === 1) return true;
319
+ if (matchesAncestorChain(ancestors, segments.slice(0, -1))) return true;
320
+ }
321
+ }
322
+
323
+ const requiredTag = normalized.tag ? String(normalized.tag).toLowerCase() : null;
324
+ const requiredId = normalized.id ? String(normalized.id) : null;
325
+ const requiredClasses = Array.isArray(normalized.classes)
326
+ ? normalized.classes.filter(Boolean).map((className) => String(className))
327
+ : [];
328
+
329
+ const hasStructuredSelector = Boolean(requiredTag || requiredId || requiredClasses.length > 0);
330
+ if (!hasStructuredSelector) return false;
331
+ if (requiredTag && nodeTag !== requiredTag) return false;
332
+ if (requiredId && nodeId !== requiredId) return false;
333
+ if (requiredClasses.length > 0 && !requiredClasses.every((className) => nodeClasses.has(className))) {
334
+ return false;
335
+ }
336
+ return true;
337
+ }
338
+
339
+ // Cleanup
340
+ destroy() {
341
+ this.subscriptions.clear();
342
+ this.elementWatchers.clear();
343
+ this.lastSnapshot = null;
344
+ }
345
+ }
346
+
347
+ // Global instance
348
+ let globalNotifier = null;
349
+
350
+ export function getChangeNotifier() {
351
+ if (!globalNotifier) {
352
+ globalNotifier = new ChangeNotifier();
353
+ }
354
+ return globalNotifier;
355
+ }
356
+
357
+ export function destroyChangeNotifier() {
358
+ if (globalNotifier) {
359
+ globalNotifier.destroy();
360
+ globalNotifier = null;
361
+ }
362
+ }