ghost-bridge 1.1.0 → 1.2.0

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.
@@ -159,7 +159,15 @@
159
159
  }
160
160
  }
161
161
 
162
- document.querySelectorAll('[data-ghost-ref]').forEach(el => el.removeAttribute('data-ghost-ref'));
162
+ function clearRefs(scanTarget) {
163
+ const all = scanTarget.querySelectorAll('*');
164
+ for (const el of all) {
165
+ if (el.hasAttribute('data-ghost-ref')) el.removeAttribute('data-ghost-ref');
166
+ if (el.shadowRoot) clearRefs(el.shadowRoot);
167
+ }
168
+ }
169
+
170
+ clearRefs(document);
163
171
  scanRoot(root);
164
172
 
165
173
  return {
@@ -203,7 +211,7 @@
203
211
  })()`
204
212
  }
205
213
 
206
- function buildPageContentExpression({ mode, selector, maxLength, includeMetadata }) {
214
+ function buildPageContentExpression({ mode, selector, maxLength, offset = 0, includeMetadata }) {
207
215
  const selectorStr = selector ? JSON.stringify(selector) : 'null'
208
216
  const modeStr = JSON.stringify(mode)
209
217
 
@@ -217,6 +225,7 @@
217
225
  const selector = ${selectorStr};
218
226
  const mode = ${modeStr};
219
227
  const maxLength = ${maxLength};
228
+ const offset = ${offset};
220
229
  const includeMetadata = ${includeMetadata};
221
230
 
222
231
  function getMetadata() {
@@ -269,28 +278,6 @@
269
278
  return structured;
270
279
  }
271
280
 
272
- function smartTruncateText(text, limit) {
273
- if (text.length <= limit) {
274
- return { content: text, truncated: false };
275
- }
276
-
277
- if (limit < 400) {
278
- return { content: text.slice(0, limit), truncated: true, note: '内容过长,已截断' };
279
- }
280
-
281
- const headLength = Math.max(200, Math.floor(limit * 0.8));
282
- const tailLength = Math.max(120, limit - headLength - 80);
283
- const head = text.slice(0, headLength).trimEnd();
284
- const tail = text.slice(-tailLength).trimStart();
285
- const omittedChars = Math.max(0, text.length - head.length - tail.length);
286
-
287
- return {
288
- content: head + '\\n\\n... [已省略 ' + omittedChars + ' 个字符] ...\\n\\n' + tail,
289
- truncated: true,
290
- note: '内容过长,已保留开头与结尾片段'
291
- };
292
- }
293
-
294
281
  const targetElement = resolveTargetElement();
295
282
  if (targetElement?.error) return targetElement;
296
283
 
@@ -301,39 +288,57 @@
301
288
  if (mode === 'text') {
302
289
  // 递归收集同源 iframe 内的文本:跨域 iframe 访问 contentDocument 会抛错,跳过即可。
303
290
  // 大量文档类页面(钉钉文档、italent 等)正文都在 iframe 里,不递归会拿到空文本,
304
- // 迫使模型退化为整页截图读文档——那是长会话里最昂贵的 token 开销
291
+ // 迫使模型退化为整页截图读文档——那是长会话里最昂贵的 token 开销
305
292
  function collectText(el) {
306
- let text = el.innerText || el.textContent || '';
293
+ const collected = {
294
+ text: el.innerText || el.textContent || '',
295
+ iframeCount: 0,
296
+ readableIframeCount: 0,
297
+ crossOriginSkipped: 0
298
+ };
307
299
  try {
308
300
  var frames = el.querySelectorAll('iframe');
309
301
  for (var i = 0; i < frames.length; i++) {
302
+ collected.iframeCount++;
310
303
  try {
311
304
  var doc = frames[i].contentDocument;
312
- if (doc && doc.body) text += '\\n\\n' + collectText(doc.body);
313
- } catch (e) {}
305
+ if (doc && doc.body) {
306
+ const nested = collectText(doc.body);
307
+ collected.readableIframeCount++;
308
+ collected.text += '\\n\\n' + nested.text;
309
+ collected.iframeCount += nested.iframeCount;
310
+ collected.readableIframeCount += nested.readableIframeCount;
311
+ collected.crossOriginSkipped += nested.crossOriginSkipped;
312
+ } else {
313
+ collected.crossOriginSkipped++;
314
+ }
315
+ } catch (e) {
316
+ collected.crossOriginSkipped++;
317
+ }
314
318
  }
315
319
  } catch (e) {}
316
- return text;
320
+ return collected;
317
321
  }
318
- let text = collectText(targetElement);
322
+ const collected = collectText(targetElement);
323
+ let text = collected.text;
319
324
  text = text.replace(/\\n{3,}/g, '\\n\\n').trim();
320
325
  result.contentLength = text.length;
321
- result.includesIframes = true;
322
- const truncated = smartTruncateText(text, maxLength);
323
- result.content = truncated.content;
324
- result.truncated = truncated.truncated;
325
- if (truncated.note) result.note = truncated.note;
326
+ result.iframeCount = collected.iframeCount;
327
+ result.readableIframeCount = collected.readableIframeCount;
328
+ result.crossOriginSkipped = collected.crossOriginSkipped;
329
+ result.includesIframes = collected.readableIframeCount > 0;
330
+ result.offset = offset;
331
+ result.content = text.slice(offset, offset + maxLength);
332
+ result.truncated = offset > 0 || text.length > offset + maxLength;
333
+ result.hasMore = text.length > offset + maxLength;
326
334
  } else if (mode === 'html') {
327
335
  let html = targetElement.outerHTML || '';
328
336
  result.contentLength = html.length;
329
- if (html.length > maxLength) {
330
- result.content = html.slice(0, maxLength);
331
- result.truncated = true;
332
- result.note = 'HTML 已截断,可能不完整';
333
- } else {
334
- result.content = html;
335
- result.truncated = false;
336
- }
337
+ result.offset = offset;
338
+ result.content = html.slice(offset, offset + maxLength);
339
+ result.truncated = offset > 0 || html.length > offset + maxLength;
340
+ result.hasMore = html.length > offset + maxLength;
341
+ if (result.truncated) result.note = 'HTML 分页片段可能不是完整标签';
337
342
  } else if (mode === 'structured') {
338
343
  const structured = buildStructuredContent(targetElement);
339
344
  result.structured = structured;
@@ -421,7 +426,15 @@
421
426
  }
422
427
  }
423
428
 
424
- document.querySelectorAll('[data-ghost-ref]').forEach(el => el.removeAttribute('data-ghost-ref'));
429
+ function clearRefs(scanTarget) {
430
+ const all = scanTarget.querySelectorAll('*');
431
+ for (const el of all) {
432
+ if (el.hasAttribute('data-ghost-ref')) el.removeAttribute('data-ghost-ref');
433
+ if (el.shadowRoot) clearRefs(el.shadowRoot);
434
+ }
435
+ }
436
+
437
+ clearRefs(document);
425
438
 
426
439
  let rootEl = document.body;
427
440
  const sel = ${selectorStr};
@@ -450,9 +463,363 @@
450
463
  })()`
451
464
  }
452
465
 
466
+ // This runtime is serialized into Runtime.evaluate expressions. Keep it self-contained:
467
+ // it intentionally depends only on the supplied page window and standard DOM APIs.
468
+ function createLocatorRuntime(rootWindow) {
469
+ const STORE_KEY = '__ghostActionElements'
470
+
471
+ function normalize(value) {
472
+ return String(value == null ? '' : value).replace(/\s+/g, ' ').trim()
473
+ }
474
+
475
+ function matches(actual, expected, mode) {
476
+ const left = normalize(actual)
477
+ const right = normalize(expected)
478
+ if (!right) return false
479
+ return mode === 'contains' ? left.includes(right) : left === right
480
+ }
481
+
482
+ function elementWindow(element) {
483
+ return element?.ownerDocument?.defaultView || rootWindow
484
+ }
485
+
486
+ function styleOf(element) {
487
+ try {
488
+ return elementWindow(element).getComputedStyle(element)
489
+ } catch (_) {
490
+ return null
491
+ }
492
+ }
493
+
494
+ function isVisible(element) {
495
+ if (!element || element.isConnected === false || element.hidden) return false
496
+ if (element.getAttribute?.('aria-hidden') === 'true') return false
497
+ const style = styleOf(element)
498
+ if (style && (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0)) return false
499
+ const rect = element.getBoundingClientRect?.()
500
+ return Boolean(rect && (rect.width > 0 || rect.height > 0))
501
+ }
502
+
503
+ function isDisabled(element) {
504
+ if (!element) return true
505
+ if (element.disabled || element.getAttribute?.('aria-disabled') === 'true') return true
506
+ try {
507
+ return Boolean(element.closest?.('fieldset[disabled]'))
508
+ } catch (_) {
509
+ return false
510
+ }
511
+ }
512
+
513
+ function elementText(element) {
514
+ return normalize(element?.innerText || element?.textContent || '')
515
+ }
516
+
517
+ function rootById(element, id) {
518
+ const root = element?.getRootNode?.()
519
+ if (root?.getElementById) return root.getElementById(id)
520
+ if (root?.querySelector) {
521
+ try {
522
+ return root.querySelector(`[id="${String(id).replace(/["\\]/g, '\\$&')}"]`)
523
+ } catch (_) {}
524
+ }
525
+ return element?.ownerDocument?.getElementById?.(id) || null
526
+ }
527
+
528
+ function labelText(element) {
529
+ const parts = []
530
+ try {
531
+ if (element.labels) {
532
+ for (const label of element.labels) parts.push(elementText(label))
533
+ }
534
+ } catch (_) {}
535
+ if (!parts.length) {
536
+ const wrapped = element.closest?.('label')
537
+ if (wrapped) parts.push(elementText(wrapped))
538
+ }
539
+ if (!parts.length && element.id && element.ownerDocument?.querySelectorAll) {
540
+ try {
541
+ for (const label of element.ownerDocument.querySelectorAll('label')) {
542
+ if (label.htmlFor === element.id || label.getAttribute?.('for') === element.id) parts.push(elementText(label))
543
+ }
544
+ } catch (_) {}
545
+ }
546
+ return normalize(parts.filter(Boolean).join(' '))
547
+ }
548
+
549
+ function accessibleName(element) {
550
+ const ariaLabel = normalize(element.getAttribute?.('aria-label'))
551
+ if (ariaLabel) return ariaLabel
552
+
553
+ const labelledBy = normalize(element.getAttribute?.('aria-labelledby'))
554
+ if (labelledBy) {
555
+ const text = labelledBy.split(' ').map((id) => elementText(rootById(element, id))).filter(Boolean).join(' ')
556
+ if (text) return normalize(text)
557
+ }
558
+
559
+ const label = labelText(element)
560
+ if (label) return label
561
+ const alt = normalize(element.getAttribute?.('alt'))
562
+ if (alt) return alt
563
+ const tag = String(element.tagName || '').toLowerCase()
564
+ const type = String(element.type || '').toLowerCase()
565
+ if (tag === 'input' && ['button', 'submit', 'reset'].includes(type)) {
566
+ const value = normalize(element.value)
567
+ if (value) return value
568
+ }
569
+ return elementText(element)
570
+ }
571
+
572
+ function implicitRole(element) {
573
+ const explicit = normalize(element.getAttribute?.('role')).toLowerCase()
574
+ if (explicit) return explicit.split(' ')[0]
575
+ const tag = String(element.tagName || '').toLowerCase()
576
+ const type = String(element.type || '').toLowerCase()
577
+ if (tag === 'button') return 'button'
578
+ if (tag === 'a' && element.getAttribute?.('href')) return 'link'
579
+ if (tag === 'textarea') return 'textbox'
580
+ if (tag === 'select') return element.multiple ? 'listbox' : 'combobox'
581
+ if (tag === 'img') return 'img'
582
+ if (/^h[1-6]$/.test(tag)) return 'heading'
583
+ if (tag === 'input') {
584
+ if (['button', 'submit', 'reset', 'image'].includes(type)) return 'button'
585
+ if (type === 'checkbox') return 'checkbox'
586
+ if (type === 'radio') return 'radio'
587
+ if (type === 'range') return 'slider'
588
+ if (type === 'number') return 'spinbutton'
589
+ if (!['hidden', 'color', 'file'].includes(type)) return type === 'search' ? 'searchbox' : 'textbox'
590
+ }
591
+ return ''
592
+ }
593
+
594
+ function collectContexts() {
595
+ const contexts = []
596
+ const seenRoots = new Set()
597
+
598
+ function visit(root, frames) {
599
+ if (!root || seenRoots.has(root)) return
600
+ seenRoots.add(root)
601
+ contexts.push({ root, frames })
602
+ let elements = []
603
+ try { elements = Array.from(root.querySelectorAll('*')) } catch (_) {}
604
+ for (const element of elements) {
605
+ if (element.shadowRoot) visit(element.shadowRoot, frames)
606
+ if (String(element.tagName || '').toLowerCase() === 'iframe') {
607
+ try {
608
+ const frameDocument = element.contentDocument
609
+ if (frameDocument?.documentElement) visit(frameDocument, frames.concat(element))
610
+ } catch (_) {}
611
+ }
612
+ }
613
+ }
614
+
615
+ visit(rootWindow.document, [])
616
+ return contexts
617
+ }
618
+
619
+ function semanticMatch(element, locator, mode) {
620
+ if (locator.testId !== undefined && !matches(element.getAttribute?.('data-testid'), locator.testId, mode)) return false
621
+ if (locator.role !== undefined && normalize(implicitRole(element)).toLowerCase() !== normalize(locator.role).toLowerCase()) return false
622
+ if (locator.name !== undefined && !matches(accessibleName(element), locator.name, mode)) return false
623
+ if (locator.label !== undefined) {
624
+ const tag = String(element.tagName || '').toLowerCase()
625
+ if (!['button', 'input', 'meter', 'output', 'progress', 'select', 'textarea'].includes(tag)) return false
626
+ if (!matches(labelText(element) || accessibleName(element), locator.label, mode)) return false
627
+ }
628
+ if (locator.placeholder !== undefined && !matches(element.getAttribute?.('placeholder') || element.placeholder, locator.placeholder, mode)) return false
629
+ if (locator.text !== undefined) {
630
+ if (!matches(elementText(element), locator.text, mode)) return false
631
+ try {
632
+ const childMatches = Array.from(element.querySelectorAll('*')).some((child) => matches(elementText(child), locator.text, mode))
633
+ if (childMatches) return false
634
+ } catch (_) {}
635
+ }
636
+ return true
637
+ }
638
+
639
+ function find(locator = {}, { visibleOnly = false } = {}) {
640
+ if (!locator || typeof locator !== 'object') return { error: 'locator 必须是对象' }
641
+ const keys = ['css', 'testId', 'role', 'name', 'label', 'placeholder', 'text']
642
+ if (!keys.some((key) => locator[key] !== undefined && locator[key] !== '')) {
643
+ return { error: 'locator 至少需要 css/testId/role/name/label/placeholder/text 之一' }
644
+ }
645
+ if (locator.match && !['exact', 'contains'].includes(locator.match)) return { error: 'locator.match 仅支持 exact 或 contains' }
646
+ if (locator.nth !== undefined && (!Number.isInteger(locator.nth) || locator.nth < 0)) return { error: 'locator.nth 必须是从 0 开始的整数' }
647
+
648
+ const mode = locator.match || 'exact'
649
+ const found = []
650
+ const seen = new Set()
651
+ for (const context of collectContexts()) {
652
+ let elements = []
653
+ try {
654
+ elements = Array.from(context.root.querySelectorAll(locator.css || '*'))
655
+ } catch (error) {
656
+ return { error: `无效的 CSS 选择器: ${error.message}` }
657
+ }
658
+ for (const element of elements) {
659
+ if (seen.has(element)) continue
660
+ seen.add(element)
661
+ if (!semanticMatch(element, locator, mode)) continue
662
+ if (visibleOnly && !isVisible(element)) continue
663
+ found.push({ element, frames: context.frames })
664
+ }
665
+ }
666
+ return { found }
667
+ }
668
+
669
+ function summary(item, index) {
670
+ const element = item.element
671
+ const result = {
672
+ nth: index,
673
+ tag: String(element.tagName || '').toLowerCase(),
674
+ role: implicitRole(element) || undefined,
675
+ name: accessibleName(element).slice(0, 100) || undefined,
676
+ text: elementText(element).slice(0, 100) || undefined,
677
+ placeholder: normalize(element.getAttribute?.('placeholder') || element.placeholder).slice(0, 80) || undefined,
678
+ testId: normalize(element.getAttribute?.('data-testid')).slice(0, 80) || undefined,
679
+ disabled: isDisabled(element) || undefined,
680
+ }
681
+ return Object.fromEntries(Object.entries(result).filter(([, value]) => value !== undefined && value !== ''))
682
+ }
683
+
684
+ function selectOne(locator, options) {
685
+ const result = find(locator, options)
686
+ if (result.error) return result
687
+ const matches = result.found
688
+ if (!matches.length) return { error: 'locator 未匹配到元素', locator }
689
+ if (locator.nth !== undefined) {
690
+ if (!matches[locator.nth]) return { error: `locator.nth=${locator.nth} 超出匹配范围(共 ${matches.length} 个)`, locator }
691
+ return { selected: matches[locator.nth], count: matches.length }
692
+ }
693
+ if (matches.length > 1) {
694
+ return {
695
+ error: `locator 匹配到 ${matches.length} 个元素,请增加条件或指定 nth`,
696
+ ambiguous: true,
697
+ matchCount: matches.length,
698
+ candidates: matches.slice(0, 5).map(summary),
699
+ }
700
+ }
701
+ return { selected: matches[0], count: 1 }
702
+ }
703
+
704
+ function scrollAndMeasure(item) {
705
+ for (const frame of item.frames) frame.scrollIntoView?.({ block: 'center', inline: 'center' })
706
+ item.element.scrollIntoView?.({ block: 'center', inline: 'center' })
707
+ const rect = item.element.getBoundingClientRect()
708
+ let left = rect.left
709
+ let top = rect.top
710
+ for (const frame of item.frames) {
711
+ const frameRect = frame.getBoundingClientRect()
712
+ left += frameRect.left + (frame.clientLeft || 0)
713
+ top += frameRect.top + (frame.clientTop || 0)
714
+ }
715
+ return {
716
+ cx: Math.round(left + rect.width / 2),
717
+ cy: Math.round(top + rect.height / 2),
718
+ }
719
+ }
720
+
721
+ function locate(locator, actionId) {
722
+ const result = selectOne(locator, { visibleOnly: true })
723
+ if (result.error) return result
724
+ const item = result.selected
725
+ const position = scrollAndMeasure(item)
726
+ if (!isVisible(item.element)) return { error: '元素滚动后仍不可见', locator }
727
+ if (!rootWindow[STORE_KEY]) rootWindow[STORE_KEY] = Object.create(null)
728
+ rootWindow[STORE_KEY][actionId] = item
729
+ return {
730
+ found: true,
731
+ ...summary(item, locator.nth || 0),
732
+ ...position,
733
+ matchCount: result.count,
734
+ }
735
+ }
736
+
737
+ function probe(locator, state) {
738
+ const result = find(locator, { visibleOnly: false })
739
+ if (result.error) return result
740
+ const all = result.found
741
+ const selected = locator.nth === undefined ? all : (all[locator.nth] ? [all[locator.nth]] : [])
742
+ const visible = selected.filter((item) => isVisible(item.element))
743
+ const enabled = visible.filter((item) => !isDisabled(item.element))
744
+ let satisfied = false
745
+ if (state === 'attached') satisfied = selected.length > 0
746
+ else if (state === 'detached') satisfied = selected.length === 0
747
+ else if (state === 'hidden') satisfied = visible.length === 0
748
+ else if (state === 'enabled') satisfied = enabled.length > 0
749
+ else satisfied = visible.length > 0
750
+ return {
751
+ satisfied,
752
+ state,
753
+ matchCount: all.length,
754
+ visibleCount: visible.length,
755
+ enabledCount: enabled.length,
756
+ candidates: satisfied ? undefined : all.slice(0, 3).map(summary),
757
+ }
758
+ }
759
+
760
+ function use(actionId, command, payload = {}) {
761
+ const store = rootWindow[STORE_KEY]
762
+ const item = store?.[actionId]
763
+ const element = item?.element
764
+ if (!element || element.isConnected === false) return { error: '动作执行前元素已从页面移除' }
765
+ if (command === 'focus') element.focus?.()
766
+ else if (command === 'prepareFill') {
767
+ element.focus?.()
768
+ element.select?.()
769
+ } else if (command === 'dispatchInput') {
770
+ element.dispatchEvent(new (elementWindow(element).Event)('input', { bubbles: true }))
771
+ element.dispatchEvent(new (elementWindow(element).Event)('change', { bubbles: true }))
772
+ } else if (command === 'select') {
773
+ if (String(element.tagName || '').toLowerCase() !== 'select') return { error: 'select 动作只能用于 <select> 元素' }
774
+ const values = Array.from(element.options || []).map((option) => String(option.value))
775
+ if (!values.includes(String(payload.value))) return { error: `下拉框不存在值 "${String(payload.value)}"` }
776
+ element.value = String(payload.value)
777
+ element.dispatchEvent(new (elementWindow(element).Event)('input', { bubbles: true }))
778
+ element.dispatchEvent(new (elementWindow(element).Event)('change', { bubbles: true }))
779
+ }
780
+ return { success: true }
781
+ }
782
+
783
+ function cleanup(actionId) {
784
+ if (rootWindow[STORE_KEY]) delete rootWindow[STORE_KEY][actionId]
785
+ return true
786
+ }
787
+
788
+ return { version: 1, locate, probe, use, cleanup }
789
+ }
790
+
791
+ const locatorRuntimeSource = () => `(${createLocatorRuntime.toString()})(window)`
792
+
793
+ function buildInstallLocatorRuntimeExpression() {
794
+ return `(function(){window.__ghostLocatorRuntime=${locatorRuntimeSource()};return true;})()`
795
+ }
796
+
797
+ function buildLocateElementExpression({ locator, ref, selector, actionId }) {
798
+ const semanticLocator = locator || { css: ref ? `[data-ghost-ref="${String(ref).replace(/["\\]/g, '\\$&')}"]` : String(selector || '') }
799
+ return `(function(){try{return window.__ghostLocatorRuntime.locate(${JSON.stringify(semanticLocator)},${JSON.stringify(actionId)});}catch(e){return {error:e.message};}})()`
800
+ }
801
+
802
+ function buildElementCommandExpression({ actionId, command, payload }) {
803
+ return `(function(){try{return window.__ghostLocatorRuntime.use(${JSON.stringify(actionId)},${JSON.stringify(command)},${JSON.stringify(payload || {})});}catch(e){return {error:e.message};}})()`
804
+ }
805
+
806
+ function buildCleanupElementExpression(actionId) {
807
+ return `(function(){return window.__ghostLocatorRuntime ? window.__ghostLocatorRuntime.cleanup(${JSON.stringify(actionId)}) : true;})()`
808
+ }
809
+
810
+ function buildLocatorProbeExpression({ locator, state }) {
811
+ return `(function(){try{return window.__ghostLocatorRuntime.probe(${JSON.stringify(locator)},${JSON.stringify(state)});}catch(e){return {error:e.message};}})()`
812
+ }
813
+
453
814
  global.GhostBridgeDom = {
454
815
  buildInspectPageExpression,
455
816
  buildPageContentExpression,
456
817
  buildInteractiveSnapshotExpression,
818
+ buildInstallLocatorRuntimeExpression,
819
+ buildLocateElementExpression,
820
+ buildElementCommandExpression,
821
+ buildCleanupElementExpression,
822
+ buildLocatorProbeExpression,
823
+ createLocatorRuntime,
457
824
  }
458
825
  })(self)
@@ -0,0 +1,17 @@
1
+ (function initGhostBridgeRuntime(global) {
2
+ async function evaluateScript({ sendCommand, target, code, awaitPromise = true, timeoutMs, withTimeout, label = 'eval_script' }) {
3
+ const command = sendCommand(target, 'Runtime.evaluate', {
4
+ expression: code,
5
+ returnByValue: true,
6
+ awaitPromise,
7
+ timeout: timeoutMs,
8
+ })
9
+ const { result, exceptionDetails } = await withTimeout(command, timeoutMs + 1000, label)
10
+ if (exceptionDetails) {
11
+ throw new Error(exceptionDetails.exception?.description || exceptionDetails.text || '脚本执行失败')
12
+ }
13
+ return result?.value
14
+ }
15
+
16
+ global.GhostBridgeRuntime = { evaluateScript }
17
+ })(self)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Ghost Bridge",
4
- "version": "1.1.0",
4
+ "version": "1.2.0",
5
5
  "description": "Zero-restart Chrome debugger bridge for Claude MCP, optimized for no-sourcemap production debugging.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -34,4 +34,4 @@
34
34
  "background": {
35
35
  "service_worker": "background.js"
36
36
  }
37
- }
37
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghost-bridge",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Ghost Bridge: Zero-restart Chrome debugger bridge for Claude MCP. Includes CLI for easy setup.",
@@ -38,6 +38,7 @@
38
38
  "scripts": {
39
39
  "start": "node dist/server.js",
40
40
  "build": "node scripts/build.js",
41
+ "test": "node --test",
41
42
  "prepublishOnly": "npm run build"
42
43
  },
43
44
  "devDependencies": {