This is page 41 of 60. Use http://codebase.md/hangwin/mcp-chrome?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ └── workflows
│ └── build-release.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│ └── extensions.json
├── app
│ ├── chrome-extension
│ │ ├── _locales
│ │ │ ├── de
│ │ │ │ └── messages.json
│ │ │ ├── en
│ │ │ │ └── messages.json
│ │ │ ├── ja
│ │ │ │ └── messages.json
│ │ │ ├── ko
│ │ │ │ └── messages.json
│ │ │ ├── zh_CN
│ │ │ │ └── messages.json
│ │ │ └── zh_TW
│ │ │ └── messages.json
│ │ ├── .env.example
│ │ ├── assets
│ │ │ └── vue.svg
│ │ ├── common
│ │ │ ├── agent-models.ts
│ │ │ ├── constants.ts
│ │ │ ├── element-marker-types.ts
│ │ │ ├── message-types.ts
│ │ │ ├── node-types.ts
│ │ │ ├── rr-v3-keepalive-protocol.ts
│ │ │ ├── step-types.ts
│ │ │ ├── tool-handler.ts
│ │ │ └── web-editor-types.ts
│ │ ├── entrypoints
│ │ │ ├── background
│ │ │ │ ├── element-marker
│ │ │ │ │ ├── element-marker-storage.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── keepalive-manager.ts
│ │ │ │ ├── native-host.ts
│ │ │ │ ├── quick-panel
│ │ │ │ │ ├── agent-handler.ts
│ │ │ │ │ ├── commands.ts
│ │ │ │ │ └── tabs-handler.ts
│ │ │ │ ├── record-replay
│ │ │ │ │ ├── actions
│ │ │ │ │ │ ├── adapter.ts
│ │ │ │ │ │ ├── handlers
│ │ │ │ │ │ │ ├── assert.ts
│ │ │ │ │ │ │ ├── click.ts
│ │ │ │ │ │ │ ├── common.ts
│ │ │ │ │ │ │ ├── control-flow.ts
│ │ │ │ │ │ │ ├── delay.ts
│ │ │ │ │ │ │ ├── dom.ts
│ │ │ │ │ │ │ ├── drag.ts
│ │ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ │ ├── fill.ts
│ │ │ │ │ │ │ ├── http.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── key.ts
│ │ │ │ │ │ │ ├── navigate.ts
│ │ │ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ │ │ ├── script.ts
│ │ │ │ │ │ │ ├── scroll.ts
│ │ │ │ │ │ │ ├── tabs.ts
│ │ │ │ │ │ │ └── wait.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── registry.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── engine
│ │ │ │ │ │ ├── constants.ts
│ │ │ │ │ │ ├── execution-mode.ts
│ │ │ │ │ │ ├── logging
│ │ │ │ │ │ │ └── run-logger.ts
│ │ │ │ │ │ ├── plugins
│ │ │ │ │ │ │ ├── breakpoint.ts
│ │ │ │ │ │ │ ├── manager.ts
│ │ │ │ │ │ │ └── types.ts
│ │ │ │ │ │ ├── policies
│ │ │ │ │ │ │ ├── retry.ts
│ │ │ │ │ │ │ └── wait.ts
│ │ │ │ │ │ ├── runners
│ │ │ │ │ │ │ ├── after-script-queue.ts
│ │ │ │ │ │ │ ├── control-flow-runner.ts
│ │ │ │ │ │ │ ├── step-executor.ts
│ │ │ │ │ │ │ ├── step-runner.ts
│ │ │ │ │ │ │ └── subflow-runner.ts
│ │ │ │ │ │ ├── scheduler.ts
│ │ │ │ │ │ ├── state-manager.ts
│ │ │ │ │ │ └── utils
│ │ │ │ │ │ └── expression.ts
│ │ │ │ │ ├── flow-runner.ts
│ │ │ │ │ ├── flow-store.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── legacy-types.ts
│ │ │ │ │ ├── nodes
│ │ │ │ │ │ ├── assert.ts
│ │ │ │ │ │ ├── click.ts
│ │ │ │ │ │ ├── conditional.ts
│ │ │ │ │ │ ├── download-screenshot-attr-event-frame-loop.ts
│ │ │ │ │ │ ├── drag.ts
│ │ │ │ │ │ ├── execute-flow.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── fill.ts
│ │ │ │ │ │ ├── http.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── key.ts
│ │ │ │ │ │ ├── loops.ts
│ │ │ │ │ │ ├── navigate.ts
│ │ │ │ │ │ ├── script.ts
│ │ │ │ │ │ ├── scroll.ts
│ │ │ │ │ │ ├── tabs.ts
│ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ └── wait.ts
│ │ │ │ │ ├── recording
│ │ │ │ │ │ ├── browser-event-listener.ts
│ │ │ │ │ │ ├── content-injection.ts
│ │ │ │ │ │ ├── content-message-handler.ts
│ │ │ │ │ │ ├── flow-builder.ts
│ │ │ │ │ │ ├── recorder-manager.ts
│ │ │ │ │ │ └── session-manager.ts
│ │ │ │ │ ├── rr-utils.ts
│ │ │ │ │ ├── selector-engine.ts
│ │ │ │ │ ├── storage
│ │ │ │ │ │ └── indexeddb-manager.ts
│ │ │ │ │ ├── trigger-store.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── record-replay-v3
│ │ │ │ │ ├── bootstrap.ts
│ │ │ │ │ ├── domain
│ │ │ │ │ │ ├── debug.ts
│ │ │ │ │ │ ├── errors.ts
│ │ │ │ │ │ ├── events.ts
│ │ │ │ │ │ ├── flow.ts
│ │ │ │ │ │ ├── ids.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── json.ts
│ │ │ │ │ │ ├── policy.ts
│ │ │ │ │ │ ├── triggers.ts
│ │ │ │ │ │ └── variables.ts
│ │ │ │ │ ├── engine
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── keepalive
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── offscreen-keepalive.ts
│ │ │ │ │ │ ├── kernel
│ │ │ │ │ │ │ ├── artifacts.ts
│ │ │ │ │ │ │ ├── breakpoints.ts
│ │ │ │ │ │ │ ├── debug-controller.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── kernel.ts
│ │ │ │ │ │ │ ├── recovery-kernel.ts
│ │ │ │ │ │ │ ├── runner.ts
│ │ │ │ │ │ │ └── traversal.ts
│ │ │ │ │ │ ├── plugins
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── register-v2-replay-nodes.ts
│ │ │ │ │ │ │ ├── registry.ts
│ │ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ │ └── v2-action-adapter.ts
│ │ │ │ │ │ ├── queue
│ │ │ │ │ │ │ ├── enqueue-run.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── leasing.ts
│ │ │ │ │ │ │ ├── queue.ts
│ │ │ │ │ │ │ └── scheduler.ts
│ │ │ │ │ │ ├── recovery
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── recovery-coordinator.ts
│ │ │ │ │ │ ├── storage
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── storage-port.ts
│ │ │ │ │ │ ├── transport
│ │ │ │ │ │ │ ├── events-bus.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── rpc-server.ts
│ │ │ │ │ │ │ └── rpc.ts
│ │ │ │ │ │ └── triggers
│ │ │ │ │ │ ├── command-trigger.ts
│ │ │ │ │ │ ├── context-menu-trigger.ts
│ │ │ │ │ │ ├── cron-trigger.ts
│ │ │ │ │ │ ├── dom-trigger.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── interval-trigger.ts
│ │ │ │ │ │ ├── manual-trigger.ts
│ │ │ │ │ │ ├── once-trigger.ts
│ │ │ │ │ │ ├── trigger-handler.ts
│ │ │ │ │ │ ├── trigger-manager.ts
│ │ │ │ │ │ └── url-trigger.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── storage
│ │ │ │ │ ├── db.ts
│ │ │ │ │ ├── events.ts
│ │ │ │ │ ├── flows.ts
│ │ │ │ │ ├── import
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── v2-reader.ts
│ │ │ │ │ │ └── v2-to-v3.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── persistent-vars.ts
│ │ │ │ │ ├── queue.ts
│ │ │ │ │ ├── runs.ts
│ │ │ │ │ └── triggers.ts
│ │ │ │ ├── semantic-similarity.ts
│ │ │ │ ├── storage-manager.ts
│ │ │ │ ├── tools
│ │ │ │ │ ├── base-browser.ts
│ │ │ │ │ ├── browser
│ │ │ │ │ │ ├── bookmark.ts
│ │ │ │ │ │ ├── common.ts
│ │ │ │ │ │ ├── computer.ts
│ │ │ │ │ │ ├── console-buffer.ts
│ │ │ │ │ │ ├── console.ts
│ │ │ │ │ │ ├── dialog.ts
│ │ │ │ │ │ ├── download.ts
│ │ │ │ │ │ ├── element-picker.ts
│ │ │ │ │ │ ├── file-upload.ts
│ │ │ │ │ │ ├── gif-auto-capture.ts
│ │ │ │ │ │ ├── gif-enhanced-renderer.ts
│ │ │ │ │ │ ├── gif-recorder.ts
│ │ │ │ │ │ ├── history.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── inject-script.ts
│ │ │ │ │ │ ├── interaction.ts
│ │ │ │ │ │ ├── javascript.ts
│ │ │ │ │ │ ├── keyboard.ts
│ │ │ │ │ │ ├── network-capture-debugger.ts
│ │ │ │ │ │ ├── network-capture-web-request.ts
│ │ │ │ │ │ ├── network-capture.ts
│ │ │ │ │ │ ├── network-request.ts
│ │ │ │ │ │ ├── performance.ts
│ │ │ │ │ │ ├── read-page.ts
│ │ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ │ ├── userscript.ts
│ │ │ │ │ │ ├── vector-search.ts
│ │ │ │ │ │ ├── web-fetcher.ts
│ │ │ │ │ │ └── window.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── record-replay.ts
│ │ │ │ ├── utils
│ │ │ │ │ └── sidepanel.ts
│ │ │ │ └── web-editor
│ │ │ │ └── index.ts
│ │ │ ├── builder
│ │ │ │ ├── App.vue
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ ├── content.ts
│ │ │ ├── element-picker.content.ts
│ │ │ ├── offscreen
│ │ │ │ ├── gif-encoder.ts
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ └── rr-keepalive.ts
│ │ │ ├── options
│ │ │ │ ├── App.vue
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ ├── popup
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── builder
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── Canvas.vue
│ │ │ │ │ │ │ ├── EdgePropertyPanel.vue
│ │ │ │ │ │ │ ├── KeyValueEditor.vue
│ │ │ │ │ │ │ ├── nodes
│ │ │ │ │ │ │ │ ├── node-util.ts
│ │ │ │ │ │ │ │ ├── NodeCard.vue
│ │ │ │ │ │ │ │ └── NodeIf.vue
│ │ │ │ │ │ │ ├── properties
│ │ │ │ │ │ │ │ ├── PropertyAssert.vue
│ │ │ │ │ │ │ │ ├── PropertyClick.vue
│ │ │ │ │ │ │ │ ├── PropertyCloseTab.vue
│ │ │ │ │ │ │ │ ├── PropertyDelay.vue
│ │ │ │ │ │ │ │ ├── PropertyDrag.vue
│ │ │ │ │ │ │ │ ├── PropertyExecuteFlow.vue
│ │ │ │ │ │ │ │ ├── PropertyExtract.vue
│ │ │ │ │ │ │ │ ├── PropertyFill.vue
│ │ │ │ │ │ │ │ ├── PropertyForeach.vue
│ │ │ │ │ │ │ │ ├── PropertyFormRenderer.vue
│ │ │ │ │ │ │ │ ├── PropertyFromSpec.vue
│ │ │ │ │ │ │ │ ├── PropertyHandleDownload.vue
│ │ │ │ │ │ │ │ ├── PropertyHttp.vue
│ │ │ │ │ │ │ │ ├── PropertyIf.vue
│ │ │ │ │ │ │ │ ├── PropertyKey.vue
│ │ │ │ │ │ │ │ ├── PropertyLoopElements.vue
│ │ │ │ │ │ │ │ ├── PropertyNavigate.vue
│ │ │ │ │ │ │ │ ├── PropertyOpenTab.vue
│ │ │ │ │ │ │ │ ├── PropertyScreenshot.vue
│ │ │ │ │ │ │ │ ├── PropertyScript.vue
│ │ │ │ │ │ │ │ ├── PropertyScroll.vue
│ │ │ │ │ │ │ │ ├── PropertySetAttribute.vue
│ │ │ │ │ │ │ │ ├── PropertySwitchFrame.vue
│ │ │ │ │ │ │ │ ├── PropertySwitchTab.vue
│ │ │ │ │ │ │ │ ├── PropertyTrigger.vue
│ │ │ │ │ │ │ │ ├── PropertyTriggerEvent.vue
│ │ │ │ │ │ │ │ ├── PropertyWait.vue
│ │ │ │ │ │ │ │ ├── PropertyWhile.vue
│ │ │ │ │ │ │ │ └── SelectorEditor.vue
│ │ │ │ │ │ │ ├── PropertyPanel.vue
│ │ │ │ │ │ │ ├── Sidebar.vue
│ │ │ │ │ │ │ └── TriggerPanel.vue
│ │ │ │ │ │ ├── model
│ │ │ │ │ │ │ ├── form-widget-registry.ts
│ │ │ │ │ │ │ ├── node-spec-registry.ts
│ │ │ │ │ │ │ ├── node-spec.ts
│ │ │ │ │ │ │ ├── node-specs-builtin.ts
│ │ │ │ │ │ │ ├── toast.ts
│ │ │ │ │ │ │ ├── transforms.ts
│ │ │ │ │ │ │ ├── ui-nodes.ts
│ │ │ │ │ │ │ ├── validation.ts
│ │ │ │ │ │ │ └── variables.ts
│ │ │ │ │ │ ├── store
│ │ │ │ │ │ │ └── useBuilderStore.ts
│ │ │ │ │ │ └── widgets
│ │ │ │ │ │ ├── FieldCode.vue
│ │ │ │ │ │ ├── FieldDuration.vue
│ │ │ │ │ │ ├── FieldExpression.vue
│ │ │ │ │ │ ├── FieldKeySequence.vue
│ │ │ │ │ │ ├── FieldSelector.vue
│ │ │ │ │ │ ├── FieldTargetLocator.vue
│ │ │ │ │ │ └── VarInput.vue
│ │ │ │ │ ├── ConfirmDialog.vue
│ │ │ │ │ ├── ElementMarkerManagement.vue
│ │ │ │ │ ├── icons
│ │ │ │ │ │ ├── BoltIcon.vue
│ │ │ │ │ │ ├── CheckIcon.vue
│ │ │ │ │ │ ├── DatabaseIcon.vue
│ │ │ │ │ │ ├── DocumentIcon.vue
│ │ │ │ │ │ ├── EditIcon.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MarkerIcon.vue
│ │ │ │ │ │ ├── RecordIcon.vue
│ │ │ │ │ │ ├── RefreshIcon.vue
│ │ │ │ │ │ ├── StopIcon.vue
│ │ │ │ │ │ ├── TabIcon.vue
│ │ │ │ │ │ ├── TrashIcon.vue
│ │ │ │ │ │ ├── VectorIcon.vue
│ │ │ │ │ │ └── WorkflowIcon.vue
│ │ │ │ │ ├── LocalModelPage.vue
│ │ │ │ │ ├── ModelCacheManagement.vue
│ │ │ │ │ ├── ProgressIndicator.vue
│ │ │ │ │ └── ScheduleDialog.vue
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ └── style.css
│ │ │ ├── quick-panel.content.ts
│ │ │ ├── shared
│ │ │ │ ├── composables
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── useRRV3Rpc.ts
│ │ │ │ └── utils
│ │ │ │ ├── index.ts
│ │ │ │ └── rr-flow-convert.ts
│ │ │ ├── sidepanel
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── agent
│ │ │ │ │ │ ├── AttachmentPreview.vue
│ │ │ │ │ │ ├── ChatInput.vue
│ │ │ │ │ │ ├── CliSettings.vue
│ │ │ │ │ │ ├── ConnectionStatus.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MessageItem.vue
│ │ │ │ │ │ ├── MessageList.vue
│ │ │ │ │ │ ├── ProjectCreateForm.vue
│ │ │ │ │ │ └── ProjectSelector.vue
│ │ │ │ │ ├── agent-chat
│ │ │ │ │ │ ├── AgentChatShell.vue
│ │ │ │ │ │ ├── AgentComposer.vue
│ │ │ │ │ │ ├── AgentConversation.vue
│ │ │ │ │ │ ├── AgentOpenProjectMenu.vue
│ │ │ │ │ │ ├── AgentProjectMenu.vue
│ │ │ │ │ │ ├── AgentRequestThread.vue
│ │ │ │ │ │ ├── AgentSessionListItem.vue
│ │ │ │ │ │ ├── AgentSessionMenu.vue
│ │ │ │ │ │ ├── AgentSessionSettingsPanel.vue
│ │ │ │ │ │ ├── AgentSessionsView.vue
│ │ │ │ │ │ ├── AgentSettingsMenu.vue
│ │ │ │ │ │ ├── AgentTimeline.vue
│ │ │ │ │ │ ├── AgentTimelineItem.vue
│ │ │ │ │ │ ├── AgentTopBar.vue
│ │ │ │ │ │ ├── ApplyMessageChip.vue
│ │ │ │ │ │ ├── AttachmentCachePanel.vue
│ │ │ │ │ │ ├── ComposerDrawer.vue
│ │ │ │ │ │ ├── ElementChip.vue
│ │ │ │ │ │ ├── FakeCaretOverlay.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── SelectionChip.vue
│ │ │ │ │ │ ├── timeline
│ │ │ │ │ │ │ ├── markstream-thinking.ts
│ │ │ │ │ │ │ ├── ThinkingNode.vue
│ │ │ │ │ │ │ ├── TimelineNarrativeStep.vue
│ │ │ │ │ │ │ ├── TimelineStatusStep.vue
│ │ │ │ │ │ │ ├── TimelineToolCallStep.vue
│ │ │ │ │ │ │ ├── TimelineToolResultCardStep.vue
│ │ │ │ │ │ │ └── TimelineUserPromptStep.vue
│ │ │ │ │ │ └── WebEditorChanges.vue
│ │ │ │ │ ├── AgentChat.vue
│ │ │ │ │ ├── rr-v3
│ │ │ │ │ │ └── DebuggerPanel.vue
│ │ │ │ │ ├── SidepanelNavigator.vue
│ │ │ │ │ └── workflows
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── WorkflowListItem.vue
│ │ │ │ │ └── WorkflowsView.vue
│ │ │ │ ├── composables
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── useAgentChat.ts
│ │ │ │ │ ├── useAgentChatViewRoute.ts
│ │ │ │ │ ├── useAgentInputPreferences.ts
│ │ │ │ │ ├── useAgentProjects.ts
│ │ │ │ │ ├── useAgentServer.ts
│ │ │ │ │ ├── useAgentSessions.ts
│ │ │ │ │ ├── useAgentTheme.ts
│ │ │ │ │ ├── useAgentThreads.ts
│ │ │ │ │ ├── useAttachments.ts
│ │ │ │ │ ├── useFakeCaret.ts
│ │ │ │ │ ├── useFloatingDrag.ts
│ │ │ │ │ ├── useOpenProjectPreference.ts
│ │ │ │ │ ├── useRRV3Debugger.ts
│ │ │ │ │ ├── useRRV3Rpc.ts
│ │ │ │ │ ├── useTextareaAutoResize.ts
│ │ │ │ │ ├── useWebEditorTxState.ts
│ │ │ │ │ └── useWorkflowsV3.ts
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ ├── styles
│ │ │ │ │ └── agent-chat.css
│ │ │ │ └── utils
│ │ │ │ └── loading-texts.ts
│ │ │ ├── styles
│ │ │ │ └── tailwind.css
│ │ │ ├── web-editor-v2
│ │ │ │ ├── attr-ui-refactor.md
│ │ │ │ ├── constants.ts
│ │ │ │ ├── core
│ │ │ │ │ ├── css-compare.ts
│ │ │ │ │ ├── cssom-styles-collector.ts
│ │ │ │ │ ├── debug-source.ts
│ │ │ │ │ ├── design-tokens
│ │ │ │ │ │ ├── design-tokens-service.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── token-detector.ts
│ │ │ │ │ │ ├── token-resolver.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── editor.ts
│ │ │ │ │ ├── element-key.ts
│ │ │ │ │ ├── event-controller.ts
│ │ │ │ │ ├── execution-tracker.ts
│ │ │ │ │ ├── hmr-consistency.ts
│ │ │ │ │ ├── locator.ts
│ │ │ │ │ ├── message-listener.ts
│ │ │ │ │ ├── payload-builder.ts
│ │ │ │ │ ├── perf-monitor.ts
│ │ │ │ │ ├── position-tracker.ts
│ │ │ │ │ ├── props-bridge.ts
│ │ │ │ │ ├── snap-engine.ts
│ │ │ │ │ ├── transaction-aggregator.ts
│ │ │ │ │ └── transaction-manager.ts
│ │ │ │ ├── drag
│ │ │ │ │ └── drag-reorder-controller.ts
│ │ │ │ ├── overlay
│ │ │ │ │ ├── canvas-overlay.ts
│ │ │ │ │ └── handles-controller.ts
│ │ │ │ ├── selection
│ │ │ │ │ └── selection-engine.ts
│ │ │ │ ├── ui
│ │ │ │ │ ├── breadcrumbs.ts
│ │ │ │ │ ├── floating-drag.ts
│ │ │ │ │ ├── icons.ts
│ │ │ │ │ ├── property-panel
│ │ │ │ │ │ ├── class-editor.ts
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── alignment-grid.ts
│ │ │ │ │ │ │ ├── icon-button-group.ts
│ │ │ │ │ │ │ ├── input-container.ts
│ │ │ │ │ │ │ ├── slider-input.ts
│ │ │ │ │ │ │ └── token-pill.ts
│ │ │ │ │ │ ├── components-tree.ts
│ │ │ │ │ │ ├── controls
│ │ │ │ │ │ │ ├── appearance-control.ts
│ │ │ │ │ │ │ ├── background-control.ts
│ │ │ │ │ │ │ ├── border-control.ts
│ │ │ │ │ │ │ ├── color-field.ts
│ │ │ │ │ │ │ ├── css-helpers.ts
│ │ │ │ │ │ │ ├── effects-control.ts
│ │ │ │ │ │ │ ├── gradient-control.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── layout-control.ts
│ │ │ │ │ │ │ ├── number-stepping.ts
│ │ │ │ │ │ │ ├── position-control.ts
│ │ │ │ │ │ │ ├── size-control.ts
│ │ │ │ │ │ │ ├── spacing-control.ts
│ │ │ │ │ │ │ ├── token-picker.ts
│ │ │ │ │ │ │ └── typography-control.ts
│ │ │ │ │ │ ├── css-defaults.ts
│ │ │ │ │ │ ├── css-panel.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── property-panel.ts
│ │ │ │ │ │ ├── props-panel.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── shadow-host.ts
│ │ │ │ │ └── toolbar.ts
│ │ │ │ └── utils
│ │ │ │ └── disposables.ts
│ │ │ ├── web-editor-v2.ts
│ │ │ └── welcome
│ │ │ ├── App.vue
│ │ │ ├── index.html
│ │ │ └── main.ts
│ │ ├── env.d.ts
│ │ ├── eslint.config.js
│ │ ├── inject-scripts
│ │ │ ├── accessibility-tree-helper.js
│ │ │ ├── click-helper.js
│ │ │ ├── dom-observer.js
│ │ │ ├── element-marker.js
│ │ │ ├── element-picker.js
│ │ │ ├── fill-helper.js
│ │ │ ├── inject-bridge.js
│ │ │ ├── interactive-elements-helper.js
│ │ │ ├── keyboard-helper.js
│ │ │ ├── network-helper.js
│ │ │ ├── props-agent.js
│ │ │ ├── recorder.js
│ │ │ ├── screenshot-helper.js
│ │ │ ├── wait-helper.js
│ │ │ ├── web-editor.js
│ │ │ └── web-fetcher-helper.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── icon
│ │ │ │ ├── 128.png
│ │ │ │ ├── 16.png
│ │ │ │ ├── 32.png
│ │ │ │ ├── 48.png
│ │ │ │ └── 96.png
│ │ │ ├── libs
│ │ │ │ └── ort.min.js
│ │ │ └── wxt.svg
│ │ ├── README.md
│ │ ├── shared
│ │ │ ├── element-picker
│ │ │ │ ├── controller.ts
│ │ │ │ └── index.ts
│ │ │ ├── quick-panel
│ │ │ │ ├── core
│ │ │ │ │ ├── agent-bridge.ts
│ │ │ │ │ ├── search-engine.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── providers
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── tabs-provider.ts
│ │ │ │ └── ui
│ │ │ │ ├── ai-chat-panel.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── message-renderer.ts
│ │ │ │ ├── panel-shell.ts
│ │ │ │ ├── quick-entries.ts
│ │ │ │ ├── search-input.ts
│ │ │ │ ├── shadow-host.ts
│ │ │ │ └── styles.ts
│ │ │ └── selector
│ │ │ ├── dom-path.ts
│ │ │ ├── fingerprint.ts
│ │ │ ├── generator.ts
│ │ │ ├── index.ts
│ │ │ ├── locator.ts
│ │ │ ├── shadow-dom.ts
│ │ │ ├── stability.ts
│ │ │ ├── strategies
│ │ │ │ ├── anchor-relpath.ts
│ │ │ │ ├── aria.ts
│ │ │ │ ├── css-path.ts
│ │ │ │ ├── css-unique.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── testid.ts
│ │ │ │ └── text.ts
│ │ │ └── types.ts
│ │ ├── tailwind.config.ts
│ │ ├── tests
│ │ │ ├── __mocks__
│ │ │ │ └── hnswlib-wasm-static.ts
│ │ │ ├── record-replay
│ │ │ │ ├── _test-helpers.ts
│ │ │ │ ├── adapter-policy.contract.test.ts
│ │ │ │ ├── flow-store-strip-steps.contract.test.ts
│ │ │ │ ├── high-risk-actions.integration.test.ts
│ │ │ │ ├── hybrid-actions.integration.test.ts
│ │ │ │ ├── script-control-flow.integration.test.ts
│ │ │ │ ├── session-dag-sync.contract.test.ts
│ │ │ │ ├── step-executor.contract.test.ts
│ │ │ │ └── tab-cursor.integration.test.ts
│ │ │ ├── record-replay-v3
│ │ │ │ ├── command-trigger.test.ts
│ │ │ │ ├── context-menu-trigger.test.ts
│ │ │ │ ├── cron-trigger.test.ts
│ │ │ │ ├── debugger.contract.test.ts
│ │ │ │ ├── dom-trigger.test.ts
│ │ │ │ ├── e2e.integration.test.ts
│ │ │ │ ├── events.contract.test.ts
│ │ │ │ ├── interval-trigger.test.ts
│ │ │ │ ├── manual-trigger.test.ts
│ │ │ │ ├── once-trigger.test.ts
│ │ │ │ ├── queue.contract.test.ts
│ │ │ │ ├── recovery.test.ts
│ │ │ │ ├── rpc-api.test.ts
│ │ │ │ ├── runner.onError.contract.test.ts
│ │ │ │ ├── scheduler-integration.test.ts
│ │ │ │ ├── scheduler.test.ts
│ │ │ │ ├── spec-smoke.test.ts
│ │ │ │ ├── trigger-manager.test.ts
│ │ │ │ ├── triggers.test.ts
│ │ │ │ ├── url-trigger.test.ts
│ │ │ │ ├── v2-action-adapter.test.ts
│ │ │ │ ├── v2-adapter-integration.test.ts
│ │ │ │ ├── v2-to-v3-conversion.test.ts
│ │ │ │ └── v3-e2e-harness.ts
│ │ │ ├── vitest.setup.ts
│ │ │ └── web-editor-v2
│ │ │ ├── design-tokens.test.ts
│ │ │ ├── drag-reorder-controller.test.ts
│ │ │ ├── event-controller.test.ts
│ │ │ ├── locator.test.ts
│ │ │ ├── property-panel-live-sync.test.ts
│ │ │ ├── selection-engine.test.ts
│ │ │ ├── snap-engine.test.ts
│ │ │ └── test-utils
│ │ │ └── dom.ts
│ │ ├── tsconfig.json
│ │ ├── types
│ │ │ ├── gifenc.d.ts
│ │ │ └── icons.d.ts
│ │ ├── utils
│ │ │ ├── cdp-session-manager.ts
│ │ │ ├── content-indexer.ts
│ │ │ ├── i18n.ts
│ │ │ ├── image-utils.ts
│ │ │ ├── indexeddb-client.ts
│ │ │ ├── lru-cache.ts
│ │ │ ├── model-cache-manager.ts
│ │ │ ├── offscreen-manager.ts
│ │ │ ├── output-sanitizer.ts
│ │ │ ├── screenshot-context.ts
│ │ │ ├── semantic-similarity-engine.ts
│ │ │ ├── simd-math-engine.ts
│ │ │ ├── text-chunker.ts
│ │ │ └── vector-database.ts
│ │ ├── vitest.config.ts
│ │ ├── workers
│ │ │ ├── ort-wasm-simd-threaded.jsep.mjs
│ │ │ ├── ort-wasm-simd-threaded.jsep.wasm
│ │ │ ├── ort-wasm-simd-threaded.mjs
│ │ │ ├── ort-wasm-simd-threaded.wasm
│ │ │ ├── simd_math_bg.wasm
│ │ │ ├── simd_math.js
│ │ │ └── similarity.worker.js
│ │ └── wxt.config.ts
│ └── native-server
│ ├── .npmignore
│ ├── debug.sh
│ ├── install.md
│ ├── jest.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── agent
│ │ │ ├── attachment-service.ts
│ │ │ ├── ccr-detector.ts
│ │ │ ├── chat-service.ts
│ │ │ ├── db
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── directory-picker.ts
│ │ │ ├── engines
│ │ │ │ ├── claude.ts
│ │ │ │ ├── codex.ts
│ │ │ │ └── types.ts
│ │ │ ├── message-service.ts
│ │ │ ├── open-project.ts
│ │ │ ├── project-service.ts
│ │ │ ├── project-types.ts
│ │ │ ├── session-service.ts
│ │ │ ├── storage.ts
│ │ │ ├── stream-manager.ts
│ │ │ ├── tool-bridge.ts
│ │ │ └── types.ts
│ │ ├── cli.ts
│ │ ├── constant
│ │ │ └── index.ts
│ │ ├── file-handler.ts
│ │ ├── index.ts
│ │ ├── mcp
│ │ │ ├── mcp-server-stdio.ts
│ │ │ ├── mcp-server.ts
│ │ │ ├── register-tools.ts
│ │ │ └── stdio-config.json
│ │ ├── native-messaging-host.ts
│ │ ├── scripts
│ │ │ ├── browser-config.ts
│ │ │ ├── build.ts
│ │ │ ├── constant.ts
│ │ │ ├── doctor.ts
│ │ │ ├── postinstall.ts
│ │ │ ├── register-dev.ts
│ │ │ ├── register.ts
│ │ │ ├── report.ts
│ │ │ ├── run_host.bat
│ │ │ ├── run_host.sh
│ │ │ └── utils.ts
│ │ ├── server
│ │ │ ├── index.ts
│ │ │ ├── routes
│ │ │ │ ├── agent.ts
│ │ │ │ └── index.ts
│ │ │ └── server.test.ts
│ │ ├── shims
│ │ │ └── devtools.d.ts
│ │ ├── trace-analyzer.ts
│ │ ├── types
│ │ │ └── devtools-frontend.d.ts
│ │ └── util
│ │ └── logger.ts
│ └── tsconfig.json
├── commitlint.config.cjs
├── docs
│ ├── ARCHITECTURE_zh.md
│ ├── ARCHITECTURE.md
│ ├── CHANGELOG.md
│ ├── CONTRIBUTING_zh.md
│ ├── CONTRIBUTING.md
│ ├── ISSUE.md
│ ├── mcp-cli-config.md
│ ├── TOOLS_zh.md
│ ├── TOOLS.md
│ ├── TROUBLESHOOTING_zh.md
│ ├── TROUBLESHOOTING.md
│ ├── VisualEditor_zh.md
│ ├── VisualEditor.md
│ └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── package.json
├── packages
│ ├── shared
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── agent-types.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── labels.ts
│ │ │ ├── node-spec-registry.ts
│ │ │ ├── node-spec.ts
│ │ │ ├── node-specs-builtin.ts
│ │ │ ├── rr-graph.ts
│ │ │ ├── step-types.ts
│ │ │ ├── tools.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ └── wasm-simd
│ ├── .gitignore
│ ├── BUILD.md
│ ├── Cargo.toml
│ ├── package.json
│ ├── README.md
│ └── src
│ └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│ ├── content-analize.md
│ ├── excalidraw-prompt.md
│ └── modify-web.md
├── README_zh.md
├── README.md
└── releases
├── chrome-extension
│ └── latest
│ └── chrome-mcp-server-lastest.zip
└── README.md
```
# Files
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/network-capture-debugger.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
2 | import { BaseBrowserToolExecutor } from '../base-browser';
3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
4 | import { cdpSessionManager } from '@/utils/cdp-session-manager';
5 | import { NETWORK_FILTERS } from '@/common/constants';
6 |
7 | interface NetworkDebuggerStartToolParams {
8 | url?: string; // URL to navigate to or focus. If not provided, uses active tab.
9 | maxCaptureTime?: number;
10 | inactivityTimeout?: number; // Inactivity timeout (milliseconds)
11 | includeStatic?: boolean; // if include static resources
12 | }
13 |
14 | // Network request object interface
15 | interface NetworkRequestInfo {
16 | requestId: string;
17 | url: string;
18 | method: string;
19 | requestHeaders?: Record<string, string>; // Will be removed after common headers extraction
20 | responseHeaders?: Record<string, string>; // Will be removed after common headers extraction
21 | requestTime?: number; // Timestamp of the request
22 | responseTime?: number; // Timestamp of the response
23 | type: string; // Resource type (e.g., Document, XHR, Fetch, Script, Stylesheet)
24 | status: string; // 'pending', 'complete', 'error'
25 | statusCode?: number;
26 | statusText?: string;
27 | requestBody?: string;
28 | responseBody?: string;
29 | base64Encoded?: boolean; // For responseBody
30 | encodedDataLength?: number; // Actual bytes received
31 | errorText?: string; // If loading failed
32 | canceled?: boolean; // If loading was canceled
33 | mimeType?: string;
34 | specificRequestHeaders?: Record<string, string>; // Headers unique to this request
35 | specificResponseHeaders?: Record<string, string>; // Headers unique to this response
36 | [key: string]: any; // Allow other properties from debugger events
37 | }
38 |
39 | const DEBUGGER_PROTOCOL_VERSION = '1.3';
40 | const MAX_RESPONSE_BODY_SIZE_BYTES = 1 * 1024 * 1024; // 1MB
41 | const DEFAULT_MAX_CAPTURE_TIME_MS = 3 * 60 * 1000; // 3 minutes
42 | const DEFAULT_INACTIVITY_TIMEOUT_MS = 60 * 1000; // 1 minute
43 |
44 | /**
45 | * Network capture start tool - uses Chrome Debugger API to start capturing network requests
46 | */
47 | class NetworkDebuggerStartTool extends BaseBrowserToolExecutor {
48 | name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_START;
49 | private captureData: Map<number, any> = new Map(); // tabId -> capture data
50 | private captureTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> max capture timer
51 | private inactivityTimers: Map<number, NodeJS.Timeout> = new Map(); // tabId -> inactivity timer
52 | private lastActivityTime: Map<number, number> = new Map(); // tabId -> timestamp of last network activity
53 | private pendingResponseBodies: Map<string, Promise<any>> = new Map(); // requestId -> promise for getResponseBody
54 | private requestCounters: Map<number, number> = new Map(); // tabId -> count of captured requests (after filtering)
55 | private static MAX_REQUESTS_PER_CAPTURE = 100; // Max requests to store to prevent memory issues
56 | public static instance: NetworkDebuggerStartTool | null = null;
57 |
58 | constructor() {
59 | super();
60 | if (NetworkDebuggerStartTool.instance) {
61 | return NetworkDebuggerStartTool.instance;
62 | }
63 | NetworkDebuggerStartTool.instance = this;
64 |
65 | chrome.debugger.onEvent.addListener(this.handleDebuggerEvent.bind(this));
66 | chrome.debugger.onDetach.addListener(this.handleDebuggerDetach.bind(this));
67 | chrome.tabs.onRemoved.addListener(this.handleTabRemoved.bind(this));
68 | chrome.tabs.onCreated.addListener(this.handleTabCreated.bind(this));
69 | }
70 |
71 | private handleTabRemoved(tabId: number) {
72 | if (this.captureData.has(tabId)) {
73 | console.log(`NetworkDebuggerStartTool: Tab ${tabId} was closed, cleaning up resources.`);
74 | this.cleanupCapture(tabId);
75 | }
76 | }
77 |
78 | /**
79 | * Handle tab creation events
80 | * If a new tab is opened from a tab that is currently capturing, automatically start capturing the new tab's requests
81 | */
82 | private async handleTabCreated(tab: chrome.tabs.Tab) {
83 | try {
84 | // Check if there are any tabs currently capturing
85 | if (this.captureData.size === 0) return;
86 |
87 | // Get the openerTabId of the new tab (ID of the tab that opened this tab)
88 | const openerTabId = tab.openerTabId;
89 | if (!openerTabId) return;
90 |
91 | // Check if the opener tab is currently capturing
92 | if (!this.captureData.has(openerTabId)) return;
93 |
94 | // Get the new tab's ID
95 | const newTabId = tab.id;
96 | if (!newTabId) return;
97 |
98 | console.log(
99 | `NetworkDebuggerStartTool: New tab ${newTabId} created from capturing tab ${openerTabId}, will extend capture to it.`,
100 | );
101 |
102 | // Get the opener tab's capture settings
103 | const openerCaptureInfo = this.captureData.get(openerTabId);
104 | if (!openerCaptureInfo) return;
105 |
106 | // Wait a short time to ensure the tab is ready
107 | await new Promise((resolve) => setTimeout(resolve, 500));
108 |
109 | // Start capturing requests for the new tab
110 | await this.startCaptureForTab(newTabId, {
111 | maxCaptureTime: openerCaptureInfo.maxCaptureTime,
112 | inactivityTimeout: openerCaptureInfo.inactivityTimeout,
113 | includeStatic: openerCaptureInfo.includeStatic,
114 | });
115 |
116 | console.log(`NetworkDebuggerStartTool: Successfully extended capture to new tab ${newTabId}`);
117 | } catch (error) {
118 | console.error(`NetworkDebuggerStartTool: Error extending capture to new tab:`, error);
119 | }
120 | }
121 |
122 | /**
123 | * Start network request capture for specified tab
124 | * @param tabId Tab ID
125 | * @param options Capture options
126 | */
127 | private async startCaptureForTab(
128 | tabId: number,
129 | options: {
130 | maxCaptureTime: number;
131 | inactivityTimeout: number;
132 | includeStatic: boolean;
133 | },
134 | ): Promise<void> {
135 | const { maxCaptureTime, inactivityTimeout, includeStatic } = options;
136 |
137 | // If already capturing, stop first
138 | if (this.captureData.has(tabId)) {
139 | console.log(
140 | `NetworkDebuggerStartTool: Already capturing on tab ${tabId}. Stopping previous session.`,
141 | );
142 | await this.stopCapture(tabId);
143 | }
144 |
145 | try {
146 | // Get tab information
147 | const tab = await chrome.tabs.get(tabId);
148 |
149 | // Attach via shared manager (handles conflicts and refcount)
150 | await cdpSessionManager.attach(tabId, 'network-capture');
151 |
152 | // Enable network tracking
153 | try {
154 | await cdpSessionManager.sendCommand(tabId, 'Network.enable');
155 | } catch (error: any) {
156 | await cdpSessionManager
157 | .detach(tabId, 'network-capture')
158 | .catch((e) => console.warn('Error detaching after failed enable:', e));
159 | throw error;
160 | }
161 |
162 | // Initialize capture data
163 | this.captureData.set(tabId, {
164 | startTime: Date.now(),
165 | tabUrl: tab.url,
166 | tabTitle: tab.title,
167 | maxCaptureTime,
168 | inactivityTimeout,
169 | includeStatic,
170 | requests: {},
171 | limitReached: false,
172 | });
173 |
174 | // Initialize request counter
175 | this.requestCounters.set(tabId, 0);
176 |
177 | // Update last activity time
178 | this.updateLastActivityTime(tabId);
179 |
180 | console.log(
181 | `NetworkDebuggerStartTool: Started capture for tab ${tabId} (${tab.url}). Max requests: ${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}, Max time: ${maxCaptureTime}ms, Inactivity: ${inactivityTimeout}ms.`,
182 | );
183 |
184 | // Set maximum capture time
185 | if (maxCaptureTime > 0) {
186 | this.captureTimers.set(
187 | tabId,
188 | setTimeout(async () => {
189 | console.log(
190 | `NetworkDebuggerStartTool: Max capture time (${maxCaptureTime}ms) reached for tab ${tabId}.`,
191 | );
192 | await this.stopCapture(tabId, true); // Auto-stop due to max time
193 | }, maxCaptureTime),
194 | );
195 | }
196 | } catch (error: any) {
197 | console.error(`NetworkDebuggerStartTool: Error starting capture for tab ${tabId}:`, error);
198 |
199 | // Clean up resources
200 | if (this.captureData.has(tabId)) {
201 | await cdpSessionManager
202 | .detach(tabId, 'network-capture')
203 | .catch((e) => console.warn('Cleanup detach error:', e));
204 | this.cleanupCapture(tabId);
205 | }
206 |
207 | throw error;
208 | }
209 | }
210 |
211 | private handleDebuggerEvent(source: chrome.debugger.Debuggee, method: string, params?: any) {
212 | if (!source.tabId) return;
213 |
214 | const tabId = source.tabId;
215 | const captureInfo = this.captureData.get(tabId);
216 |
217 | if (!captureInfo) return; // Not capturing for this tab
218 |
219 | // Update last activity time for any relevant network event
220 | this.updateLastActivityTime(tabId);
221 |
222 | switch (method) {
223 | case 'Network.requestWillBeSent':
224 | this.handleRequestWillBeSent(tabId, params);
225 | break;
226 | case 'Network.responseReceived':
227 | this.handleResponseReceived(tabId, params);
228 | break;
229 | case 'Network.loadingFinished':
230 | this.handleLoadingFinished(tabId, params);
231 | break;
232 | case 'Network.loadingFailed':
233 | this.handleLoadingFailed(tabId, params);
234 | break;
235 | }
236 | }
237 |
238 | private handleDebuggerDetach(source: chrome.debugger.Debuggee, reason: string) {
239 | if (source.tabId && this.captureData.has(source.tabId)) {
240 | console.log(
241 | `NetworkDebuggerStartTool: Debugger detached from tab ${source.tabId}, reason: ${reason}. Cleaning up.`,
242 | );
243 | // Potentially inform the user or log the result if the detachment was unexpected
244 | this.cleanupCapture(source.tabId); // Ensure cleanup happens
245 | }
246 | }
247 |
248 | private updateLastActivityTime(tabId: number) {
249 | this.lastActivityTime.set(tabId, Date.now());
250 | const captureInfo = this.captureData.get(tabId);
251 |
252 | if (captureInfo && captureInfo.inactivityTimeout > 0) {
253 | if (this.inactivityTimers.has(tabId)) {
254 | clearTimeout(this.inactivityTimers.get(tabId)!);
255 | }
256 | this.inactivityTimers.set(
257 | tabId,
258 | setTimeout(() => this.checkInactivity(tabId), captureInfo.inactivityTimeout),
259 | );
260 | }
261 | }
262 |
263 | private checkInactivity(tabId: number) {
264 | const captureInfo = this.captureData.get(tabId);
265 | if (!captureInfo) return;
266 |
267 | const lastActivity = this.lastActivityTime.get(tabId) || captureInfo.startTime; // Use startTime if no activity yet
268 | const now = Date.now();
269 | const inactiveTime = now - lastActivity;
270 |
271 | if (inactiveTime >= captureInfo.inactivityTimeout) {
272 | console.log(
273 | `NetworkDebuggerStartTool: No activity for ${inactiveTime}ms (threshold: ${captureInfo.inactivityTimeout}ms), stopping capture for tab ${tabId}`,
274 | );
275 | this.stopCaptureByInactivity(tabId);
276 | } else {
277 | // Reschedule check for the remaining time, this handles system sleep or other interruptions
278 | const remainingTime = Math.max(0, captureInfo.inactivityTimeout - inactiveTime);
279 | this.inactivityTimers.set(
280 | tabId,
281 | setTimeout(() => this.checkInactivity(tabId), remainingTime),
282 | );
283 | }
284 | }
285 |
286 | private async stopCaptureByInactivity(tabId: number) {
287 | const captureInfo = this.captureData.get(tabId);
288 | if (!captureInfo) return;
289 |
290 | console.log(`NetworkDebuggerStartTool: Stopping capture due to inactivity for tab ${tabId}.`);
291 | // Potentially, we might want to notify the client/user that this happened.
292 | // For now, just stop and make the results available if StopTool is called.
293 | await this.stopCapture(tabId, true); // Pass a flag indicating it's an auto-stop
294 | }
295 |
296 | /**
297 | * Check if URL should be filtered based on EXCLUDED_DOMAINS patterns.
298 | * Uses full URL substring match to support patterns like 'facebook.com/tr'.
299 | */
300 | private shouldFilterRequestByUrl(url: string): boolean {
301 | const normalizedUrl = String(url || '').toLowerCase();
302 | if (!normalizedUrl) return false;
303 | return NETWORK_FILTERS.EXCLUDED_DOMAINS.some((pattern) => normalizedUrl.includes(pattern));
304 | }
305 |
306 | private shouldFilterRequestByExtension(url: string, includeStatic: boolean): boolean {
307 | if (includeStatic) return false;
308 |
309 | try {
310 | const urlObj = new URL(url);
311 | const path = urlObj.pathname.toLowerCase();
312 | return NETWORK_FILTERS.STATIC_RESOURCE_EXTENSIONS.some((ext) => path.endsWith(ext));
313 | } catch {
314 | return false;
315 | }
316 | }
317 |
318 | private shouldFilterByMimeType(mimeType: string, includeStatic: boolean): boolean {
319 | if (!mimeType) return false;
320 |
321 | // Never filter API MIME types
322 | if (NETWORK_FILTERS.API_MIME_TYPES.some((apiMime) => mimeType.startsWith(apiMime))) {
323 | return false;
324 | }
325 |
326 | // Filter static MIME types when not including static resources
327 | if (!includeStatic) {
328 | return NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>
329 | mimeType.startsWith(staticMime),
330 | );
331 | }
332 |
333 | return false;
334 | }
335 |
336 | private handleRequestWillBeSent(tabId: number, params: any) {
337 | const captureInfo = this.captureData.get(tabId);
338 | if (!captureInfo) return;
339 |
340 | const { requestId, request, timestamp, type, loaderId, frameId } = params;
341 |
342 | // Initial filtering by URL (ads, analytics) and extension (if !includeStatic)
343 | if (
344 | this.shouldFilterRequestByUrl(request.url) ||
345 | this.shouldFilterRequestByExtension(request.url, captureInfo.includeStatic)
346 | ) {
347 | return;
348 | }
349 |
350 | const currentCount = this.requestCounters.get(tabId) || 0;
351 | if (currentCount >= NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE) {
352 | // console.log(`NetworkDebuggerStartTool: Request limit (${NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE}) reached for tab ${tabId}. Ignoring: ${request.url}`);
353 | captureInfo.limitReached = true; // Mark that limit was hit
354 | return;
355 | }
356 |
357 | // Store initial request info
358 | // Ensure we don't overwrite if a redirect (same requestId) occurred, though usually loaderId changes
359 | if (!captureInfo.requests[requestId]) {
360 | // Or check based on loaderId as well if needed
361 | captureInfo.requests[requestId] = {
362 | requestId,
363 | url: request.url,
364 | method: request.method,
365 | requestHeaders: request.headers, // Temporary, will be processed
366 | requestTime: timestamp * 1000, // Convert seconds to milliseconds
367 | type: type || 'Other',
368 | status: 'pending', // Initial status
369 | loaderId, // Useful for tracking redirects
370 | frameId, // Useful for context
371 | };
372 |
373 | if (request.postData) {
374 | captureInfo.requests[requestId].requestBody = request.postData;
375 | }
376 | // console.log(`NetworkDebuggerStartTool: Captured request for tab ${tabId}: ${request.method} ${request.url}`);
377 | } else {
378 | // This could be a redirect. Update URL and other relevant fields.
379 | // Chrome often issues a new `requestWillBeSent` for redirects with the same `requestId` but a new `loaderId`.
380 | // console.log(`NetworkDebuggerStartTool: Request ${requestId} updated (likely redirect) for tab ${tabId} to URL: ${request.url}`);
381 | const existingRequest = captureInfo.requests[requestId];
382 | existingRequest.url = request.url; // Update URL due to redirect
383 | existingRequest.requestTime = timestamp * 1000; // Update time for the redirected request
384 | if (request.headers) existingRequest.requestHeaders = request.headers;
385 | if (request.postData) existingRequest.requestBody = request.postData;
386 | else delete existingRequest.requestBody;
387 | }
388 | }
389 |
390 | private handleResponseReceived(tabId: number, params: any) {
391 | const captureInfo = this.captureData.get(tabId);
392 | if (!captureInfo) return;
393 |
394 | const { requestId, response, timestamp, type } = params; // type here is resource type
395 | const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];
396 |
397 | if (!requestInfo) {
398 | // console.warn(`NetworkDebuggerStartTool: Received response for unknown requestId ${requestId} on tab ${tabId}`);
399 | return;
400 | }
401 |
402 | // Secondary filtering based on MIME type, now that we have it
403 | if (this.shouldFilterByMimeType(response.mimeType, captureInfo.includeStatic)) {
404 | // console.log(`NetworkDebuggerStartTool: Filtering request by MIME type (${response.mimeType}): ${requestInfo.url}`);
405 | delete captureInfo.requests[requestId]; // Remove from captured data
406 | // Note: We don't decrement requestCounter here as it's meant to track how many *potential* requests were processed up to MAX_REQUESTS.
407 | // Or, if MAX_REQUESTS is strictly for *stored* requests, then decrement. For now, let's assume it's for stored.
408 | // const currentCount = this.requestCounters.get(tabId) || 0;
409 | // if (currentCount > 0) this.requestCounters.set(tabId, currentCount -1);
410 | return;
411 | }
412 |
413 | // If not filtered by MIME, then increment actual stored request counter
414 | const currentStoredCount = Object.keys(captureInfo.requests).length; // A bit inefficient but accurate
415 | this.requestCounters.set(tabId, currentStoredCount);
416 |
417 | requestInfo.status = response.status === 0 ? 'pending' : 'complete'; // status 0 can mean pending or blocked
418 | requestInfo.statusCode = response.status;
419 | requestInfo.statusText = response.statusText;
420 | requestInfo.responseHeaders = response.headers; // Temporary
421 | requestInfo.mimeType = response.mimeType;
422 | requestInfo.responseTime = timestamp * 1000; // Convert seconds to milliseconds
423 | if (type) requestInfo.type = type; // Update resource type if provided by this event
424 |
425 | // console.log(`NetworkDebuggerStartTool: Received response for ${requestId} on tab ${tabId}: ${response.status}`);
426 | }
427 |
428 | private async handleLoadingFinished(tabId: number, params: any) {
429 | const captureInfo = this.captureData.get(tabId);
430 | if (!captureInfo) return;
431 |
432 | const { requestId, encodedDataLength } = params;
433 | const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];
434 |
435 | if (!requestInfo) {
436 | // console.warn(`NetworkDebuggerStartTool: LoadingFinished for unknown requestId ${requestId} on tab ${tabId}`);
437 | return;
438 | }
439 |
440 | requestInfo.encodedDataLength = encodedDataLength;
441 | if (requestInfo.status === 'pending') requestInfo.status = 'complete'; // Mark as complete if not already
442 | // requestInfo.responseTime is usually set by responseReceived, but this timestamp is later.
443 | // timestamp here is when the resource finished loading. Could be useful for duration calculation.
444 |
445 | if (this.shouldCaptureResponseBody(requestInfo)) {
446 | try {
447 | // console.log(`NetworkDebuggerStartTool: Attempting to get response body for ${requestId} (${requestInfo.url})`);
448 | const responseBodyData = await this.getResponseBody(tabId, requestId);
449 | if (responseBodyData) {
450 | if (
451 | responseBodyData.body &&
452 | responseBodyData.body.length > MAX_RESPONSE_BODY_SIZE_BYTES
453 | ) {
454 | requestInfo.responseBody =
455 | responseBodyData.body.substring(0, MAX_RESPONSE_BODY_SIZE_BYTES) +
456 | `\n\n... [Response truncated, total size: ${responseBodyData.body.length} bytes] ...`;
457 | } else {
458 | requestInfo.responseBody = responseBodyData.body;
459 | }
460 | requestInfo.base64Encoded = responseBodyData.base64Encoded;
461 | // console.log(`NetworkDebuggerStartTool: Successfully got response body for ${requestId}, size: ${requestInfo.responseBody?.length || 0} bytes`);
462 | }
463 | } catch (error) {
464 | // console.warn(`NetworkDebuggerStartTool: Failed to get response body for ${requestId}:`, error);
465 | requestInfo.errorText =
466 | (requestInfo.errorText || '') +
467 | ` Failed to get body: ${error instanceof Error ? error.message : String(error)}`;
468 | }
469 | }
470 | }
471 |
472 | private shouldCaptureResponseBody(requestInfo: NetworkRequestInfo): boolean {
473 | const mimeType = requestInfo.mimeType || '';
474 |
475 | // Prioritize API MIME types for body capture
476 | if (NETWORK_FILTERS.API_MIME_TYPES.some((type) => mimeType.startsWith(type))) {
477 | return true;
478 | }
479 |
480 | // Heuristics for other potential API calls not perfectly matching MIME types
481 | const url = requestInfo.url.toLowerCase();
482 | if (
483 | /\/(api|service|rest|graphql|query|data|rpc|v[0-9]+)\//i.test(url) ||
484 | url.includes('.json') ||
485 | url.includes('json=') ||
486 | url.includes('format=json')
487 | ) {
488 | // If it looks like an API call by URL structure, try to get body,
489 | // unless it's a known non-API MIME type that slipped through (e.g. a script from a /api/ path)
490 | if (
491 | mimeType &&
492 | NETWORK_FILTERS.STATIC_MIME_TYPES_TO_FILTER.some((staticMime) =>
493 | mimeType.startsWith(staticMime),
494 | )
495 | ) {
496 | return false; // e.g. a CSS file served from an /api/ path
497 | }
498 | return true;
499 | }
500 |
501 | return false;
502 | }
503 |
504 | private handleLoadingFailed(tabId: number, params: any) {
505 | const captureInfo = this.captureData.get(tabId);
506 | if (!captureInfo) return;
507 |
508 | const { requestId, errorText, canceled, type } = params;
509 | const requestInfo: NetworkRequestInfo = captureInfo.requests[requestId];
510 |
511 | if (!requestInfo) {
512 | // console.warn(`NetworkDebuggerStartTool: LoadingFailed for unknown requestId ${requestId} on tab ${tabId}`);
513 | return;
514 | }
515 |
516 | requestInfo.status = 'error';
517 | requestInfo.errorText = errorText;
518 | requestInfo.canceled = canceled;
519 | if (type) requestInfo.type = type;
520 | // timestamp here is when loading failed.
521 | // console.log(`NetworkDebuggerStartTool: Loading failed for ${requestId} on tab ${tabId}: ${errorText}`);
522 | }
523 |
524 | private async getResponseBody(
525 | tabId: number,
526 | requestId: string,
527 | ): Promise<{ body: string; base64Encoded: boolean } | null> {
528 | const pendingKey = `${tabId}_${requestId}`;
529 | if (this.pendingResponseBodies.has(pendingKey)) {
530 | return this.pendingResponseBodies.get(pendingKey)!; // Return existing promise
531 | }
532 |
533 | const responseBodyPromise = (async () => {
534 | try {
535 | // Will attach temporarily if needed
536 | const result = (await cdpSessionManager.sendCommand(tabId, 'Network.getResponseBody', {
537 | requestId,
538 | })) as { body: string; base64Encoded: boolean };
539 | return result;
540 | } finally {
541 | this.pendingResponseBodies.delete(pendingKey); // Clean up after promise resolves or rejects
542 | }
543 | })();
544 |
545 | this.pendingResponseBodies.set(pendingKey, responseBodyPromise);
546 | return responseBodyPromise;
547 | }
548 |
549 | private cleanupCapture(tabId: number) {
550 | if (this.captureTimers.has(tabId)) {
551 | clearTimeout(this.captureTimers.get(tabId)!);
552 | this.captureTimers.delete(tabId);
553 | }
554 | if (this.inactivityTimers.has(tabId)) {
555 | clearTimeout(this.inactivityTimers.get(tabId)!);
556 | this.inactivityTimers.delete(tabId);
557 | }
558 |
559 | this.lastActivityTime.delete(tabId);
560 | this.captureData.delete(tabId);
561 | this.requestCounters.delete(tabId);
562 |
563 | // Abort pending getResponseBody calls for this tab
564 | // Note: Promises themselves cannot be "aborted" externally in a standard way once created.
565 | // We can delete them from the map, so new calls won't use them,
566 | // and the original promise will eventually resolve or reject.
567 | const keysToDelete: string[] = [];
568 | this.pendingResponseBodies.forEach((_, key) => {
569 | if (key.startsWith(`${tabId}_`)) {
570 | keysToDelete.push(key);
571 | }
572 | });
573 | keysToDelete.forEach((key) => this.pendingResponseBodies.delete(key));
574 |
575 | console.log(`NetworkDebuggerStartTool: Cleaned up resources for tab ${tabId}.`);
576 | }
577 |
578 | // isAutoStop is true if stop was triggered by timeout, false if by user/explicit call
579 | async stopCapture(tabId: number, isAutoStop: boolean = false): Promise<any> {
580 | const captureInfo = this.captureData.get(tabId);
581 | if (!captureInfo) {
582 | return { success: false, message: 'No capture in progress for this tab.' };
583 | }
584 |
585 | console.log(
586 | `NetworkDebuggerStartTool: Stopping capture for tab ${tabId}. Auto-stop: ${isAutoStop}`,
587 | );
588 |
589 | try {
590 | // Attempt to disable network and detach via manager; it will no-op if others own the session
591 | try {
592 | await cdpSessionManager.sendCommand(tabId, 'Network.disable');
593 | } catch (e) {
594 | console.warn(
595 | `NetworkDebuggerStartTool: Error disabling network for tab ${tabId} (possibly already detached):`,
596 | e,
597 | );
598 | }
599 | try {
600 | await cdpSessionManager.detach(tabId, 'network-capture');
601 | } catch (e) {
602 | console.warn(
603 | `NetworkDebuggerStartTool: Error detaching debugger for tab ${tabId} (possibly already detached):`,
604 | e,
605 | );
606 | }
607 | } catch (error: any) {
608 | // Catch errors from getTargets or general logic
609 | console.error(
610 | 'NetworkDebuggerStartTool: Error during debugger interaction in stopCapture:',
611 | error,
612 | );
613 | // Proceed to cleanup and data formatting
614 | }
615 |
616 | // Process data even if detach/disable failed, as some data might have been captured.
617 | const allRequests = Object.values(captureInfo.requests) as NetworkRequestInfo[];
618 | const commonRequestHeaders = this.analyzeCommonHeaders(allRequests, 'requestHeaders');
619 | const commonResponseHeaders = this.analyzeCommonHeaders(allRequests, 'responseHeaders');
620 |
621 | const processedRequests = allRequests.map((req) => {
622 | const finalReq: Partial<NetworkRequestInfo> &
623 | Pick<NetworkRequestInfo, 'requestId' | 'url' | 'method' | 'type' | 'status'> = { ...req };
624 |
625 | if (finalReq.requestHeaders) {
626 | finalReq.specificRequestHeaders = this.filterOutCommonHeaders(
627 | finalReq.requestHeaders,
628 | commonRequestHeaders,
629 | );
630 | delete finalReq.requestHeaders; // Remove original full headers
631 | } else {
632 | finalReq.specificRequestHeaders = {};
633 | }
634 |
635 | if (finalReq.responseHeaders) {
636 | finalReq.specificResponseHeaders = this.filterOutCommonHeaders(
637 | finalReq.responseHeaders,
638 | commonResponseHeaders,
639 | );
640 | delete finalReq.responseHeaders; // Remove original full headers
641 | } else {
642 | finalReq.specificResponseHeaders = {};
643 | }
644 | return finalReq as NetworkRequestInfo; // Cast back to full type
645 | });
646 |
647 | // Sort requests by requestTime
648 | processedRequests.sort((a, b) => (a.requestTime || 0) - (b.requestTime || 0));
649 |
650 | const resultData = {
651 | captureStartTime: captureInfo.startTime,
652 | captureEndTime: Date.now(),
653 | totalDurationMs: Date.now() - captureInfo.startTime,
654 | commonRequestHeaders,
655 | commonResponseHeaders,
656 | requests: processedRequests,
657 | requestCount: processedRequests.length, // Actual stored requests
658 | totalRequestsReceivedBeforeLimit: captureInfo.limitReached
659 | ? NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE
660 | : processedRequests.length,
661 | requestLimitReached: !!captureInfo.limitReached,
662 | stoppedBy: isAutoStop
663 | ? this.lastActivityTime.get(tabId)
664 | ? 'inactivity_timeout'
665 | : 'max_capture_time'
666 | : 'user_request',
667 | tabUrl: captureInfo.tabUrl,
668 | tabTitle: captureInfo.tabTitle,
669 | };
670 |
671 | console.log(
672 | `NetworkDebuggerStartTool: Capture stopped for tab ${tabId}. ${resultData.requestCount} requests processed. Limit reached: ${resultData.requestLimitReached}. Stopped by: ${resultData.stoppedBy}`,
673 | );
674 |
675 | this.cleanupCapture(tabId); // Final cleanup of all internal states for this tab
676 |
677 | return {
678 | success: true,
679 | message: `Capture stopped. ${resultData.requestCount} requests.`,
680 | data: resultData,
681 | };
682 | }
683 |
684 | private analyzeCommonHeaders(
685 | requests: NetworkRequestInfo[],
686 | headerTypeKey: 'requestHeaders' | 'responseHeaders',
687 | ): Record<string, string> {
688 | if (!requests || requests.length === 0) return {};
689 |
690 | const headerValueCounts = new Map<string, Map<string, number>>(); // headerName -> (headerValue -> count)
691 | let requestsWithHeadersCount = 0;
692 |
693 | for (const req of requests) {
694 | const headers = req[headerTypeKey] as Record<string, string> | undefined;
695 | if (headers && Object.keys(headers).length > 0) {
696 | requestsWithHeadersCount++;
697 | for (const name in headers) {
698 | // Normalize header name to lowercase for consistent counting
699 | const lowerName = name.toLowerCase();
700 | const value = headers[name];
701 | if (!headerValueCounts.has(lowerName)) {
702 | headerValueCounts.set(lowerName, new Map());
703 | }
704 | const values = headerValueCounts.get(lowerName)!;
705 | values.set(value, (values.get(value) || 0) + 1);
706 | }
707 | }
708 | }
709 |
710 | if (requestsWithHeadersCount === 0) return {};
711 |
712 | const commonHeaders: Record<string, string> = {};
713 | headerValueCounts.forEach((values, name) => {
714 | values.forEach((count, value) => {
715 | if (count === requestsWithHeadersCount) {
716 | // This (name, value) pair is present in all requests that have this type of headers.
717 | // We need to find the original casing for the header name.
718 | // This is tricky as HTTP headers are case-insensitive. Let's pick the first encountered one.
719 | // A more robust way would be to store original names, but lowercase comparison is standard.
720 | // For simplicity, we'll use the lowercase name for commonHeaders keys.
721 | // Or, find one original casing:
722 | let originalName = name;
723 | for (const req of requests) {
724 | const hdrs = req[headerTypeKey] as Record<string, string> | undefined;
725 | if (hdrs) {
726 | const foundName = Object.keys(hdrs).find((k) => k.toLowerCase() === name);
727 | if (foundName) {
728 | originalName = foundName;
729 | break;
730 | }
731 | }
732 | }
733 | commonHeaders[originalName] = value;
734 | }
735 | });
736 | });
737 | return commonHeaders;
738 | }
739 |
740 | private filterOutCommonHeaders(
741 | headers: Record<string, string>,
742 | commonHeaders: Record<string, string>,
743 | ): Record<string, string> {
744 | if (!headers || typeof headers !== 'object') return {};
745 |
746 | const specificHeaders: Record<string, string> = {};
747 | const commonHeadersLower: Record<string, string> = {};
748 |
749 | // Use Object.keys to avoid ESLint no-prototype-builtins warning
750 | Object.keys(commonHeaders).forEach((commonName) => {
751 | commonHeadersLower[commonName.toLowerCase()] = commonHeaders[commonName];
752 | });
753 |
754 | // Use Object.keys to avoid ESLint no-prototype-builtins warning
755 | Object.keys(headers).forEach((name) => {
756 | const lowerName = name.toLowerCase();
757 | // If the header (by name, case-insensitively) is not in commonHeaders OR
758 | // if its value is different from the common one, then it's specific.
759 | if (!(lowerName in commonHeadersLower) || headers[name] !== commonHeadersLower[lowerName]) {
760 | specificHeaders[name] = headers[name];
761 | }
762 | });
763 |
764 | return specificHeaders;
765 | }
766 |
767 | async execute(args: NetworkDebuggerStartToolParams): Promise<ToolResult> {
768 | const {
769 | url: targetUrl,
770 | maxCaptureTime = DEFAULT_MAX_CAPTURE_TIME_MS,
771 | inactivityTimeout = DEFAULT_INACTIVITY_TIMEOUT_MS,
772 | includeStatic = false,
773 | } = args;
774 |
775 | console.log(
776 | `NetworkDebuggerStartTool: Executing with args: url=${targetUrl}, maxTime=${maxCaptureTime}, inactivityTime=${inactivityTimeout}, includeStatic=${includeStatic}`,
777 | );
778 |
779 | let tabToOperateOn: chrome.tabs.Tab | undefined;
780 |
781 | try {
782 | if (targetUrl) {
783 | const existingTabs = await chrome.tabs.query({
784 | url: targetUrl.startsWith('http') ? targetUrl : `*://*/*${targetUrl}*`,
785 | }); // More specific query
786 | if (existingTabs.length > 0 && existingTabs[0]?.id) {
787 | tabToOperateOn = existingTabs[0];
788 | // Ensure window gets focus and tab is truly activated
789 | await chrome.windows.update(tabToOperateOn.windowId, { focused: true });
790 | await chrome.tabs.update(tabToOperateOn.id!, { active: true });
791 | } else {
792 | tabToOperateOn = await chrome.tabs.create({ url: targetUrl, active: true });
793 | // Wait for tab to be somewhat ready. A better way is to listen to tabs.onUpdated status='complete'
794 | // but for debugger attachment, it just needs the tabId.
795 | await new Promise((resolve) => setTimeout(resolve, 500)); // Short delay
796 | }
797 | } else {
798 | const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
799 | if (activeTabs.length > 0 && activeTabs[0]?.id) {
800 | tabToOperateOn = activeTabs[0];
801 | } else {
802 | return createErrorResponse('No active tab found and no URL provided.');
803 | }
804 | }
805 |
806 | if (!tabToOperateOn?.id) {
807 | return createErrorResponse('Failed to identify or create a target tab.');
808 | }
809 | const tabId = tabToOperateOn.id;
810 |
811 | // Use startCaptureForTab method to start capture
812 | try {
813 | await this.startCaptureForTab(tabId, {
814 | maxCaptureTime,
815 | inactivityTimeout,
816 | includeStatic,
817 | });
818 | } catch (error: any) {
819 | return createErrorResponse(
820 | `Failed to start capture for tab ${tabId}: ${error.message || String(error)}`,
821 | );
822 | }
823 |
824 | return {
825 | content: [
826 | {
827 | type: 'text',
828 | text: JSON.stringify({
829 | success: true,
830 | message: `Network capture started on tab ${tabId}. Waiting for stop command or timeout.`,
831 | tabId,
832 | url: tabToOperateOn.url,
833 | maxCaptureTime,
834 | inactivityTimeout,
835 | includeStatic,
836 | maxRequests: NetworkDebuggerStartTool.MAX_REQUESTS_PER_CAPTURE,
837 | }),
838 | },
839 | ],
840 | isError: false,
841 | };
842 | } catch (error: any) {
843 | console.error('NetworkDebuggerStartTool: Critical error during execute:', error);
844 | // If a tabId was involved and debugger might be attached, try to clean up.
845 | const tabIdToClean = tabToOperateOn?.id;
846 | if (tabIdToClean && this.captureData.has(tabIdToClean)) {
847 | await cdpSessionManager
848 | .detach(tabIdToClean, 'network-capture')
849 | .catch((e) => console.warn('Cleanup detach error:', e));
850 | this.cleanupCapture(tabIdToClean);
851 | }
852 | return createErrorResponse(
853 | `Error in NetworkDebuggerStartTool: ${error.message || String(error)}`,
854 | );
855 | }
856 | }
857 | }
858 |
859 | /**
860 | * Network capture stop tool - stops capture and returns results for the active tab
861 | */
862 | class NetworkDebuggerStopTool extends BaseBrowserToolExecutor {
863 | name = TOOL_NAMES.BROWSER.NETWORK_DEBUGGER_STOP;
864 | public static instance: NetworkDebuggerStopTool | null = null;
865 |
866 | constructor() {
867 | super();
868 | if (NetworkDebuggerStopTool.instance) {
869 | return NetworkDebuggerStopTool.instance;
870 | }
871 | NetworkDebuggerStopTool.instance = this;
872 | }
873 |
874 | async execute(): Promise<ToolResult> {
875 | console.log(`NetworkDebuggerStopTool: Executing command.`);
876 |
877 | const startTool = NetworkDebuggerStartTool.instance;
878 | if (!startTool) {
879 | return createErrorResponse(
880 | 'NetworkDebuggerStartTool instance not available. Cannot stop capture.',
881 | );
882 | }
883 |
884 | // Get all tabs currently capturing
885 | const ongoingCaptures = Array.from(startTool['captureData'].keys());
886 | console.log(
887 | `NetworkDebuggerStopTool: Found ${ongoingCaptures.length} ongoing captures: ${ongoingCaptures.join(', ')}`,
888 | );
889 |
890 | if (ongoingCaptures.length === 0) {
891 | return createErrorResponse('No active network captures found in any tab.');
892 | }
893 |
894 | // Get current active tab
895 | const activeTabs = await chrome.tabs.query({ active: true, currentWindow: true });
896 | const activeTabId = activeTabs[0]?.id;
897 |
898 | // Determine the primary tab to stop
899 | let primaryTabId: number;
900 |
901 | if (activeTabId && startTool['captureData'].has(activeTabId)) {
902 | // If current active tab is capturing, prioritize stopping it
903 | primaryTabId = activeTabId;
904 | console.log(
905 | `NetworkDebuggerStopTool: Active tab ${activeTabId} is capturing, will stop it first.`,
906 | );
907 | } else if (ongoingCaptures.length === 1) {
908 | // If only one tab is capturing, stop it
909 | primaryTabId = ongoingCaptures[0];
910 | console.log(
911 | `NetworkDebuggerStopTool: Only one tab ${primaryTabId} is capturing, stopping it.`,
912 | );
913 | } else {
914 | // If multiple tabs are capturing but current active tab is not among them, stop the first one
915 | primaryTabId = ongoingCaptures[0];
916 | console.log(
917 | `NetworkDebuggerStopTool: Multiple tabs capturing, active tab not among them. Stopping tab ${primaryTabId} first.`,
918 | );
919 | }
920 |
921 | // Stop capture for the primary tab
922 | const result = await this.performStop(startTool, primaryTabId);
923 |
924 | // If multiple tabs are capturing, stop other tabs
925 | if (ongoingCaptures.length > 1) {
926 | const otherTabIds = ongoingCaptures.filter((id) => id !== primaryTabId);
927 | console.log(
928 | `NetworkDebuggerStopTool: Stopping ${otherTabIds.length} additional captures: ${otherTabIds.join(', ')}`,
929 | );
930 |
931 | for (const tabId of otherTabIds) {
932 | try {
933 | await startTool.stopCapture(tabId);
934 | } catch (error) {
935 | console.error(`NetworkDebuggerStopTool: Error stopping capture on tab ${tabId}:`, error);
936 | }
937 | }
938 | }
939 |
940 | return result;
941 | }
942 |
943 | private async performStop(
944 | startTool: NetworkDebuggerStartTool,
945 | tabId: number,
946 | ): Promise<ToolResult> {
947 | console.log(`NetworkDebuggerStopTool: Attempting to stop capture for tab ${tabId}.`);
948 | const stopResult = await startTool.stopCapture(tabId);
949 |
950 | if (!stopResult?.success) {
951 | return createErrorResponse(
952 | stopResult?.message ||
953 | `Failed to stop network capture for tab ${tabId}. It might not have been capturing.`,
954 | );
955 | }
956 |
957 | const resultData = stopResult.data || {};
958 |
959 | // Get all tabs still capturing (there might be other tabs still capturing after stopping)
960 | const remainingCaptures = Array.from(startTool['captureData'].keys());
961 |
962 | // Sort requests by time
963 | if (resultData.requests && Array.isArray(resultData.requests)) {
964 | resultData.requests.sort(
965 | (a: NetworkRequestInfo, b: NetworkRequestInfo) =>
966 | (a.requestTime || 0) - (b.requestTime || 0),
967 | );
968 | }
969 |
970 | return {
971 | content: [
972 | {
973 | type: 'text',
974 | text: JSON.stringify({
975 | success: true,
976 | message: `Capture for tab ${tabId} (${resultData.tabUrl || 'N/A'}) stopped. ${resultData.requestCount || 0} requests captured.`,
977 | tabId: tabId,
978 | tabUrl: resultData.tabUrl || 'N/A',
979 | tabTitle: resultData.tabTitle || 'Unknown Tab',
980 | requestCount: resultData.requestCount || 0,
981 | commonRequestHeaders: resultData.commonRequestHeaders || {},
982 | commonResponseHeaders: resultData.commonResponseHeaders || {},
983 | requests: resultData.requests || [],
984 | captureStartTime: resultData.captureStartTime,
985 | captureEndTime: resultData.captureEndTime,
986 | totalDurationMs: resultData.totalDurationMs,
987 | settingsUsed: resultData.settingsUsed || {},
988 | remainingCaptures: remainingCaptures,
989 | totalRequestsReceived: resultData.totalRequestsReceived || resultData.requestCount || 0,
990 | requestLimitReached: resultData.requestLimitReached || false,
991 | }),
992 | },
993 | ],
994 | isError: false,
995 | };
996 | }
997 | }
998 |
999 | export const networkDebuggerStartTool = new NetworkDebuggerStartTool();
1000 | export const networkDebuggerStopTool = new NetworkDebuggerStopTool();
1001 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/gif-recorder.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * GIF Recorder Tool
3 | *
4 | * Records browser tab activity as an animated GIF.
5 | *
6 | * Features:
7 | * - Two recording modes:
8 | * 1. Fixed FPS mode (start): Captures frames at regular intervals
9 | * 2. Auto-capture mode (auto_start): Captures frames on tool actions
10 | * - Configurable frame rate, duration, and dimensions
11 | * - Quality/size optimization options
12 | * - CDP-based screenshot capture for background recording
13 | * - Offscreen document encoding via gifenc
14 | */
15 |
16 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
17 | import { BaseBrowserToolExecutor } from '../base-browser';
18 | import { TOOL_NAMES } from 'chrome-mcp-shared';
19 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
20 | import {
21 | MessageTarget,
22 | OFFSCREEN_MESSAGE_TYPES,
23 | OffscreenMessageType,
24 | } from '@/common/message-types';
25 | import { cdpSessionManager } from '@/utils/cdp-session-manager';
26 | import { offscreenManager } from '@/utils/offscreen-manager';
27 | import { createImageBitmapFromUrl } from '@/utils/image-utils';
28 | import {
29 | startAutoCapture,
30 | stopAutoCapture,
31 | isAutoCaptureActive,
32 | getAutoCaptureStatus,
33 | captureFrameOnAction,
34 | captureInitialFrame,
35 | type ActionMetadata,
36 | type GifEnhancedRenderingConfig,
37 | } from './gif-auto-capture';
38 |
39 | // ============================================================================
40 | // Constants
41 | // ============================================================================
42 |
43 | const DEFAULT_FPS = 5;
44 | const DEFAULT_DURATION_MS = 5000;
45 | const DEFAULT_MAX_FRAMES = 50;
46 | const DEFAULT_WIDTH = 800;
47 | const DEFAULT_HEIGHT = 600;
48 | const DEFAULT_MAX_COLORS = 256;
49 | const CDP_SESSION_KEY = 'gif-recorder';
50 |
51 | // ============================================================================
52 | // Types
53 | // ============================================================================
54 |
55 | type GifRecorderAction =
56 | | 'start'
57 | | 'stop'
58 | | 'status'
59 | | 'auto_start'
60 | | 'capture'
61 | | 'clear'
62 | | 'export';
63 |
64 | interface GifRecorderParams {
65 | action: GifRecorderAction;
66 | tabId?: number;
67 | fps?: number;
68 | durationMs?: number;
69 | maxFrames?: number;
70 | width?: number;
71 | height?: number;
72 | maxColors?: number;
73 | filename?: string;
74 | // Auto-capture mode specific
75 | captureDelayMs?: number;
76 | frameDelayCs?: number;
77 | enhancedRendering?: GifEnhancedRenderingConfig;
78 | // Manual annotation for action="capture"
79 | annotation?: string;
80 | // Export action specific
81 | download?: boolean; // true to download, false to upload via drag&drop
82 | coordinates?: { x: number; y: number }; // target position for drag&drop upload
83 | ref?: string; // element ref for drag&drop upload (alternative to coordinates)
84 | selector?: string; // CSS selector for drag&drop upload (alternative to coordinates)
85 | }
86 |
87 | interface RecordingState {
88 | isRecording: boolean;
89 | isStopping: boolean;
90 | tabId: number;
91 | width: number;
92 | height: number;
93 | fps: number;
94 | durationMs: number;
95 | frameIntervalMs: number;
96 | frameDelayCs: number;
97 | maxFrames: number;
98 | maxColors: number;
99 | frameCount: number;
100 | startTime: number;
101 | captureTimer: ReturnType<typeof setTimeout> | null;
102 | captureInProgress: Promise<void> | null;
103 | canvas: OffscreenCanvas;
104 | ctx: OffscreenCanvasRenderingContext2D;
105 | filename?: string;
106 | }
107 |
108 | interface GifResult {
109 | success: boolean;
110 | action: GifRecorderAction;
111 | tabId?: number;
112 | frameCount?: number;
113 | durationMs?: number;
114 | byteLength?: number;
115 | downloadId?: number;
116 | filename?: string;
117 | fullPath?: string;
118 | isRecording?: boolean;
119 | mode?: 'fixed_fps' | 'auto_capture';
120 | actionsCount?: number;
121 | error?: string;
122 | // Clear action specific
123 | clearedAutoCapture?: boolean;
124 | clearedFixedFps?: boolean;
125 | clearedCache?: boolean;
126 | // Export action specific (drag&drop upload)
127 | uploadTarget?: {
128 | x: number;
129 | y: number;
130 | tagName?: string;
131 | id?: string;
132 | };
133 | }
134 |
135 | // ============================================================================
136 | // Recording State Management
137 | // ============================================================================
138 |
139 | let recordingState: RecordingState | null = null;
140 | let stopPromise: Promise<GifResult> | null = null;
141 |
142 | // Auto-capture mode state
143 | interface AutoCaptureMetadata {
144 | tabId: number;
145 | filename?: string;
146 | }
147 | let autoCaptureMetadata: AutoCaptureMetadata | null = null;
148 |
149 | // Last recorded GIF cache for export
150 | interface ExportableGif {
151 | gifData: Uint8Array;
152 | width: number;
153 | height: number;
154 | frameCount: number;
155 | durationMs: number;
156 | tabId: number;
157 | filename?: string;
158 | actionsCount?: number;
159 | mode: 'fixed_fps' | 'auto_capture';
160 | createdAt: number;
161 | }
162 | let lastRecordedGif: ExportableGif | null = null;
163 |
164 | // Maximum cache lifetime for exportable GIF (5 minutes)
165 | const EXPORT_CACHE_LIFETIME_MS = 5 * 60 * 1000;
166 |
167 | // ============================================================================
168 | // Offscreen Document Communication
169 | // ============================================================================
170 |
171 | type OffscreenResponseBase = { success: boolean; error?: string };
172 |
173 | async function sendToOffscreen<TResponse extends OffscreenResponseBase>(
174 | type: OffscreenMessageType,
175 | payload: Record<string, unknown> = {},
176 | ): Promise<TResponse> {
177 | await offscreenManager.ensureOffscreenDocument();
178 |
179 | let lastError: unknown;
180 | for (let attempt = 1; attempt <= 3; attempt++) {
181 | try {
182 | const response = (await chrome.runtime.sendMessage({
183 | target: MessageTarget.Offscreen,
184 | type,
185 | ...payload,
186 | })) as TResponse | undefined;
187 |
188 | if (!response) {
189 | throw new Error('No response received from offscreen document');
190 | }
191 | if (!response.success) {
192 | throw new Error(response.error || 'Unknown offscreen error');
193 | }
194 |
195 | return response;
196 | } catch (error) {
197 | lastError = error;
198 | if (attempt < 3) {
199 | await new Promise((resolve) => setTimeout(resolve, 50 * attempt));
200 | continue;
201 | }
202 | throw error;
203 | }
204 | }
205 |
206 | throw lastError instanceof Error ? lastError : new Error(String(lastError));
207 | }
208 |
209 | // ============================================================================
210 | // Frame Capture
211 | // ============================================================================
212 |
213 | async function captureFrame(
214 | tabId: number,
215 | width: number,
216 | height: number,
217 | ctx: OffscreenCanvasRenderingContext2D,
218 | ): Promise<Uint8ClampedArray> {
219 | // Get viewport metrics
220 | const metrics: { layoutViewport?: { clientWidth: number; clientHeight: number } } =
221 | await cdpSessionManager.sendCommand(tabId, 'Page.getLayoutMetrics', {});
222 |
223 | const viewportWidth = metrics.layoutViewport?.clientWidth || width;
224 | const viewportHeight = metrics.layoutViewport?.clientHeight || height;
225 |
226 | // Capture screenshot
227 | const screenshot: { data: string } = await cdpSessionManager.sendCommand(
228 | tabId,
229 | 'Page.captureScreenshot',
230 | {
231 | format: 'png',
232 | clip: {
233 | x: 0,
234 | y: 0,
235 | width: viewportWidth,
236 | height: viewportHeight,
237 | scale: 1,
238 | },
239 | },
240 | );
241 |
242 | const imageBitmap = await createImageBitmapFromUrl(`data:image/png;base64,${screenshot.data}`);
243 |
244 | // Scale image to target dimensions
245 | ctx.clearRect(0, 0, width, height);
246 | ctx.drawImage(imageBitmap, 0, 0, width, height);
247 | imageBitmap.close();
248 |
249 | const imageData = ctx.getImageData(0, 0, width, height);
250 | return imageData.data;
251 | }
252 |
253 | async function captureAndEncodeFrame(state: RecordingState): Promise<void> {
254 | const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx);
255 |
256 | await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {
257 | imageData: Array.from(frameData),
258 | width: state.width,
259 | height: state.height,
260 | delay: state.frameDelayCs,
261 | maxColors: state.maxColors,
262 | });
263 |
264 | if (recordingState === state && state.isRecording && !state.isStopping) {
265 | state.frameCount += 1;
266 | }
267 | }
268 |
269 | async function captureTick(state: RecordingState): Promise<void> {
270 | if (recordingState !== state || !state.isRecording || state.isStopping) {
271 | return;
272 | }
273 |
274 | const elapsed = Date.now() - state.startTime;
275 | if (elapsed >= state.durationMs || state.frameCount >= state.maxFrames) {
276 | await stopRecording();
277 | return;
278 | }
279 |
280 | const startedAt = Date.now();
281 | state.captureInProgress = captureAndEncodeFrame(state);
282 |
283 | try {
284 | await state.captureInProgress;
285 | } catch (error) {
286 | console.error('Frame capture error:', error);
287 | } finally {
288 | if (recordingState === state) {
289 | state.captureInProgress = null;
290 | }
291 | }
292 |
293 | if (recordingState !== state || !state.isRecording || state.isStopping) {
294 | return;
295 | }
296 |
297 | const elapsedAfter = Date.now() - state.startTime;
298 | if (elapsedAfter >= state.durationMs || state.frameCount >= state.maxFrames) {
299 | await stopRecording();
300 | return;
301 | }
302 |
303 | const delayMs = Math.max(0, state.frameIntervalMs - (Date.now() - startedAt));
304 | state.captureTimer = setTimeout(() => {
305 | void captureTick(state).catch((error) => {
306 | console.error('GIF recorder tick error:', error);
307 | });
308 | }, delayMs);
309 | }
310 |
311 | // ============================================================================
312 | // Recording Control
313 | // ============================================================================
314 |
315 | async function startRecording(
316 | tabId: number,
317 | fps: number,
318 | durationMs: number,
319 | maxFrames: number,
320 | width: number,
321 | height: number,
322 | maxColors: number,
323 | filename?: string,
324 | ): Promise<GifResult> {
325 | if (stopPromise || recordingState?.isRecording || recordingState?.isStopping) {
326 | return {
327 | success: false,
328 | action: 'start',
329 | error: 'Recording already in progress',
330 | };
331 | }
332 |
333 | try {
334 | await cdpSessionManager.attach(tabId, CDP_SESSION_KEY);
335 | } catch (error) {
336 | return {
337 | success: false,
338 | action: 'start',
339 | error: error instanceof Error ? error.message : String(error),
340 | };
341 | }
342 |
343 | try {
344 | await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});
345 |
346 | if (typeof OffscreenCanvas === 'undefined') {
347 | throw new Error('OffscreenCanvas not available in this context');
348 | }
349 |
350 | const canvas = new OffscreenCanvas(width, height);
351 | const ctx = canvas.getContext('2d');
352 | if (!ctx) {
353 | throw new Error('Failed to get canvas context');
354 | }
355 |
356 | const frameIntervalMs = Math.round(1000 / fps);
357 | const frameDelayCs = Math.max(1, Math.round(100 / fps));
358 |
359 | const state: RecordingState = {
360 | isRecording: true,
361 | isStopping: false,
362 | tabId,
363 | width,
364 | height,
365 | fps,
366 | durationMs,
367 | frameIntervalMs,
368 | frameDelayCs,
369 | maxFrames,
370 | maxColors,
371 | frameCount: 0,
372 | startTime: Date.now(),
373 | captureTimer: null,
374 | captureInProgress: null,
375 | canvas,
376 | ctx,
377 | filename,
378 | };
379 |
380 | recordingState = state;
381 |
382 | // Capture first frame eagerly so start() fails fast if capture/encoding is broken
383 | await captureAndEncodeFrame(state);
384 |
385 | state.captureTimer = setTimeout(() => {
386 | void captureTick(state).catch((error) => {
387 | console.error('GIF recorder tick error:', error);
388 | });
389 | }, frameIntervalMs);
390 |
391 | return {
392 | success: true,
393 | action: 'start',
394 | tabId,
395 | isRecording: true,
396 | };
397 | } catch (error) {
398 | recordingState = null;
399 | try {
400 | await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);
401 | } catch {
402 | // ignore
403 | }
404 | return {
405 | success: false,
406 | action: 'start',
407 | error: error instanceof Error ? error.message : String(error),
408 | };
409 | }
410 | }
411 |
412 | async function stopRecording(): Promise<GifResult> {
413 | if (stopPromise) {
414 | return stopPromise;
415 | }
416 |
417 | if (!recordingState || (!recordingState.isRecording && !recordingState.isStopping)) {
418 | return {
419 | success: false,
420 | action: 'stop',
421 | error: 'No recording in progress',
422 | };
423 | }
424 |
425 | stopPromise = (async () => {
426 | const state = recordingState!;
427 | const tabId = state.tabId;
428 |
429 | // Stop capture timer
430 | if (state.captureTimer) {
431 | clearTimeout(state.captureTimer);
432 | state.captureTimer = null;
433 | }
434 |
435 | state.isStopping = true;
436 | state.isRecording = false;
437 |
438 | try {
439 | await state.captureInProgress;
440 | } catch {
441 | // ignore
442 | }
443 |
444 | // Best-effort final frame capture to preserve end state
445 | try {
446 | const frameData = await captureFrame(state.tabId, state.width, state.height, state.ctx);
447 | await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_ADD_FRAME, {
448 | imageData: Array.from(frameData),
449 | width: state.width,
450 | height: state.height,
451 | delay: state.frameDelayCs,
452 | maxColors: state.maxColors,
453 | });
454 | state.frameCount += 1;
455 | } catch (error) {
456 | console.warn('GIF recorder: Final frame capture error (non-fatal):', error);
457 | }
458 |
459 | const frameCount = state.frameCount;
460 | const durationMs = Date.now() - state.startTime;
461 | const filename = state.filename;
462 |
463 | try {
464 | if (frameCount <= 0) {
465 | try {
466 | await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});
467 | } catch {
468 | // ignore
469 | }
470 | return {
471 | success: false,
472 | action: 'stop' as const,
473 | tabId,
474 | frameCount,
475 | durationMs,
476 | error: 'No frames captured',
477 | };
478 | }
479 |
480 | const response = await sendToOffscreen<{
481 | success: boolean;
482 | gifData?: number[];
483 | byteLength?: number;
484 | }>(OFFSCREEN_MESSAGE_TYPES.GIF_FINISH, {});
485 |
486 | if (!response.gifData || response.gifData.length === 0) {
487 | return {
488 | success: false,
489 | action: 'stop' as const,
490 | tabId,
491 | frameCount,
492 | durationMs,
493 | error: 'No frames captured',
494 | };
495 | }
496 |
497 | // Convert to Uint8Array and create blob
498 | const gifBytes = new Uint8Array(response.gifData);
499 |
500 | // Cache for later export
501 | lastRecordedGif = {
502 | gifData: gifBytes,
503 | width: state.width,
504 | height: state.height,
505 | frameCount,
506 | durationMs,
507 | tabId,
508 | filename,
509 | mode: 'fixed_fps',
510 | createdAt: Date.now(),
511 | };
512 |
513 | const blob = new Blob([gifBytes], { type: 'image/gif' });
514 | const dataUrl = await blobToDataUrl(blob);
515 |
516 | // Save GIF file
517 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
518 | const outputFilename = filename?.replace(/[^a-z0-9_-]/gi, '_') || `recording_${timestamp}`;
519 | const fullFilename = outputFilename.endsWith('.gif')
520 | ? outputFilename
521 | : `${outputFilename}.gif`;
522 |
523 | const downloadId = await chrome.downloads.download({
524 | url: dataUrl,
525 | filename: fullFilename,
526 | saveAs: false,
527 | });
528 |
529 | // Wait briefly to get download info
530 | await new Promise((resolve) => setTimeout(resolve, 100));
531 |
532 | let fullPath: string | undefined;
533 | try {
534 | const [downloadItem] = await chrome.downloads.search({ id: downloadId });
535 | fullPath = downloadItem?.filename;
536 | } catch {
537 | // Ignore path lookup errors
538 | }
539 |
540 | return {
541 | success: true,
542 | action: 'stop' as const,
543 | tabId,
544 | frameCount,
545 | durationMs,
546 | byteLength: response.byteLength ?? gifBytes.byteLength,
547 | downloadId,
548 | filename: fullFilename,
549 | fullPath,
550 | };
551 | } catch (error) {
552 | return {
553 | success: false,
554 | action: 'stop' as const,
555 | error: error instanceof Error ? error.message : String(error),
556 | };
557 | } finally {
558 | try {
559 | await cdpSessionManager.detach(tabId, CDP_SESSION_KEY);
560 | } catch {
561 | // ignore
562 | }
563 | recordingState = null;
564 | }
565 | })();
566 |
567 | return await stopPromise.finally(() => {
568 | stopPromise = null;
569 | });
570 | }
571 |
572 | function getRecordingStatus(): GifResult {
573 | if (!recordingState) {
574 | return {
575 | success: true,
576 | action: 'status',
577 | isRecording: false,
578 | };
579 | }
580 |
581 | return {
582 | success: true,
583 | action: 'status',
584 | isRecording: recordingState.isRecording,
585 | tabId: recordingState.tabId,
586 | frameCount: recordingState.frameCount,
587 | durationMs: Date.now() - recordingState.startTime,
588 | };
589 | }
590 |
591 | // ============================================================================
592 | // Utilities
593 | // ============================================================================
594 |
595 | function blobToDataUrl(blob: Blob): Promise<string> {
596 | return new Promise((resolve, reject) => {
597 | const reader = new FileReader();
598 | reader.onload = () => resolve(reader.result as string);
599 | reader.onerror = () => reject(new Error('Failed to read blob'));
600 | reader.readAsDataURL(blob);
601 | });
602 | }
603 |
604 | function normalizePositiveInt(value: unknown, fallback: number, max?: number): number {
605 | if (typeof value !== 'number' || !Number.isFinite(value)) {
606 | return fallback;
607 | }
608 | const result = Math.max(1, Math.floor(value));
609 | return max !== undefined ? Math.min(result, max) : result;
610 | }
611 |
612 | // ============================================================================
613 | // Tool Implementation
614 | // ============================================================================
615 |
616 | class GifRecorderTool extends BaseBrowserToolExecutor {
617 | name = TOOL_NAMES.BROWSER.GIF_RECORDER;
618 |
619 | async execute(args: GifRecorderParams): Promise<ToolResult> {
620 | const action = args.action;
621 | const validActions = ['start', 'stop', 'status', 'auto_start', 'capture', 'clear', 'export'];
622 |
623 | if (!action || !validActions.includes(action)) {
624 | return createErrorResponse(
625 | `Parameter [action] is required and must be one of: ${validActions.join(', ')}`,
626 | );
627 | }
628 |
629 | try {
630 | switch (action) {
631 | case 'start': {
632 | // Fixed-FPS mode: captures frames at regular intervals
633 | const tab = await this.resolveTargetTab(args.tabId);
634 | if (!tab?.id) {
635 | return createErrorResponse(
636 | typeof args.tabId === 'number'
637 | ? `Tab not found: ${args.tabId}`
638 | : 'No active tab found',
639 | );
640 | }
641 |
642 | if (this.isRestrictedUrl(tab.url)) {
643 | return createErrorResponse(
644 | 'Cannot record special browser pages or web store pages due to security restrictions.',
645 | );
646 | }
647 |
648 | // Check if auto-capture is active
649 | if (isAutoCaptureActive(tab.id)) {
650 | return createErrorResponse(
651 | 'Auto-capture mode is active for this tab. Use action="stop" to stop it first.',
652 | );
653 | }
654 |
655 | const fps = normalizePositiveInt(args.fps, DEFAULT_FPS, 30);
656 | const durationMs = normalizePositiveInt(args.durationMs, DEFAULT_DURATION_MS, 60000);
657 | const maxFrames = normalizePositiveInt(args.maxFrames, DEFAULT_MAX_FRAMES, 300);
658 | const width = normalizePositiveInt(args.width, DEFAULT_WIDTH, 1920);
659 | const height = normalizePositiveInt(args.height, DEFAULT_HEIGHT, 1080);
660 | const maxColors = normalizePositiveInt(args.maxColors, DEFAULT_MAX_COLORS, 256);
661 |
662 | const result = await startRecording(
663 | tab.id,
664 | fps,
665 | durationMs,
666 | maxFrames,
667 | width,
668 | height,
669 | maxColors,
670 | args.filename,
671 | );
672 |
673 | if (result.success) {
674 | result.mode = 'fixed_fps';
675 | }
676 |
677 | return this.buildResponse(result);
678 | }
679 |
680 | case 'auto_start': {
681 | // Auto-capture mode: captures frames when tools succeed
682 | const tab = await this.resolveTargetTab(args.tabId);
683 | if (!tab?.id) {
684 | return createErrorResponse(
685 | typeof args.tabId === 'number'
686 | ? `Tab not found: ${args.tabId}`
687 | : 'No active tab found',
688 | );
689 | }
690 |
691 | if (this.isRestrictedUrl(tab.url)) {
692 | return createErrorResponse(
693 | 'Cannot record special browser pages or web store pages due to security restrictions.',
694 | );
695 | }
696 |
697 | // Check if fixed-FPS recording is active
698 | if (recordingState?.isRecording && recordingState.tabId === tab.id) {
699 | return createErrorResponse(
700 | 'Fixed-FPS recording is active for this tab. Use action="stop" to stop it first.',
701 | );
702 | }
703 |
704 | // Check if auto-capture is already active
705 | if (isAutoCaptureActive(tab.id)) {
706 | return createErrorResponse('Auto-capture is already active for this tab.');
707 | }
708 |
709 | const width = normalizePositiveInt(args.width, DEFAULT_WIDTH, 1920);
710 | const height = normalizePositiveInt(args.height, DEFAULT_HEIGHT, 1080);
711 | const maxColors = normalizePositiveInt(args.maxColors, DEFAULT_MAX_COLORS, 256);
712 | const maxFrames = normalizePositiveInt(args.maxFrames, 100, 300);
713 | const captureDelayMs = normalizePositiveInt(args.captureDelayMs, 150, 2000);
714 | const frameDelayCs = normalizePositiveInt(args.frameDelayCs, 20, 100);
715 |
716 | const startResult = await startAutoCapture(tab.id, {
717 | width,
718 | height,
719 | maxColors,
720 | maxFrames,
721 | captureDelayMs,
722 | frameDelayCs,
723 | enhancedRendering: args.enhancedRendering,
724 | });
725 |
726 | if (!startResult.success) {
727 | return this.buildResponse({
728 | success: false,
729 | action: 'auto_start',
730 | tabId: tab.id,
731 | error: startResult.error,
732 | });
733 | }
734 |
735 | // Store metadata for stop
736 | autoCaptureMetadata = {
737 | tabId: tab.id,
738 | filename: args.filename,
739 | };
740 |
741 | // Capture initial frame
742 | await captureInitialFrame(tab.id);
743 |
744 | return this.buildResponse({
745 | success: true,
746 | action: 'auto_start',
747 | tabId: tab.id,
748 | mode: 'auto_capture',
749 | isRecording: true,
750 | });
751 | }
752 |
753 | case 'capture': {
754 | // Manual frame capture in auto mode
755 | const tab = await this.resolveTargetTab(args.tabId);
756 | if (!tab?.id) {
757 | return createErrorResponse(
758 | typeof args.tabId === 'number'
759 | ? `Tab not found: ${args.tabId}`
760 | : 'No active tab found',
761 | );
762 | }
763 |
764 | if (!isAutoCaptureActive(tab.id)) {
765 | return createErrorResponse(
766 | 'Auto-capture is not active for this tab. Use action="auto_start" first.',
767 | );
768 | }
769 |
770 | // Support optional annotation for manual captures
771 | const annotation =
772 | typeof args.annotation === 'string' && args.annotation.trim().length > 0
773 | ? args.annotation.trim()
774 | : undefined;
775 |
776 | const action: ActionMetadata | undefined = annotation
777 | ? { type: 'annotation', label: annotation }
778 | : undefined;
779 |
780 | const captureResult = await captureFrameOnAction(tab.id, action, true);
781 |
782 | return this.buildResponse({
783 | success: captureResult.success,
784 | action: 'capture',
785 | tabId: tab.id,
786 | frameCount: captureResult.frameNumber,
787 | error: captureResult.error,
788 | });
789 | }
790 |
791 | case 'stop': {
792 | // Stop either mode
793 | // Check auto-capture first
794 | const autoTab = autoCaptureMetadata?.tabId;
795 | if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {
796 | const stopResult = await stopAutoCapture(autoTab);
797 | const filename = autoCaptureMetadata?.filename;
798 | autoCaptureMetadata = null;
799 |
800 | if (!stopResult.success || !stopResult.gifData) {
801 | return this.buildResponse({
802 | success: false,
803 | action: 'stop',
804 | tabId: autoTab,
805 | mode: 'auto_capture',
806 | frameCount: stopResult.frameCount,
807 | durationMs: stopResult.durationMs,
808 | actionsCount: stopResult.actions?.length,
809 | error: stopResult.error || 'No GIF data generated',
810 | });
811 | }
812 |
813 | // Cache for later export
814 | lastRecordedGif = {
815 | gifData: stopResult.gifData,
816 | width: DEFAULT_WIDTH, // auto mode uses default dimensions
817 | height: DEFAULT_HEIGHT,
818 | frameCount: stopResult.frameCount ?? 0,
819 | durationMs: stopResult.durationMs ?? 0,
820 | tabId: autoTab,
821 | filename,
822 | actionsCount: stopResult.actions?.length,
823 | mode: 'auto_capture',
824 | createdAt: Date.now(),
825 | };
826 |
827 | // Save GIF file
828 | const blob = new Blob([stopResult.gifData], { type: 'image/gif' });
829 | const dataUrl = await blobToDataUrl(blob);
830 |
831 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
832 | const outputFilename =
833 | filename?.replace(/[^a-z0-9_-]/gi, '_') || `recording_${timestamp}`;
834 | const fullFilename = outputFilename.endsWith('.gif')
835 | ? outputFilename
836 | : `${outputFilename}.gif`;
837 |
838 | const downloadId = await chrome.downloads.download({
839 | url: dataUrl,
840 | filename: fullFilename,
841 | saveAs: false,
842 | });
843 |
844 | await new Promise((resolve) => setTimeout(resolve, 100));
845 |
846 | let fullPath: string | undefined;
847 | try {
848 | const [downloadItem] = await chrome.downloads.search({ id: downloadId });
849 | fullPath = downloadItem?.filename;
850 | } catch {
851 | // Ignore
852 | }
853 |
854 | return this.buildResponse({
855 | success: true,
856 | action: 'stop',
857 | tabId: autoTab,
858 | mode: 'auto_capture',
859 | frameCount: stopResult.frameCount,
860 | durationMs: stopResult.durationMs,
861 | byteLength: stopResult.gifData.byteLength,
862 | actionsCount: stopResult.actions?.length,
863 | downloadId,
864 | filename: fullFilename,
865 | fullPath,
866 | });
867 | }
868 |
869 | // Fall back to fixed-FPS stop
870 | const result = await stopRecording();
871 | if (result.success) {
872 | result.mode = 'fixed_fps';
873 | }
874 | return this.buildResponse(result);
875 | }
876 |
877 | case 'status': {
878 | // Check auto-capture status first
879 | const autoTab = autoCaptureMetadata?.tabId;
880 | if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {
881 | const status = getAutoCaptureStatus(autoTab);
882 | return this.buildResponse({
883 | success: true,
884 | action: 'status',
885 | tabId: autoTab,
886 | isRecording: status.active,
887 | mode: 'auto_capture',
888 | frameCount: status.frameCount,
889 | durationMs: status.durationMs,
890 | actionsCount: status.actionsCount,
891 | });
892 | }
893 |
894 | // Fall back to fixed-FPS status
895 | const result = getRecordingStatus();
896 | if (result.isRecording) {
897 | result.mode = 'fixed_fps';
898 | }
899 | return this.buildResponse(result);
900 | }
901 |
902 | case 'clear': {
903 | // Clear all recording state and cached GIF
904 | let clearedAuto = false;
905 | let clearedFixedFps = false;
906 | let clearedCache = false;
907 |
908 | // Stop auto-capture if active
909 | const autoTab = autoCaptureMetadata?.tabId;
910 | if (autoTab !== undefined && isAutoCaptureActive(autoTab)) {
911 | await stopAutoCapture(autoTab);
912 | autoCaptureMetadata = null;
913 | clearedAuto = true;
914 | }
915 |
916 | // Stop fixed-FPS recording if active or stopping
917 | if (recordingState) {
918 | // Cancel timer and cleanup without waiting for finish
919 | if (recordingState.captureTimer) {
920 | clearTimeout(recordingState.captureTimer);
921 | recordingState.captureTimer = null;
922 | }
923 | try {
924 | await recordingState.captureInProgress;
925 | } catch {
926 | // ignore
927 | }
928 | try {
929 | await cdpSessionManager.detach(recordingState.tabId, CDP_SESSION_KEY);
930 | } catch {
931 | // ignore
932 | }
933 | const wasRecording = recordingState.isRecording || recordingState.isStopping;
934 | recordingState = null;
935 | stopPromise = null; // Clear any pending stop promise
936 | if (wasRecording) {
937 | clearedFixedFps = true;
938 | }
939 | }
940 |
941 | // Reset offscreen encoder
942 | try {
943 | await sendToOffscreen(OFFSCREEN_MESSAGE_TYPES.GIF_RESET, {});
944 | } catch {
945 | // ignore
946 | }
947 |
948 | // Clear cached GIF
949 | if (lastRecordedGif) {
950 | lastRecordedGif = null;
951 | clearedCache = true;
952 | }
953 |
954 | return this.buildResponse({
955 | success: true,
956 | action: 'clear',
957 | clearedAutoCapture: clearedAuto,
958 | clearedFixedFps,
959 | clearedCache,
960 | } as GifResult);
961 | }
962 |
963 | case 'export': {
964 | // Export the last recorded GIF (download or drag&drop upload)
965 |
966 | // Check if cache is valid
967 | if (!lastRecordedGif) {
968 | return createErrorResponse(
969 | 'No recorded GIF available for export. Use action="stop" to finish a recording first.',
970 | );
971 | }
972 |
973 | // Check cache expiration
974 | if (Date.now() - lastRecordedGif.createdAt > EXPORT_CACHE_LIFETIME_MS) {
975 | lastRecordedGif = null;
976 | return createErrorResponse('Cached GIF has expired. Please record a new GIF.');
977 | }
978 |
979 | const download = args.download !== false; // Default to download
980 |
981 | if (download) {
982 | // Download mode
983 | const blob = new Blob([lastRecordedGif.gifData], { type: 'image/gif' });
984 | const dataUrl = await blobToDataUrl(blob);
985 |
986 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
987 | const filename = args.filename ?? lastRecordedGif.filename;
988 | const outputFilename = filename?.replace(/[^a-z0-9_-]/gi, '_') || `export_${timestamp}`;
989 | const fullFilename = outputFilename.endsWith('.gif')
990 | ? outputFilename
991 | : `${outputFilename}.gif`;
992 |
993 | const downloadId = await chrome.downloads.download({
994 | url: dataUrl,
995 | filename: fullFilename,
996 | saveAs: false,
997 | });
998 |
999 | await new Promise((resolve) => setTimeout(resolve, 100));
1000 |
1001 | let fullPath: string | undefined;
1002 | try {
1003 | const [downloadItem] = await chrome.downloads.search({ id: downloadId });
1004 | fullPath = downloadItem?.filename;
1005 | } catch {
1006 | // Ignore
1007 | }
1008 |
1009 | return this.buildResponse({
1010 | success: true,
1011 | action: 'export',
1012 | mode: lastRecordedGif.mode,
1013 | frameCount: lastRecordedGif.frameCount,
1014 | durationMs: lastRecordedGif.durationMs,
1015 | byteLength: lastRecordedGif.gifData.byteLength,
1016 | downloadId,
1017 | filename: fullFilename,
1018 | fullPath,
1019 | });
1020 | } else {
1021 | // Drag&drop upload mode
1022 | const { coordinates, ref, selector } = args;
1023 |
1024 | if (!coordinates && !ref && !selector) {
1025 | return createErrorResponse(
1026 | 'For drag&drop upload, provide coordinates, ref, or selector to identify the drop target.',
1027 | );
1028 | }
1029 |
1030 | // Resolve target tab
1031 | const tab = await this.resolveTargetTab(args.tabId);
1032 | if (!tab?.id) {
1033 | return createErrorResponse(
1034 | typeof args.tabId === 'number'
1035 | ? `Tab not found: ${args.tabId}`
1036 | : 'No active tab found',
1037 | );
1038 | }
1039 |
1040 | // Security check
1041 | if (this.isRestrictedUrl(tab.url)) {
1042 | return createErrorResponse(
1043 | 'Cannot upload to special browser pages or web store pages.',
1044 | );
1045 | }
1046 |
1047 | // Prepare GIF data as base64
1048 | const gifBase64 = btoa(
1049 | Array.from(lastRecordedGif.gifData)
1050 | .map((b) => String.fromCharCode(b))
1051 | .join(''),
1052 | );
1053 |
1054 | // Resolve drop target coordinates
1055 | let targetX: number | undefined;
1056 | let targetY: number | undefined;
1057 |
1058 | if (ref) {
1059 | // Use the project's built-in ref resolution mechanism
1060 | try {
1061 | await this.injectContentScript(tab.id, [
1062 | 'inject-scripts/accessibility-tree-helper.js',
1063 | ]);
1064 | const resolved = await this.sendMessageToTab(tab.id, {
1065 | action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
1066 | ref,
1067 | });
1068 | if (resolved?.success && resolved.center) {
1069 | targetX = resolved.center.x;
1070 | targetY = resolved.center.y;
1071 | } else {
1072 | return createErrorResponse(`Could not resolve ref: ${ref}`);
1073 | }
1074 | } catch (err) {
1075 | return createErrorResponse(
1076 | `Failed to resolve ref: ${err instanceof Error ? err.message : String(err)}`,
1077 | );
1078 | }
1079 | } else if (selector) {
1080 | // Use executeScript to get element center coordinates by CSS selector
1081 | try {
1082 | const [result] = await chrome.scripting.executeScript({
1083 | target: { tabId: tab.id },
1084 | func: (cssSelector: string) => {
1085 | const el = document.querySelector(cssSelector);
1086 | if (!el) return null;
1087 | const rect = el.getBoundingClientRect();
1088 | return {
1089 | x: rect.left + rect.width / 2,
1090 | y: rect.top + rect.height / 2,
1091 | };
1092 | },
1093 | args: [selector],
1094 | });
1095 |
1096 | if (result?.result) {
1097 | targetX = result.result.x;
1098 | targetY = result.result.y;
1099 | } else {
1100 | return createErrorResponse(`Could not find element: ${selector}`);
1101 | }
1102 | } catch (err) {
1103 | return createErrorResponse(
1104 | `Failed to resolve selector: ${err instanceof Error ? err.message : String(err)}`,
1105 | );
1106 | }
1107 | } else if (coordinates) {
1108 | targetX = coordinates.x;
1109 | targetY = coordinates.y;
1110 | }
1111 |
1112 | if (typeof targetX !== 'number' || typeof targetY !== 'number') {
1113 | return createErrorResponse('Invalid drop target coordinates.');
1114 | }
1115 |
1116 | // Execute drag&drop upload
1117 | try {
1118 | const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
1119 | const filename =
1120 | args.filename ?? lastRecordedGif.filename ?? `recording_${timestamp}`;
1121 | const fullFilename = filename.endsWith('.gif') ? filename : `${filename}.gif`;
1122 |
1123 | const [result] = await chrome.scripting.executeScript({
1124 | target: { tabId: tab.id },
1125 | func: (base64Data: string, x: number, y: number, fname: string) => {
1126 | // Convert base64 to Blob
1127 | const byteChars = atob(base64Data);
1128 | const byteArray = new Uint8Array(byteChars.length);
1129 | for (let i = 0; i < byteChars.length; i++) {
1130 | byteArray[i] = byteChars.charCodeAt(i);
1131 | }
1132 | const blob = new Blob([byteArray], { type: 'image/gif' });
1133 | const file = new File([blob], fname, { type: 'image/gif' });
1134 |
1135 | // Find drop target element
1136 | const target = document.elementFromPoint(x, y);
1137 | if (!target) {
1138 | return { success: false, error: 'No element at drop coordinates' };
1139 | }
1140 |
1141 | // Create DataTransfer with the file
1142 | const dt = new DataTransfer();
1143 | dt.items.add(file);
1144 |
1145 | // Dispatch drag events
1146 | const events = ['dragenter', 'dragover', 'drop'] as const;
1147 | for (const eventType of events) {
1148 | const evt = new DragEvent(eventType, {
1149 | bubbles: true,
1150 | cancelable: true,
1151 | dataTransfer: dt,
1152 | clientX: x,
1153 | clientY: y,
1154 | });
1155 | target.dispatchEvent(evt);
1156 | }
1157 |
1158 | return {
1159 | success: true,
1160 | targetTagName: target.tagName,
1161 | targetId: target.id || undefined,
1162 | };
1163 | },
1164 | args: [gifBase64, targetX, targetY, fullFilename],
1165 | });
1166 |
1167 | if (!result?.result?.success) {
1168 | return createErrorResponse(result?.result?.error || 'Drag&drop upload failed');
1169 | }
1170 |
1171 | return this.buildResponse({
1172 | success: true,
1173 | action: 'export',
1174 | mode: lastRecordedGif.mode,
1175 | frameCount: lastRecordedGif.frameCount,
1176 | durationMs: lastRecordedGif.durationMs,
1177 | byteLength: lastRecordedGif.gifData.byteLength,
1178 | uploadTarget: {
1179 | x: targetX,
1180 | y: targetY,
1181 | tagName: result.result.targetTagName,
1182 | id: result.result.targetId,
1183 | },
1184 | } as GifResult);
1185 | } catch (err) {
1186 | return createErrorResponse(
1187 | `Drag&drop upload failed: ${err instanceof Error ? err.message : String(err)}`,
1188 | );
1189 | }
1190 | }
1191 | }
1192 |
1193 | default:
1194 | return createErrorResponse(`Unknown action: ${action}`);
1195 | }
1196 | } catch (error) {
1197 | console.error('GifRecorderTool.execute error:', error);
1198 | return createErrorResponse(
1199 | `GIF recorder error: ${error instanceof Error ? error.message : String(error)}`,
1200 | );
1201 | }
1202 | }
1203 |
1204 | private isRestrictedUrl(url?: string): boolean {
1205 | if (!url) return false;
1206 | return (
1207 | url.startsWith('chrome://') ||
1208 | url.startsWith('edge://') ||
1209 | url.startsWith('https://chrome.google.com/webstore') ||
1210 | url.startsWith('https://microsoftedge.microsoft.com/')
1211 | );
1212 | }
1213 |
1214 | private async resolveTargetTab(tabId?: number): Promise<chrome.tabs.Tab | null> {
1215 | if (typeof tabId === 'number') {
1216 | return this.tryGetTab(tabId);
1217 | }
1218 | try {
1219 | return await this.getActiveTabOrThrow();
1220 | } catch {
1221 | return null;
1222 | }
1223 | }
1224 |
1225 | private buildResponse(result: GifResult): ToolResult {
1226 | return {
1227 | content: [{ type: 'text', text: JSON.stringify(result) }],
1228 | isError: !result.success,
1229 | };
1230 | }
1231 | }
1232 |
1233 | export const gifRecorderTool = new GifRecorderTool();
1234 |
1235 | // Re-export auto-capture utilities for use by other tools (e.g., chrome_computer, chrome_navigate)
1236 | export {
1237 | captureFrameOnAction,
1238 | isAutoCaptureActive,
1239 | type ActionMetadata,
1240 | type ActionType,
1241 | } from './gif-auto-capture';
1242 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/tests/record-replay-v3/rpc-api.test.ts:
--------------------------------------------------------------------------------
```typescript
1 | /* eslint-disable @typescript-eslint/no-unsafe-function-type */
2 | /**
3 | * @fileoverview Record-Replay V3 RPC API Tests
4 | * @description
5 | * Tests for the queue management RPC APIs:
6 | * - rr_v3.enqueueRun
7 | * - rr_v3.listQueue
8 | * - rr_v3.cancelQueueItem
9 | *
10 | * Tests for Flow CRUD RPC APIs:
11 | * - rr_v3.saveFlow
12 | * - rr_v3.deleteFlow
13 | */
14 |
15 | import { beforeEach, describe, expect, it, vi } from 'vitest';
16 |
17 | import type { FlowV3 } from '@/entrypoints/background/record-replay-v3/domain/flow';
18 | import type { RunRecordV3 } from '@/entrypoints/background/record-replay-v3/domain/events';
19 | import type { StoragePort } from '@/entrypoints/background/record-replay-v3/engine/storage/storage-port';
20 | import type { EventsBus } from '@/entrypoints/background/record-replay-v3/engine/transport/events-bus';
21 | import type { RunScheduler } from '@/entrypoints/background/record-replay-v3/engine/queue/scheduler';
22 | import type { RunQueueItem } from '@/entrypoints/background/record-replay-v3/engine/queue/queue';
23 | import { RpcServer } from '@/entrypoints/background/record-replay-v3/engine/transport/rpc-server';
24 |
25 | // ==================== Test Utilities ====================
26 |
27 | function createMockStorage(): StoragePort {
28 | const flowsMap = new Map<string, FlowV3>();
29 | const runsMap = new Map<string, RunRecordV3>();
30 | const queueMap = new Map<string, RunQueueItem>();
31 | const eventsLog: Array<{ runId: string; type: string }> = [];
32 |
33 | return {
34 | flows: {
35 | list: vi.fn(async () => Array.from(flowsMap.values())),
36 | get: vi.fn(async (id: string) => flowsMap.get(id) ?? null),
37 | save: vi.fn(async (flow: FlowV3) => {
38 | flowsMap.set(flow.id, flow);
39 | }),
40 | delete: vi.fn(async (id: string) => {
41 | flowsMap.delete(id);
42 | }),
43 | },
44 | runs: {
45 | list: vi.fn(async () => Array.from(runsMap.values())),
46 | get: vi.fn(async (id: string) => runsMap.get(id) ?? null),
47 | save: vi.fn(async (record: RunRecordV3) => {
48 | runsMap.set(record.id, record);
49 | }),
50 | patch: vi.fn(async (id: string, patch: Partial<RunRecordV3>) => {
51 | const existing = runsMap.get(id);
52 | if (existing) {
53 | runsMap.set(id, { ...existing, ...patch });
54 | }
55 | }),
56 | },
57 | events: {
58 | append: vi.fn(async (event: { runId: string; type: string }) => {
59 | eventsLog.push(event);
60 | return { ...event, ts: Date.now(), seq: eventsLog.length };
61 | }),
62 | list: vi.fn(async () => eventsLog),
63 | },
64 | queue: {
65 | enqueue: vi.fn(async (input) => {
66 | const now = Date.now();
67 | const item: RunQueueItem = {
68 | ...input,
69 | priority: input.priority ?? 0,
70 | maxAttempts: input.maxAttempts ?? 1,
71 | status: 'queued',
72 | createdAt: now,
73 | updatedAt: now,
74 | attempt: 0,
75 | };
76 | queueMap.set(input.id, item);
77 | return item;
78 | }),
79 | claimNext: vi.fn(async () => null),
80 | heartbeat: vi.fn(async () => {}),
81 | reclaimExpiredLeases: vi.fn(async () => []),
82 | markRunning: vi.fn(async () => {}),
83 | markPaused: vi.fn(async () => {}),
84 | markDone: vi.fn(async () => {}),
85 | cancel: vi.fn(async (runId: string) => {
86 | queueMap.delete(runId);
87 | }),
88 | get: vi.fn(async (runId: string) => queueMap.get(runId) ?? null),
89 | list: vi.fn(async (status?: string) => {
90 | const items = Array.from(queueMap.values());
91 | if (status) {
92 | return items.filter((item) => item.status === status);
93 | }
94 | return items;
95 | }),
96 | },
97 | persistentVars: {
98 | get: vi.fn(async () => undefined),
99 | set: vi.fn(async () => ({ key: '', value: null, updatedAt: 0 })),
100 | delete: vi.fn(async () => {}),
101 | list: vi.fn(async () => []),
102 | },
103 | triggers: {
104 | list: vi.fn(async () => []),
105 | get: vi.fn(async () => null),
106 | save: vi.fn(async () => {}),
107 | delete: vi.fn(async () => {}),
108 | },
109 | // Expose internal maps for assertions
110 | _internal: { flowsMap, runsMap, queueMap, eventsLog },
111 | } as unknown as StoragePort & {
112 | _internal: {
113 | flowsMap: Map<string, FlowV3>;
114 | runsMap: Map<string, RunRecordV3>;
115 | queueMap: Map<string, RunQueueItem>;
116 | eventsLog: Array<{ runId: string; type: string }>;
117 | };
118 | };
119 | }
120 |
121 | function createMockEventsBus(): EventsBus {
122 | const subscribers: Array<(event: unknown) => void> = [];
123 | return {
124 | subscribe: vi.fn((callback: (event: unknown) => void) => {
125 | subscribers.push(callback);
126 | return () => {
127 | const idx = subscribers.indexOf(callback);
128 | if (idx >= 0) subscribers.splice(idx, 1);
129 | };
130 | }),
131 | append: vi.fn(async (event) => {
132 | const fullEvent = { ...event, ts: Date.now(), seq: 1 };
133 | subscribers.forEach((cb) => cb(fullEvent));
134 | return fullEvent as ReturnType<EventsBus['append']> extends Promise<infer T> ? T : never;
135 | }),
136 | list: vi.fn(async () => []),
137 | } as EventsBus;
138 | }
139 |
140 | function createMockScheduler(): RunScheduler {
141 | return {
142 | start: vi.fn(),
143 | stop: vi.fn(),
144 | kick: vi.fn(async () => {}),
145 | getState: vi.fn(() => ({
146 | started: false,
147 | ownerId: 'test-owner',
148 | maxParallelRuns: 3,
149 | activeRunIds: [],
150 | })),
151 | dispose: vi.fn(),
152 | };
153 | }
154 |
155 | function createTestFlow(id: string, options: { withNodes?: boolean } = {}): FlowV3 {
156 | const now = new Date().toISOString();
157 | const nodes =
158 | options.withNodes !== false
159 | ? [
160 | { id: 'node-start', kind: 'test', config: {} },
161 | { id: 'node-end', kind: 'test', config: {} },
162 | ]
163 | : [];
164 | return {
165 | schemaVersion: 3,
166 | id: id as FlowV3['id'],
167 | name: `Test Flow ${id}`,
168 | entryNodeId: 'node-start' as FlowV3['entryNodeId'],
169 | nodes: nodes as FlowV3['nodes'],
170 | edges: [{ id: 'edge-1', from: 'node-start', to: 'node-end' }] as FlowV3['edges'],
171 | variables: [],
172 | createdAt: now,
173 | updatedAt: now,
174 | };
175 | }
176 |
177 | // Helper type for accessing internal maps in mock storage
178 | interface MockStorageInternal {
179 | flowsMap: Map<string, FlowV3>;
180 | runsMap: Map<string, RunRecordV3>;
181 | queueMap: Map<string, RunQueueItem>;
182 | eventsLog: Array<{ runId: string; type: string }>;
183 | }
184 |
185 | // Access _internal property with type safety
186 | function getInternal(storage: StoragePort): MockStorageInternal {
187 | return (storage as unknown as { _internal: MockStorageInternal })._internal;
188 | }
189 |
190 | // ==================== Tests ====================
191 |
192 | describe('V3 RPC Queue Management APIs', () => {
193 | let storage: ReturnType<typeof createMockStorage>;
194 | let events: EventsBus;
195 | let scheduler: RunScheduler;
196 | let server: RpcServer;
197 | let runIdCounter: number;
198 | let fixedNow: number;
199 |
200 | beforeEach(() => {
201 | storage = createMockStorage();
202 | events = createMockEventsBus();
203 | scheduler = createMockScheduler();
204 | runIdCounter = 0;
205 | fixedNow = 1_700_000_000_000;
206 |
207 | server = new RpcServer({
208 | storage,
209 | events,
210 | scheduler,
211 | generateRunId: () => `run-${++runIdCounter}`,
212 | now: () => fixedNow,
213 | });
214 | });
215 |
216 | describe('rr_v3.enqueueRun', () => {
217 | it('creates run record, enqueues, emits event, and kicks scheduler', async () => {
218 | // Setup: add a flow
219 | const flow = createTestFlow('flow-1');
220 | getInternal(storage).flowsMap.set(flow.id, flow);
221 |
222 | // Act: call enqueueRun via handleRequest
223 | const result = await (server as unknown as { handleRequest: Function }).handleRequest(
224 | { method: 'rr_v3.enqueueRun', params: { flowId: 'flow-1' }, requestId: 'req-1' },
225 | { subscriptions: new Set() },
226 | );
227 |
228 | // Assert: run record created
229 | expect(storage.runs.save).toHaveBeenCalledTimes(1);
230 | const savedRun = (storage.runs.save as ReturnType<typeof vi.fn>).mock.calls[0][0];
231 | expect(savedRun).toMatchObject({
232 | id: 'run-1',
233 | flowId: 'flow-1',
234 | status: 'queued',
235 | attempt: 0,
236 | maxAttempts: 1,
237 | });
238 |
239 | // Assert: enqueued
240 | expect(storage.queue.enqueue).toHaveBeenCalledTimes(1);
241 |
242 | // Assert: event emitted via EventsBus
243 | expect(events.append).toHaveBeenCalledWith(
244 | expect.objectContaining({
245 | runId: 'run-1',
246 | type: 'run.queued',
247 | flowId: 'flow-1',
248 | }),
249 | );
250 |
251 | // Assert: scheduler kicked
252 | expect(scheduler.kick).toHaveBeenCalledTimes(1);
253 |
254 | // Assert: result
255 | expect(result).toMatchObject({
256 | runId: 'run-1',
257 | position: 1,
258 | });
259 | });
260 |
261 | it('throws if flowId is missing', async () => {
262 | await expect(
263 | (server as unknown as { handleRequest: Function }).handleRequest(
264 | { method: 'rr_v3.enqueueRun', params: {}, requestId: 'req-1' },
265 | { subscriptions: new Set() },
266 | ),
267 | ).rejects.toThrow('flowId is required');
268 | });
269 |
270 | it('throws if flow does not exist', async () => {
271 | await expect(
272 | (server as unknown as { handleRequest: Function }).handleRequest(
273 | { method: 'rr_v3.enqueueRun', params: { flowId: 'non-existent' }, requestId: 'req-1' },
274 | { subscriptions: new Set() },
275 | ),
276 | ).rejects.toThrow('Flow "non-existent" not found');
277 | });
278 |
279 | it('respects custom priority and maxAttempts', async () => {
280 | const flow = createTestFlow('flow-1');
281 | getInternal(storage).flowsMap.set(flow.id, flow);
282 |
283 | await (server as unknown as { handleRequest: Function }).handleRequest(
284 | {
285 | method: 'rr_v3.enqueueRun',
286 | params: { flowId: 'flow-1', priority: 10, maxAttempts: 3 },
287 | requestId: 'req-1',
288 | },
289 | { subscriptions: new Set() },
290 | );
291 |
292 | expect(storage.queue.enqueue).toHaveBeenCalledWith(
293 | expect.objectContaining({
294 | priority: 10,
295 | maxAttempts: 3,
296 | }),
297 | );
298 | });
299 |
300 | it('passes args and debug config', async () => {
301 | const flow = createTestFlow('flow-1');
302 | getInternal(storage).flowsMap.set(flow.id, flow);
303 |
304 | const args = { url: 'https://example.com' };
305 | const debug = { pauseOnStart: true, breakpoints: ['node-1'] };
306 |
307 | await (server as unknown as { handleRequest: Function }).handleRequest(
308 | {
309 | method: 'rr_v3.enqueueRun',
310 | params: { flowId: 'flow-1', args, debug },
311 | requestId: 'req-1',
312 | },
313 | { subscriptions: new Set() },
314 | );
315 |
316 | expect(storage.runs.save).toHaveBeenCalledWith(
317 | expect.objectContaining({
318 | args,
319 | debug,
320 | }),
321 | );
322 | });
323 |
324 | it('rejects NaN priority', async () => {
325 | const flow = createTestFlow('flow-1');
326 | getInternal(storage).flowsMap.set(flow.id, flow);
327 |
328 | await expect(
329 | (server as unknown as { handleRequest: Function }).handleRequest(
330 | {
331 | method: 'rr_v3.enqueueRun',
332 | params: { flowId: 'flow-1', priority: NaN },
333 | requestId: 'req-1',
334 | },
335 | { subscriptions: new Set() },
336 | ),
337 | ).rejects.toThrow('priority must be a finite number');
338 | });
339 |
340 | it('rejects Infinity maxAttempts', async () => {
341 | const flow = createTestFlow('flow-1');
342 | getInternal(storage).flowsMap.set(flow.id, flow);
343 |
344 | await expect(
345 | (server as unknown as { handleRequest: Function }).handleRequest(
346 | {
347 | method: 'rr_v3.enqueueRun',
348 | params: { flowId: 'flow-1', maxAttempts: Infinity },
349 | requestId: 'req-1',
350 | },
351 | { subscriptions: new Set() },
352 | ),
353 | ).rejects.toThrow('maxAttempts must be a finite number');
354 | });
355 |
356 | it('rejects maxAttempts < 1', async () => {
357 | const flow = createTestFlow('flow-1');
358 | getInternal(storage).flowsMap.set(flow.id, flow);
359 |
360 | await expect(
361 | (server as unknown as { handleRequest: Function }).handleRequest(
362 | {
363 | method: 'rr_v3.enqueueRun',
364 | params: { flowId: 'flow-1', maxAttempts: 0 },
365 | requestId: 'req-1',
366 | },
367 | { subscriptions: new Set() },
368 | ),
369 | ).rejects.toThrow('maxAttempts must be >= 1');
370 | });
371 |
372 | it('persists startNodeId in RunRecord when provided', async () => {
373 | // Setup: add a flow with multiple nodes
374 | const flow = createTestFlow('flow-start-node');
375 | getInternal(storage).flowsMap.set(flow.id, flow);
376 |
377 | // Act: enqueue with startNodeId
378 | const targetNodeId = flow.nodes[0].id; // Use the first node
379 | await (server as unknown as { handleRequest: Function }).handleRequest(
380 | {
381 | method: 'rr_v3.enqueueRun',
382 | params: { flowId: 'flow-start-node', startNodeId: targetNodeId },
383 | requestId: 'req-1',
384 | },
385 | { subscriptions: new Set() },
386 | );
387 |
388 | // Assert: RunRecord should have startNodeId
389 | const runsMap = getInternal(storage).runsMap;
390 | expect(runsMap.size).toBe(1);
391 | const runRecord = Array.from(runsMap.values())[0];
392 | expect(runRecord.startNodeId).toBe(targetNodeId);
393 | });
394 |
395 | it('throws if startNodeId does not exist in flow', async () => {
396 | // Setup: add a flow
397 | const flow = createTestFlow('flow-invalid-start');
398 | getInternal(storage).flowsMap.set(flow.id, flow);
399 |
400 | // Act & Assert
401 | await expect(
402 | (server as unknown as { handleRequest: Function }).handleRequest(
403 | {
404 | method: 'rr_v3.enqueueRun',
405 | params: { flowId: 'flow-invalid-start', startNodeId: 'non-existent-node' },
406 | requestId: 'req-1',
407 | },
408 | { subscriptions: new Set() },
409 | ),
410 | ).rejects.toThrow('startNodeId "non-existent-node" not found in flow');
411 | });
412 | });
413 |
414 | describe('rr_v3.listQueue', () => {
415 | it('returns all queue items sorted by priority DESC and createdAt ASC', async () => {
416 | // Setup: add items with different priorities and times
417 | getInternal(storage).queueMap.set('run-1', {
418 | id: 'run-1',
419 | flowId: 'flow-1',
420 | status: 'queued',
421 | priority: 5,
422 | createdAt: 1000,
423 | updatedAt: 1000,
424 | attempt: 0,
425 | maxAttempts: 1,
426 | });
427 | getInternal(storage).queueMap.set('run-2', {
428 | id: 'run-2',
429 | flowId: 'flow-1',
430 | status: 'queued',
431 | priority: 10,
432 | createdAt: 2000,
433 | updatedAt: 2000,
434 | attempt: 0,
435 | maxAttempts: 1,
436 | });
437 | getInternal(storage).queueMap.set('run-3', {
438 | id: 'run-3',
439 | flowId: 'flow-1',
440 | status: 'queued',
441 | priority: 10,
442 | createdAt: 1500,
443 | updatedAt: 1500,
444 | attempt: 0,
445 | maxAttempts: 1,
446 | });
447 |
448 | const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
449 | { method: 'rr_v3.listQueue', params: {}, requestId: 'req-1' },
450 | { subscriptions: new Set() },
451 | )) as RunQueueItem[];
452 |
453 | // run-3 (priority 10, earlier) > run-2 (priority 10, later) > run-1 (priority 5)
454 | expect(result.map((r) => r.id)).toEqual(['run-3', 'run-2', 'run-1']);
455 | });
456 |
457 | it('filters by status', async () => {
458 | getInternal(storage).queueMap.set('run-1', {
459 | id: 'run-1',
460 | flowId: 'flow-1',
461 | status: 'queued',
462 | priority: 0,
463 | createdAt: 1000,
464 | updatedAt: 1000,
465 | attempt: 0,
466 | maxAttempts: 1,
467 | });
468 | getInternal(storage).queueMap.set('run-2', {
469 | id: 'run-2',
470 | flowId: 'flow-1',
471 | status: 'running',
472 | priority: 0,
473 | createdAt: 2000,
474 | updatedAt: 2000,
475 | attempt: 1,
476 | maxAttempts: 1,
477 | });
478 |
479 | const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
480 | { method: 'rr_v3.listQueue', params: { status: 'queued' }, requestId: 'req-1' },
481 | { subscriptions: new Set() },
482 | )) as RunQueueItem[];
483 |
484 | expect(result).toHaveLength(1);
485 | expect(result[0].id).toBe('run-1');
486 | });
487 |
488 | it('rejects invalid status', async () => {
489 | await expect(
490 | (server as unknown as { handleRequest: Function }).handleRequest(
491 | { method: 'rr_v3.listQueue', params: { status: 'invalid' }, requestId: 'req-1' },
492 | { subscriptions: new Set() },
493 | ),
494 | ).rejects.toThrow('status must be one of: queued, running, paused');
495 | });
496 | });
497 |
498 | describe('rr_v3.cancelQueueItem', () => {
499 | it('cancels queue item, patches run, and emits event', async () => {
500 | // Setup
501 | getInternal(storage).queueMap.set('run-1', {
502 | id: 'run-1',
503 | flowId: 'flow-1',
504 | status: 'queued',
505 | priority: 0,
506 | createdAt: 1000,
507 | updatedAt: 1000,
508 | attempt: 0,
509 | maxAttempts: 1,
510 | });
511 | getInternal(storage).runsMap.set('run-1', {
512 | schemaVersion: 3,
513 | id: 'run-1',
514 | flowId: 'flow-1',
515 | status: 'queued',
516 | createdAt: 1000,
517 | updatedAt: 1000,
518 | attempt: 0,
519 | maxAttempts: 1,
520 | nextSeq: 0,
521 | });
522 |
523 | const result = await (server as unknown as { handleRequest: Function }).handleRequest(
524 | { method: 'rr_v3.cancelQueueItem', params: { runId: 'run-1' }, requestId: 'req-1' },
525 | { subscriptions: new Set() },
526 | );
527 |
528 | // Assert: queue.cancel called
529 | expect(storage.queue.cancel).toHaveBeenCalledWith('run-1', fixedNow, undefined);
530 |
531 | // Assert: run patched
532 | expect(storage.runs.patch).toHaveBeenCalledWith('run-1', {
533 | status: 'canceled',
534 | updatedAt: fixedNow,
535 | finishedAt: fixedNow,
536 | });
537 |
538 | // Assert: event emitted via EventsBus
539 | expect(events.append).toHaveBeenCalledWith(
540 | expect.objectContaining({
541 | runId: 'run-1',
542 | type: 'run.canceled',
543 | }),
544 | );
545 |
546 | // Assert: result
547 | expect(result).toMatchObject({ ok: true, runId: 'run-1' });
548 | });
549 |
550 | it('throws if runId is missing', async () => {
551 | await expect(
552 | (server as unknown as { handleRequest: Function }).handleRequest(
553 | { method: 'rr_v3.cancelQueueItem', params: {}, requestId: 'req-1' },
554 | { subscriptions: new Set() },
555 | ),
556 | ).rejects.toThrow('runId is required');
557 | });
558 |
559 | it('throws if queue item does not exist', async () => {
560 | await expect(
561 | (server as unknown as { handleRequest: Function }).handleRequest(
562 | {
563 | method: 'rr_v3.cancelQueueItem',
564 | params: { runId: 'non-existent' },
565 | requestId: 'req-1',
566 | },
567 | { subscriptions: new Set() },
568 | ),
569 | ).rejects.toThrow('Queue item "non-existent" not found');
570 | });
571 |
572 | it('throws if queue item is not queued', async () => {
573 | getInternal(storage).queueMap.set('run-1', {
574 | id: 'run-1',
575 | flowId: 'flow-1',
576 | status: 'running',
577 | priority: 0,
578 | createdAt: 1000,
579 | updatedAt: 1000,
580 | attempt: 1,
581 | maxAttempts: 1,
582 | });
583 |
584 | await expect(
585 | (server as unknown as { handleRequest: Function }).handleRequest(
586 | { method: 'rr_v3.cancelQueueItem', params: { runId: 'run-1' }, requestId: 'req-1' },
587 | { subscriptions: new Set() },
588 | ),
589 | ).rejects.toThrow('Cannot cancel queue item "run-1" with status "running"');
590 | });
591 |
592 | it('includes reason in cancel event', async () => {
593 | getInternal(storage).queueMap.set('run-1', {
594 | id: 'run-1',
595 | flowId: 'flow-1',
596 | status: 'queued',
597 | priority: 0,
598 | createdAt: 1000,
599 | updatedAt: 1000,
600 | attempt: 0,
601 | maxAttempts: 1,
602 | });
603 |
604 | await (server as unknown as { handleRequest: Function }).handleRequest(
605 | {
606 | method: 'rr_v3.cancelQueueItem',
607 | params: { runId: 'run-1', reason: 'User requested cancellation' },
608 | requestId: 'req-1',
609 | },
610 | { subscriptions: new Set() },
611 | );
612 |
613 | expect(storage.queue.cancel).toHaveBeenCalledWith(
614 | 'run-1',
615 | fixedNow,
616 | 'User requested cancellation',
617 | );
618 | expect(events.append).toHaveBeenCalledWith(
619 | expect.objectContaining({
620 | reason: 'User requested cancellation',
621 | }),
622 | );
623 | });
624 | });
625 | });
626 |
627 | describe('V3 RPC Flow CRUD APIs', () => {
628 | let storage: ReturnType<typeof createMockStorage>;
629 | let events: EventsBus;
630 | let scheduler: RunScheduler;
631 | let server: RpcServer;
632 | let fixedNow: number;
633 |
634 | beforeEach(() => {
635 | storage = createMockStorage();
636 | events = createMockEventsBus();
637 | scheduler = createMockScheduler();
638 | fixedNow = 1_700_000_000_000;
639 |
640 | server = new RpcServer({
641 | storage,
642 | events,
643 | scheduler,
644 | now: () => fixedNow,
645 | });
646 | });
647 |
648 | describe('rr_v3.saveFlow', () => {
649 | it('saves a new flow with all required fields', async () => {
650 | const flowInput = {
651 | name: 'My New Flow',
652 | entryNodeId: 'node-1',
653 | nodes: [
654 | { id: 'node-1', kind: 'click', config: { selector: '#btn' } },
655 | { id: 'node-2', kind: 'delay', config: { ms: 1000 } },
656 | ],
657 | edges: [{ id: 'e1', from: 'node-1', to: 'node-2' }],
658 | };
659 |
660 | const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
661 | { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },
662 | { subscriptions: new Set() },
663 | )) as FlowV3;
664 |
665 | // Assert: flow saved
666 | expect(storage.flows.save).toHaveBeenCalledTimes(1);
667 |
668 | // Assert: returned flow has all fields
669 | expect(result.schemaVersion).toBe(3);
670 | expect(result.id).toMatch(/^flow_\d+_[a-z0-9]+$/);
671 | expect(result.name).toBe('My New Flow');
672 | expect(result.entryNodeId).toBe('node-1');
673 | expect(result.nodes).toHaveLength(2);
674 | expect(result.edges).toHaveLength(1);
675 | expect(result.createdAt).toBeDefined();
676 | expect(result.updatedAt).toBeDefined();
677 | });
678 |
679 | it('updates an existing flow', async () => {
680 | // Setup: add existing flow with a past timestamp
681 | const existing = createTestFlow('flow-1');
682 | const pastDate = new Date(Date.now() - 100000).toISOString(); // 100 seconds ago
683 | existing.createdAt = pastDate;
684 | existing.updatedAt = pastDate;
685 | getInternal(storage).flowsMap.set(existing.id, existing);
686 |
687 | const flowInput = {
688 | id: 'flow-1',
689 | name: 'Updated Flow',
690 | entryNodeId: 'node-start',
691 | nodes: [{ id: 'node-start', kind: 'navigate', config: { url: 'https://example.com' } }],
692 | edges: [],
693 | createdAt: existing.createdAt, // Preserve original createdAt
694 | };
695 |
696 | const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
697 | { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },
698 | { subscriptions: new Set() },
699 | )) as FlowV3;
700 |
701 | // Assert: flow updated
702 | expect(result.id).toBe('flow-1');
703 | expect(result.name).toBe('Updated Flow');
704 | expect(result.createdAt).toBe(existing.createdAt);
705 | expect(result.updatedAt).not.toBe(existing.updatedAt);
706 | });
707 |
708 | it('preserves createdAt when updating without providing it', async () => {
709 | // Setup: add existing flow with a past timestamp
710 | const existing = createTestFlow('flow-1');
711 | const pastDate = new Date(Date.now() - 100000).toISOString();
712 | existing.createdAt = pastDate;
713 | existing.updatedAt = pastDate;
714 | getInternal(storage).flowsMap.set(existing.id, existing);
715 |
716 | // Update without providing createdAt - should inherit from existing
717 | const flowInput = {
718 | id: 'flow-1',
719 | name: 'Updated Without CreatedAt',
720 | entryNodeId: 'node-start',
721 | nodes: [{ id: 'node-start', kind: 'test', config: {} }],
722 | edges: [],
723 | // Note: createdAt is NOT provided
724 | };
725 |
726 | const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
727 | { method: 'rr_v3.saveFlow', params: { flow: flowInput }, requestId: 'req-1' },
728 | { subscriptions: new Set() },
729 | )) as FlowV3;
730 |
731 | // Assert: createdAt is inherited from existing flow
732 | expect(result.createdAt).toBe(existing.createdAt);
733 | expect(result.updatedAt).not.toBe(existing.updatedAt);
734 | });
735 |
736 | it('throws if flow is missing', async () => {
737 | await expect(
738 | (server as unknown as { handleRequest: Function }).handleRequest(
739 | { method: 'rr_v3.saveFlow', params: {}, requestId: 'req-1' },
740 | { subscriptions: new Set() },
741 | ),
742 | ).rejects.toThrow('flow is required');
743 | });
744 |
745 | it('throws if name is missing', async () => {
746 | await expect(
747 | (server as unknown as { handleRequest: Function }).handleRequest(
748 | {
749 | method: 'rr_v3.saveFlow',
750 | params: {
751 | flow: {
752 | entryNodeId: 'node-1',
753 | nodes: [{ id: 'node-1', kind: 'test', config: {} }],
754 | },
755 | },
756 | requestId: 'req-1',
757 | },
758 | { subscriptions: new Set() },
759 | ),
760 | ).rejects.toThrow('flow.name is required');
761 | });
762 |
763 | it('throws if entryNodeId is missing', async () => {
764 | await expect(
765 | (server as unknown as { handleRequest: Function }).handleRequest(
766 | {
767 | method: 'rr_v3.saveFlow',
768 | params: {
769 | flow: {
770 | name: 'Test',
771 | nodes: [{ id: 'node-1', kind: 'test', config: {} }],
772 | },
773 | },
774 | requestId: 'req-1',
775 | },
776 | { subscriptions: new Set() },
777 | ),
778 | ).rejects.toThrow('flow.entryNodeId is required');
779 | });
780 |
781 | it('throws if entryNodeId does not exist in nodes', async () => {
782 | await expect(
783 | (server as unknown as { handleRequest: Function }).handleRequest(
784 | {
785 | method: 'rr_v3.saveFlow',
786 | params: {
787 | flow: {
788 | name: 'Test',
789 | entryNodeId: 'non-existent',
790 | nodes: [{ id: 'node-1', kind: 'test', config: {} }],
791 | },
792 | },
793 | requestId: 'req-1',
794 | },
795 | { subscriptions: new Set() },
796 | ),
797 | ).rejects.toThrow('Entry node "non-existent" does not exist in flow');
798 | });
799 |
800 | it('throws if edge references non-existent source node', async () => {
801 | await expect(
802 | (server as unknown as { handleRequest: Function }).handleRequest(
803 | {
804 | method: 'rr_v3.saveFlow',
805 | params: {
806 | flow: {
807 | name: 'Test',
808 | entryNodeId: 'node-1',
809 | nodes: [{ id: 'node-1', kind: 'test', config: {} }],
810 | edges: [{ id: 'e1', from: 'non-existent', to: 'node-1' }],
811 | },
812 | },
813 | requestId: 'req-1',
814 | },
815 | { subscriptions: new Set() },
816 | ),
817 | ).rejects.toThrow('Edge "e1" references non-existent source node "non-existent"');
818 | });
819 |
820 | it('throws if edge references non-existent target node', async () => {
821 | await expect(
822 | (server as unknown as { handleRequest: Function }).handleRequest(
823 | {
824 | method: 'rr_v3.saveFlow',
825 | params: {
826 | flow: {
827 | name: 'Test',
828 | entryNodeId: 'node-1',
829 | nodes: [{ id: 'node-1', kind: 'test', config: {} }],
830 | edges: [{ id: 'e1', from: 'node-1', to: 'non-existent' }],
831 | },
832 | },
833 | requestId: 'req-1',
834 | },
835 | { subscriptions: new Set() },
836 | ),
837 | ).rejects.toThrow('Edge "e1" references non-existent target node "non-existent"');
838 | });
839 |
840 | it('validates node structure', async () => {
841 | await expect(
842 | (server as unknown as { handleRequest: Function }).handleRequest(
843 | {
844 | method: 'rr_v3.saveFlow',
845 | params: {
846 | flow: {
847 | name: 'Test',
848 | entryNodeId: 'node-1',
849 | nodes: [{ id: 'node-1' }], // missing kind
850 | },
851 | },
852 | requestId: 'req-1',
853 | },
854 | { subscriptions: new Set() },
855 | ),
856 | ).rejects.toThrow('flow.nodes[0].kind is required');
857 | });
858 |
859 | it('generates edge ID if not provided', async () => {
860 | const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
861 | {
862 | method: 'rr_v3.saveFlow',
863 | params: {
864 | flow: {
865 | name: 'Test',
866 | entryNodeId: 'node-1',
867 | nodes: [
868 | { id: 'node-1', kind: 'test', config: {} },
869 | { id: 'node-2', kind: 'test', config: {} },
870 | ],
871 | edges: [{ from: 'node-1', to: 'node-2' }], // no id
872 | },
873 | },
874 | requestId: 'req-1',
875 | },
876 | { subscriptions: new Set() },
877 | )) as FlowV3;
878 |
879 | expect(result.edges[0].id).toMatch(/^edge_0_[a-z0-9]+$/);
880 | });
881 |
882 | it('saves flow with optional fields', async () => {
883 | const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
884 | {
885 | method: 'rr_v3.saveFlow',
886 | params: {
887 | flow: {
888 | name: 'Test',
889 | description: 'A test flow',
890 | entryNodeId: 'node-1',
891 | nodes: [
892 | { id: 'node-1', kind: 'test', config: {}, name: 'Start Node', disabled: false },
893 | ],
894 | edges: [],
895 | // 符合 VariableDefinition 类型:name 必填,description/default/label 可选
896 | variables: [
897 | { name: 'url', description: 'Target URL', default: 'https://example.com' },
898 | ],
899 | // 符合 FlowPolicy 类型
900 | policy: { runTimeoutMs: 30000, defaultNodePolicy: { onError: { kind: 'stop' } } },
901 | meta: { tags: ['test', 'demo'] },
902 | },
903 | },
904 | requestId: 'req-1',
905 | },
906 | { subscriptions: new Set() },
907 | )) as FlowV3;
908 |
909 | expect(result.description).toBe('A test flow');
910 | expect(result.variables).toHaveLength(1);
911 | expect(result.policy).toEqual({
912 | runTimeoutMs: 30000,
913 | defaultNodePolicy: { onError: { kind: 'stop' } },
914 | });
915 | expect(result.meta).toEqual({ tags: ['test', 'demo'] });
916 | expect(result.nodes[0].name).toBe('Start Node');
917 | });
918 |
919 | it('throws if variable is missing name', async () => {
920 | await expect(
921 | (server as unknown as { handleRequest: Function }).handleRequest(
922 | {
923 | method: 'rr_v3.saveFlow',
924 | params: {
925 | flow: {
926 | name: 'Test',
927 | entryNodeId: 'node-1',
928 | nodes: [{ id: 'node-1', kind: 'test', config: {} }],
929 | variables: [{ description: 'Missing name field' }],
930 | },
931 | },
932 | requestId: 'req-1',
933 | },
934 | { subscriptions: new Set() },
935 | ),
936 | ).rejects.toThrow('flow.variables[0].name is required');
937 | });
938 |
939 | it('throws if duplicate variable names', async () => {
940 | await expect(
941 | (server as unknown as { handleRequest: Function }).handleRequest(
942 | {
943 | method: 'rr_v3.saveFlow',
944 | params: {
945 | flow: {
946 | name: 'Test',
947 | entryNodeId: 'node-1',
948 | nodes: [{ id: 'node-1', kind: 'test', config: {} }],
949 | variables: [
950 | { name: 'myVar' },
951 | { name: 'myVar' }, // duplicate
952 | ],
953 | },
954 | },
955 | requestId: 'req-1',
956 | },
957 | { subscriptions: new Set() },
958 | ),
959 | ).rejects.toThrow('Duplicate variable name: "myVar"');
960 | });
961 |
962 | it('throws if duplicate node IDs', async () => {
963 | await expect(
964 | (server as unknown as { handleRequest: Function }).handleRequest(
965 | {
966 | method: 'rr_v3.saveFlow',
967 | params: {
968 | flow: {
969 | name: 'Test',
970 | entryNodeId: 'node-1',
971 | nodes: [
972 | { id: 'node-1', kind: 'test', config: {} },
973 | { id: 'node-1', kind: 'test', config: {} }, // duplicate
974 | ],
975 | },
976 | },
977 | requestId: 'req-1',
978 | },
979 | { subscriptions: new Set() },
980 | ),
981 | ).rejects.toThrow('Duplicate node ID: "node-1"');
982 | });
983 |
984 | it('throws if duplicate edge IDs', async () => {
985 | await expect(
986 | (server as unknown as { handleRequest: Function }).handleRequest(
987 | {
988 | method: 'rr_v3.saveFlow',
989 | params: {
990 | flow: {
991 | name: 'Test',
992 | entryNodeId: 'node-1',
993 | nodes: [
994 | { id: 'node-1', kind: 'test', config: {} },
995 | { id: 'node-2', kind: 'test', config: {} },
996 | ],
997 | edges: [
998 | { id: 'e1', from: 'node-1', to: 'node-2' },
999 | { id: 'e1', from: 'node-2', to: 'node-1' }, // duplicate
1000 | ],
1001 | },
1002 | },
1003 | requestId: 'req-1',
1004 | },
1005 | { subscriptions: new Set() },
1006 | ),
1007 | ).rejects.toThrow('Duplicate edge ID: "e1"');
1008 | });
1009 | });
1010 |
1011 | describe('rr_v3.deleteFlow', () => {
1012 | it('deletes an existing flow', async () => {
1013 | // Setup: add flow
1014 | const flow = createTestFlow('flow-1');
1015 | getInternal(storage).flowsMap.set(flow.id, flow);
1016 |
1017 | const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1018 | { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1019 | { subscriptions: new Set() },
1020 | );
1021 |
1022 | expect(storage.flows.delete).toHaveBeenCalledWith('flow-1');
1023 | expect(result).toEqual({ ok: true, flowId: 'flow-1' });
1024 | });
1025 |
1026 | it('throws if flowId is missing', async () => {
1027 | await expect(
1028 | (server as unknown as { handleRequest: Function }).handleRequest(
1029 | { method: 'rr_v3.deleteFlow', params: {}, requestId: 'req-1' },
1030 | { subscriptions: new Set() },
1031 | ),
1032 | ).rejects.toThrow('flowId is required');
1033 | });
1034 |
1035 | it('throws if flow does not exist', async () => {
1036 | await expect(
1037 | (server as unknown as { handleRequest: Function }).handleRequest(
1038 | { method: 'rr_v3.deleteFlow', params: { flowId: 'non-existent' }, requestId: 'req-1' },
1039 | { subscriptions: new Set() },
1040 | ),
1041 | ).rejects.toThrow('Flow "non-existent" not found');
1042 | });
1043 |
1044 | it('throws if flow has linked triggers', async () => {
1045 | // Setup: add flow and trigger
1046 | const flow = createTestFlow('flow-1');
1047 | getInternal(storage).flowsMap.set(flow.id, flow);
1048 |
1049 | // Mock triggers.list to return a trigger linked to this flow
1050 | (storage.triggers.list as ReturnType<typeof vi.fn>).mockResolvedValue([
1051 | { id: 'trigger-1', kind: 'manual', flowId: 'flow-1', enabled: true },
1052 | ]);
1053 |
1054 | await expect(
1055 | (server as unknown as { handleRequest: Function }).handleRequest(
1056 | { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1057 | { subscriptions: new Set() },
1058 | ),
1059 | ).rejects.toThrow('Cannot delete flow "flow-1": it has 1 linked trigger(s): trigger-1');
1060 | });
1061 |
1062 | it('throws if flow has multiple linked triggers', async () => {
1063 | // Setup
1064 | const flow = createTestFlow('flow-1');
1065 | getInternal(storage).flowsMap.set(flow.id, flow);
1066 |
1067 | (storage.triggers.list as ReturnType<typeof vi.fn>).mockResolvedValue([
1068 | { id: 'trigger-1', kind: 'manual', flowId: 'flow-1', enabled: true },
1069 | { id: 'trigger-2', kind: 'cron', flowId: 'flow-1', enabled: true, cron: '0 * * * *' },
1070 | ]);
1071 |
1072 | await expect(
1073 | (server as unknown as { handleRequest: Function }).handleRequest(
1074 | { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1075 | { subscriptions: new Set() },
1076 | ),
1077 | ).rejects.toThrow(
1078 | 'Cannot delete flow "flow-1": it has 2 linked trigger(s): trigger-1, trigger-2',
1079 | );
1080 | });
1081 |
1082 | it('throws if flow has queued runs', async () => {
1083 | // Setup
1084 | const flow = createTestFlow('flow-1');
1085 | getInternal(storage).flowsMap.set(flow.id, flow);
1086 |
1087 | // Add queued run
1088 | getInternal(storage).queueMap.set('run-1', {
1089 | id: 'run-1',
1090 | flowId: 'flow-1',
1091 | status: 'queued',
1092 | priority: 0,
1093 | createdAt: 1000,
1094 | updatedAt: 1000,
1095 | attempt: 0,
1096 | maxAttempts: 1,
1097 | });
1098 |
1099 | await expect(
1100 | (server as unknown as { handleRequest: Function }).handleRequest(
1101 | { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1102 | { subscriptions: new Set() },
1103 | ),
1104 | ).rejects.toThrow('Cannot delete flow "flow-1": it has 1 queued run(s): run-1');
1105 | });
1106 |
1107 | it('allows deletion when runs are running (not queued)', async () => {
1108 | // Setup
1109 | const flow = createTestFlow('flow-1');
1110 | getInternal(storage).flowsMap.set(flow.id, flow);
1111 |
1112 | // Add running run (not queued) - should NOT block deletion
1113 | getInternal(storage).queueMap.set('run-1', {
1114 | id: 'run-1',
1115 | flowId: 'flow-1',
1116 | status: 'running', // running, not queued
1117 | priority: 0,
1118 | createdAt: 1000,
1119 | updatedAt: 1000,
1120 | attempt: 1,
1121 | maxAttempts: 1,
1122 | });
1123 |
1124 | const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1125 | { method: 'rr_v3.deleteFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1126 | { subscriptions: new Set() },
1127 | );
1128 |
1129 | expect(result).toEqual({ ok: true, flowId: 'flow-1' });
1130 | });
1131 | });
1132 |
1133 | describe('rr_v3.getFlow', () => {
1134 | it('returns flow by id', async () => {
1135 | const flow = createTestFlow('flow-1');
1136 | getInternal(storage).flowsMap.set(flow.id, flow);
1137 |
1138 | const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1139 | { method: 'rr_v3.getFlow', params: { flowId: 'flow-1' }, requestId: 'req-1' },
1140 | { subscriptions: new Set() },
1141 | );
1142 |
1143 | expect(result).toEqual(flow);
1144 | });
1145 |
1146 | it('returns null for non-existent flow', async () => {
1147 | const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1148 | { method: 'rr_v3.getFlow', params: { flowId: 'non-existent' }, requestId: 'req-1' },
1149 | { subscriptions: new Set() },
1150 | );
1151 |
1152 | expect(result).toBeNull();
1153 | });
1154 |
1155 | it('throws if flowId is missing', async () => {
1156 | await expect(
1157 | (server as unknown as { handleRequest: Function }).handleRequest(
1158 | { method: 'rr_v3.getFlow', params: {}, requestId: 'req-1' },
1159 | { subscriptions: new Set() },
1160 | ),
1161 | ).rejects.toThrow('flowId is required');
1162 | });
1163 | });
1164 |
1165 | describe('rr_v3.listFlows', () => {
1166 | it('returns all flows', async () => {
1167 | const flow1 = createTestFlow('flow-1');
1168 | const flow2 = createTestFlow('flow-2');
1169 | getInternal(storage).flowsMap.set(flow1.id, flow1);
1170 | getInternal(storage).flowsMap.set(flow2.id, flow2);
1171 |
1172 | const result = (await (server as unknown as { handleRequest: Function }).handleRequest(
1173 | { method: 'rr_v3.listFlows', params: {}, requestId: 'req-1' },
1174 | { subscriptions: new Set() },
1175 | )) as FlowV3[];
1176 |
1177 | expect(result).toHaveLength(2);
1178 | expect(result.map((f) => f.id).sort()).toEqual(['flow-1', 'flow-2']);
1179 | });
1180 |
1181 | it('returns empty array when no flows exist', async () => {
1182 | const result = await (server as unknown as { handleRequest: Function }).handleRequest(
1183 | { method: 'rr_v3.listFlows', params: {}, requestId: 'req-1' },
1184 | { subscriptions: new Set() },
1185 | );
1186 |
1187 | expect(result).toEqual([]);
1188 | });
1189 | });
1190 | });
1191 |
```