From 21fa5403278c119a29af1ece760d8107dcc1bd25 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Wed, 13 May 2026 22:45:27 -0400 Subject: [PATCH 01/11] [issues/552] Claude Code cold-start re-focus loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary The first paste after binding to Claude Code fires before the chat panel's webview IPC clipboard reader is ready, silently losing the link. This adds a cold-start re-focus loop that re-sends focus commands at a configurable interval throughout a configurable window, giving the panel time to initialize before paste dispatch. Subsequent pastes use the existing 200ms warm-path delay. ## Changes - `ColdRefocusConfig` value object (`totalMs`, `intervalMs`) — shared by all webview-based AI assistants (Gemini #529 reuses it) - `AIAssistantFocusCapability.refocusDuring()` — loops focus-command sends at `intervalMs` ticks for up to `totalMs` on cold start; `panelIsWarm` flag ensures the loop only fires once per binding session - `getColdRefocus` hook on `BuiltinAiAssistantDef` — reads two new VS Code settings, validates `totalMs > intervalMs`, falls back to defaults with a warning when invalid - `ConfigReader` on `DestinationBuilderContext` — destination builders can now resolve settings at bind time without threading `ConfigReader` through layers - Two new settings: `rangelink.destinations.claudeCode.coldStartDelayMs` (default 2500, range 500–15000) and `coldRefocusIntervalMs` (default 300, range 100–5000) - Documentation: CHANGELOG added; README updated with settings table (`Unreleased` markers) - QA: `claude-code-006` (default config valid) and `claude-code-007` (invalid config fallback), both `automated: true` ## Test Plan - [x] All 1888 unit tests pass - [x] New unit tests: `AIAssistantFocusCapability.test.ts` (10 tests — cold start, command fallback, all-fail, warm path, refocus timing, no refocus on warm, elapsed logging, invalid config fallback) - [x] Updated tests: `FocusCapabilityFactory.test.ts`, `destinationBuilders.test.ts`, `DestinationRegistry.test.ts`, `packageJsonContracts.test.ts`, `settingDefaults.test.ts`, `ComposablePasteDestination.integration.test.ts` - [x] Integration tests: `claude-code-006` and `claude-code-007` in `builtInAiAssistants.test.ts` - [x] QA coverage validator passes (123 automated + 115 assisted) Closes https://github.com/couimet/rangeLink/issues/552 --- .../rangelink-vscode-extension/CHANGELOG.md | 7 +- packages/rangelink-vscode-extension/README.md | 7 + .../rangelink-vscode-extension/package.json | 16 ++ .../qa/qa-test-cases-v1.1.0.yaml | 36 +++ .../suite/builtInAiAssistants.test.ts | 57 ++++- .../constants/packageJsonContracts.test.ts | 30 ++- .../constants/settingDefaults.test.ts | 12 + ...osablePasteDestination.integration.test.ts | 3 + .../destinations/DestinationRegistry.test.ts | 30 ++- .../AIAssistantFocusCapability.test.ts | 208 ++++++++++++++++++ .../FocusCapabilityFactory.test.ts | 5 +- .../destinations/destinationBuilders.test.ts | 81 ++++++- .../src/constants/settingDefaults.ts | 7 + .../src/constants/settingKeys.ts | 11 + .../src/createWiringServices.ts | 1 + .../src/destinations/DestinationRegistry.ts | 4 + .../AIAssistantFocusCapability.ts | 55 +++++ .../capabilities/ColdRefocusConfig.ts | 14 ++ .../capabilities/FocusCapabilityFactory.ts | 7 +- .../src/destinations/capabilities/index.ts | 2 + .../src/destinations/destinationBuilders.ts | 40 +++- 21 files changed, 610 insertions(+), 23 deletions(-) create mode 100644 packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts create mode 100644 packages/rangelink-vscode-extension/src/destinations/capabilities/ColdRefocusConfig.ts diff --git a/packages/rangelink-vscode-extension/CHANGELOG.md b/packages/rangelink-vscode-extension/CHANGELOG.md index d134da8b3..91d3f7537 100644 --- a/packages/rangelink-vscode-extension/CHANGELOG.md +++ b/packages/rangelink-vscode-extension/CHANGELOG.md @@ -116,6 +116,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Forgiving filename navigation** - Links with bare filenames (no directory path) now navigate when exactly one matching file exists in the workspace (#342) - Covers links generated by AI tools that omit directory prefixes (e.g., `RangeLinkNavigationHandler.ts#L10`) - When the filename is ambiguous (multiple matches) or not found, the existing "Cannot find file" warning is shown +- **Claude Code cold-start re-focus loop** - Adds configurable `coldStartDelayMs` and `coldRefocusIntervalMs` settings under `rangelink.destinations.claudeCode.*` to ensure the chat panel is ready before the first paste dispatch after binding. + - **List Bookmarks (R-B-L)** - `Cmd+R Cmd+B Cmd+L` (Mac) / `Ctrl+R Ctrl+B Ctrl+L` (Win/Linux) - Select a bookmark to paste its link to bound destination (or clipboard if unbound) - Manage bookmarks via the gear icon action in the bookmark list + --> ### Changed diff --git a/packages/rangelink-vscode-extension/README.md b/packages/rangelink-vscode-extension/README.md index c3978de74..fe0aae092 100644 --- a/packages/rangelink-vscode-extension/README.md +++ b/packages/rangelink-vscode-extension/README.md @@ -491,6 +491,13 @@ Set the navigation settings to `false` for a quieter workflow. Navigation itself When you have more terminals than this threshold, the destination picker shows a "More terminals..." option instead of listing all terminals individually. +### Claude Code Settings Unreleased + +| Setting | Default | Description | +| --------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------ | +| `rangelink.destinations.claudeCode.coldStartDelayMs` | `1500` | Total duration (ms) of the cold-start re-focus window for Claude Code | +| `rangelink.destinations.claudeCode.coldRefocusIntervalMs` | `300` | Interval (ms) between successive focus-command re-sends during the cold-start window | + ### Delimiter Settings | Setting | Default | Description | diff --git a/packages/rangelink-vscode-extension/package.json b/packages/rangelink-vscode-extension/package.json index 71866cbaf..85e5dbf73 100644 --- a/packages/rangelink-vscode-extension/package.json +++ b/packages/rangelink-vscode-extension/package.json @@ -532,6 +532,22 @@ "description": "Delimiter used between start and end positions (e.g., - in #L10-L20)", "pattern": "^[^0-9]+$" }, + "rangelink.destinations.claudeCode.coldStartDelayMs": { + "type": "number", + "default": 1500, + "minimum": 500, + "maximum": 15000, + "description": "Total duration (ms) of the cold-start re-focus window for Claude Code. During this window, focus commands are re-sent at the coldRefocusIntervalMs cadence to ensure the chat panel is ready before paste dispatch.", + "title": "Claude Code Cold Start Delay" + }, + "rangelink.destinations.claudeCode.coldRefocusIntervalMs": { + "type": "number", + "default": 300, + "minimum": 100, + "maximum": 5000, + "description": "Interval (ms) between successive focus-command re-sends during the Claude Code cold-start window.", + "title": "Claude Code Cold Re-focus Interval" + }, "rangelink.features.bookmarks.enabled": { "type": "boolean", "default": false, diff --git a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml index 48b4da5c1..53c39dafa 100644 --- a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml +++ b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml @@ -3141,6 +3141,42 @@ test_cases: command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-005"' automated: assisted + - id: claude-code-006 + labels: + - requires-extensions + - cold-start + feature: 'Built-in AI Assistants' + scenario: 'Cold-start default settings produce a valid ColdRefocusConfig where totalMs > intervalMs' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'VS Code is running with the standard test:release:with-extensions configuration' + steps: + - 'Read the rangelink.destinations.claudeCode.coldStartDelayMs and coldRefocusIntervalMs defaults from VS Code configuration' + - 'Confirm coldStartDelayMs (1500) > coldRefocusIntervalMs (300)' + expected_result: 'Default settings produce a valid ColdRefocusConfig. coldStartDelayMs is greater than coldRefocusIntervalMs.' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-006"' + automated: true + + - id: claude-code-007 + labels: + - requires-extensions + - cold-start + - validation + feature: 'Built-in AI Assistants' + scenario: 'Cold-start validation rejects invalid config (totalMs <= intervalMs) and falls back to defaults with a warning' + preconditions: + - 'Claude Code extension (anthropic.claude-code) is installed and active' + - 'VS Code is running with the standard test:release:with-extensions configuration' + steps: + - 'Set rangelink.destinations.claudeCode.coldStartDelayMs to 100 and coldRefocusIntervalMs to 400 (invalid: delay <= interval)' + - 'Bind to Claude Code Chat to trigger getColdRefocus validation' + - 'Confirm a warning log is emitted about invalid cold-start config' + - 'Confirm the fallback defaults (1500 / 300) are used instead of the invalid values' + - 'Restore original configuration values' + expected_result: 'Invalid config is rejected. Warning is logged. Defaults (2500 / 300) are used as fallback.' + command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-007"' + automated: true + - id: cursor-ai-003 labels: - clipboard diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts index 16a4da173..1a6952050 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts @@ -194,7 +194,10 @@ suite('Built-in AI Assistants', () => { 'Human reported the cold-send RangeLink did not appear in Claude Code chat', ); - // Select lines 3-4 for warm send + // Select lines 3-4 for warm send. Re-focus the editor first — the cold + // verdict dialog stole focus and the editor must be active for the send + // command to see the selection. + await vscode.window.showTextDocument(doc); editor.selection = new vscode.Selection(2, 0, 3, 6); await settle(); @@ -309,4 +312,56 @@ standardSuite('Built-in AI Assistants — Destination Picker', (log) => { log('✓ github-copilot-chat-001 — log confirms "GitHub Copilot Chat" appears in R-D picker'); }); + + test('claude-code-006: Cold-start default settings produce correct ColdRefocusConfig', async function (this: MochaContext) { + const config = vscode.workspace.getConfiguration('rangelink.destinations.claudeCode'); + const totalMs = config.get('coldStartDelayMs', 1500); + const intervalMs = config.get('coldRefocusIntervalMs', 300); + + assert.strictEqual(totalMs, 1500, 'Expected default coldStartDelayMs to be 1500'); + assert.strictEqual(intervalMs, 300, 'Expected default coldRefocusIntervalMs to be 300'); + assert.ok( + totalMs > intervalMs, + `Expected coldStartDelayMs (${totalMs}) > coldRefocusIntervalMs (${intervalMs})`, + ); + + log('✓ Default cold-start config produces valid ColdRefocusConfig'); + }); + + test('claude-code-007: Cold-start validation rejects invalid config and falls back to defaults', async function (this: MochaContext) { + if (!vscode.extensions.getExtension(CLAUDE_CODE_EXTENSION_ID)) { + log('Skipping claude-code-007 — Claude Code extension not installed in this test config'); + this.skip(); + } + + const config = vscode.workspace.getConfiguration('rangelink.destinations.claudeCode'); + + try { + // Set invalid config: delay (100) <= interval (400) + await config.update('coldStartDelayMs', 100, vscode.ConfigurationTarget.Workspace); + await config.update('coldRefocusIntervalMs', 400, vscode.ConfigurationTarget.Workspace); + await settle(); + + const logCapture = getLogCapture(); + logCapture.mark('before-cc-007'); + + await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); + await settle(); + + const lines = logCapture.getLinesSince('before-cc-007'); + const warningLog = lines.find((line) => + line.includes('coldStartDelayMs must be greater than coldRefocusIntervalMs'), + ); + assert.ok( + warningLog, + 'Expected validation warning log when coldStartDelayMs <= coldRefocusIntervalMs', + ); + + log('✓ claude-code-007 — invalid config triggers fallback to defaults'); + } finally { + // Restore config to defaults + await config.update('coldStartDelayMs', undefined, vscode.ConfigurationTarget.Workspace); + await config.update('coldRefocusIntervalMs', undefined, vscode.ConfigurationTarget.Workspace); + } + }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts b/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts index 6ab2e8bed..67b2b9e35 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts @@ -726,8 +726,36 @@ describe('package.json contributions', () => { }); }); + describe('destination settings', () => { + it('rangelink.destinations.claudeCode.coldStartDelayMs', () => { + expect(properties['rangelink.destinations.claudeCode.coldStartDelayMs']).toStrictEqual({ + type: 'number', + default: 1500, + minimum: 500, + maximum: 15000, + description: + 'Total duration (ms) of the cold-start re-focus window for Claude Code. During this window, focus commands are re-sent at the coldRefocusIntervalMs cadence to ensure the chat panel is ready before paste dispatch.', + title: 'Claude Code Cold Start Delay', + }); + }); + + it('rangelink.destinations.claudeCode.coldRefocusIntervalMs', () => { + expect(properties['rangelink.destinations.claudeCode.coldRefocusIntervalMs']).toStrictEqual( + { + type: 'number', + default: 300, + minimum: 100, + maximum: 5000, + description: + 'Interval (ms) between successive focus-command re-sends during the Claude Code cold-start window.', + title: 'Claude Code Cold Re-focus Interval', + }, + ); + }); + }); + it('has the expected number of configuration properties', () => { - expect(Object.keys(properties)).toHaveLength(15); + expect(Object.keys(properties)).toHaveLength(17); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/constants/settingDefaults.test.ts b/packages/rangelink-vscode-extension/src/__tests__/constants/settingDefaults.test.ts index ab6619060..826085a95 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/constants/settingDefaults.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/constants/settingDefaults.test.ts @@ -3,6 +3,8 @@ import { DEFAULT_DELIMITER_LINE, DEFAULT_DELIMITER_POSITION, DEFAULT_DELIMITER_RANGE, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, DEFAULT_SMART_PADDING_PASTE_BOOKMARK, DEFAULT_SMART_PADDING_PASTE_CONTENT, DEFAULT_SMART_PADDING_PASTE_LINK, @@ -46,4 +48,14 @@ describe('settingDefaults', () => { expect(DEFAULT_SMART_PADDING_PASTE_LINK).toBe('both'); }); }); + + describe('destination defaults', () => { + it('DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS is 1500', () => { + expect(DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS).toBe(1500); + }); + + it('DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS is 300', () => { + expect(DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS).toBe(300); + }); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts index 423f2598a..41710faed 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/ComposablePasteDestination.integration.test.ts @@ -139,6 +139,7 @@ describe('ComposablePasteDestination Integration Tests', () => { const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['ai.assistant.focus'], + undefined, insertFactory, mockLogger, ); @@ -188,6 +189,7 @@ describe('ComposablePasteDestination Integration Tests', () => { const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['command.first', 'command.second', 'command.third'], + undefined, insertFactory, mockLogger, ); @@ -236,6 +238,7 @@ describe('ComposablePasteDestination Integration Tests', () => { const focusCapability = new AIAssistantFocusCapability( mockAdapter, ['command.first', 'command.second'], + undefined, insertFactory, mockLogger, ); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts index 66e15e291..487a21b92 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/DestinationRegistry.test.ts @@ -8,6 +8,7 @@ import { import type { BindOptions } from '../../types'; import { createBaseMockPasteDestination, + createMockConfigReader, createMockEligibilityCheckerFactory, createMockFocusCapabilityFactory, createMockUri, @@ -17,6 +18,7 @@ import { describe('DestinationRegistry', () => { const mockLogger = createMockLogger(); const mockAdapter = createMockVscodeAdapter(); + const mockConfigReader = createMockConfigReader(); const createMockFactories = () => ({ focusCapability: createMockFocusCapabilityFactory(), @@ -28,6 +30,7 @@ describe('DestinationRegistry', () => { factories.focusCapability, factories.eligibilityChecker, mockAdapter, + mockConfigReader, mockLogger, ); @@ -96,6 +99,7 @@ describe('DestinationRegistry', () => { eligibilityChecker: factories.eligibilityChecker, }, ideAdapter: mockAdapter, + configReader: mockConfigReader, logger: mockLogger, }); }); @@ -228,16 +232,20 @@ describe('DestinationRegistry', () => { let capturedCapability: unknown; const builder: DestinationBuilder = (_options, context) => { - capturedCapability = context.factories.focusCapability.createAIAssistantCapability([ - 'focus', - ]); + capturedCapability = context.factories.focusCapability.createAIAssistantCapability( + ['focus'], + undefined, + ); return createBaseMockPasteDestination({ id: 'terminal' }); }; registry.register('terminal', builder); registry.create({ kind: 'terminal', terminal: {} as never }); expect(capturedCapability).toBe(mockCapability); - expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith(['focus']); + expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith( + ['focus'], + undefined, + ); }); it('should allow mocking EligibilityCheckerFactory methods in builder', () => { @@ -315,9 +323,10 @@ describe('DestinationRegistry', () => { const registry = createRegistry(factories); const builder: DestinationBuilder = (_options, context) => { - const capability = context.factories.focusCapability.createAIAssistantCapability([ - 'focus.cmd', - ]); + const capability = context.factories.focusCapability.createAIAssistantCapability( + ['focus.cmd'], + undefined, + ); const checker = context.factories.eligibilityChecker.createContentEligibilityChecker(); expect(capability).toBe(mockCapability); @@ -330,9 +339,10 @@ describe('DestinationRegistry', () => { const destination = registry.create({ kind: 'cursor-ai' }); expect(destination).toBeDefined(); - expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith([ - 'focus.cmd', - ]); + expect(factories.focusCapability.createAIAssistantCapability).toHaveBeenCalledWith( + ['focus.cmd'], + undefined, + ); expect(factories.eligibilityChecker.createContentEligibilityChecker).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts new file mode 100644 index 000000000..56e927ed1 --- /dev/null +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts @@ -0,0 +1,208 @@ +import type { LoggingContext } from 'barebone-logger'; +import { createMockLogger } from 'barebone-logger-testing'; + +import { AIAssistantFocusCapability } from '../../../destinations/capabilities/AIAssistantFocusCapability'; +import type { ColdRefocusConfig } from '../../../destinations/capabilities/ColdRefocusConfig'; +import { FocusErrorReason } from '../../../destinations/capabilities/FocusCapability'; +import type { FocusResult } from '../../../destinations/capabilities/FocusCapability'; +import type { InsertFactory } from '../../../destinations/capabilities/insertFactories'; +import { createMockVscodeAdapter } from '../../helpers'; + +const FOCUS_COMMANDS = ['ai.focus']; +const CTX: LoggingContext = { fn: 'test' }; + +const createMockInsertFactory = (): jest.Mocked> => ({ + forTarget: jest.fn().mockReturnValue(undefined), +}); + +describe('AIAssistantFocusCapability', () => { + let mockAdapter: ReturnType; + let mockLogger: ReturnType; + let mockInsertFactory: jest.Mocked>; + + beforeEach(() => { + jest.useFakeTimers(); + mockAdapter = createMockVscodeAdapter(); + mockLogger = createMockLogger(); + mockInsertFactory = createMockInsertFactory(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + const createCapability = ( + commands: string[] = FOCUS_COMMANDS, + getColdRefocus?: () => ColdRefocusConfig, + ): AIAssistantFocusCapability => + new AIAssistantFocusCapability( + mockAdapter, + commands, + getColdRefocus, + mockInsertFactory, + mockLogger, + ); + + it('succeeds on first focus command and returns inserter', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const capability = createCapability(); + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockAdapter.executeCommand).toHaveBeenCalledWith('ai.focus'); + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'test', command: 'ai.focus' }, + 'Focus command succeeded', + ); + }); + + it('falls back through command list until one succeeds', async () => { + jest + .spyOn(mockAdapter, 'executeCommand') + .mockRejectedValueOnce(new Error('first failed')) + .mockResolvedValueOnce(undefined); + const capability = createCapability(['cmd.a', 'cmd.b', 'cmd.c']); + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(2); + expect(mockAdapter.executeCommand).toHaveBeenNthCalledWith(1, 'cmd.a'); + expect(mockAdapter.executeCommand).toHaveBeenNthCalledWith(2, 'cmd.b'); + }); + + it('returns error when all focus commands fail', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockRejectedValue(new Error('all failed')); + const capability = createCapability(['cmd.a', 'cmd.b']); + const result = await capability.focus(CTX); + + expect(result).toBeErrWith((error: FocusResult['error']) => { + expect(error.reason).toBe(FocusErrorReason.COMMAND_FOCUS_FAILED); + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'test', allCommandsFailed: true }, + 'All focus commands failed', + ); + }); + + it('waits FOCUS_TO_PASTE_DELAY_MS when no coldRefocus configured (warm delay)', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const capability = createCapability(); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + }); + + it('waits FOCUS_TO_PASTE_DELAY_MS on second focus after cold-start (warm)', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 900, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const firstFocus = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(900); + await firstFocus; + + const secondFocus = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await secondFocus; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + }); + + it('re-fires focus commands at each interval during cold-start', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 900, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + + await jest.advanceTimersByTimeAsync(900); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(3); + }); + + it('does not refocus on warm path even with coldRefocus configured', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 900, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const firstFocus = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(900); + await firstFocus; + + (mockAdapter.executeCommand as jest.Mock).mockClear(); + + const secondFocus = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + await secondFocus; + + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); + }); + + it('logs elapsed time after cold refocus loop', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 900, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(900); + await focusPromise; + + expect(mockLogger.debug).toHaveBeenCalledWith( + { fn: 'test', totalMs: expect.any(Number) as number, intervalMs: 300 }, + 'Cold refocus loop completed', + ); + }); + + it('falls back to warm delay when intervalMs is 0', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 2500, intervalMs: 0 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'test', totalMs: 2500, intervalMs: 0 }, + 'Invalid cold refocus config, falling back to warm delay', + ); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); + }); + + it('falls back to warm delay when totalMs is 0', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 0, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts index 2cf2d6f66..e2ca82d4d 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/FocusCapabilityFactory.test.ts @@ -32,7 +32,10 @@ describe('FocusCapabilityFactory', () => { }); it('creates AIAssistantFocusCapability', () => { - const capability = factory.createAIAssistantCapability(['workbench.action.chat.open']); + const capability = factory.createAIAssistantCapability( + ['workbench.action.chat.open'], + undefined, + ); expect(capability).toBeInstanceOf(AIAssistantFocusCapability); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts index af3387613..f52d5e494 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts @@ -9,9 +9,9 @@ import { type DestinationBuilder, registerAllDestinationBuilders, } from '../../destinations'; -import { AutoPasteResult } from '../../types'; -import type { DestinationKind } from '../../types'; +import { AutoPasteResult, type DestinationKind } from '../../types'; import { + createMockConfigReader, createMockDocument, createMockEditor, createMockEligibilityCheckerFactory, @@ -35,6 +35,7 @@ describe('destinationBuilders', () => { eligibilityChecker: createMockEligibilityCheckerFactory(), }, ideAdapter: createMockVscodeAdapter(adapterOverrides), + configReader: createMockConfigReader(), logger: mockLogger, }); @@ -730,4 +731,80 @@ describe('destinationBuilders', () => { expect(builders.has('custom-ai:anthropic.claude-code')).toBe(false); }); }); + + describe('getColdRefocus', () => { + it('provides a getColdRefocus function for Claude Code', () => { + const builder = getBuiltinBuilder('claude-code'); + const context = createMockContext(); + builder({ kind: 'claude-code' }, context); + + const [, getColdRefocusArg] = ( + context.factories.focusCapability.createAIAssistantCapability as jest.Mock + ).mock.calls[0]; + expect(typeof getColdRefocusArg).toBe('function'); + + const result = getColdRefocusArg(); + expect(result).toStrictEqual({ + totalMs: 1500, + intervalMs: 300, + }); + }); + + it('does not provide getColdRefocus for non-Claude-Code assistants', () => { + const builder = getBuiltinBuilder('cursor-ai'); + const context = createMockContext(); + builder({ kind: 'cursor-ai' }, context); + + const [, getColdRefocusArg] = ( + context.factories.focusCapability.createAIAssistantCapability as jest.Mock + ).mock.calls[0]; + expect(getColdRefocusArg).toBeUndefined(); + }); + + it('uses config values from settings when valid', () => { + const builder = getBuiltinBuilder('claude-code'); + const context = createMockContext(); + context.configReader.getWithDefault = jest.fn().mockImplementation((_key: string) => { + if (_key === 'destinations.claudeCode.coldStartDelayMs') return 5000; + if (_key === 'destinations.claudeCode.coldRefocusIntervalMs') return 500; + return undefined; + }); + + builder({ kind: 'claude-code' }, context); + const [, getColdRefocusFn] = ( + context.factories.focusCapability.createAIAssistantCapability as jest.Mock + ).mock.calls[0]; + + const result = getColdRefocusFn(); + expect(result).toStrictEqual({ + totalMs: 5000, + intervalMs: 500, + }); + }); + + it('falls back to defaults when config values are invalid (totalMs <= intervalMs)', () => { + const builder = getBuiltinBuilder('claude-code'); + const context = createMockContext(); + context.configReader.getWithDefault = jest.fn().mockImplementation((_key: string) => { + if (_key === 'destinations.claudeCode.coldStartDelayMs') return 100; + if (_key === 'destinations.claudeCode.coldRefocusIntervalMs') return 500; + return undefined; + }); + + builder({ kind: 'claude-code' }, context); + const [, getColdRefocusFn] = ( + context.factories.focusCapability.createAIAssistantCapability as jest.Mock + ).mock.calls[0]; + + const result = getColdRefocusFn(); + expect(result).toStrictEqual({ + totalMs: 1500, + intervalMs: 300, + }); + expect(context.logger.warn).toHaveBeenCalledWith( + { fn: 'claudeCode.getColdRefocus', totalMs: 100, intervalMs: 500 }, + 'coldStartDelayMs must be greater than coldRefocusIntervalMs, using defaults', + ); + }); + }); }); diff --git a/packages/rangelink-vscode-extension/src/constants/settingDefaults.ts b/packages/rangelink-vscode-extension/src/constants/settingDefaults.ts index 04594cc3a..e660334ac 100644 --- a/packages/rangelink-vscode-extension/src/constants/settingDefaults.ts +++ b/packages/rangelink-vscode-extension/src/constants/settingDefaults.ts @@ -31,6 +31,13 @@ export const DEFAULT_DELIMITER_LINE = DEFAULT_DELIMITERS.line; export const DEFAULT_DELIMITER_POSITION = DEFAULT_DELIMITERS.position; export const DEFAULT_DELIMITER_RANGE = DEFAULT_DELIMITERS.range; +// ============================================================================= +// Destination Defaults +// ============================================================================= + +export const DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS = 300; +export const DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS = 1500; + // ============================================================================= // Navigation Defaults // ============================================================================= diff --git a/packages/rangelink-vscode-extension/src/constants/settingKeys.ts b/packages/rangelink-vscode-extension/src/constants/settingKeys.ts index df775cb12..9908faf35 100644 --- a/packages/rangelink-vscode-extension/src/constants/settingKeys.ts +++ b/packages/rangelink-vscode-extension/src/constants/settingKeys.ts @@ -17,6 +17,17 @@ export const SETTING_NAMESPACE = 'rangelink'; export const SETTING_CLIPBOARD_PRESERVE = 'clipboard.preserve'; +// ============================================================================= +// Destination Settings +// ============================================================================= + +export const SETTINGS_DESTINATIONS_PREFIX = 'destinations.'; + +export const SETTING_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS = + 'destinations.claudeCode.coldRefocusIntervalMs'; +export const SETTING_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS = + 'destinations.claudeCode.coldStartDelayMs'; + // ============================================================================= // Feature Flags — TODO: #366 remove when bookmarks graduates from beta // ============================================================================= diff --git a/packages/rangelink-vscode-extension/src/createWiringServices.ts b/packages/rangelink-vscode-extension/src/createWiringServices.ts index 8d689018a..971a8937e 100644 --- a/packages/rangelink-vscode-extension/src/createWiringServices.ts +++ b/packages/rangelink-vscode-extension/src/createWiringServices.ts @@ -98,6 +98,7 @@ export const createWiringServices = ( focusCapabilityFactory, eligibilityCheckerFactory, ideAdapter, + configReader, logger, ); const customAssistants = parseCustomAiAssistants(configReader, logger); diff --git a/packages/rangelink-vscode-extension/src/destinations/DestinationRegistry.ts b/packages/rangelink-vscode-extension/src/destinations/DestinationRegistry.ts index fde5073e5..033dfb413 100644 --- a/packages/rangelink-vscode-extension/src/destinations/DestinationRegistry.ts +++ b/packages/rangelink-vscode-extension/src/destinations/DestinationRegistry.ts @@ -1,5 +1,6 @@ import type { Logger } from 'barebone-logger'; +import type { ConfigReader } from '../config/ConfigReader'; import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors'; import type { VscodeAdapter } from '../ide/vscode/VscodeAdapter'; import { MessageCode } from '../types'; @@ -44,6 +45,7 @@ export interface DestinationBuilderFactories { export interface DestinationBuilderContext { readonly factories: DestinationBuilderFactories; readonly ideAdapter: VscodeAdapter; + readonly configReader: ConfigReader; readonly logger: Logger; } @@ -84,6 +86,7 @@ export class DestinationRegistry { focusCapabilityFactory: FocusCapabilityFactory, eligibilityCheckerFactory: EligibilityCheckerFactory, ideAdapter: VscodeAdapter, + configReader: ConfigReader, logger: Logger, ) { this.context = { @@ -92,6 +95,7 @@ export class DestinationRegistry { eligibilityChecker: eligibilityCheckerFactory, }, ideAdapter, + configReader, logger, }; } diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts index 106fdc0cd..17c6c6668 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts @@ -1,8 +1,10 @@ import type { Logger, LoggingContext } from 'barebone-logger'; import { Result } from 'rangelink-core-ts'; +import { FOCUS_TO_PASTE_DELAY_MS } from '../../constants/aiAssistantPasteConstants'; import type { VscodeAdapter } from '../../ide/vscode/VscodeAdapter'; +import type { ColdRefocusConfig } from './ColdRefocusConfig'; import { FocusErrorReason, type FocusCapability, type FocusResult } from './FocusCapability'; import type { InsertFactory } from './insertFactories'; @@ -10,14 +12,19 @@ import type { InsertFactory } from './insertFactories'; * FocusCapability for AI assistant destinations. * * Executes focus commands to open the AI assistant panel. + * On cold start (first focus after bind), re-fires focus commands + * at intervals to keep the panel open while it initializes. * Uses InsertFactory injection for decoupled clipboard-based paste. * * Used by: Claude Code, Cursor AI, GitHub Copilot Chat */ export class AIAssistantFocusCapability implements FocusCapability { + private panelIsWarm = false; + constructor( private readonly ideAdapter: VscodeAdapter, private readonly focusCommands: string[], + private readonly getColdRefocus: (() => ColdRefocusConfig) | undefined, private readonly insertFactory: InsertFactory, private readonly logger: Logger, ) {} @@ -28,6 +35,16 @@ export class AIAssistantFocusCapability implements FocusCapability { await this.ideAdapter.executeCommand(command); this.logger.debug({ ...context, command }, 'Focus command succeeded'); + const coldRefocus = this.getColdRefocus?.(); + + if (!this.panelIsWarm && coldRefocus) { + await this.refocusDuring(context, coldRefocus); + } else { + await new Promise((resolve) => setTimeout(resolve, FOCUS_TO_PASTE_DELAY_MS)); + } + + this.panelIsWarm = true; + return Result.ok({ inserter: this.insertFactory.forTarget(), }); @@ -41,4 +58,42 @@ export class AIAssistantFocusCapability implements FocusCapability { reason: FocusErrorReason.COMMAND_FOCUS_FAILED, }); } + + private async refocusDuring(context: LoggingContext, refocus: ColdRefocusConfig): Promise { + if (refocus.totalMs <= 0 || refocus.intervalMs <= 0) { + this.logger.warn( + { ...context, totalMs: refocus.totalMs, intervalMs: refocus.intervalMs }, + 'Invalid cold refocus config, falling back to warm delay', + ); + await new Promise((resolve) => setTimeout(resolve, FOCUS_TO_PASTE_DELAY_MS)); + return; + } + + const start = Date.now(); + let elapsed = 0; + + while (elapsed < refocus.totalMs) { + const waitMs = Math.min(refocus.intervalMs, refocus.totalMs - elapsed); + await new Promise((resolve) => setTimeout(resolve, waitMs)); + elapsed += waitMs; + + if (elapsed >= refocus.totalMs) { + break; + } + + for (const command of this.focusCommands) { + try { + await this.ideAdapter.executeCommand(command); + break; + } catch { + // try next command + } + } + } + + this.logger.debug( + { ...context, totalMs: Date.now() - start, intervalMs: refocus.intervalMs }, + 'Cold refocus loop completed', + ); + } } diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/ColdRefocusConfig.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/ColdRefocusConfig.ts new file mode 100644 index 000000000..79359b1b7 --- /dev/null +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/ColdRefocusConfig.ts @@ -0,0 +1,14 @@ +/** + * Configuration for the cold-start re-focus loop. + * + * When an AI assistant's chat panel is first opened (cold start), the Webview's + * IPC clipboard reader may not be ready. The re-focus loop re-sends focus commands + * at `intervalMs` ticks for up to `totalMs`, giving the panel time to initialize + * before paste dispatch. + */ +export interface ColdRefocusConfig { + /** Total duration of the re-focus window in milliseconds. */ + readonly totalMs: number; + /** Interval between successive focus-command sends within the window. */ + readonly intervalMs: number; +} diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts index dd5d258d5..39c11ba21 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/FocusCapabilityFactory.ts @@ -6,6 +6,7 @@ import type { VscodeAdapter } from '../../ide/vscode/VscodeAdapter'; import type { FocusTier } from '../types'; import { AIAssistantFocusCapability } from './AIAssistantFocusCapability'; +import type { ColdRefocusConfig } from './ColdRefocusConfig'; import { EditorFocusCapability } from './EditorFocusCapability'; import type { FocusCapability } from './FocusCapability'; import { @@ -49,10 +50,14 @@ export class FocusCapabilityFactory { ); } - createAIAssistantCapability(focusCommands: string[]): FocusCapability { + createAIAssistantCapability( + focusCommands: string[], + getColdRefocus: (() => ColdRefocusConfig) | undefined, + ): FocusCapability { return new AIAssistantFocusCapability( this.ideAdapter, focusCommands, + getColdRefocus, new AIAssistantInsertFactory(this.ideAdapter, this.logger), this.logger, ); diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/index.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/index.ts index a39fe997b..9f052d790 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/index.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/index.ts @@ -13,6 +13,8 @@ export { AIAssistantFocusCapability } from './AIAssistantFocusCapability'; export { EditorFocusCapability } from './EditorFocusCapability'; export { TerminalFocusCapability } from './TerminalFocusCapability'; export { FocusCapabilityFactory } from './FocusCapabilityFactory'; +export { LazyResolvedFocusCapability } from './LazyResolvedFocusCapability'; +export { ColdRefocusConfig } from './ColdRefocusConfig'; export type { InsertFactory } from './insertFactories'; export { diff --git a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts index cb87b0af7..e83ac4681 100644 --- a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts +++ b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts @@ -8,6 +8,14 @@ import type * as vscode from 'vscode'; import type { CustomAiAssistantConfig } from '../config/parseCustomAiAssistants'; +import { + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, +} from '../constants/settingDefaults'; +import { + SETTING_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + SETTING_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, +} from '../constants/settingKeys'; import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors'; import { AutoPasteResult, @@ -29,6 +37,7 @@ import { CURSOR_AI_FOCUS_COMMANDS, GITHUB_COPILOT_CHAT_FOCUS_COMMANDS, } from './aiAssistantFocusCommands'; +import type { ColdRefocusConfig } from './capabilities/ColdRefocusConfig'; import { ComposablePasteDestination } from './ComposablePasteDestination'; import type { DestinationBuilder, DestinationBuilderContext } from './DestinationRegistry'; import { compareEditorsByUri } from './equality/compareEditorsByUri'; @@ -46,6 +55,7 @@ interface BuiltinAiAssistantDef { readonly jumpMessageCode: MessageCode; readonly userInstructionMessageCode: MessageCode; readonly isAvailable: (context: DestinationBuilderContext) => boolean | Promise; + readonly getColdRefocus?: (context: DestinationBuilderContext) => ColdRefocusConfig; } const BUILTIN_AI_ASSISTANTS: Record = { @@ -64,6 +74,29 @@ const BUILTIN_AI_ASSISTANTS: Record = { jumpMessageCode: MessageCode.STATUS_BAR_JUMP_SUCCESS_CLAUDE_CODE, userInstructionMessageCode: MessageCode.INFO_CLAUDE_CODE_USER_INSTRUCTIONS, isAvailable: (ctx) => isClaudeCodeAvailable(ctx.ideAdapter, ctx.logger), + getColdRefocus: (context) => { + const totalMs = context.configReader.getWithDefault( + SETTING_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, + ) as number; + const intervalMs = context.configReader.getWithDefault( + SETTING_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + ) as number; + + if (totalMs <= intervalMs) { + context.logger.warn( + { fn: 'claudeCode.getColdRefocus', totalMs, intervalMs }, + 'coldStartDelayMs must be greater than coldRefocusIntervalMs, using defaults', + ); + return { + totalMs: DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_START_DELAY_MS, + intervalMs: DEFAULT_DESTINATIONS_CLAUDE_CODE_COLD_REFOCUS_INTERVAL_MS, + }; + } + + return { totalMs, intervalMs }; + }, }, 'github.copilot-chat': { kind: 'github-copilot-chat', @@ -180,9 +213,10 @@ const buildBuiltinAiAssistantDestination = ( ComposablePasteDestination.createAiAssistant({ id: def.kind, displayName: def.displayName, - focusCapability: context.factories.focusCapability.createAIAssistantCapability([ - ...def.focusCommands, - ]), + focusCapability: context.factories.focusCapability.createAIAssistantCapability( + [...def.focusCommands], + def.getColdRefocus !== undefined ? () => def.getColdRefocus!(context) : undefined, + ), isAvailable: async () => def.isAvailable(context), jumpSuccessMessage: formatMessage(def.jumpMessageCode), loggingDetails: {}, From 2141ae02a0dda5d03f60d7b62f6a5efd440843c2 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 08:17:03 -0400 Subject: [PATCH 02/11] [PR feedback] Address 4 genuine findings, tune default to 1500, remove hardcoded values from QA YAML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied the 4 valid CodeRabbit findings from the review: fixed QA YAML inconsistency where expected_result still referenced 2500 while the step had been updated to 1500 (both now use setting names instead of hardcoded values so they age when defaults change), fixed T003 violation using enum constant in assertion instead of string literal, fixed T006 violation accessing .mock.calls[0] directly instead of Jest matchers, and added totalMs <= intervalMs validation at the capability level so callers that bypass builder validation still get the guard. Lowered coldStartDelayMs default from 2500 to 1500 after real-world testing showed Claude Code launches significantly faster than Gemini — validated across multiple cold-start test runs. Fixed cc-005 warm-send editor focus loss after verdict dialog. Benefits: - No hardcoded numbers in QA labels — future default changes won't create stale YAML - Capability-level guard matches builder-level guard — no silent first-paste loss through bypass paths - Tests survive T003/T006 audit Ignored Feedback: - Revert default to 2500 (3238771995, 3238772004, 3238772013): 1500 was validated across 3 cold-start test runs and is the correct default for Claude Code - Use constants instead of literals in integration test assertions (3238772006): T003 requires literals in assertions to freeze contracts; importing the constant would test the constant equals itself Ref: https://github.com/couimet/rangeLink/pull/567#pullrequestreview-4286916674 --- .../qa/qa-test-cases-v1.1.0.yaml | 6 ++-- .../AIAssistantFocusCapability.test.ts | 22 ++++++++++++-- .../destinations/destinationBuilders.test.ts | 29 +++++++++++-------- .../AIAssistantFocusCapability.ts | 2 +- 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml index 53c39dafa..2a8b71ba9 100644 --- a/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml +++ b/packages/rangelink-vscode-extension/qa/qa-test-cases-v1.1.0.yaml @@ -3152,7 +3152,7 @@ test_cases: - 'VS Code is running with the standard test:release:with-extensions configuration' steps: - 'Read the rangelink.destinations.claudeCode.coldStartDelayMs and coldRefocusIntervalMs defaults from VS Code configuration' - - 'Confirm coldStartDelayMs (1500) > coldRefocusIntervalMs (300)' + - 'Confirm coldStartDelayMs > coldRefocusIntervalMs' expected_result: 'Default settings produce a valid ColdRefocusConfig. coldStartDelayMs is greater than coldRefocusIntervalMs.' command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-006"' automated: true @@ -3171,9 +3171,9 @@ test_cases: - 'Set rangelink.destinations.claudeCode.coldStartDelayMs to 100 and coldRefocusIntervalMs to 400 (invalid: delay <= interval)' - 'Bind to Claude Code Chat to trigger getColdRefocus validation' - 'Confirm a warning log is emitted about invalid cold-start config' - - 'Confirm the fallback defaults (1500 / 300) are used instead of the invalid values' + - 'Confirm the fallback defaults from coldStartDelayMs / coldRefocusIntervalMs are used instead of the invalid values' - 'Restore original configuration values' - expected_result: 'Invalid config is rejected. Warning is logged. Defaults (2500 / 300) are used as fallback.' + expected_result: 'Invalid config is rejected. Warning is logged. Defaults from coldStartDelayMs / coldRefocusIntervalMs are used as fallback.' command_to_run: 'pnpm test:release:with-extensions --grep "claude-code-007"' automated: true diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts index 56e927ed1..af57b2fe2 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/capabilities/AIAssistantFocusCapability.test.ts @@ -3,7 +3,6 @@ import { createMockLogger } from 'barebone-logger-testing'; import { AIAssistantFocusCapability } from '../../../destinations/capabilities/AIAssistantFocusCapability'; import type { ColdRefocusConfig } from '../../../destinations/capabilities/ColdRefocusConfig'; -import { FocusErrorReason } from '../../../destinations/capabilities/FocusCapability'; import type { FocusResult } from '../../../destinations/capabilities/FocusCapability'; import type { InsertFactory } from '../../../destinations/capabilities/insertFactories'; import { createMockVscodeAdapter } from '../../helpers'; @@ -84,7 +83,7 @@ describe('AIAssistantFocusCapability', () => { const result = await capability.focus(CTX); expect(result).toBeErrWith((error: FocusResult['error']) => { - expect(error.reason).toBe(FocusErrorReason.COMMAND_FOCUS_FAILED); + expect(error.reason).toBe('COMMAND_FOCUS_FAILED'); }); expect(mockLogger.warn).toHaveBeenCalledWith( { fn: 'test', allCommandsFailed: true }, @@ -205,4 +204,23 @@ describe('AIAssistantFocusCapability', () => { }); expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); }); + + it('falls back to warm delay when totalMs <= intervalMs (positive-but-invalid)', async () => { + jest.spyOn(mockAdapter, 'executeCommand').mockResolvedValue(undefined); + const coldRefocus = (): ColdRefocusConfig => ({ totalMs: 300, intervalMs: 300 }); + const capability = createCapability(FOCUS_COMMANDS, coldRefocus); + + const focusPromise = capability.focus(CTX); + await jest.advanceTimersByTimeAsync(200); + const result = await focusPromise; + + expect(result).toBeOkWith((value: FocusResult['value']) => { + expect(value.inserter).toBeUndefined(); + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'test', totalMs: 300, intervalMs: 300 }, + 'Invalid cold refocus config, falling back to warm delay', + ); + expect(mockAdapter.executeCommand).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts index f52d5e494..810b97a7b 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts @@ -738,9 +738,11 @@ describe('destinationBuilders', () => { const context = createMockContext(); builder({ kind: 'claude-code' }, context); - const [, getColdRefocusArg] = ( - context.factories.focusCapability.createAIAssistantCapability as jest.Mock - ).mock.calls[0]; + const createCapabilityMock = context.factories.focusCapability + .createAIAssistantCapability as jest.Mock; + expect(createCapabilityMock).toHaveBeenCalledTimes(1); + const [capabilities, getColdRefocusArg] = createCapabilityMock.mock.calls[0]; + expect(capabilities).toStrictEqual(['claude-vscode.focus', 'claude-vscode.sidebar.open', 'claude-vscode.editor.open']); expect(typeof getColdRefocusArg).toBe('function'); const result = getColdRefocusArg(); @@ -755,9 +757,10 @@ describe('destinationBuilders', () => { const context = createMockContext(); builder({ kind: 'cursor-ai' }, context); - const [, getColdRefocusArg] = ( - context.factories.focusCapability.createAIAssistantCapability as jest.Mock - ).mock.calls[0]; + const createCapabilityMock = context.factories.focusCapability + .createAIAssistantCapability as jest.Mock; + expect(createCapabilityMock).toHaveBeenCalledTimes(1); + const [, getColdRefocusArg] = createCapabilityMock.mock.calls[0]; expect(getColdRefocusArg).toBeUndefined(); }); @@ -771,9 +774,10 @@ describe('destinationBuilders', () => { }); builder({ kind: 'claude-code' }, context); - const [, getColdRefocusFn] = ( - context.factories.focusCapability.createAIAssistantCapability as jest.Mock - ).mock.calls[0]; + const createCapabilityMock = context.factories.focusCapability + .createAIAssistantCapability as jest.Mock; + expect(createCapabilityMock).toHaveBeenCalledTimes(1); + const [, getColdRefocusFn] = createCapabilityMock.mock.calls[0]; const result = getColdRefocusFn(); expect(result).toStrictEqual({ @@ -792,9 +796,10 @@ describe('destinationBuilders', () => { }); builder({ kind: 'claude-code' }, context); - const [, getColdRefocusFn] = ( - context.factories.focusCapability.createAIAssistantCapability as jest.Mock - ).mock.calls[0]; + const createCapabilityMock = context.factories.focusCapability + .createAIAssistantCapability as jest.Mock; + expect(createCapabilityMock).toHaveBeenCalledTimes(1); + const [, getColdRefocusFn] = createCapabilityMock.mock.calls[0]; const result = getColdRefocusFn(); expect(result).toStrictEqual({ diff --git a/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts b/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts index 17c6c6668..b57eec82e 100644 --- a/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts +++ b/packages/rangelink-vscode-extension/src/destinations/capabilities/AIAssistantFocusCapability.ts @@ -60,7 +60,7 @@ export class AIAssistantFocusCapability implements FocusCapability { } private async refocusDuring(context: LoggingContext, refocus: ColdRefocusConfig): Promise { - if (refocus.totalMs <= 0 || refocus.intervalMs <= 0) { + if (refocus.totalMs <= 0 || refocus.intervalMs <= 0 || refocus.totalMs <= refocus.intervalMs) { this.logger.warn( { ...context, totalMs: refocus.totalMs, intervalMs: refocus.intervalMs }, 'Invalid cold refocus config, falling back to warm delay', From e0247f762e1cae26406b9e3c043fcd6296fdfb1d Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 08:18:19 -0400 Subject: [PATCH 03/11] Ran `pnpm fix` --- .../src/__tests__/destinations/destinationBuilders.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts index 810b97a7b..de560eaf2 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts @@ -742,7 +742,11 @@ describe('destinationBuilders', () => { .createAIAssistantCapability as jest.Mock; expect(createCapabilityMock).toHaveBeenCalledTimes(1); const [capabilities, getColdRefocusArg] = createCapabilityMock.mock.calls[0]; - expect(capabilities).toStrictEqual(['claude-vscode.focus', 'claude-vscode.sidebar.open', 'claude-vscode.editor.open']); + expect(capabilities).toStrictEqual([ + 'claude-vscode.focus', + 'claude-vscode.sidebar.open', + 'claude-vscode.editor.open', + ]); expect(typeof getColdRefocusArg).toBe('function'); const result = getColdRefocusArg(); From 32ea1781d3b0d2c9a2dea6b0f9d7f24573b8e439 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 09:15:43 -0400 Subject: [PATCH 04/11] Add a call to R-J to force focus and trigger the expected log msg --- .../__integration-tests__/suite/builtInAiAssistants.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts index 1a6952050..9bbc29404 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts @@ -7,6 +7,7 @@ import { CMD_BIND_TO_CLAUDE_CODE, CMD_BIND_TO_DESTINATION, CMD_COPY_LINK_RELATIVE, + CMD_JUMP_TO_DESTINATION, CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { CLAUDE_CODE_EXTENSION_ID } from '../../utils/aiAssistants/isClaudeCodeAvailable'; @@ -348,6 +349,9 @@ standardSuite('Built-in AI Assistants — Destination Picker', (log) => { await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); await settle(); + await vscode.commands.executeCommand(CMD_JUMP_TO_DESTINATION); + await settle(); + const lines = logCapture.getLinesSince('before-cc-007'); const warningLog = lines.find((line) => line.includes('coldStartDelayMs must be greater than coldRefocusIntervalMs'), From c72339bcd9b8424a63f429f68359126182720a14 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 10:07:11 -0400 Subject: [PATCH 05/11] [refactor] Centralize unbind and closeAllEditors into standardSuite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration test suite had duplicated cleanup logic across 20 files — each teardown called unbind and closeAllEditors individually. This caused drift: some teardowns missed `await settle()` after unbind, leaving pending promise work that bled into the next suite. When a suite left a terminal bound and the next suite's setup tried to bind, the pending unbind resolved after the new bind, making VS Code see an already-bound state and show a stuck confirmation picker that nobody dismissed. Moving unbind (setup + suiteTeardown) and closeAllEditors (suiteTeardown) into standardSuite makes cleanup the framework's responsibility. Tests no longer opt in to correct behavior — they get it by construction. Also converts the last remaining raw `suite()` to `standardSuite()` (builtInAiAssistants) and fixes claude-code-007 by adding CMD_JUMP_TO_DESTINATION to trigger the focus path where cold-refocus validation warnings live. Benefits: - Single source of truth for test lifecycle cleanup — impossible for individual suites to forget or mis-order operations - Eliminates the class of bugs where missing `await settle()` after state mutations causes cross-suite contamination - 91 lines of duplicated boilerplate removed; each test file's teardown now only manages its own fixtures --- .../__integration-tests__/helpers/standardSuite.ts | 7 +++++++ .../suite/bindToDestination.test.ts | 2 -- .../suite/builtInAiAssistants.test.ts | 13 +------------ .../suite/clipboardPreservation.test.ts | 6 ------ .../suite/contextMenuEditorContent.test.ts | 4 ---- .../suite/contextMenuEditorTab.test.ts | 5 +---- .../suite/contextMenuExplorer.test.ts | 8 +------- .../suite/contextMenuTerminal.test.ts | 7 ------- .../suite/coreSendCommands.test.ts | 7 ------- .../suite/customAiAssistants.test.ts | 5 ----- .../suite/dirtyBufferWarning.test.ts | 4 ---- .../suite/editorBindingValidation.test.ts | 5 +---- .../__integration-tests__/suite/filePicker.test.ts | 3 --- .../suite/goToRangeLink.test.ts | 2 -- .../suite/linkGeneration.test.ts | 2 -- .../suite/sendFilePath.test.ts | 4 ---- .../suite/smartPadding.test.ts | 7 ------- .../suite/statusBarMenu.test.ts | 5 ----- .../suite/terminalPicker.test.ts | 2 -- .../suite/textEditorDestination.test.ts | 4 ---- 20 files changed, 11 insertions(+), 91 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts index 0c880b51c..25bdce78d 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts @@ -1,3 +1,7 @@ +import * as vscode from 'vscode'; + +import { CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; + import { closeAllEditors } from './fileHelpers'; import { createLogger } from './logHelpers'; import { resetRangelinkSettings } from './settingsHelpers'; @@ -13,10 +17,13 @@ export const standardSuite = (name: string, fn: (log: (msg: string) => void) => setup(async () => { await resetRangelinkSettings(log); + await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); await settle(); }); suiteTeardown(async () => { + await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); + await settle(); await closeAllEditors(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/bindToDestination.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/bindToDestination.test.ts index 45a02384e..877484078 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/bindToDestination.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/bindToDestination.test.ts @@ -29,12 +29,10 @@ standardSuite('R-D Bind to Destination', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts index 9bbc29404..3b2b0cd2a 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts @@ -8,15 +8,11 @@ import { CMD_BIND_TO_DESTINATION, CMD_COPY_LINK_RELATIVE, CMD_JUMP_TO_DESTINATION, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { CLAUDE_CODE_EXTENSION_ID } from '../../utils/aiAssistants/isClaudeCodeAvailable'; import { - activateExtension, assertStatusBarMsgLogged, cleanupFiles, - closeAllEditors, - createLogger, createWorkspaceFile, extractQuickPickItemsLogged, getLogCapture, @@ -31,17 +27,10 @@ const AI_ASSISTANTS_GROUP_LABEL = 'AI Assistants'; const CLAUDE_CODE_DISPLAY_NAME = 'Claude Code Chat'; const CLAUDE_CODE_BIND_STATUS_BAR_MESSAGE = '✓ RangeLink bound to Claude Code Chat'; -suite('Built-in AI Assistants', () => { - const log = createLogger('builtInAiAssistants'); +standardSuite('Built-in AI Assistants', (log) => { const tmpFileUris: vscode.Uri[] = []; - suiteSetup(async () => { - await activateExtension(); - }); - teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts index 549ad16c9..4b0655682 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts @@ -7,7 +7,6 @@ import { CMD_COPY_LINK_RELATIVE, CMD_PASTE_CURRENT_FILE_PATH_RELATIVE, CMD_TERMINAL_PASTE_SELECTED_TEXT, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { VSCODE_CMD_TERMINAL_SELECT_ALL } from '../../constants/vscodeCommandIds'; import { @@ -16,7 +15,6 @@ import { assertTerminalBufferContains, type CapturingTerminal, cleanupFiles, - closeAllEditors, CLIPBOARD_SENTINEL, createAndBindCapturingTerminal, createTerminal, @@ -123,10 +121,8 @@ standardSuite('Clipboard Preservation — Assisted', (log) => { const tmpTerminals: vscode.Terminal[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); await vscode.commands.executeCommand('dummyAi.clearAll'); for (const t of tmpTerminals.splice(0)) t.dispose(); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.splice(0); await settle(); @@ -301,8 +297,6 @@ standardSuite('Clipboard Preservation — Assisted', (log) => { }); test('clipboard-preservation-009: always mode — dismissed picker leaves clipboard unchanged', async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - const lines = Array.from({ length: 5 }, (_, i) => `line ${i + 1}`); const fileUri = createWorkspaceFile('cbp-009', lines.join('\n') + '\n'); tmpFileUris.push(fileUri); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts index 216b54c3f..3f4898fb2 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorContent.test.ts @@ -3,7 +3,6 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; import { assertClipboardWriteLogged, assertFilePathLogged, @@ -12,7 +11,6 @@ import { assertStatusBarMsgLogged, assertTerminalBufferContains, cleanupFiles, - closeAllEditors, createAndBindCapturingTerminal, createAndOpenFile, getLogCapture, @@ -57,12 +55,10 @@ standardSuite('Context Menus — Editor Content', (log) => { }); teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts index cfa6d0424..dc470c069 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuEditorTab.test.ts @@ -2,7 +2,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { CMD_BIND_TO_TERMINAL_HERE, CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; +import { CMD_BIND_TO_TERMINAL_HERE } from '../../constants/commandIds'; import { assertClipboardWriteLogged, assertFilePathLogged, @@ -11,7 +11,6 @@ import { assertStatusBarMsgLogged, assertTerminalBufferEquals, cleanupFiles, - closeAllEditors, createAndOpenFile, createCapturingTerminal, getLogCapture, @@ -28,12 +27,10 @@ standardSuite('Context Menus — Editor Tab', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts index c653d94bd..8fc381776 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuExplorer.test.ts @@ -3,7 +3,7 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { CMD_BIND_TO_TERMINAL_HERE, CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; +import { CMD_BIND_TO_TERMINAL_HERE } from '../../constants/commandIds'; import { assertClipboardWriteLogged, assertFilePathLogged, @@ -13,7 +13,6 @@ import { assertStatusBarMsgLogged, assertTerminalBufferEquals, cleanupFiles, - closeAllEditors, createAndOpenFile, createCapturingTerminal, getLogCapture, @@ -31,12 +30,10 @@ standardSuite('Context Menus — Explorer', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); @@ -186,9 +183,6 @@ standardSuite('Context Menus — Explorer', (log) => { const uri = await createAndOpenFile('ctxmenu-exp-005', FILE_CONTENT, undefined, tmpFileUris); const fn = path.basename(uri.fsPath); - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await settle(); - const logCapture = getLogCapture(); logCapture.mark('before-ctxmenu-exp-005'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts index a32341482..f2f1fe631 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/contextMenuTerminal.test.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_TERMINAL_HERE, CMD_CONTEXT_EDITOR_CONTENT_BIND, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { TERMINAL_READY_MS, @@ -14,7 +13,6 @@ import { assertStatusBarMsgLogged, assertTerminalBufferContains, cleanupFiles, - closeAllEditors, createAndBindCapturingTerminal, createAndOpenFile, createCapturingTerminal, @@ -34,12 +32,10 @@ standardSuite('Context Menus — Terminal', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); @@ -425,9 +421,6 @@ standardSuite('Context Menus — Terminal', (log) => { // --------------------------------------------------------------------------- test('[assisted] send-terminal-selection-011: "Send Selection" (unbound) opens destination picker and delivers to picked terminal', async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await settle(); - const sourceName = 'rl-sts-011-SOURCE'; const destName = 'rl-sts-011-DEST'; diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/coreSendCommands.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/coreSendCommands.test.ts index 92e43f213..2495eee63 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/coreSendCommands.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/coreSendCommands.test.ts @@ -10,7 +10,6 @@ import { CMD_COPY_PORTABLE_LINK_RELATIVE, CMD_PASTE_TO_DESTINATION, CMD_TERMINAL_PASTE_SELECTED_TEXT, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { assertClipboardChanged, @@ -21,7 +20,6 @@ import { assertToastLogged, type CapturingTerminal, cleanupFiles, - closeAllEditors, createAndBindCapturingTerminal, createWorkspaceFile, extractQuickPickItemsLogged, @@ -43,10 +41,8 @@ standardSuite('Core Send Commands', (log) => { const tmpTerminals: vscode.Terminal[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); await vscode.commands.executeCommand('dummyAi.clearAll'); for (const t of tmpTerminals.splice(0)) t.dispose(); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.splice(0); await settle(); @@ -309,9 +305,6 @@ standardSuite('Core Send Commands', (log) => { test('[assisted] send-terminal-selection-004: R-V with no bound destination opens destination picker', async () => { const MARKER = 'rl-sts-004-marker'; - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await settle(); - const srcTerminal = vscode.window.createTerminal({ name: 'csc-sts-004-src' }); srcTerminal.show(true); tmpTerminals.push(srcTerminal); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts index 328c897c0..4973f7afb 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/customAiAssistants.test.ts @@ -8,7 +8,6 @@ import { assertClipboardRestored, assertToastLogged, cleanupFiles, - closeAllEditors, createAndOpenFile, extractQuickPickItemsLogged, getLogCapture, @@ -267,9 +266,7 @@ standardSuite('Custom AI Assistants — Cold Start', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); await vscode.commands.executeCommand('dummyAi.clearAll'); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); @@ -329,9 +326,7 @@ standardSuite('Custom AI Assistants — Paste Flow', (log) => { }); teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); await vscode.commands.executeCommand('dummyAi.clearAll'); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts index 85a1222db..02c53af8e 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode'; import { CMD_COPY_LINK_ONLY_RELATIVE, CMD_COPY_LINK_RELATIVE, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { assertClipboardRestored, @@ -39,7 +38,6 @@ standardSuite('Dirty Buffer Warning', (_log) => { }); teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); await vscode.workspace .getConfiguration('rangelink') .update('warnOnDirtyBuffer', undefined, vscode.ConfigurationTarget.Workspace); @@ -401,11 +399,9 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (log) => { }); teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); await vscode.workspace .getConfiguration('rangelink') .update('warnOnDirtyBuffer', undefined, vscode.ConfigurationTarget.Workspace); - await closeAllEditors(); await settle(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts index e67c1151b..1c16610a5 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/editorBindingValidation.test.ts @@ -4,10 +4,9 @@ import * as path from 'node:path'; import * as vscode from 'vscode'; -import { CMD_BIND_TO_DESTINATION, CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; +import { CMD_BIND_TO_DESTINATION } from '../../constants/commandIds'; import { cleanupFiles, - closeAllEditors, createWorkspaceFile, extractQuickPickItemsLogged, findTestItemsByPrefix, @@ -23,8 +22,6 @@ standardSuite('Editor Binding Validation', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await closeAllEditors(); cleanupFiles(tmpFileUris); await settle(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts index a0d43c9ee..eafe730fb 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/filePicker.test.ts @@ -7,7 +7,6 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_DESTINATION, CMD_OPEN_STATUS_BAR_MENU } from '../../constants/commandIds'; import { cleanupFiles, - closeAllEditors, createAndOpenFile, extractQuickPickItemsLogged, findTestItemsByPrefix, @@ -27,8 +26,6 @@ standardSuite('File Picker', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/goToRangeLink.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/goToRangeLink.test.ts index 95f531920..896abd251 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/goToRangeLink.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/goToRangeLink.test.ts @@ -8,7 +8,6 @@ import { assertInputBoxLogged, assertToastLogged, cleanupFiles, - closeAllEditors, createAndOpenFile, extractQuickPickItemsLogged, getLogCapture, @@ -42,7 +41,6 @@ standardSuite('R-G Go to Link', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/linkGeneration.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/linkGeneration.test.ts index 12dc26303..21d227ce8 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/linkGeneration.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/linkGeneration.test.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode'; import { cleanupFiles, - closeAllEditors, createAndOpenFile, createWorkspaceFile, openEditor, @@ -161,7 +160,6 @@ standardSuite('Link Generation — Clickable Links (Assisted)', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await closeAllEditors(); cleanupFiles(tmpFileUris); tmpFileUris.length = 0; await settle(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts index 2346df0f3..ebf33e6e9 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_TEXT_EDITOR_HERE, CMD_PASTE_CURRENT_FILE_PATH_RELATIVE, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { assertFilePathLogged, @@ -14,7 +13,6 @@ import { assertTerminalBufferEquals, assertToastLogged, cleanupFiles, - closeAllEditors, createAndBindCapturingTerminal, createCapturingTerminal, createFileAt, @@ -33,9 +31,7 @@ standardSuite('Send File Path', (log) => { const tmpTerminals: vscode.Terminal[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); for (const t of tmpTerminals.splice(0)) t.dispose(); - await closeAllEditors(); cleanupFiles(tmpFileUris.splice(0)); await vscode.workspace .getConfiguration('rangelink') diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts index 3855ff80b..c3dfa37ef 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts @@ -6,7 +6,6 @@ import * as vscode from 'vscode'; import { cleanupFiles, - closeAllEditors, getWorkspaceRoot, settle, standardSuite, @@ -22,9 +21,6 @@ standardSuite('Smart Padding — Editor-to-Editor R-V', (log) => { }); setup(async () => { - log('setup: unbinding any stale destination'); - await vscode.commands.executeCommand('rangelink.unbindDestination'); - const ts = Date.now(); const sourcePath = path.join(getWorkspaceRoot(), `__rl-test-sp-source-${ts}.txt`); const destPath = path.join(getWorkspaceRoot(), `__rl-test-sp-dest-${ts}.txt`); @@ -38,9 +34,6 @@ standardSuite('Smart Padding — Editor-to-Editor R-V', (log) => { }); teardown(async () => { - log('teardown: unbinding + closing editors'); - await vscode.commands.executeCommand('rangelink.unbindDestination'); - await closeAllEditors(); cleanupFiles([sourceFileUri, destFileUri]); log('teardown: complete'); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts index 115eb4617..887bbace1 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts @@ -5,7 +5,6 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_TERMINAL_HERE, CMD_OPEN_STATUS_BAR_MENU, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { assertQuickPickItemsLogged, @@ -133,7 +132,6 @@ standardSuite('R-M Status Bar Menu', (log) => { log('✓ Bound-state menu items validated via log capture'); } finally { - await vscode.commands.executeCommand('rangelink.unbindDestination'); terminal.dispose(); } }); @@ -154,9 +152,6 @@ standardSuite('R-M Status Bar Menu', (log) => { }); test('status-bar-menu-006: R-M menu shows destination picker items when no destination is bound', async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await settle(); - const logCapture = getLogCapture(); logCapture.mark('before-006'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/terminalPicker.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/terminalPicker.test.ts index 7323dbc5d..f73c4d952 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/terminalPicker.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/terminalPicker.test.ts @@ -28,12 +28,10 @@ standardSuite('Terminal Picker', (log) => { const terminals: vscode.Terminal[] = []; teardown(async () => { - await vscode.commands.executeCommand('rangelink.unbindDestination'); for (const t of terminals) { t.dispose(); } terminals.length = 0; - await closeAllEditors(); await settle(); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts index cfbf63fcb..bf5c9bca9 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts @@ -5,13 +5,11 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_TEXT_EDITOR_HERE, CMD_COPY_LINK_RELATIVE, - CMD_UNBIND_DESTINATION, } from '../../constants/commandIds'; import { assertNoToastLogged, assertToastLogged, cleanupFiles, - closeAllEditors, createWorkspaceFile, getLogCapture, settle, @@ -23,8 +21,6 @@ standardSuite('Text Editor Destination', (log) => { const tmpFileUris: vscode.Uri[] = []; teardown(async () => { - await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); - await closeAllEditors(); cleanupFiles(tmpFileUris); await settle(); }); From c021500fde92ccc324265b6b8faf1d4f366493f7 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 10:13:22 -0400 Subject: [PATCH 06/11] Ran `pnpm fix` --- .../__integration-tests__/suite/dirtyBufferWarning.test.ts | 5 +---- .../src/__integration-tests__/suite/statusBarMenu.test.ts | 5 +---- .../suite/textEditorDestination.test.ts | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts index 02c53af8e..14257b978 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts @@ -2,10 +2,7 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; -import { - CMD_COPY_LINK_ONLY_RELATIVE, - CMD_COPY_LINK_RELATIVE, -} from '../../constants/commandIds'; +import { CMD_COPY_LINK_ONLY_RELATIVE, CMD_COPY_LINK_RELATIVE } from '../../constants/commandIds'; import { assertClipboardRestored, assertNoToastLogged, diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts index 887bbace1..ece0650f6 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/statusBarMenu.test.ts @@ -2,10 +2,7 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; -import { - CMD_BIND_TO_TERMINAL_HERE, - CMD_OPEN_STATUS_BAR_MENU, -} from '../../constants/commandIds'; +import { CMD_BIND_TO_TERMINAL_HERE, CMD_OPEN_STATUS_BAR_MENU } from '../../constants/commandIds'; import { assertQuickPickItemsLogged, cleanupFiles, diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts index bf5c9bca9..8178e1822 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/textEditorDestination.test.ts @@ -2,10 +2,7 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; -import { - CMD_BIND_TO_TEXT_EDITOR_HERE, - CMD_COPY_LINK_RELATIVE, -} from '../../constants/commandIds'; +import { CMD_BIND_TO_TEXT_EDITOR_HERE, CMD_COPY_LINK_RELATIVE } from '../../constants/commandIds'; import { assertNoToastLogged, assertToastLogged, From dab78733ac9e46eb0808650090019ef385c8f7d7 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 11:10:27 -0400 Subject: [PATCH 07/11] [fix] Add closeAllEditors to setup to prevent intra-suite editor contamination The centralization commit moved closeAllEditors from individual teardown hooks (afterEach) into suiteTeardown. But Mocha's suiteTeardown runs once after all tests, while teardown ran after each test. Editors from test N survived into test N+1, causing cross-test file pollution in pickers and stale editor timeouts in tab-group tests. --- .../src/__integration-tests__/helpers/standardSuite.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts index 25bdce78d..46fe73c66 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts @@ -18,6 +18,7 @@ export const standardSuite = (name: string, fn: (log: (msg: string) => void) => setup(async () => { await resetRangelinkSettings(log); await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); + await closeAllEditors(); await settle(); }); From 382e3c27683aaa5901d13e079a0ba7878ae76ab6 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 11:34:09 -0400 Subject: [PATCH 08/11] Resettting config in teradown is no longer needed: setup() takes care of it for every test --- .../suite/builtInAiAssistants.test.ts | 44 ++++++++----------- .../suite/clipboardPreservation.test.ts | 6 --- .../suite/dirtyBufferWarning.test.ts | 13 ------ .../suite/sendFilePath.test.ts | 3 -- 4 files changed, 19 insertions(+), 47 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts index 3b2b0cd2a..3d76d5fbf 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts @@ -326,35 +326,29 @@ standardSuite('Built-in AI Assistants — Destination Picker', (log) => { const config = vscode.workspace.getConfiguration('rangelink.destinations.claudeCode'); - try { - // Set invalid config: delay (100) <= interval (400) - await config.update('coldStartDelayMs', 100, vscode.ConfigurationTarget.Workspace); - await config.update('coldRefocusIntervalMs', 400, vscode.ConfigurationTarget.Workspace); - await settle(); + // Set invalid config: delay (100) <= interval (400) + await config.update('coldStartDelayMs', 100, vscode.ConfigurationTarget.Workspace); + await config.update('coldRefocusIntervalMs', 400, vscode.ConfigurationTarget.Workspace); + await settle(); - const logCapture = getLogCapture(); - logCapture.mark('before-cc-007'); + const logCapture = getLogCapture(); + logCapture.mark('before-cc-007'); - await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); - await settle(); + await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); + await settle(); - await vscode.commands.executeCommand(CMD_JUMP_TO_DESTINATION); - await settle(); + await vscode.commands.executeCommand(CMD_JUMP_TO_DESTINATION); + await settle(); - const lines = logCapture.getLinesSince('before-cc-007'); - const warningLog = lines.find((line) => - line.includes('coldStartDelayMs must be greater than coldRefocusIntervalMs'), - ); - assert.ok( - warningLog, - 'Expected validation warning log when coldStartDelayMs <= coldRefocusIntervalMs', - ); + const lines = logCapture.getLinesSince('before-cc-007'); + const warningLog = lines.find((line) => + line.includes('coldStartDelayMs must be greater than coldRefocusIntervalMs'), + ); + assert.ok( + warningLog, + 'Expected validation warning log when coldStartDelayMs <= coldRefocusIntervalMs', + ); - log('✓ claude-code-007 — invalid config triggers fallback to defaults'); - } finally { - // Restore config to defaults - await config.update('coldStartDelayMs', undefined, vscode.ConfigurationTarget.Workspace); - await config.update('coldRefocusIntervalMs', undefined, vscode.ConfigurationTarget.Workspace); - } + log('✓ claude-code-007 — invalid config triggers fallback to defaults'); }); }); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts index 4b0655682..606e4c6e3 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts @@ -55,12 +55,6 @@ standardSuite('Clipboard Preservation', (_log) => { editor.selection = new vscode.Selection(new vscode.Position(0, 0), new vscode.Position(0, 7)); }); - teardown(async () => { - await vscode.workspace - .getConfiguration('rangelink') - .update('clipboard.preserve', undefined, vscode.ConfigurationTarget.Global); - }); - test('clipboard-preservation-003: R-F with preserve=always restores clipboard to sentinel after send', async () => { await vscode.workspace .getConfiguration('rangelink') diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts index 14257b978..f4a9ef5c9 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/dirtyBufferWarning.test.ts @@ -34,12 +34,6 @@ standardSuite('Dirty Buffer Warning', (_log) => { cleanupFiles([testFileUri]); }); - teardown(async () => { - await vscode.workspace - .getConfiguration('rangelink') - .update('warnOnDirtyBuffer', undefined, vscode.ConfigurationTarget.Workspace); - }); - test('dirty-buffer-warning-004: warnOnDirtyBuffer=false — R-C generates link without showing warning dialog', async () => { const editor = await openEditor(testFileUri); @@ -395,13 +389,6 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (log) => { cleanupFiles([testFileUri]); }); - teardown(async () => { - await vscode.workspace - .getConfiguration('rangelink') - .update('warnOnDirtyBuffer', undefined, vscode.ConfigurationTarget.Workspace); - await settle(); - }); - test('[assisted] dirty-buffer-warning-001: warnOnDirtyBuffer=true — R-L on dirty file shows warning dialog', async () => { const capturing = await createAndBindCapturingTerminal('dirty-buffer-test'); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts index ebf33e6e9..cec9c8c38 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/sendFilePath.test.ts @@ -33,9 +33,6 @@ standardSuite('Send File Path', (log) => { teardown(async () => { for (const t of tmpTerminals.splice(0)) t.dispose(); cleanupFiles(tmpFileUris.splice(0)); - await vscode.workspace - .getConfiguration('rangelink') - .update('clipboard.preserve', undefined, vscode.ConfigurationTarget.Global); await settle(); }); From 191d5010f345a5f76d9cc65ececd94ec8b434714 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 12:09:03 -0400 Subject: [PATCH 09/11] [fix] Restore suiteSetup state that standardSuite.setup now clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit standardSuite.setup now calls closeAllEditors and CMD_UNBIND_DESTINATION before each test. Two suites create shared state in suiteSetup — an untitled document and a bound capturing terminal — that setup destroys. The tests then fail because the document is closed (no selection change event fires) or no destination is bound (send command blocks on picker). Add per-test setup hooks that restore the shared state after standardSuite.setup clears it: reopen the untitled document, rebind the terminal. Also added a centralization of `getLogCapture().isCapturing()` in `StandardSuite.setup()` for every test to get it --- .../helpers/editorHelpers.ts | 18 ++++++++++++++ .../__integration-tests__/helpers/index.ts | 2 +- .../helpers/standardSuite.ts | 7 ++++++ .../suite/clipboardPreservation.test.ts | 4 ++++ .../suite/navigationToastSettings.test.ts | 5 ---- .../suite/smartPadding.test.ts | 7 +++--- .../suite/untitledNavigation.test.ts | 24 +++++++------------ 7 files changed, 42 insertions(+), 25 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts index 60578a0a6..f531d4499 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts @@ -35,6 +35,24 @@ export const clearEditorSelection = async (): Promise => { } }; +export const openUntitledDoc = async ( + options?: { + content?: string; + language?: string; + viewColumn?: vscode.ViewColumn; + }, +): Promise => { + const { + content = '', + language = 'plaintext', + viewColumn = vscode.ViewColumn.One, + } = options ?? {}; + const doc = await vscode.workspace.openTextDocument({ content, language }); + await vscode.window.showTextDocument(doc, viewColumn); + await settle(); + return doc; +}; + export const selectAll = (editor: vscode.TextEditor): void => { const lastLine = editor.document.lineCount - 1; const lastChar = editor.document.lineAt(lastLine).text.length; diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts index 333d89b79..d9da75fee 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts @@ -12,7 +12,7 @@ export { CLIPBOARD_SENTINEL, writeClipboardSentinel, } from './clipboardHelpers'; -export { clearEditorSelection, selectAll, waitForActiveEditor } from './editorHelpers'; +export { clearEditorSelection, openUntitledDoc, selectAll, waitForActiveEditor } from './editorHelpers'; export { cleanupFiles, closeAllEditors, diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts index 46fe73c66..d861dc818 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/standardSuite.ts @@ -1,8 +1,11 @@ +import assert from 'node:assert'; + import * as vscode from 'vscode'; import { CMD_UNBIND_DESTINATION } from '../../constants/commandIds'; import { closeAllEditors } from './fileHelpers'; +import { getLogCapture } from './getLogCapture'; import { createLogger } from './logHelpers'; import { resetRangelinkSettings } from './settingsHelpers'; import { activateExtension, settle } from './testEnv'; @@ -16,6 +19,10 @@ export const standardSuite = (name: string, fn: (log: (msg: string) => void) => }); setup(async () => { + assert.ok( + getLogCapture().isCapturing, + 'RANGELINK_CAPTURE_LOGS must be true for toast assertions', + ); await resetRangelinkSettings(log); await vscode.commands.executeCommand(CMD_UNBIND_DESTINATION); await closeAllEditors(); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts index 606e4c6e3..8516abe91 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/clipboardPreservation.test.ts @@ -4,6 +4,7 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_TEXT_EDITOR_HERE, + CMD_BIND_TO_TERMINAL_HERE, CMD_COPY_LINK_RELATIVE, CMD_PASTE_CURRENT_FILE_PATH_RELATIVE, CMD_TERMINAL_PASTE_SELECTED_TEXT, @@ -50,6 +51,9 @@ standardSuite('Clipboard Preservation', (_log) => { }); setup(async () => { + capturing.terminal.show(true); + await vscode.commands.executeCommand(CMD_BIND_TO_TERMINAL_HERE); + await settle(); editor = await vscode.window.showTextDocument(editor.document); await writeClipboardSentinel(); editor.selection = new vscode.Selection(new vscode.Position(0, 0), new vscode.Position(0, 7)); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/navigationToastSettings.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/navigationToastSettings.test.ts index ae2c3d26b..7d6ce15e0 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/navigationToastSettings.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/navigationToastSettings.test.ts @@ -21,11 +21,6 @@ standardSuite('Navigation Toast Settings', (_log) => { let testFileUri: vscode.Uri; suiteSetup(async () => { - assert.ok( - getLogCapture().isCapturing, - 'RANGELINK_CAPTURE_LOGS must be true for toast assertions', - ); - const lines = Array.from({ length: 10 }, (_, i) => `line ${i + 1} content here`); testFileUri = createWorkspaceFile('toast settings', lines.join('\n') + '\n'); testFilename = path.basename(testFileUri.fsPath); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts index c3dfa37ef..156f6971d 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/smartPadding.test.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { cleanupFiles, getWorkspaceRoot, + openUntitledDoc, settle, standardSuite, waitForActiveEditor, @@ -77,8 +78,7 @@ standardSuite('Smart Padding — Editor-to-Editor R-V', (log) => { await vscode.window.showTextDocument(sourceDoc, vscode.ViewColumn.One); log('setupUntitledEditorPair: creating untitled dest in ViewColumn.Two'); - const destDoc = await vscode.workspace.openTextDocument({ content: '', language: 'plaintext' }); - await vscode.window.showTextDocument(destDoc, vscode.ViewColumn.Two); + const destDoc = await openUntitledDoc({ viewColumn: vscode.ViewColumn.Two }); log( `setupUntitledEditorPair: dest scheme=${destDoc.uri.scheme}, uri=${destDoc.uri.toString()}`, ); @@ -310,8 +310,7 @@ standardSuite('Smart Padding — Editor-to-Editor R-V', (log) => { fs.writeFileSync(sourceFileUri.fsPath, sourceContent, 'utf8'); - const destDoc = await vscode.workspace.openTextDocument({ content: '', language: 'plaintext' }); - await vscode.window.showTextDocument(destDoc, vscode.ViewColumn.Two); + const destDoc = await openUntitledDoc({ viewColumn: vscode.ViewColumn.Two }); const originalUri = destDoc.uri.toString(); const originalLanguage = destDoc.languageId; log(`langswitch: dest uri=${originalUri}, language=${originalLanguage}`); diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/untitledNavigation.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/untitledNavigation.test.ts index 2de33bb82..b9d179dd2 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/untitledNavigation.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/untitledNavigation.test.ts @@ -10,6 +10,7 @@ import { assertToastLogged, clearEditorSelection, getLogCapture, + openUntitledDoc, settle, standardSuite, } from '../helpers'; @@ -70,27 +71,20 @@ const navigateToUntitledLink = ( }); }; +const UNTITLED_CONTENT = Array.from( + { length: 15 }, + (_, i) => `untitled line ${i + 1} content here`, +).join('\n'); + standardSuite('Untitled File Navigation', (_log) => { let untitledDoc: vscode.TextDocument; let untitledDisplayName: string; - suiteSetup(async () => { - assert.ok( - getLogCapture().isCapturing, - 'RANGELINK_CAPTURE_LOGS must be true for toast assertions', - ); - - untitledDoc = await vscode.workspace.openTextDocument({ - content: Array.from({ length: 15 }, (_, i) => `untitled line ${i + 1} content here`).join( - '\n', - ), - language: 'plaintext', - }); - await vscode.window.showTextDocument(untitledDoc, vscode.ViewColumn.One); - + setup(async () => { + untitledDoc = await openUntitledDoc({ content: UNTITLED_CONTENT }); assert.strictEqual(untitledDoc.uri.scheme, 'untitled', 'Expected untitled document'); - untitledDisplayName = getUntitledDisplayName(untitledDoc.uri); + await settle(); }); // untitled-navigation-001: Navigate to single line in untitled file From f0d507716ad902235a8fa4d4b47f3473cbdc5fe4 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 12:09:32 -0400 Subject: [PATCH 10/11] Ran `pnpm fix` --- .../__integration-tests__/helpers/editorHelpers.ts | 12 +++++------- .../src/__integration-tests__/helpers/index.ts | 7 ++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts index f531d4499..5550d4241 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/editorHelpers.ts @@ -35,13 +35,11 @@ export const clearEditorSelection = async (): Promise => { } }; -export const openUntitledDoc = async ( - options?: { - content?: string; - language?: string; - viewColumn?: vscode.ViewColumn; - }, -): Promise => { +export const openUntitledDoc = async (options?: { + content?: string; + language?: string; + viewColumn?: vscode.ViewColumn; +}): Promise => { const { content = '', language = 'plaintext', diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts index d9da75fee..a32500349 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/index.ts @@ -12,7 +12,12 @@ export { CLIPBOARD_SENTINEL, writeClipboardSentinel, } from './clipboardHelpers'; -export { clearEditorSelection, openUntitledDoc, selectAll, waitForActiveEditor } from './editorHelpers'; +export { + clearEditorSelection, + openUntitledDoc, + selectAll, + waitForActiveEditor, +} from './editorHelpers'; export { cleanupFiles, closeAllEditors, From fa7a16cea2227382874771228614b0b76578bc33 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 14 May 2026 12:44:32 -0400 Subject: [PATCH 11/11] [PR feedback] Strengthen claude-code-007 assertions and clarity per CodeRabbit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied 3 CodeRabbit findings on the claude-code-007 integration test: strengthened the warning-log assertion to verify "using defaults" appears in the message (proving fallback values were returned, not just that the invalid state was detected), added a comment explaining why CMD_JUMP_TO_DESTINATION is needed — the validation lives inside getColdRefocus, a thunk only invoked during focus(), not bind(), and CMD_JUMP_TO_DESTINATION triggers focusBoundDestination() → focus() → getColdRefocus() → validation warning — and replaced magic numbers 100/400 with named INVALID_DELAY_MS and INVALID_INTERVAL_MS constants with a comment explaining the principle (inverting the valid delay > interval relationship) without hardcoding default values that would age when defaults change. Ref: https://github.com/couimet/rangeLink/pull/567#pullrequestreview-4291571575 --- .../suite/builtInAiAssistants.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts index 3d76d5fbf..4a0ed80ef 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/suite/builtInAiAssistants.test.ts @@ -326,14 +326,24 @@ standardSuite('Built-in AI Assistants — Destination Picker', (log) => { const config = vscode.workspace.getConfiguration('rangelink.destinations.claudeCode'); - // Set invalid config: delay (100) <= interval (400) - await config.update('coldStartDelayMs', 100, vscode.ConfigurationTarget.Workspace); - await config.update('coldRefocusIntervalMs', 400, vscode.ConfigurationTarget.Workspace); + // Set delay <= interval so the validation rejects it (by default, delay > + // interval so the config is valid; flipping that relationship makes it invalid). + const INVALID_DELAY_MS = 100; + const INVALID_INTERVAL_MS = 400; + await config.update('coldStartDelayMs', INVALID_DELAY_MS, vscode.ConfigurationTarget.Workspace); + await config.update( + 'coldRefocusIntervalMs', + INVALID_INTERVAL_MS, + vscode.ConfigurationTarget.Workspace, + ); await settle(); const logCapture = getLogCapture(); logCapture.mark('before-cc-007'); + // Validation lives inside getColdRefocus, which is a thunk only invoked + // during focus() — not at bind() time. CMD_JUMP_TO_DESTINATION triggers + // focusBoundDestination() → focus() → getColdRefocus() → validation warning. await vscode.commands.executeCommand(CMD_BIND_TO_CLAUDE_CODE); await settle(); @@ -341,12 +351,14 @@ standardSuite('Built-in AI Assistants — Destination Picker', (log) => { await settle(); const lines = logCapture.getLinesSince('before-cc-007'); - const warningLog = lines.find((line) => - line.includes('coldStartDelayMs must be greater than coldRefocusIntervalMs'), + const warningLog = lines.find( + (line) => + line.includes('coldStartDelayMs must be greater than coldRefocusIntervalMs') && + line.includes('using defaults'), ); assert.ok( warningLog, - 'Expected validation warning log when coldStartDelayMs <= coldRefocusIntervalMs', + 'Expected validation warning log with "using defaults" when coldStartDelayMs <= coldRefocusIntervalMs', ); log('✓ claude-code-007 — invalid config triggers fallback to defaults');