@agent-scope/runtime 1.17.1 → 1.17.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.
Files changed (2) hide show
  1. package/README.md +596 -0
  2. package/package.json +4 -3
package/README.md ADDED
@@ -0,0 +1,596 @@
1
+ # @agent-scope/runtime
2
+
3
+ Browser-side React instrumentation for the Scope platform.
4
+
5
+ `@agent-scope/runtime` captures a complete snapshot of a running React application: the full component tree with serialized props, state, and context; render timing; console output; JavaScript errors; and Suspense boundary status. It does this by installing a DevTools hook before React loads and walking React's internal fiber tree at capture time.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ npm install @agent-scope/runtime
13
+ ```
14
+
15
+ Requires `@agent-scope/core` as a peer dependency (automatically installed as a direct dependency).
16
+
17
+ ---
18
+
19
+ ## What it does / when to use it
20
+
21
+ Use `@agent-scope/runtime` whenever you need a programmatic snapshot of a React application at runtime. Typical use-cases:
22
+
23
+ - **AI agents / LLM tools** — capture the current UI state so a language model can reason about it.
24
+ - **Automated tests** — assert on the rendered component tree without touching the DOM.
25
+ - **Performance monitoring** — surface re-render counts and render durations per component.
26
+ - **DevTools / debugging dashboards** — build custom UI inspectors that augment React DevTools.
27
+
28
+ The package is entirely passive — it does not modify React or your components. The DevTools hook intercepts React's internal renderer registration, and the fiber walker reads fiber fields at capture time without writing back.
29
+
30
+ ---
31
+
32
+ ## Quick Start
33
+
34
+ ```ts
35
+ import { capture } from "@agent-scope/runtime";
36
+
37
+ // capture() must be called from the browser, after React has rendered
38
+ const result = await capture();
39
+
40
+ console.log(result.tree.name); // "App"
41
+ console.log(result.tree.children[0]?.name); // "Router"
42
+ console.log(result.capturedIn); // e.g. 12 (ms)
43
+ ```
44
+
45
+ For large trees (2 500+ components), use the lightweight mode to reduce payload size by ~99%:
46
+
47
+ ```ts
48
+ const result = await capture({ lightweight: true });
49
+ // result.tree is a LightweightComponentNode — no props/state/context/source
50
+ ```
51
+
52
+ ---
53
+
54
+ ## API Reference
55
+
56
+ ### `capture(options?)`
57
+
58
+ ```ts
59
+ // Full capture
60
+ function capture(options?: CaptureOptions): Promise<CaptureResult>;
61
+
62
+ // Lightweight overload (explicit discriminant)
63
+ function capture(options: CaptureOptions & { lightweight: true }): Promise<LightweightCaptureResult>;
64
+ ```
65
+
66
+ #### `CaptureOptions`
67
+
68
+ ```ts
69
+ interface CaptureOptions {
70
+ /**
71
+ * URL to record in the result. Defaults to window.location.href.
72
+ */
73
+ url?: string;
74
+
75
+ /**
76
+ * Milliseconds to wait for React to register a renderer before timing out.
77
+ * Default: 10_000.
78
+ */
79
+ reactTimeout?: number;
80
+
81
+ /**
82
+ * When true, skip serialising props/state/context/source/renderCount/renderDuration.
83
+ * Returns a LightweightCaptureResult with a LightweightComponentNode tree.
84
+ * Expected size reduction: ~99%.
85
+ * Default: false.
86
+ */
87
+ lightweight?: boolean;
88
+
89
+ /**
90
+ * When true, include host (DOM) elements (div, span, etc.) in the tree.
91
+ * Default: false — host elements are skipped and their children are
92
+ * promoted up to the nearest React component.
93
+ */
94
+ includeHostElements?: boolean;
95
+ }
96
+ ```
97
+
98
+ #### `CaptureResult`
99
+
100
+ ```ts
101
+ type CaptureResult = {
102
+ url: string;
103
+ timestamp: number; // Unix ms when capture started
104
+ capturedIn: number; // wall-clock ms for the full capture
105
+ tree: ComponentNode; // full component tree (from @agent-scope/core)
106
+ consoleEntries: ConsoleEntry[];
107
+ errors: CapturedError[];
108
+ suspenseBoundaries: SuspenseBoundaryInfo[];
109
+ };
110
+ ```
111
+
112
+ #### `LightweightCaptureResult`
113
+
114
+ ```ts
115
+ interface LightweightCaptureResult {
116
+ url: string;
117
+ timestamp: number;
118
+ capturedIn: number;
119
+ tree: LightweightComponentNode; // no props/state/context/source/renderCount/renderDuration
120
+ consoleEntries: ConsoleEntry[];
121
+ errors: CapturedError[];
122
+ suspenseBoundaries: SuspenseBoundaryInfo[];
123
+ }
124
+ ```
125
+
126
+ **Throws** when:
127
+ - No React renderer registers within `reactTimeout` ms.
128
+ - React has rendered no components (empty fiber tree).
129
+
130
+ ---
131
+
132
+ ### DevTools Hook
133
+
134
+ ```ts
135
+ import { installHook, getHook, getRenderers, waitForReact } from "@agent-scope/runtime";
136
+ ```
137
+
138
+ The hook must be installed **before React is loaded** to intercept renderer registration. `capture()` calls `installHook()` automatically; you only need these APIs when integrating at a lower level.
139
+
140
+ ```ts
141
+ /**
142
+ * Install the Scope DevTools hook at window.__REACT_DEVTOOLS_GLOBAL_HOOK__.
143
+ *
144
+ * - If no hook exists: installs a fresh Scope hook.
145
+ * - If a Scope hook is already there: returns the existing instance (idempotent).
146
+ * - If real React DevTools are already installed: patches inject() to forward
147
+ * renderer registrations to Scope as well, without displacing the existing hook.
148
+ */
149
+ function installHook(): ScopeDevToolsHook;
150
+
151
+ /** Returns the installed hook, or null if installHook() has not been called. */
152
+ function getHook(): ScopeDevToolsHook | null;
153
+
154
+ /** Returns all React renderers registered since the hook was installed. */
155
+ function getRenderers(): ReactRenderer[];
156
+
157
+ /**
158
+ * Wait until at least one React renderer registers.
159
+ * @param timeout Maximum wait in ms (default: 10_000).
160
+ * @throws If timeout elapses with no renderer.
161
+ */
162
+ function waitForReact(timeout?: number): Promise<ReactRenderer>;
163
+ ```
164
+
165
+ **`ScopeDevToolsHook` shape** (installed at `window.__REACT_DEVTOOLS_GLOBAL_HOOK__`):
166
+
167
+ ```ts
168
+ interface ScopeDevToolsHook {
169
+ isDisabled: false; // tells React that DevTools are present
170
+ supportsFiber: true; // signals React fiber-mode support
171
+ inject(renderer: unknown): number;
172
+ onCommitFiberRoot(rendererID: number, root: unknown, priorityLevel: unknown): void;
173
+ onCommitFiberUnmount(rendererID: number, fiber: unknown): void;
174
+ onPostCommitFiberRoot(rendererID: number, root: unknown): void;
175
+ _renderers: Map<number, ReactRenderer>;
176
+ _isScopeHook: true; // marker so we can detect our own hook
177
+ }
178
+ ```
179
+
180
+ React calls `hook.inject(renderer)` once per renderer when it initialises. Scope records the renderer and its `fiberRoots` set; `capture()` uses `fiberRoots` to locate the entry-point fiber.
181
+
182
+ ---
183
+
184
+ ### Fiber Walker
185
+
186
+ ```ts
187
+ import { walkFiber, walkFiberRoot, walkFiberLightweight, walkFiberRootLightweight } from "@agent-scope/runtime";
188
+ ```
189
+
190
+ The walker converts a React fiber (an internal React object) into a `ComponentNode` tree. You rarely call these directly — `capture()` wraps them — but they are exported for custom instrumentation.
191
+
192
+ ```ts
193
+ /**
194
+ * Walk a fiber and its descendants, returning a ComponentNode tree.
195
+ * Returns null if the root fiber should be skipped (HostRoot, Fragment, etc.).
196
+ */
197
+ function walkFiber(fiber: Fiber | null, options?: WalkOptions): ComponentNode | null;
198
+
199
+ /**
200
+ * Walk from a fiber root object (e.g. from ReactRenderer.fiberRoots).
201
+ * Skips the HostRoot wrapper; returns the first meaningful ComponentNode.
202
+ */
203
+ function walkFiberRoot(fiberRoot: any, options?: WalkOptions): ComponentNode | null;
204
+
205
+ /** Same as walkFiber but emits LightweightComponentNode (no props/state/etc.). */
206
+ function walkFiberLightweight(fiber: Fiber | null, options?: WalkOptions): LightweightComponentNode | null;
207
+
208
+ /** Same as walkFiberRoot but lightweight. */
209
+ function walkFiberRootLightweight(fiberRoot: any, options?: WalkOptions): LightweightComponentNode | null;
210
+ ```
211
+
212
+ **Walking algorithm:**
213
+
214
+ 1. Start at the given fiber.
215
+ 2. If the fiber should be skipped (`HostRoot`, `HostText`, `Fragment`, `SuspenseComponent`, or `HostComponent` when `includeHostElements` is false), perform a **transparent skip** — the fiber is invisible in the output but its children are promoted up to the nearest visible ancestor.
216
+ 3. Otherwise: extract name, type, source, props, hooks, context, and profiling data, then recurse into `fiber.child` and `fiber.sibling`.
217
+ 4. A `WeakSet` cycle guard prevents infinite loops in corrupt fiber trees.
218
+
219
+ **Name resolution order** (mirrors `extractName()`):
220
+ 1. `type.displayName`
221
+ 2. `type.name` (function/class name; inferred property names like `"render"` are rejected)
222
+ 3. Inner `render.displayName` / `render.name` for `forwardRef` wrappers
223
+ 4. Inner `type.displayName` / `type.name` for `memo` wrappers
224
+ 5. String tag for host elements (`"div"`, `"span"`, …)
225
+ 6. `"Anonymous"` as last resort
226
+
227
+ **Type classification** (the `ComponentType` field):
228
+
229
+ | React fiber tag | `type` result |
230
+ |---|---|
231
+ | `FunctionComponent`, `IndeterminateComponent` | `"function"` |
232
+ | `ClassComponent` | `"class"` |
233
+ | `ForwardRef` | `"forward_ref"` |
234
+ | `MemoComponent`, `SimpleMemoComponent` | `"memo"` |
235
+ | `HostComponent`, `HostText` | `"host"` |
236
+
237
+ **Example** (from `src/__tests__/fiber-walker.test.ts`):
238
+
239
+ ```ts
240
+ // Tree: App → [Header, Footer]
241
+ // fiber structure:
242
+ const footer = makeFiber({ tag: FunctionComponent, type: Footer, index: 1 });
243
+ const header = makeFiber({ tag: FunctionComponent, type: Header, index: 0, sibling: footer });
244
+ const app = makeFiber({ tag: FunctionComponent, type: App, child: header });
245
+
246
+ const node = walkFiber(app);
247
+ // node.name → "App"
248
+ // node.children[0].name → "Header"
249
+ // node.children[1].name → "Footer"
250
+
251
+ // Host element transparent skip:
252
+ // App → div → Button (div is skipped, Button is promoted to App's child)
253
+ const button = makeFiber({ tag: FunctionComponent, type: Button });
254
+ const div = makeFiber({ tag: HostComponent, type: "div", child: button });
255
+ const app2 = makeFiber({ tag: FunctionComponent, type: App, child: div });
256
+
257
+ walkFiber(app2).children[0].name // → "Button" (div is invisible)
258
+ ```
259
+
260
+ ---
261
+
262
+ ### Hooks Extractor
263
+
264
+ ```ts
265
+ import { extractHooks } from "@agent-scope/runtime";
266
+ ```
267
+
268
+ Extracts `HookState[]` from a fiber's `memoizedState` linked list by duck-typing each node's shape (React does not tag hooks internally).
269
+
270
+ ```ts
271
+ function extractHooks(fiber: any): HookState[];
272
+ ```
273
+
274
+ **Detection heuristics** per hook type:
275
+
276
+ | Hook | Detection pattern |
277
+ |---|---|
278
+ | `useEffect` | `memoizedState.create` is a function AND `"deps"` key exists AND `tag & 0b01000` (Passive flag) |
279
+ | `useLayoutEffect` | Same as useEffect but `tag & 0b00100` (Layout flag) |
280
+ | `useRef` | `queue === null`, `memoizedState` is `{ current: value }` (exactly one key named `"current"`) |
281
+ | `useMemo` | `queue === null`, `memoizedState` is `[value, deps]` tuple (array of length 2, deps is array or null), value is NOT a function |
282
+ | `useCallback` | Same tuple as useMemo, but value IS a function |
283
+ | `useState` | `queue.dispatch` is a function AND `queue.lastRenderedReducer.name === "basicStateReducer"` |
284
+ | `useReducer` | `queue.dispatch` is a function AND reducer name is NOT `"basicStateReducer"` |
285
+ | `custom` | None of the above |
286
+
287
+ **Example outputs** (from `src/__tests__/hooks-extractor.test.ts`):
288
+
289
+ ```ts
290
+ // useState(42)
291
+ { type: "useState", name: null, value: { type: "number", value: 42 }, deps: null, hasCleanup: null }
292
+
293
+ // useEffect(() => cleanup, [dep])
294
+ { type: "useEffect", name: null, value: { type: "undefined" }, deps: [{ type: "string", value: "abc" }], hasCleanup: true }
295
+
296
+ // useLayoutEffect(() => {}, ["dep"])
297
+ { type: "useLayoutEffect", name: null, value: { type: "undefined" }, deps: [{ type: "string", value: "dep" }], hasCleanup: true }
298
+
299
+ // useMemo(() => 99, [1, 2])
300
+ { type: "useMemo", name: null, value: { type: "number", value: 99 }, deps: [{ type: "number", value: 1 }, { type: "number", value: 2 }], hasCleanup: null }
301
+
302
+ // useCallback(fn, ["dep"])
303
+ { type: "useCallback", name: null, value: { type: "function", preview: "..." }, deps: [{ type: "string", value: "dep" }], hasCleanup: null }
304
+
305
+ // useRef(domEl)
306
+ { type: "useRef", name: null, value: { type: "object", ... }, deps: null, hasCleanup: null }
307
+
308
+ // useReducer (user-supplied reducer)
309
+ { type: "useReducer", name: null, value: { type: "object", ... }, deps: null, hasCleanup: null }
310
+ ```
311
+
312
+ ---
313
+
314
+ ### Profiler
315
+
316
+ ```ts
317
+ import { installProfiler, getProfilingData, resetProfilingData } from "@agent-scope/runtime";
318
+ ```
319
+
320
+ Accumulates per-component render counts and self-durations across React commits.
321
+
322
+ ```ts
323
+ /**
324
+ * Wrap hook.onCommitFiberRoot to intercept every React commit.
325
+ * Safe to call multiple times (idempotent).
326
+ */
327
+ function installProfiler(hook: ScopeDevToolsHook): void;
328
+
329
+ /**
330
+ * Get accumulated profiling data for a fiber by its numeric ID.
331
+ * Returns null when no data has been recorded.
332
+ */
333
+ function getProfilingData(fiberId: number): ProfilingSnapshot | null;
334
+
335
+ interface ProfilingSnapshot {
336
+ renderCount: number;
337
+ renderDuration: number; // sum of selfBaseDuration across all recorded commits (ms)
338
+ }
339
+
340
+ /**
341
+ * Clear all profiling data and reset the installed state.
342
+ * Call this before starting a new capture session.
343
+ */
344
+ function resetProfilingData(): void;
345
+ ```
346
+
347
+ **How it works:**
348
+
349
+ On each React commit, the profiler walks `root.current.alternate` (the work-in-progress fiber subtree). For any fiber with `actualDuration >= 0` (meaning it was actually rendered in this commit — bailed-out fibers have `actualDuration === -1`), it accumulates `selfBaseDuration` (render time for this fiber only, excluding children) into a `Map<fiberId, ProfilingRecord>`.
350
+
351
+ ```ts
352
+ // After 3 re-renders of the same component:
353
+ getProfilingData(componentId);
354
+ // → { renderCount: 3, renderDuration: 6.5 } (sum of selfBaseDuration across 3 commits)
355
+ ```
356
+
357
+ ---
358
+
359
+ ### Console Interceptor
360
+
361
+ ```ts
362
+ import {
363
+ installConsoleInterceptor,
364
+ uninstallConsoleInterceptor,
365
+ getConsoleEntries,
366
+ clearConsoleEntries,
367
+ } from "@agent-scope/runtime";
368
+ ```
369
+
370
+ Monkey-patches all `console.*` methods to record `ConsoleEntry` objects, including React component attribution.
371
+
372
+ ```ts
373
+ /** Patch console.log/warn/error/info/debug/group/groupCollapsed/table/trace. Idempotent. */
374
+ function installConsoleInterceptor(): void;
375
+
376
+ /** Restore all original console methods. Idempotent. */
377
+ function uninstallConsoleInterceptor(): void;
378
+
379
+ /** Returns a shallow copy of all captured entries since the last clear. */
380
+ function getConsoleEntries(): ConsoleEntry[];
381
+
382
+ /** Empty the capture buffer. */
383
+ function clearConsoleEntries(): void;
384
+ ```
385
+
386
+ **Component attribution** — reads `window.__REACT_DEVTOOLS_GLOBAL_HOOK__._currentFiber` to identify the currently-rendering component. `componentName` is `null` for calls made outside a render (effects, event handlers, async callbacks).
387
+
388
+ The interceptor **never suppresses output** — every wrapped method calls the original implementation first.
389
+
390
+ **Example entries** (from `src/__tests__/console-interceptor.test.ts`):
391
+
392
+ ```ts
393
+ // console.log("test message") inside MyComponent's render:
394
+ {
395
+ level: "log",
396
+ args: [{ type: "string", value: "test message", preview: '"test message"' }],
397
+ timestamp: 1700000000000,
398
+ componentName: "MyComponent",
399
+ preview: "test message",
400
+ }
401
+
402
+ // console.warn("outside render") outside a component:
403
+ {
404
+ level: "warn",
405
+ args: [{ type: "string", value: "outside render", preview: '"outside render"' }],
406
+ timestamp: 1700000000001,
407
+ componentName: null,
408
+ preview: "outside render",
409
+ }
410
+ ```
411
+
412
+ ---
413
+
414
+ ### Error Detector
415
+
416
+ ```ts
417
+ import { detectErrors } from "@agent-scope/runtime";
418
+
419
+ function detectErrors(rootFiber: Fiber | null): CapturedError[];
420
+ ```
421
+
422
+ Walks the fiber tree to find React error boundaries (`getDerivedStateFromError` or `componentDidCatch`) that are currently holding a caught error in their `memoizedState`.
423
+
424
+ State shape detection (boundaries vary in how they store caught errors):
425
+ 1. `state instanceof Error` — direct Error instance
426
+ 2. `state.hasError === true && state.error instanceof Error` — two-field pattern
427
+ 3. `state.error instanceof Error` — one-field pattern
428
+ 4. Scans all state keys for any value that `instanceof Error`
429
+
430
+ ```ts
431
+ // Example output for a caught TypeError:
432
+ {
433
+ message: "type error",
434
+ name: "TypeError",
435
+ stack: "TypeError: type error\n at ...",
436
+ source: null, // stack parsing left to consumers
437
+ componentName: "ThrowingChild", // attributed to boundary's first child
438
+ timestamp: 1700000000000,
439
+ capturedBy: "boundary",
440
+ }
441
+ ```
442
+
443
+ ---
444
+
445
+ ### Suspense Detector
446
+
447
+ ```ts
448
+ import { detectSuspenseBoundaries } from "@agent-scope/runtime";
449
+
450
+ function detectSuspenseBoundaries(rootFiber: Fiber | null): SuspenseBoundaryInfo[];
451
+ ```
452
+
453
+ Finds all `<Suspense>` boundaries in the fiber tree and classifies their current status:
454
+
455
+ | `memoizedState` | `isSuspended` | Meaning |
456
+ |---|---|---|
457
+ | `null` | `false` | Resolved — primary content visible |
458
+ | `{ dehydrated: ... }` | `true` | Pending — SSR dehydrated boundary |
459
+ | `{ then: function }` | `true` | Pending — thrown promise still in-flight |
460
+ | any other non-null | `true` | Fallback shown |
461
+
462
+ ```ts
463
+ // Example output:
464
+ {
465
+ id: 99, // fiber._debugID or fiber.index
466
+ componentName: "DataPage", // nearest named ancestor
467
+ isSuspended: true,
468
+ suspendedDuration: null, // timing unavailable at capture time
469
+ fallbackName: "LoadingSpinner", // second child of Suspense fiber
470
+ source: { fileName: "App.tsx", lineNumber: 42, columnNumber: 6 },
471
+ }
472
+ ```
473
+
474
+ ---
475
+
476
+ ### Context Extractor
477
+
478
+ ```ts
479
+ import { extractContextConsumptions, extractContextProviders } from "@agent-scope/runtime";
480
+ ```
481
+
482
+ ```ts
483
+ /** Extract all contexts consumed by a fiber (via useContext or contextType). */
484
+ function extractContextConsumptions(fiber: Fiber): ContextConsumption[];
485
+
486
+ /** Extract the provided value from a ContextProvider fiber (tag 10). */
487
+ function extractContextProviders(fiber: Fiber): Array<{ contextName: string | null; value: SerializedValue }>;
488
+ ```
489
+
490
+ Reads `fiber.dependencies.firstContext` (React 17+) or `fiber.contextDependencies.first` (React 16) to walk the context dependency linked list. For each dependency, resolves the context name from `context.displayName` (or constructor name heuristic), and serializes `context._currentValue`.
491
+
492
+ **Example** (from `src/__tests__/context-extractor.test.ts`):
493
+
494
+ ```ts
495
+ // Component consuming ThemeContext with value "dark":
496
+ [{
497
+ contextName: "ThemeContext",
498
+ value: { type: "string", value: "dark", preview: '"dark"' },
499
+ didTriggerRender: false,
500
+ }]
501
+
502
+ // Component consuming multiple contexts:
503
+ [
504
+ { contextName: "ThemeContext", value: { type: "string", value: "dark" }, didTriggerRender: false },
505
+ { contextName: "LocaleContext", value: { type: "string", value: "en" }, didTriggerRender: false },
506
+ { contextName: "AuthContext", value: { type: "object", preview: "..." }, didTriggerRender: false },
507
+ ]
508
+ ```
509
+
510
+ ---
511
+
512
+ ### `ScopeRuntime` Class
513
+
514
+ A lightweight buffer manager for PageReport objects, useful when you capture continuously.
515
+
516
+ ```ts
517
+ import { ScopeRuntime, createRuntime } from "@agent-scope/runtime";
518
+
519
+ interface RuntimeOptions {
520
+ /** Called whenever a new PageReport is captured. */
521
+ onCapture?: (report: PageReport) => void;
522
+ /** Maximum number of reports to buffer. Default: 100. */
523
+ bufferSize?: number;
524
+ }
525
+
526
+ class ScopeRuntime {
527
+ constructor(options?: RuntimeOptions);
528
+
529
+ /** Add a report to the buffer (evicts oldest if full). */
530
+ record(report: PageReport): void;
531
+
532
+ /** Return all buffered reports and clear the buffer. */
533
+ flush(): PageReport[];
534
+
535
+ /** Read-only snapshot of the current buffer. */
536
+ getBuffer(): readonly PageReport[];
537
+
538
+ /** Find a ComponentNode by fiber ID within a tree. */
539
+ static findNode(root: ComponentNode, id: number): ComponentNode | null;
540
+ }
541
+
542
+ function createRuntime(options?: RuntimeOptions): ScopeRuntime;
543
+ ```
544
+
545
+ ---
546
+
547
+ ## Internal Architecture
548
+
549
+ ```
550
+ src/
551
+ ├── index.ts ← barrel export
552
+ ├── capture.ts ← capture() — orchestrates all subsystems
553
+ ├── devtools-hook.ts ← installHook(), waitForReact(), ScopeDevToolsHook
554
+ ├── fiber-walker.ts ← walkFiber(), walkFiberRoot(), lightweight variants
555
+ │ extractName(), classifyType()
556
+ ├── hooks-extractor.ts ← extractHooks() — hook type detection via duck-typing
557
+ ├── profiler.ts ← installProfiler(), getProfilingData(), resetProfilingData()
558
+ ├── context-extractor.ts ← extractContextConsumptions(), extractContextProviders()
559
+ ├── console-interceptor.ts ← installConsoleInterceptor(), getConsoleEntries()
560
+ ├── error-detector.ts ← detectErrors()
561
+ └── suspense-detector.ts ← detectSuspenseBoundaries()
562
+ ```
563
+
564
+ **Capture orchestration** (`capture.ts`):
565
+
566
+ ```
567
+ capture()
568
+
569
+ ├─ installHook() ← ensure hook is at window.__REACT_DEVTOOLS_GLOBAL_HOOK__
570
+ ├─ installProfiler(hook) ← start accumulating render timing
571
+ ├─ installConsoleInterceptor() + clearConsoleEntries()
572
+ ├─ waitForReact() ← async: wait for renderer.inject()
573
+
574
+ ├─ [renderer registered]
575
+ │ │
576
+ │ ├─ fiberRoot = renderer.fiberRoots.first
577
+ │ ├─ detectErrors(rootChild)
578
+ │ ├─ detectSuspenseBoundaries(rootChild)
579
+ │ ├─ getConsoleEntries()
580
+ │ │
581
+ │ └─ lightweight ?
582
+ │ ├─ yes → walkFiberRootLightweight(fiberRoot)
583
+ │ └─ no → walkFiberRoot(fiberRoot)
584
+
585
+ └─ uninstallConsoleInterceptor() ← always runs (finally block)
586
+ ```
587
+
588
+ ---
589
+
590
+ ## Used by
591
+
592
+ | Package | How |
593
+ |---|---|
594
+ | `@agent-scope/cli` | Calls `capture()` to produce PageReport JSON for the report command |
595
+ | `@agent-scope/manifest` | Builds component manifests from `CaptureResult.tree` |
596
+ | Test fixture apps | Import `capture()` in integration tests to assert on live React trees |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-scope/runtime",
3
- "version": "1.17.1",
3
+ "version": "1.17.3",
4
4
  "description": "Browser instrumentation for Scope — captures React render events",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  "module": "./dist/index.js",
21
21
  "types": "./dist/index.d.ts",
22
22
  "files": [
23
- "dist"
23
+ "dist",
24
+ "README.md"
24
25
  ],
25
26
  "scripts": {
26
27
  "build": "tsup",
@@ -29,7 +30,7 @@
29
30
  "clean": "rm -rf dist"
30
31
  },
31
32
  "dependencies": {
32
- "@agent-scope/core": "1.17.1"
33
+ "@agent-scope/core": "1.17.3"
33
34
  },
34
35
  "devDependencies": {
35
36
  "tsup": "*",