mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-13 21:07:00 -07:00
Merge remote-tracking branch 'origin/main' into mk-bundling-no-npmrc
This commit is contained in:
+403
-102
@@ -15,8 +15,29 @@ Learn how to enable and setup OpenTelemetry for Gemini CLI.
|
||||
- [Collector-Based Export (Advanced)](#collector-based-export-advanced-1)
|
||||
- [Logs and Metrics](#logs-and-metrics)
|
||||
- [Logs](#logs)
|
||||
- [Sessions](#sessions)
|
||||
- [Tools](#tools)
|
||||
- [Files](#files)
|
||||
- [API](#api)
|
||||
- [Model Routing](#model-routing)
|
||||
- [Chat and Streaming](#chat-and-streaming)
|
||||
- [Resilience](#resilience)
|
||||
- [Extensions](#extensions)
|
||||
- [Agent Runs](#agent-runs)
|
||||
- [IDE](#ide)
|
||||
- [UI](#ui)
|
||||
- [Metrics](#metrics)
|
||||
- [Custom](#custom)
|
||||
- [Sessions](#sessions-1)
|
||||
- [Tools](#tools-1)
|
||||
- [API](#api-1)
|
||||
- [Token Usage](#token-usage)
|
||||
- [Files](#files-1)
|
||||
- [Chat and Streaming](#chat-and-streaming-1)
|
||||
- [Model Routing](#model-routing-1)
|
||||
- [Agent Runs](#agent-runs-1)
|
||||
- [UI](#ui-1)
|
||||
- [Performance](#performance)
|
||||
- [GenAI Semantic Convention](#genai-semantic-convention)
|
||||
|
||||
## Key Benefits
|
||||
@@ -210,10 +231,13 @@ attributes on all logs and metrics.
|
||||
### Logs
|
||||
|
||||
Logs are timestamped records of specific events. The following events are logged
|
||||
for Gemini CLI:
|
||||
for Gemini CLI, grouped by category.
|
||||
|
||||
- `gemini_cli.config`: This event occurs once at startup with the CLI's
|
||||
configuration.
|
||||
#### Sessions
|
||||
|
||||
Captures startup configuration and user prompt submissions.
|
||||
|
||||
- `gemini_cli.config`: Emitted once at startup with the CLI configuration.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `embedding_model` (string)
|
||||
@@ -222,81 +246,42 @@ for Gemini CLI:
|
||||
- `approval_mode` (string)
|
||||
- `api_key_enabled` (boolean)
|
||||
- `vertex_ai_enabled` (boolean)
|
||||
- `code_assist_enabled` (boolean)
|
||||
- `log_prompts_enabled` (boolean)
|
||||
- `log_user_prompts_enabled` (boolean)
|
||||
- `file_filtering_respect_git_ignore` (boolean)
|
||||
- `debug_mode` (boolean)
|
||||
- `mcp_servers` (string)
|
||||
- `output_format` (string: "text", "json", or "stream-json")
|
||||
- `mcp_servers_count` (int)
|
||||
- `mcp_tools` (string, if applicable)
|
||||
- `mcp_tools_count` (int, if applicable)
|
||||
- `output_format` ("text", "json", or "stream-json")
|
||||
|
||||
- `gemini_cli.user_prompt`: This event occurs when a user submits a prompt.
|
||||
- `gemini_cli.user_prompt`: Emitted when a user submits a prompt.
|
||||
- **Attributes**:
|
||||
- `prompt_length` (int)
|
||||
- `prompt_id` (string)
|
||||
- `prompt` (string, this attribute is excluded if `log_prompts_enabled` is
|
||||
configured to be `false`)
|
||||
- `prompt` (string; excluded if `telemetry.logPrompts` is `false`)
|
||||
- `auth_type` (string)
|
||||
|
||||
- `gemini_cli.tool_call`: This event occurs for each function call.
|
||||
#### Tools
|
||||
|
||||
Captures tool executions, output truncation, and Smart Edit behavior.
|
||||
|
||||
- `gemini_cli.tool_call`: Emitted for each tool (function) call.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `function_args`
|
||||
- `duration_ms`
|
||||
- `success` (boolean)
|
||||
- `decision` (string: "accept", "reject", "auto_accept", or "modify", if
|
||||
applicable)
|
||||
- `decision` ("accept", "reject", "auto_accept", or "modify", if applicable)
|
||||
- `error` (if applicable)
|
||||
- `error_type` (if applicable)
|
||||
- `prompt_id` (string)
|
||||
- `tool_type` ("native" or "mcp")
|
||||
- `mcp_server_name` (string, if applicable)
|
||||
- `content_length` (int, if applicable)
|
||||
- `metadata` (if applicable, dictionary of string -> any)
|
||||
- `metadata` (if applicable)
|
||||
|
||||
- `gemini_cli.file_operation`: This event occurs for each file operation.
|
||||
- **Attributes**:
|
||||
- `tool_name` (string)
|
||||
- `operation` (string: "create", "read", "update")
|
||||
- `lines` (int, if applicable)
|
||||
- `mimetype` (string, if applicable)
|
||||
- `extension` (string, if applicable)
|
||||
- `programming_language` (string, if applicable)
|
||||
- `diff_stat` (json string, if applicable): A JSON string with the following
|
||||
members:
|
||||
- `ai_added_lines` (int)
|
||||
- `ai_removed_lines` (int)
|
||||
- `user_added_lines` (int)
|
||||
- `user_removed_lines` (int)
|
||||
|
||||
- `gemini_cli.api_request`: This event occurs when making a request to Gemini
|
||||
API.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `request_text` (if applicable)
|
||||
|
||||
- `gemini_cli.api_error`: This event occurs if the API request fails.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `error`
|
||||
- `error_type`
|
||||
- `status_code`
|
||||
- `duration_ms`
|
||||
- `auth_type`
|
||||
|
||||
- `gemini_cli.api_response`: This event occurs upon receiving a response from
|
||||
Gemini API.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `status_code`
|
||||
- `duration_ms`
|
||||
- `error` (optional)
|
||||
- `input_token_count`
|
||||
- `output_token_count`
|
||||
- `cached_content_token_count`
|
||||
- `thoughts_token_count`
|
||||
- `tool_token_count`
|
||||
- `response_text` (if applicable)
|
||||
- `auth_type`
|
||||
|
||||
- `gemini_cli.tool_output_truncated`: This event occurs when the output of a
|
||||
tool call is too large and gets truncated.
|
||||
- `gemini_cli.tool_output_truncated`: Output of a tool call was truncated.
|
||||
- **Attributes**:
|
||||
- `tool_name` (string)
|
||||
- `original_content_length` (int)
|
||||
@@ -305,32 +290,211 @@ for Gemini CLI:
|
||||
- `lines` (int)
|
||||
- `prompt_id` (string)
|
||||
|
||||
- `gemini_cli.malformed_json_response`: This event occurs when a `generateJson`
|
||||
response from Gemini API cannot be parsed as a json.
|
||||
- `gemini_cli.smart_edit_strategy`: Smart Edit strategy chosen.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `strategy` (string)
|
||||
|
||||
- `gemini_cli.flash_fallback`: This event occurs when Gemini CLI switches to
|
||||
flash as fallback.
|
||||
- `gemini_cli.smart_edit_correction`: Smart Edit correction result.
|
||||
- **Attributes**:
|
||||
- `auth_type`
|
||||
- `correction` ("success" | "failure")
|
||||
|
||||
- `gemini_cli.slash_command`: This event occurs when a user executes a slash
|
||||
command.
|
||||
#### Files
|
||||
|
||||
Tracks file operations performed by tools.
|
||||
|
||||
- `gemini_cli.file_operation`: Emitted for each file operation.
|
||||
- **Attributes**:
|
||||
- `tool_name` (string)
|
||||
- `operation` ("create" | "read" | "update")
|
||||
- `lines` (int, optional)
|
||||
- `mimetype` (string, optional)
|
||||
- `extension` (string, optional)
|
||||
- `programming_language` (string, optional)
|
||||
|
||||
#### API
|
||||
|
||||
Captures Gemini API requests, responses, and errors.
|
||||
|
||||
- `gemini_cli.api_request`: Request sent to Gemini API.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `prompt_id` (string)
|
||||
- `request_text` (string, optional)
|
||||
|
||||
- `gemini_cli.api_response`: Response received from Gemini API.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `status_code` (int|string)
|
||||
- `duration_ms` (int)
|
||||
- `input_token_count` (int)
|
||||
- `output_token_count` (int)
|
||||
- `cached_content_token_count` (int)
|
||||
- `thoughts_token_count` (int)
|
||||
- `tool_token_count` (int)
|
||||
- `total_token_count` (int)
|
||||
- `response_text` (string, optional)
|
||||
- `prompt_id` (string)
|
||||
- `auth_type` (string)
|
||||
|
||||
- `gemini_cli.api_error`: API request failed.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `error` (string)
|
||||
- `error_type` (string)
|
||||
- `status_code` (int|string)
|
||||
- `duration_ms` (int)
|
||||
- `prompt_id` (string)
|
||||
- `auth_type` (string)
|
||||
|
||||
- `gemini_cli.malformed_json_response`: `generateJson` response could not be
|
||||
parsed.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
|
||||
#### Model Routing
|
||||
|
||||
Tracks model selections via slash commands and router decisions.
|
||||
|
||||
- `gemini_cli.slash_command`: A slash command was executed.
|
||||
- **Attributes**:
|
||||
- `command` (string)
|
||||
- `subcommand` (string, if applicable)
|
||||
- `subcommand` (string, optional)
|
||||
- `status` ("success" | "error")
|
||||
|
||||
- `gemini_cli.extension_enable`: This event occurs when an extension is enabled
|
||||
- `gemini_cli.extension_install`: This event occurs when an extension is
|
||||
installed
|
||||
- `gemini_cli.slash_command.model`: Model was selected via slash command.
|
||||
- **Attributes**:
|
||||
- `model_name` (string)
|
||||
|
||||
- `gemini_cli.model_routing`: Model router made a decision.
|
||||
- **Attributes**:
|
||||
- `decision_model` (string)
|
||||
- `decision_source` (string)
|
||||
- `routing_latency_ms` (int)
|
||||
- `reasoning` (string, optional)
|
||||
- `failed` (boolean)
|
||||
- `error_message` (string, optional)
|
||||
|
||||
#### Chat and Streaming
|
||||
|
||||
Observes streaming integrity, compression, and retry behavior.
|
||||
|
||||
- `gemini_cli.chat_compression`: Chat context was compressed.
|
||||
- **Attributes**:
|
||||
- `tokens_before` (int)
|
||||
- `tokens_after` (int)
|
||||
|
||||
- `gemini_cli.chat.invalid_chunk`: Invalid chunk received from a stream.
|
||||
- **Attributes**:
|
||||
- `error.message` (string, optional)
|
||||
|
||||
- `gemini_cli.chat.content_retry`: Retry triggered due to a content error.
|
||||
- **Attributes**:
|
||||
- `attempt_number` (int)
|
||||
- `error_type` (string)
|
||||
- `retry_delay_ms` (int)
|
||||
- `model` (string)
|
||||
|
||||
- `gemini_cli.chat.content_retry_failure`: All content retries failed.
|
||||
- **Attributes**:
|
||||
- `total_attempts` (int)
|
||||
- `final_error_type` (string)
|
||||
- `total_duration_ms` (int, optional)
|
||||
- `model` (string)
|
||||
|
||||
- `gemini_cli.conversation_finished`: Conversation session ended.
|
||||
- **Attributes**:
|
||||
- `approvalMode` (string)
|
||||
- `turnCount` (int)
|
||||
|
||||
- `gemini_cli.next_speaker_check`: Next speaker determination.
|
||||
- **Attributes**:
|
||||
- `prompt_id` (string)
|
||||
- `finish_reason` (string)
|
||||
- `result` (string)
|
||||
|
||||
#### Resilience
|
||||
|
||||
Records fallback mechanisms for models and network operations.
|
||||
|
||||
- `gemini_cli.flash_fallback`: Switched to a flash model as fallback.
|
||||
- **Attributes**:
|
||||
- `auth_type` (string)
|
||||
|
||||
- `gemini_cli.ripgrep_fallback`: Switched to grep as fallback for file search.
|
||||
- **Attributes**:
|
||||
- `error` (string, optional)
|
||||
|
||||
- `gemini_cli.web_fetch_fallback_attempt`: Attempted web-fetch fallback.
|
||||
- **Attributes**:
|
||||
- `reason` ("private_ip" | "primary_failed")
|
||||
|
||||
#### Extensions
|
||||
|
||||
Tracks extension lifecycle and settings changes.
|
||||
|
||||
- `gemini_cli.extension_install`: An extension was installed.
|
||||
- **Attributes**:
|
||||
- `extension_name` (string)
|
||||
- `extension_version` (string)
|
||||
- `extension_source` (string)
|
||||
- `status` (string)
|
||||
- `gemini_cli.extension_uninstall`: This event occurs when an extension is
|
||||
uninstalled
|
||||
|
||||
- `gemini_cli.extension_uninstall`: An extension was uninstalled.
|
||||
- **Attributes**:
|
||||
- `extension_name` (string)
|
||||
- `status` (string)
|
||||
|
||||
- `gemini_cli.extension_enable`: An extension was enabled.
|
||||
- **Attributes**:
|
||||
- `extension_name` (string)
|
||||
- `setting_scope` (string)
|
||||
|
||||
- `gemini_cli.extension_disable`: An extension was disabled.
|
||||
- **Attributes**:
|
||||
- `extension_name` (string)
|
||||
- `setting_scope` (string)
|
||||
|
||||
- `gemini_cli.extension_update`: An extension was updated.
|
||||
- **Attributes**:
|
||||
- `extension_name` (string)
|
||||
- `extension_version` (string)
|
||||
- `extension_previous_version` (string)
|
||||
- `extension_source` (string)
|
||||
- `status` (string)
|
||||
|
||||
#### Agent Runs
|
||||
|
||||
Tracks agent lifecycle and outcomes.
|
||||
|
||||
- `gemini_cli.agent.start`: Agent run started.
|
||||
- **Attributes**:
|
||||
- `agent_id` (string)
|
||||
- `agent_name` (string)
|
||||
|
||||
- `gemini_cli.agent.finish`: Agent run finished.
|
||||
- **Attributes**:
|
||||
- `agent_id` (string)
|
||||
- `agent_name` (string)
|
||||
- `duration_ms` (int)
|
||||
- `turn_count` (int)
|
||||
- `terminate_reason` (string)
|
||||
|
||||
#### IDE
|
||||
|
||||
Captures IDE connectivity and conversation lifecycle events.
|
||||
|
||||
- `gemini_cli.ide_connection`: IDE companion connection.
|
||||
- **Attributes**:
|
||||
- `connection_type` (string)
|
||||
|
||||
#### UI
|
||||
|
||||
Tracks terminal rendering issues and related signals.
|
||||
|
||||
- `kitty_sequence_overflow`: Terminal kitty control sequence overflow.
|
||||
- **Attributes**:
|
||||
- `sequence_length` (int)
|
||||
- `truncated_sequence` (string)
|
||||
|
||||
### Metrics
|
||||
|
||||
@@ -338,27 +502,35 @@ Metrics are numerical measurements of behavior over time.
|
||||
|
||||
#### Custom
|
||||
|
||||
##### Sessions
|
||||
|
||||
Counts CLI sessions at startup.
|
||||
|
||||
- `gemini_cli.session.count` (Counter, Int): Incremented once per CLI startup.
|
||||
|
||||
##### Tools
|
||||
|
||||
Measures tool usage and latency.
|
||||
|
||||
- `gemini_cli.tool.call.count` (Counter, Int): Counts tool calls.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `success` (boolean)
|
||||
- `decision` (string: "accept", "reject", or "modify", if applicable)
|
||||
- `tool_type` (string: "mcp", or "native", if applicable)
|
||||
- `model_added_lines` (Int, optional): Lines added by model in the proposed
|
||||
changes, if applicable
|
||||
- `model_removed_lines` (Int, optional): Lines removed by model in the
|
||||
proposed changes, if applicable
|
||||
- `user_added_lines` (Int, optional): Lines added by user edits after model
|
||||
proposal, if applicable
|
||||
- `user_removed_lines` (Int, optional): Lines removed by user edits after
|
||||
model proposal, if applicable
|
||||
- `decision` (string: "accept", "reject", "modify", or "auto_accept", if
|
||||
applicable)
|
||||
- `tool_type` (string: "mcp" or "native", if applicable)
|
||||
- `model_added_lines` (Int, optional)
|
||||
- `model_removed_lines` (Int, optional)
|
||||
- `user_added_lines` (Int, optional)
|
||||
- `user_removed_lines` (Int, optional)
|
||||
|
||||
- `gemini_cli.tool.call.latency` (Histogram, ms): Measures tool call latency.
|
||||
- **Attributes**:
|
||||
- `function_name`
|
||||
- `decision` (string: "accept", "reject", or "modify", if applicable)
|
||||
|
||||
##### API
|
||||
|
||||
Tracks API request volume and latency.
|
||||
|
||||
- `gemini_cli.api.request.count` (Counter, Int): Counts all API requests.
|
||||
- **Attributes**:
|
||||
@@ -370,32 +542,161 @@ Metrics are numerical measurements of behavior over time.
|
||||
latency.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- **Note**: This metric overlaps with `gen_ai.client.operation.duration` below
|
||||
that's compliant with GenAI Semantic Conventions.
|
||||
- Note: Overlaps with `gen_ai.client.operation.duration` (GenAI conventions).
|
||||
|
||||
- `gemini_cli.token.usage` (Counter, Int): Counts the number of tokens used.
|
||||
##### Token Usage
|
||||
|
||||
Tracks tokens used by model and type.
|
||||
|
||||
- `gemini_cli.token.usage` (Counter, Int): Counts tokens used.
|
||||
- **Attributes**:
|
||||
- `model`
|
||||
- `type` (string: "input", "output", "thought", "cache", or "tool")
|
||||
- **Note**: This metric overlaps with `gen_ai.client.token.usage` below for
|
||||
`input`/`output` token types that's compliant with GenAI Semantic
|
||||
Conventions.
|
||||
- `type` ("input", "output", "thought", "cache", or "tool")
|
||||
- Note: Overlaps with `gen_ai.client.token.usage` for `input`/`output`.
|
||||
|
||||
##### Files
|
||||
|
||||
Counts file operations with basic context.
|
||||
|
||||
- `gemini_cli.file.operation.count` (Counter, Int): Counts file operations.
|
||||
- **Attributes**:
|
||||
- `operation` (string: "create", "read", "update"): The type of file
|
||||
operation.
|
||||
- `lines` (Int, if applicable): Number of lines in the file.
|
||||
- `mimetype` (string, if applicable): Mimetype of the file.
|
||||
- `extension` (string, if applicable): File extension of the file.
|
||||
- `programming_language` (string, if applicable): The programming language
|
||||
of the file.
|
||||
- `operation` ("create", "read", "update")
|
||||
- `lines` (Int, optional)
|
||||
- `mimetype` (string, optional)
|
||||
- `extension` (string, optional)
|
||||
- `programming_language` (string, optional)
|
||||
|
||||
##### Chat and Streaming
|
||||
|
||||
Resilience counters for compression, invalid chunks, and retries.
|
||||
|
||||
- `gemini_cli.chat_compression` (Counter, Int): Counts chat compression
|
||||
operations
|
||||
operations.
|
||||
- **Attributes**:
|
||||
- `tokens_before`: (Int): Number of tokens in context prior to compression
|
||||
- `tokens_after`: (Int): Number of tokens in context after compression
|
||||
- `tokens_before` (Int)
|
||||
- `tokens_after` (Int)
|
||||
|
||||
- `gemini_cli.chat.invalid_chunk.count` (Counter, Int): Counts invalid chunks
|
||||
from streams.
|
||||
|
||||
- `gemini_cli.chat.content_retry.count` (Counter, Int): Counts retries due to
|
||||
content errors.
|
||||
|
||||
- `gemini_cli.chat.content_retry_failure.count` (Counter, Int): Counts requests
|
||||
where all content retries failed.
|
||||
|
||||
##### Model Routing
|
||||
|
||||
Routing latency/failures and slash-command selections.
|
||||
|
||||
- `gemini_cli.slash_command.model.call_count` (Counter, Int): Counts model
|
||||
selections via slash command.
|
||||
- **Attributes**:
|
||||
- `slash_command.model.model_name` (string)
|
||||
|
||||
- `gemini_cli.model_routing.latency` (Histogram, ms): Model routing decision
|
||||
latency.
|
||||
- **Attributes**:
|
||||
- `routing.decision_model` (string)
|
||||
- `routing.decision_source` (string)
|
||||
|
||||
- `gemini_cli.model_routing.failure.count` (Counter, Int): Counts model routing
|
||||
failures.
|
||||
- **Attributes**:
|
||||
- `routing.decision_source` (string)
|
||||
- `routing.error_message` (string)
|
||||
|
||||
##### Agent Runs
|
||||
|
||||
Agent lifecycle metrics: runs, durations, and turns.
|
||||
|
||||
- `gemini_cli.agent.run.count` (Counter, Int): Counts agent runs.
|
||||
- **Attributes**:
|
||||
- `agent_name` (string)
|
||||
- `terminate_reason` (string)
|
||||
|
||||
- `gemini_cli.agent.duration` (Histogram, ms): Agent run durations.
|
||||
- **Attributes**:
|
||||
- `agent_name` (string)
|
||||
|
||||
- `gemini_cli.agent.turns` (Histogram, turns): Turns taken per agent run.
|
||||
- **Attributes**:
|
||||
- `agent_name` (string)
|
||||
|
||||
##### UI
|
||||
|
||||
UI stability signals such as flicker count.
|
||||
|
||||
- `gemini_cli.ui.flicker.count` (Counter, Int): Counts UI frames that flicker
|
||||
(render taller than terminal).
|
||||
|
||||
##### Performance
|
||||
|
||||
Optional performance monitoring for startup, CPU/memory, and phase timing.
|
||||
|
||||
- `gemini_cli.startup.duration` (Histogram, ms): CLI startup time by phase.
|
||||
- **Attributes**:
|
||||
- `phase` (string)
|
||||
- `details` (map, optional)
|
||||
|
||||
- `gemini_cli.memory.usage` (Histogram, bytes): Memory usage.
|
||||
- **Attributes**:
|
||||
- `memory_type` ("heap_used", "heap_total", "external", "rss")
|
||||
- `component` (string, optional)
|
||||
|
||||
- `gemini_cli.cpu.usage` (Histogram, percent): CPU usage percentage.
|
||||
- **Attributes**:
|
||||
- `component` (string, optional)
|
||||
|
||||
- `gemini_cli.tool.queue.depth` (Histogram, count): Number of tools in the
|
||||
execution queue.
|
||||
|
||||
- `gemini_cli.tool.execution.breakdown` (Histogram, ms): Tool time by phase.
|
||||
- **Attributes**:
|
||||
- `function_name` (string)
|
||||
- `phase` ("validation", "preparation", "execution", "result_processing")
|
||||
|
||||
- `gemini_cli.api.request.breakdown` (Histogram, ms): API request time by phase.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `phase` ("request_preparation", "network_latency", "response_processing",
|
||||
"token_processing")
|
||||
|
||||
- `gemini_cli.token.efficiency` (Histogram, ratio): Token efficiency metrics.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `metric` (string)
|
||||
- `context` (string, optional)
|
||||
|
||||
- `gemini_cli.performance.score` (Histogram, score): Composite performance
|
||||
score.
|
||||
- **Attributes**:
|
||||
- `category` (string)
|
||||
- `baseline` (number, optional)
|
||||
|
||||
- `gemini_cli.performance.regression` (Counter, Int): Regression detection
|
||||
events.
|
||||
- **Attributes**:
|
||||
- `metric` (string)
|
||||
- `severity` ("low", "medium", "high")
|
||||
- `current_value` (number)
|
||||
- `baseline_value` (number)
|
||||
|
||||
- `gemini_cli.performance.regression.percentage_change` (Histogram, percent):
|
||||
Percent change from baseline when regression detected.
|
||||
- **Attributes**:
|
||||
- `metric` (string)
|
||||
- `severity` ("low", "medium", "high")
|
||||
- `current_value` (number)
|
||||
- `baseline_value` (number)
|
||||
|
||||
- `gemini_cli.performance.baseline.comparison` (Histogram, percent): Comparison
|
||||
to baseline.
|
||||
- **Attributes**:
|
||||
- `metric` (string)
|
||||
- `category` (string)
|
||||
- `current_value` (number)
|
||||
- `baseline_value` (number)
|
||||
|
||||
#### GenAI Semantic Convention
|
||||
|
||||
|
||||
@@ -9,9 +9,6 @@ import { writeFileSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { TestRig } from './test-helper.js';
|
||||
|
||||
// Windows skip (Option A: avoid infra scope)
|
||||
const d = process.platform === 'win32' ? describe.skip : describe;
|
||||
|
||||
// BOM encoders
|
||||
const utf8BOM = (s: string) =>
|
||||
Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(s, 'utf8')]);
|
||||
@@ -53,7 +50,7 @@ const utf32BE = (s: string) => {
|
||||
let rig: TestRig;
|
||||
let dir: string;
|
||||
|
||||
d('BOM end-to-end integration', () => {
|
||||
describe('BOM end-to-end integraion', () => {
|
||||
beforeAll(async () => {
|
||||
rig = new TestRig();
|
||||
await rig.setup('bom-integration');
|
||||
|
||||
@@ -82,31 +82,76 @@ describe('GoogleCredentialProvider', () => {
|
||||
|
||||
describe('with provider instance', () => {
|
||||
let provider: GoogleCredentialProvider;
|
||||
let mockGetAccessToken: Mock;
|
||||
let mockClient: {
|
||||
getAccessToken: Mock;
|
||||
credentials?: { expiry_date: number | null };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// clear and reset mock client before each test
|
||||
mockGetAccessToken = vi.fn();
|
||||
mockClient = {
|
||||
getAccessToken: mockGetAccessToken,
|
||||
};
|
||||
(GoogleAuth.prototype.getClient as Mock).mockResolvedValue(mockClient);
|
||||
provider = new GoogleCredentialProvider(validConfig);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return credentials', async () => {
|
||||
const mockClient = {
|
||||
getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }),
|
||||
};
|
||||
(GoogleAuth.prototype.getClient as Mock).mockResolvedValue(mockClient);
|
||||
mockGetAccessToken.mockResolvedValue({ token: 'test-token' });
|
||||
|
||||
const credentials = await provider.tokens();
|
||||
|
||||
expect(credentials?.access_token).toBe('test-token');
|
||||
});
|
||||
|
||||
it('should return undefined if access token is not available', async () => {
|
||||
const mockClient = {
|
||||
getAccessToken: vi.fn().mockResolvedValue({ token: null }),
|
||||
};
|
||||
(GoogleAuth.prototype.getClient as Mock).mockResolvedValue(mockClient);
|
||||
mockGetAccessToken.mockResolvedValue({ token: null });
|
||||
|
||||
const credentials = await provider.tokens();
|
||||
expect(credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return a cached token if it is not expired', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockClient.credentials = { expiry_date: Date.now() + 3600 * 1000 }; // 1 hour
|
||||
mockGetAccessToken.mockResolvedValue({ token: 'test-token' });
|
||||
|
||||
// first call
|
||||
const firstTokens = await provider.tokens();
|
||||
expect(firstTokens?.access_token).toBe('test-token');
|
||||
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
|
||||
|
||||
// second call
|
||||
vi.advanceTimersByTime(1800 * 1000); // Advance time by 30 minutes
|
||||
const secondTokens = await provider.tokens();
|
||||
expect(secondTokens).toBe(firstTokens);
|
||||
expect(mockGetAccessToken).toHaveBeenCalledTimes(1); // Should not be called again
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should fetch a new token if the cached token is expired', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// first call
|
||||
mockClient.credentials = { expiry_date: Date.now() + 1000 }; // Expires in 1 second
|
||||
mockGetAccessToken.mockResolvedValue({ token: 'expired-token' });
|
||||
|
||||
const firstTokens = await provider.tokens();
|
||||
expect(firstTokens?.access_token).toBe('expired-token');
|
||||
expect(mockGetAccessToken).toHaveBeenCalledTimes(1);
|
||||
|
||||
// second call
|
||||
vi.advanceTimersByTime(1001); // Advance time past expiry
|
||||
mockClient.credentials = { expiry_date: Date.now() + 3600 * 1000 }; // New expiry
|
||||
mockGetAccessToken.mockResolvedValue({ token: 'new-token' });
|
||||
|
||||
const newTokens = await provider.tokens();
|
||||
expect(newTokens?.access_token).toBe('new-token');
|
||||
expect(mockGetAccessToken).toHaveBeenCalledTimes(2); // new fetch
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,11 +13,14 @@ import type {
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
import { FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
|
||||
|
||||
const ALLOWED_HOSTS = [/^.+\.googleapis\.com$/, /^(.*\.)?luci\.app$/];
|
||||
|
||||
export class GoogleCredentialProvider implements OAuthClientProvider {
|
||||
private readonly auth: GoogleAuth;
|
||||
private cachedToken?: OAuthTokens;
|
||||
private tokenExpiryTime?: number;
|
||||
|
||||
// Properties required by OAuthClientProvider, with no-op values
|
||||
readonly redirectUrl = '';
|
||||
@@ -65,6 +68,19 @@ export class GoogleCredentialProvider implements OAuthClientProvider {
|
||||
}
|
||||
|
||||
async tokens(): Promise<OAuthTokens | undefined> {
|
||||
// check for a valid, non-expired cached token.
|
||||
if (
|
||||
this.cachedToken &&
|
||||
this.tokenExpiryTime &&
|
||||
Date.now() < this.tokenExpiryTime - FIVE_MIN_BUFFER_MS
|
||||
) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
|
||||
// Clear invalid/expired cache.
|
||||
this.cachedToken = undefined;
|
||||
this.tokenExpiryTime = undefined;
|
||||
|
||||
const client = await this.auth.getClient();
|
||||
const accessTokenResponse = await client.getAccessToken();
|
||||
|
||||
@@ -73,11 +89,18 @@ export class GoogleCredentialProvider implements OAuthClientProvider {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tokens: OAuthTokens = {
|
||||
const newToken: OAuthTokens = {
|
||||
access_token: accessTokenResponse.token,
|
||||
token_type: 'Bearer',
|
||||
};
|
||||
return tokens;
|
||||
|
||||
const expiryTime = client.credentials?.expiry_date;
|
||||
if (expiryTime) {
|
||||
this.tokenExpiryTime = expiryTime;
|
||||
this.cachedToken = newToken;
|
||||
}
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
saveTokens(_tokens: OAuthTokens): void {
|
||||
|
||||
@@ -325,4 +325,41 @@ describe('OAuthUtils', () => {
|
||||
expect(() => OAuthUtils.buildResourceParameter('not-a-url')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTokenExpiry', () => {
|
||||
it('should return the expiry time in milliseconds for a valid token', () => {
|
||||
// Corresponds to a date of 2100-01-01T00:00:00Z
|
||||
const expiry = 4102444800;
|
||||
const payload = { exp: expiry };
|
||||
const token = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`;
|
||||
const result = OAuthUtils.parseTokenExpiry(token);
|
||||
expect(result).toBe(expiry * 1000);
|
||||
});
|
||||
|
||||
it('should return undefined for a token without an expiry time', () => {
|
||||
const payload = { iat: 1678886400 };
|
||||
const token = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`;
|
||||
const result = OAuthUtils.parseTokenExpiry(token);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for a token with an invalid expiry time', () => {
|
||||
const payload = { exp: 'not-a-number' };
|
||||
const token = `header.${Buffer.from(JSON.stringify(payload)).toString('base64')}.signature`;
|
||||
const result = OAuthUtils.parseTokenExpiry(token);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for a malformed token', () => {
|
||||
const token = 'not-a-valid-token';
|
||||
const result = OAuthUtils.parseTokenExpiry(token);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined for a token with invalid JSON in payload', () => {
|
||||
const token = `header.${Buffer.from('{ not valid json').toString('base64')}.signature`;
|
||||
const result = OAuthUtils.parseTokenExpiry(token);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface OAuthProtectedResourceMetadata {
|
||||
resource_encryption_enc_values_supported?: string[];
|
||||
}
|
||||
|
||||
export const FIVE_MIN_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Utility class for common OAuth operations.
|
||||
*/
|
||||
@@ -362,4 +364,26 @@ export class OAuthUtils {
|
||||
const url = new URL(endpointUrl);
|
||||
return `${url.protocol}//${url.host}${url.pathname}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JWT string to extract its expiry time.
|
||||
* @param idToken The JWT ID token.
|
||||
* @returns The expiry time in **milliseconds**, or undefined if parsing fails.
|
||||
*/
|
||||
static parseTokenExpiry(idToken: string): number | undefined {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(idToken.split('.')[1], 'base64').toString(),
|
||||
);
|
||||
|
||||
if (payload && typeof payload.exp === 'number') {
|
||||
return payload.exp * 1000; // Convert seconds to milliseconds
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse ID token for expiry time with error:', e);
|
||||
}
|
||||
|
||||
// Return undefined if try block fails or 'exp' is missing/invalid
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,11 +11,10 @@ import type {
|
||||
OAuthTokens,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import { GoogleAuth } from 'google-auth-library';
|
||||
import { OAuthUtils, FIVE_MIN_BUFFER_MS } from './oauth-utils.js';
|
||||
import type { MCPServerConfig } from '../config/config.js';
|
||||
import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js';
|
||||
|
||||
const fiveMinBufferMs = 5 * 60 * 1000;
|
||||
|
||||
function createIamApiUrl(targetSA: string): string {
|
||||
return `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${encodeURIComponent(targetSA)}:generateIdToken`;
|
||||
}
|
||||
@@ -78,7 +77,7 @@ export class ServiceAccountImpersonationProvider
|
||||
if (
|
||||
this.cachedToken &&
|
||||
this.tokenExpiryTime &&
|
||||
Date.now() < this.tokenExpiryTime - fiveMinBufferMs
|
||||
Date.now() < this.tokenExpiryTime - FIVE_MIN_BUFFER_MS
|
||||
) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
@@ -112,7 +111,7 @@ export class ServiceAccountImpersonationProvider
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const expiryTime = this.parseTokenExpiry(idToken);
|
||||
const expiryTime = OAuthUtils.parseTokenExpiry(idToken);
|
||||
// Note: We are placing the OIDC ID Token into the `access_token` field.
|
||||
// This is because the CLI uses this field to construct the
|
||||
// `Authorization: Bearer <token>` header, which is the correct way to
|
||||
@@ -146,26 +145,4 @@ export class ServiceAccountImpersonationProvider
|
||||
// No-op
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JWT string to extract its expiry time.
|
||||
* @param idToken The JWT ID token.
|
||||
* @returns The expiry time in **milliseconds**, or undefined if parsing fails.
|
||||
*/
|
||||
private parseTokenExpiry(idToken: string): number | undefined {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(idToken.split('.')[1], 'base64').toString(),
|
||||
);
|
||||
|
||||
if (payload && typeof payload.exp === 'number') {
|
||||
return payload.exp * 1000; // Convert seconds to milliseconds
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse ID token for expiry time with error:', e);
|
||||
}
|
||||
|
||||
// Return undefined if try block fails or 'exp' is missing/invalid
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,23 +201,14 @@ describe('FileExclusions', () => {
|
||||
});
|
||||
|
||||
describe('BINARY_EXTENSIONS', () => {
|
||||
it('should include common binary file extensions', () => {
|
||||
expect(BINARY_EXTENSIONS).toContain('.exe');
|
||||
expect(BINARY_EXTENSIONS).toContain('.dll');
|
||||
expect(BINARY_EXTENSIONS).toContain('.jar');
|
||||
expect(BINARY_EXTENSIONS).toContain('.zip');
|
||||
});
|
||||
|
||||
it('should include additional binary extensions', () => {
|
||||
expect(BINARY_EXTENSIONS).toContain('.dat');
|
||||
expect(BINARY_EXTENSIONS).toContain('.obj');
|
||||
expect(BINARY_EXTENSIONS).toContain('.wasm');
|
||||
});
|
||||
|
||||
it('should include media file extensions', () => {
|
||||
expect(BINARY_EXTENSIONS).toContain('.pdf');
|
||||
expect(BINARY_EXTENSIONS).toContain('.png');
|
||||
expect(BINARY_EXTENSIONS).toContain('.jpg');
|
||||
it.each([
|
||||
['common binary file extensions', ['.exe', '.dll', '.jar', '.zip']],
|
||||
['additional binary extensions', ['.dat', '.obj', '.wasm']],
|
||||
['media file extensions', ['.pdf', '.png', '.jpg']],
|
||||
])('should include %s', (_, extensions) => {
|
||||
extensions.forEach((ext) => {
|
||||
expect(BINARY_EXTENSIONS).toContain(ext);
|
||||
});
|
||||
});
|
||||
|
||||
it('should be sorted', () => {
|
||||
@@ -235,11 +226,25 @@ describe('BINARY_EXTENSIONS', () => {
|
||||
});
|
||||
|
||||
describe('extractExtensionsFromPatterns', () => {
|
||||
it('should extract simple extensions', () => {
|
||||
const patterns = ['**/*.exe', '**/*.jar', '**/*.zip'];
|
||||
it.each([
|
||||
[
|
||||
'simple extensions',
|
||||
['**/*.exe', '**/*.jar', '**/*.zip'],
|
||||
['.exe', '.jar', '.zip'],
|
||||
],
|
||||
[
|
||||
'compound extensions',
|
||||
['**/*.tar.gz', '**/*.min.js', '**/*.d.ts'],
|
||||
['.gz', '.js', '.ts'],
|
||||
],
|
||||
[
|
||||
'dotfiles',
|
||||
['**/*.gitignore', '**/*.profile', '**/*.bashrc'],
|
||||
['.bashrc', '.gitignore', '.profile'],
|
||||
],
|
||||
])('should extract %s', (_, patterns, expected) => {
|
||||
const result = extractExtensionsFromPatterns(patterns);
|
||||
|
||||
expect(result).toEqual(['.exe', '.jar', '.zip']);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should handle brace expansion patterns', () => {
|
||||
@@ -293,22 +298,6 @@ describe('extractExtensionsFromPatterns', () => {
|
||||
expect(result).toEqual(['.css', '.html', '.js', '.jsx', '.ts', '.tsx']);
|
||||
});
|
||||
|
||||
it('should handle compound extensions correctly using path.extname', () => {
|
||||
const patterns = ['**/*.tar.gz', '**/*.min.js', '**/*.d.ts'];
|
||||
const result = extractExtensionsFromPatterns(patterns);
|
||||
|
||||
// Should extract the final extension part only
|
||||
expect(result).toEqual(['.gz', '.js', '.ts']);
|
||||
});
|
||||
|
||||
it('should handle dotfiles correctly', () => {
|
||||
const patterns = ['**/*.gitignore', '**/*.profile', '**/*.bashrc'];
|
||||
const result = extractExtensionsFromPatterns(patterns);
|
||||
|
||||
// Dotfiles should be extracted properly
|
||||
expect(result).toEqual(['.bashrc', '.gitignore', '.profile']);
|
||||
});
|
||||
|
||||
it('should handle edge cases with path.extname', () => {
|
||||
const patterns = ['**/*.hidden.', '**/*.config.json'];
|
||||
const result = extractExtensionsFromPatterns(patterns);
|
||||
|
||||
@@ -8,76 +8,31 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { escapePath, unescapePath, isSubpath } from './paths.js';
|
||||
|
||||
describe('escapePath', () => {
|
||||
it('should escape spaces', () => {
|
||||
expect(escapePath('my file.txt')).toBe('my\\ file.txt');
|
||||
});
|
||||
|
||||
it('should escape tabs', () => {
|
||||
expect(escapePath('file\twith\ttabs.txt')).toBe('file\\\twith\\\ttabs.txt');
|
||||
});
|
||||
|
||||
it('should escape parentheses', () => {
|
||||
expect(escapePath('file(1).txt')).toBe('file\\(1\\).txt');
|
||||
});
|
||||
|
||||
it('should escape square brackets', () => {
|
||||
expect(escapePath('file[backup].txt')).toBe('file\\[backup\\].txt');
|
||||
});
|
||||
|
||||
it('should escape curly braces', () => {
|
||||
expect(escapePath('file{temp}.txt')).toBe('file\\{temp\\}.txt');
|
||||
});
|
||||
|
||||
it('should escape semicolons', () => {
|
||||
expect(escapePath('file;name.txt')).toBe('file\\;name.txt');
|
||||
});
|
||||
|
||||
it('should escape ampersands', () => {
|
||||
expect(escapePath('file&name.txt')).toBe('file\\&name.txt');
|
||||
});
|
||||
|
||||
it('should escape pipes', () => {
|
||||
expect(escapePath('file|name.txt')).toBe('file\\|name.txt');
|
||||
});
|
||||
|
||||
it('should escape asterisks', () => {
|
||||
expect(escapePath('file*.txt')).toBe('file\\*.txt');
|
||||
});
|
||||
|
||||
it('should escape question marks', () => {
|
||||
expect(escapePath('file?.txt')).toBe('file\\?.txt');
|
||||
});
|
||||
|
||||
it('should escape dollar signs', () => {
|
||||
expect(escapePath('file$name.txt')).toBe('file\\$name.txt');
|
||||
});
|
||||
|
||||
it('should escape backticks', () => {
|
||||
expect(escapePath('file`name.txt')).toBe('file\\`name.txt');
|
||||
});
|
||||
|
||||
it('should escape single quotes', () => {
|
||||
expect(escapePath("file'name.txt")).toBe("file\\'name.txt");
|
||||
});
|
||||
|
||||
it('should escape double quotes', () => {
|
||||
expect(escapePath('file"name.txt')).toBe('file\\"name.txt');
|
||||
});
|
||||
|
||||
it('should escape hash symbols', () => {
|
||||
expect(escapePath('file#name.txt')).toBe('file\\#name.txt');
|
||||
});
|
||||
|
||||
it('should escape exclamation marks', () => {
|
||||
expect(escapePath('file!name.txt')).toBe('file\\!name.txt');
|
||||
});
|
||||
|
||||
it('should escape tildes', () => {
|
||||
expect(escapePath('file~name.txt')).toBe('file\\~name.txt');
|
||||
});
|
||||
|
||||
it('should escape less than and greater than signs', () => {
|
||||
expect(escapePath('file<name>.txt')).toBe('file\\<name\\>.txt');
|
||||
it.each([
|
||||
['spaces', 'my file.txt', 'my\\ file.txt'],
|
||||
['tabs', 'file\twith\ttabs.txt', 'file\\\twith\\\ttabs.txt'],
|
||||
['parentheses', 'file(1).txt', 'file\\(1\\).txt'],
|
||||
['square brackets', 'file[backup].txt', 'file\\[backup\\].txt'],
|
||||
['curly braces', 'file{temp}.txt', 'file\\{temp\\}.txt'],
|
||||
['semicolons', 'file;name.txt', 'file\\;name.txt'],
|
||||
['ampersands', 'file&name.txt', 'file\\&name.txt'],
|
||||
['pipes', 'file|name.txt', 'file\\|name.txt'],
|
||||
['asterisks', 'file*.txt', 'file\\*.txt'],
|
||||
['question marks', 'file?.txt', 'file\\?.txt'],
|
||||
['dollar signs', 'file$name.txt', 'file\\$name.txt'],
|
||||
['backticks', 'file`name.txt', 'file\\`name.txt'],
|
||||
['single quotes', "file'name.txt", "file\\'name.txt"],
|
||||
['double quotes', 'file"name.txt', 'file\\"name.txt'],
|
||||
['hash symbols', 'file#name.txt', 'file\\#name.txt'],
|
||||
['exclamation marks', 'file!name.txt', 'file\\!name.txt'],
|
||||
['tildes', 'file~name.txt', 'file\\~name.txt'],
|
||||
[
|
||||
'less than and greater than signs',
|
||||
'file<name>.txt',
|
||||
'file\\<name\\>.txt',
|
||||
],
|
||||
])('should escape %s', (_, input, expected) => {
|
||||
expect(escapePath(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle multiple special characters', () => {
|
||||
@@ -135,26 +90,14 @@ describe('escapePath', () => {
|
||||
});
|
||||
|
||||
describe('unescapePath', () => {
|
||||
it('should unescape spaces', () => {
|
||||
expect(unescapePath('my\\ file.txt')).toBe('my file.txt');
|
||||
});
|
||||
|
||||
it('should unescape tabs', () => {
|
||||
expect(unescapePath('file\\\twith\\\ttabs.txt')).toBe(
|
||||
'file\twith\ttabs.txt',
|
||||
);
|
||||
});
|
||||
|
||||
it('should unescape parentheses', () => {
|
||||
expect(unescapePath('file\\(1\\).txt')).toBe('file(1).txt');
|
||||
});
|
||||
|
||||
it('should unescape square brackets', () => {
|
||||
expect(unescapePath('file\\[backup\\].txt')).toBe('file[backup].txt');
|
||||
});
|
||||
|
||||
it('should unescape curly braces', () => {
|
||||
expect(unescapePath('file\\{temp\\}.txt')).toBe('file{temp}.txt');
|
||||
it.each([
|
||||
['spaces', 'my\\ file.txt', 'my file.txt'],
|
||||
['tabs', 'file\\\twith\\\ttabs.txt', 'file\twith\ttabs.txt'],
|
||||
['parentheses', 'file\\(1\\).txt', 'file(1).txt'],
|
||||
['square brackets', 'file\\[backup\\].txt', 'file[backup].txt'],
|
||||
['curly braces', 'file\\{temp\\}.txt', 'file{temp}.txt'],
|
||||
])('should unescape %s', (_, input, expected) => {
|
||||
expect(unescapePath(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should unescape multiple special characters', () => {
|
||||
|
||||
Reference in New Issue
Block a user