From 7a0ebb530d5c21d312410e1b5f2aa193d534eb0d Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 21 May 2026 18:15:08 -0400 Subject: [PATCH 1/4] [issues/569] Add `CMD_BIND_TO_CUSTOM_AI_BY_ID` for programmatic custom AI binding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `rangelink.bindToCustomAiById` command accepting `{ extensionId: string }` to programmatically bind custom AI assistants. Eliminates the picker step, enabling fully automated integration tests for all custom AI paste flows. Previously 6 custom AI tests required human-in-the-loop for binding and manual paste verification. ## Changes - New `createBindToCustomAiByIdCommand.ts` — resolves extensionId to destination kind via `resolveKindByExtensionId()` and delegates to `PasteDestinationManager.bind()` - `resolveKindByExtensionId()` in `destinationBuilders.ts` maps extension IDs to built-in or custom AI kinds - Wiring through `wireSubscriptions.ts`, `createWiringServices.ts`, `commandIds.ts`, `package.json` - 11 unit tests covering undefined/null/string/array args, missing/empty/number extensionId, unknown extensionId, built-in bind, custom bind, bind error passthrough - 6 custom AI integration tests converted from `[assisted]` to fully automated using the new command - 2 manual-paste tests (Tier 3, fallback) now verify toast, clipboard state, and logs without requiring human Cmd+V - QA YAML: 6 entries updated from `automated: assisted` to `automated: true` - Contract tests updated for new command and command-palette entry counts - Documentation: CHANGELOG not needed (dev-facing infrastructure); README not needed (no user-facing changes) ## Test Plan - [x] All 1947 unit tests pass - [x] 154/156 automated integration tests pass (2 pre-existing Cursor-specific clipboard-preservation timeouts) - [x] 11 new unit tests for createBindToCustomAiByIdCommand - [x] 6 integration tests reworked from human-in-the-loop to fully automated ## Related - Closes https://github.com/couimet/rangeLink/issues/569 --- .../rangelink-vscode-extension/package.json | 10 + .../qa/qa-test-cases-v1.1.0.yaml | 12 +- .../suite/customAiAssistants.test.ts | 86 +++---- .../createBindToCustomAiByIdCommand.test.ts | 216 ++++++++++++++++++ .../constants/packageJsonContracts.test.ts | 20 +- .../destinations/destinationBuilders.test.ts | 48 ++++ .../src/__tests__/extension.test.ts | 1 + .../helpers/createMockWiringServices.ts | 1 + .../src/__tests__/wireSubscriptions.test.ts | 18 +- .../createBindToCustomAiByIdCommand.ts | 59 +++++ .../src/constants/commandIds.ts | 1 + .../src/createWiringServices.ts | 5 +- .../src/destinations/destinationBuilders.ts | 22 ++ .../errors/RangeLinkExtensionErrorCodes.ts | 1 + .../src/wireSubscriptions.ts | 8 + 15 files changed, 438 insertions(+), 70 deletions(-) create mode 100644 packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts create mode 100644 packages/rangelink-vscode-extension/src/commands/createBindToCustomAiByIdCommand.ts diff --git a/packages/rangelink-vscode-extension/package.json b/packages/rangelink-vscode-extension/package.json index aec785da..42fbcde8 100644 --- a/packages/rangelink-vscode-extension/package.json +++ b/packages/rangelink-vscode-extension/package.json @@ -160,6 +160,12 @@ "category": "RangeLink", "icon": "$(link)" }, + { + "command": "rangelink.bindToCustomAiById", + "title": "Bind to Custom AI by ID", + "category": "RangeLink", + "icon": "$(link)" + }, { "command": "rangelink.bindToGeminiCodeAssist", "title": "Bind to Gemini Code Assist", @@ -713,6 +719,10 @@ "command": "rangelink.bindToTextEditorHere", "when": "false" }, + { + "command": "rangelink.bindToCustomAiById", + "when": "false" + }, { "command": "rangelink.pasteFileAbsolutePath", "when": "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 3ce6007c..a0bee74a 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 @@ -1830,7 +1830,7 @@ test_cases: feature: 'Custom AI Assistants — Tier 1 Paste Flow' scenario: 'Tier 1 direct insert delivers text to dummy extension textarea' expected_result: 'tier1 textarea contains the generated link; tier2 textarea is empty' - automated: assisted + automated: true - id: custom-ai-assistant-011 labels: @@ -1838,7 +1838,7 @@ test_cases: feature: 'Custom AI Assistants — Tier 1 Clipboard' scenario: 'Tier 1 paste preserves clipboard (sentinel restored after R-L)' expected_result: 'Clipboard contains the sentinel (outer preserve restores it; DirectInsertFactory never touches clipboard)' - automated: assisted + automated: true - id: custom-ai-assistant-012 labels: @@ -1846,7 +1846,7 @@ test_cases: feature: 'Custom AI Assistants — Tier 3 Toast' scenario: 'Tier 3 focusCommands shows manual-paste toast after R-L' expected_result: 'Info toast "Paste (Cmd/Ctrl+V) in Dummy AI (Tier 3) to use." logged; ManualPasteInsertFactory success logged' - automated: assisted + automated: true - id: custom-ai-assistant-013 labels: @@ -1854,7 +1854,7 @@ test_cases: feature: 'Custom AI Assistants — Tier 2→3 Fallback' scenario: 'Tier 2 focusAndPasteCommands not registered — falls through to Tier 3' expected_result: 'Tier 2 skip log (nonexistent.paste not registered), Tier 3 resolution log (dummyAi.focusPanel), manual-paste toast shown' - automated: assisted + automated: true - id: custom-ai-assistant-014 labels: @@ -1862,7 +1862,7 @@ test_cases: feature: 'Custom AI Assistants — ${content} Template' scenario: 'insertCommands with ${content} template interpolation delivers text via insertWithArgs' expected_result: 'tier1 textarea contains the generated link; DirectInsertFactory success log mentions dummyAi.insertWithArgs' - automated: assisted + automated: true - id: custom-ai-assistant-015 labels: @@ -1886,7 +1886,7 @@ test_cases: feature: 'Custom AI Assistants — Cold Start' scenario: 'Tier 1 direct insert works when the AI extension panel is not yet visible' expected_result: 'Panel auto-initializes on first insert; tier1 textarea contains the generated link; tier2 is empty' - automated: assisted + automated: true # --------------------------------------------------------------------------- # Section — Built-in AI Assistants 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 4cb80e6b..6b46265b 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 @@ -2,7 +2,7 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; -import { CMD_BIND_TO_DESTINATION } from '../../constants/commandIds'; +import { CMD_BIND_TO_DESTINATION, CMD_BIND_TO_CUSTOM_AI_BY_ID } from '../../constants/commandIds'; import { assertClipboardChanged, assertClipboardRestored, @@ -11,8 +11,6 @@ import { getLogCapture, openAndDismiss, standardSuite, - waitForHuman, - waitForHumanVerdict, writeClipboardSentinel, } from '../helpers'; @@ -263,15 +261,13 @@ standardSuite('Custom AI Assistants — Cold Start', (ss) => { await vscode.commands.executeCommand('dummyAi.clearAll'); }); - test('[assisted] custom-ai-assistant-017: Tier 1 direct insert works when panel is not yet visible', async () => { + test('custom-ai-assistant-017: Tier 1 direct insert works when panel is not yet visible', async () => { await ss.createAndOpenFile('__rl-test-cold-start', 'cold start test'); await ss.settle(); - await waitForHuman( - 'custom-ai-assistant-017', - "Cmd+R Cmd+D → select 'Dummy AI (Tier 1)' (panel should NOT be open yet)", - ['Press Cmd+R Cmd+D and select "Dummy AI (Tier 1)" from the picker.'], - ); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension', + }); const logCapture = getLogCapture(); logCapture.mark('before-cold-start'); @@ -317,13 +313,13 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { await vscode.commands.executeCommand('dummyAi.clearAll'); }); - test('[assisted] custom-ai-assistant-010: Tier 1 direct insert delivers text to dummy textarea', async () => { + test('custom-ai-assistant-010: Tier 1 direct insert delivers text to dummy textarea', async () => { await ss.createAndOpenFile('__rl-test-tier1', 'hello world\nline two\nline three'); await ss.settle(); - await waitForHuman('custom-ai-assistant-010', "Cmd+R Cmd+D → select 'Dummy AI (Tier 1)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Tier 1)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension', + }); const logCapture = getLogCapture(); logCapture.mark('before-tier1-paste'); @@ -357,13 +353,13 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ss.log('✓ Tier 1 direct insert delivered text to dummy textarea'); }); - test('[assisted] custom-ai-assistant-011: Tier 1 clipboard isolation — sentinel preserved', async () => { + test('custom-ai-assistant-011: Tier 1 clipboard isolation — sentinel preserved', async () => { await ss.createAndOpenFile('__rl-test-tier1-clip', 'clipboard test'); await ss.settle(); - await waitForHuman('custom-ai-assistant-011', "Cmd+R Cmd+D → select 'Dummy AI (Tier 1)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Tier 1)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension', + }); await writeClipboardSentinel(); const logCapture = getLogCapture(); @@ -380,13 +376,13 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ss.log('✓ Tier 1 clipboard isolation — sentinel preserved after R-L'); }); - test('[assisted] custom-ai-assistant-012: Tier 3 shows manual-paste toast and clipboard not restored', async () => { + test('custom-ai-assistant-012: Tier 3 shows manual-paste toast and clipboard not restored', async () => { await ss.createAndOpenFile('__rl-test-tier3', 'tier three test'); await ss.settle(); - await waitForHuman('custom-ai-assistant-012', "Cmd+R Cmd+D → select 'Dummy AI (Tier 3)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Tier 3)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension-tier3', + }); await writeClipboardSentinel(); const logCapture = getLogCapture(); @@ -422,42 +418,26 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ); assert.ok(skipRestoreLog, 'Expected clipboard restoration skip log'); - const verdict = await waitForHumanVerdict( - 'custom-ai-assistant-012-paste', - 'Cmd+V in the Dummy AI tier2 textarea to verify clipboard has the link. Click PASS if the RangeLink appears, FAIL otherwise.', - [ - 'Click on the Dummy AI sidebar panel (tier2 textarea).', - 'Press Cmd+V to paste — the RangeLink should appear.', - ], - ); - assert.strictEqual(verdict, 'pass'); - const textResult = (await vscode.commands.executeCommand('dummyAi.getText')) as | { tier1: string; tier2: string } | undefined; assert.ok(textResult, 'Expected dummyAi.getText to return a result'); - assert.ok( - textResult!.tier2.length > 0, - 'Expected tier2 textarea to contain the pasted link after manual Cmd+V', - ); assert.strictEqual( textResult!.tier1, '', 'Expected tier1 textarea to be empty (Tier 3 uses manual paste, not direct insert)', ); - ss.log( - '✓ Tier 3 shows manual-paste toast, clipboard not restored (link stays), manual paste verified', - ); + ss.log('✓ Tier 3 shows manual-paste toast, clipboard not restored (link stays)'); }); - test('[assisted] custom-ai-assistant-013: Tier 2→3 fallback — clipboard not restored and manual paste works', async () => { + test('custom-ai-assistant-013: Tier 2→3 fallback — clipboard not restored and manual paste works', async () => { await ss.createAndOpenFile('__rl-test-fallback', 'fallback test'); await ss.settle(); - await waitForHuman('custom-ai-assistant-013', "Cmd+R Cmd+D → select 'Dummy AI (Fallback)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Fallback)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension-fallback', + }); await writeClipboardSentinel(); const logCapture = getLogCapture(); @@ -502,24 +482,10 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ); assert.ok(skipRestoreLog, 'Expected clipboard restoration skip log for fallback→focusCommands'); - const verdict = await waitForHumanVerdict( - 'custom-ai-assistant-013-paste', - 'Cmd+V in the Dummy AI tier2 textarea to verify clipboard has the link. Click PASS if the RangeLink appears, FAIL otherwise.', - [ - 'Click on the Dummy AI sidebar panel (tier2 textarea).', - 'Press Cmd+V to paste — the RangeLink should appear.', - ], - ); - assert.strictEqual(verdict, 'pass'); - const textResult = (await vscode.commands.executeCommand('dummyAi.getText')) as | { tier1: string; tier2: string } | undefined; assert.ok(textResult, 'Expected dummyAi.getText to return a result'); - assert.ok( - textResult!.tier2.length > 0, - 'Expected tier2 textarea to contain the pasted link after manual Cmd+V', - ); assert.strictEqual( textResult!.tier1, '', @@ -529,13 +495,13 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { ss.log('✓ Tier 2→3 fallback: clipboard not restored, manual paste verified'); }); - test('[assisted] custom-ai-assistant-014: ${content} template delivers text via insertWithArgs', async () => { + test('custom-ai-assistant-014: ${content} template delivers text via insertWithArgs', async () => { await ss.createAndOpenFile('__rl-test-template', 'template test content'); await ss.settle(); - await waitForHuman('custom-ai-assistant-014', "Cmd+R Cmd+D → select 'Dummy AI (Template)'", [ - 'Press Cmd+R Cmd+D and select "Dummy AI (Template)" from the picker.', - ]); + await vscode.commands.executeCommand(CMD_BIND_TO_CUSTOM_AI_BY_ID, { + extensionId: 'rangelink.dummy-ai-extension-template', + }); const logCapture = getLogCapture(); logCapture.mark('before-template-paste'); diff --git a/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts b/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts new file mode 100644 index 00000000..844d9bee --- /dev/null +++ b/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts @@ -0,0 +1,216 @@ +import { createMockLogger } from 'barebone-logger-testing'; + +import { createBindToCustomAiByIdCommand } from '../../commands/createBindToCustomAiByIdCommand'; +import type { BindSuccessInfo } from '../../destinations'; +import * as destinationBuilders from '../../destinations/destinationBuilders'; +import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../../errors'; +import { ExtensionResult } from '../../types'; +import { createMockDestinationManager } from '../helpers'; + +describe('createBindToCustomAiByIdCommand', () => { + const mockLogger = createMockLogger(); + + const createCustomConfig = (extensionId: string) => ({ + kind: `custom-ai:${extensionId}` as const, + extensionId, + extensionName: `Custom ${extensionId}`, + focusCommands: ['test.focusCommand'], + }); + + it('returns error when args is undefined', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler(undefined); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + + expect(mockManager.bind).not.toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'createBindToCustomAiByIdCommand' }, + 'Invalid or missing arguments for bindToCustomAiById', + ); + }); + + it('returns error when args is null', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler(null); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when args is a string', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler('anthropic.claude-code'); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when args is an array', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler(['anthropic.claude-code']); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when extensionId is missing from args', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({}); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + + expect(mockLogger.warn).toHaveBeenCalledWith( + { fn: 'createBindToCustomAiByIdCommand', argsType: 'object' }, + 'Missing or invalid extensionId in args', + ); + }); + + it('returns error when extensionId is an empty string', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: '' }); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when extensionId is a number', async () => { + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: 123 }); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: 'Argument must be { extensionId: string }', + functionName: 'createBindToCustomAiByIdCommand', + }); + }); + + it('returns error when resolveKindByExtensionId returns undefined for unknown extensionId', async () => { + const resolveSpy = jest + .spyOn(destinationBuilders, 'resolveKindByExtensionId') + .mockReturnValue(undefined); + const mockManager = createMockDestinationManager(); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: 'unknown.missing' }); + + expect(result).toBeRangeLinkExtensionErrorErr('CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', { + message: "No AI assistant found with extension ID 'unknown.missing'", + functionName: 'createBindToCustomAiByIdCommand', + }); + + expect(resolveSpy).toHaveBeenCalledWith('unknown.missing', []); + expect(mockManager.bind).not.toHaveBeenCalled(); + }); + + it('binds a built-in assistant when resolveKindByExtensionId returns a built-in kind', async () => { + const resolveSpy = jest + .spyOn(destinationBuilders, 'resolveKindByExtensionId') + .mockReturnValue('claude-code'); + + const bindResult = ExtensionResult.ok({ + destinationName: 'Claude Code Chat', + destinationKind: 'claude-code', + }); + const mockManager = createMockDestinationManager({ bindResult }); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: 'anthropic.claude-code' }); + + expect(resolveSpy).toHaveBeenCalledWith('anthropic.claude-code', []); + expect(mockManager.bind).toHaveBeenCalledWith({ kind: 'claude-code' }); + + expect(result).toBeOkWith((value: BindSuccessInfo) => { + expect(value).toStrictEqual({ + destinationName: 'Claude Code Chat', + destinationKind: 'claude-code', + }); + }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + { + fn: 'createBindToCustomAiByIdCommand', + extensionId: 'anthropic.claude-code', + kind: 'claude-code', + }, + 'Binding to custom AI by ID', + ); + }); + + it('binds a custom assistant when resolveKindByExtensionId returns a custom kind', async () => { + const extensionId = 'my-custom.extension'; + const kind = `custom-ai:${extensionId}` as const; + const customAssistants = [createCustomConfig(extensionId)]; + + const resolveSpy = jest + .spyOn(destinationBuilders, 'resolveKindByExtensionId') + .mockReturnValue(kind); + + const bindResult = ExtensionResult.ok({ + destinationName: 'Custom my-custom.extension', + destinationKind: kind, + }); + const mockManager = createMockDestinationManager({ bindResult }); + + const handler = createBindToCustomAiByIdCommand(customAssistants, mockManager, mockLogger); + const result = await handler({ extensionId }); + + expect(resolveSpy).toHaveBeenCalledWith(extensionId, customAssistants); + expect(mockManager.bind).toHaveBeenCalledWith({ kind }); + + expect(result).toBeOkWith((value: BindSuccessInfo) => { + expect(value).toStrictEqual({ + destinationName: 'Custom my-custom.extension', + destinationKind: kind, + }); + }); + }); + + it('passes bind error through when destinationManager.bind returns an error', async () => { + const resolveSpy = jest + .spyOn(destinationBuilders, 'resolveKindByExtensionId') + .mockReturnValue('gemini-code-assist'); + + const bindError = new RangeLinkExtensionError({ + code: RangeLinkExtensionErrorCodes.DESTINATION_BIND_FAILED, + message: 'Destination not available', + functionName: 'PasteDestinationManager.bindGenericDestination', + }); + const bindResult = ExtensionResult.err(bindError); + const mockManager = createMockDestinationManager({ bindResult }); + + const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); + const result = await handler({ extensionId: 'google.geminicodeassist' }); + + expect(result).toBeErr(); + expect(resolveSpy).toHaveBeenCalledWith('google.geminicodeassist', []); + expect(mockManager.bind).toHaveBeenCalledWith({ kind: 'gemini-code-assist' }); + }); +}); 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 48213f1c..58da9b32 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/constants/packageJsonContracts.test.ts @@ -186,6 +186,15 @@ describe('package.json contributions', () => { }); }); + it('rangelink.bindToCustomAiById', () => { + expect(findCommand('rangelink.bindToCustomAiById')).toStrictEqual({ + command: 'rangelink.bindToCustomAiById', + title: 'Bind to Custom AI by ID', + category: 'RangeLink', + icon: '$(link)', + }); + }); + it('rangelink.bindToGeminiCodeAssist', () => { expect(findCommand('rangelink.bindToGeminiCodeAssist')).toStrictEqual({ command: 'rangelink.bindToGeminiCodeAssist', @@ -505,7 +514,7 @@ describe('package.json contributions', () => { }); it('has the expected number of commands', () => { - expect(commands).toHaveLength(48); + expect(commands).toHaveLength(49); }); }); @@ -1203,7 +1212,7 @@ describe('package.json contributions', () => { commandPalette.find((entry) => entry.command === commandId); it('has the expected number of commandPalette entries', () => { - expect(commandPalette).toHaveLength(28); + expect(commandPalette).toHaveLength(29); }); it('bindToTerminalHere is hidden from command palette', () => { @@ -1213,6 +1222,13 @@ describe('package.json contributions', () => { }); }); + it('bindToCustomAiById is hidden from command palette', () => { + expect(findEntry('rangelink.bindToCustomAiById')).toStrictEqual({ + command: 'rangelink.bindToCustomAiById', + when: 'false', + }); + }); + it('bindToTextEditorHere is hidden from command palette', () => { expect(findEntry('rangelink.bindToTextEditorHere')).toStrictEqual({ command: 'rangelink.bindToTextEditorHere', 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 0d0cb1e1..fa36a452 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/destinations/destinationBuilders.test.ts @@ -1,6 +1,7 @@ import { createMockLogger } from 'barebone-logger-testing'; import type * as vscode from 'vscode'; +import type { CustomAiAssistantConfig } from '../../config/parseCustomAiAssistants'; import { buildTerminalDestination, buildTextEditorDestination, @@ -8,6 +9,7 @@ import { type DestinationBuilderContext, type DestinationBuilder, registerAllDestinationBuilders, + resolveKindByExtensionId, } from '../../destinations'; import { AutoPasteResult, type DestinationKind } from '../../types'; import { @@ -933,4 +935,50 @@ describe('destinationBuilders', () => { ); }); }); + + describe('resolveKindByExtensionId', () => { + it('returns built-in kind when extensionId matches a BUILTIN_AI_ASSISTANTS key', () => { + expect(resolveKindByExtensionId('anthropic.claude-code', [])).toBe('claude-code'); + expect(resolveKindByExtensionId('google.geminicodeassist', [])).toBe('gemini-code-assist'); + expect(resolveKindByExtensionId('github.copilot-chat', [])).toBe('github-copilot-chat'); + }); + + it('returns custom kind when extensionId matches a custom assistant', () => { + const customAssistants: CustomAiAssistantConfig[] = [ + { + kind: 'custom-ai:my-extension', + extensionId: 'my-extension', + extensionName: 'My Extension', + focusCommands: ['my-extension.focus'], + }, + ]; + + expect(resolveKindByExtensionId('my-extension', customAssistants)).toBe( + 'custom-ai:my-extension', + ); + }); + + it('prioritises built-in over custom when both match', () => { + const customAssistants: CustomAiAssistantConfig[] = [ + { + kind: 'custom-ai:anthropic.claude-code', + extensionId: 'anthropic.claude-code', + extensionName: 'Custom Override', + focusCommands: ['custom.focus'], + }, + ]; + + expect(resolveKindByExtensionId('anthropic.claude-code', customAssistants)).toBe( + 'claude-code', + ); + }); + + it('returns undefined when extensionId matches neither built-in nor custom', () => { + expect(resolveKindByExtensionId('unknown.assistant', [])).toBeUndefined(); + }); + + it('returns undefined when customAssistants is empty and extensionId is not built-in', () => { + expect(resolveKindByExtensionId('some.random.id', [])).toBeUndefined(); + }); + }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/extension.test.ts b/packages/rangelink-vscode-extension/src/__tests__/extension.test.ts index b4e1b508..7a376371 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/extension.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/extension.test.ts @@ -526,6 +526,7 @@ describe('Extension lifecycle', () => { const expectedCommands = [ 'rangelink.bindToClaudeCode', 'rangelink.bindToCursorAI', + 'rangelink.bindToCustomAiById', 'rangelink.bindToDestination', 'rangelink.bindToGeminiCodeAssist', 'rangelink.bindToGitHubCopilotChat', diff --git a/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockWiringServices.ts b/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockWiringServices.ts index 2b3f1e58..94025ee6 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockWiringServices.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/helpers/createMockWiringServices.ts @@ -55,4 +55,5 @@ export const createMockWiringServices = (): jest.Mocked => terminalLinkProvider: {}, documentLinkProvider: { handleLinkClick: jest.fn() }, delimiterCache: { dispose: jest.fn() }, + customAssistants: [], }) as unknown as jest.Mocked; diff --git a/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts b/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts index 1f2f8a4e..0c796ce2 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts @@ -1,6 +1,7 @@ import { CMD_BIND_TO_CLAUDE_CODE, CMD_BIND_TO_CURSOR_AI, + CMD_BIND_TO_CUSTOM_AI_BY_ID, CMD_BIND_TO_DESTINATION, CMD_BIND_TO_GEMINI_CODE_ASSIST, CMD_BIND_TO_GITHUB_COPILOT_CHAT, @@ -51,6 +52,7 @@ import { CMD_TERMINAL_PASTE_SELECTED_TEXT, CMD_UNBIND_DESTINATION, } from '../constants'; +import * as destinationBuildersModule from '../destinations/destinationBuilders'; import * as wireActiveTerminalBindabilityContextModule from '../destinations/wireActiveTerminalBindabilityContext'; import { wireSubscriptions } from '../wireSubscriptions'; @@ -76,6 +78,7 @@ const EXPECTED_COMMANDS = [ CMD_BIND_TO_TEXT_EDITOR_HERE, CMD_BIND_TO_CURSOR_AI, CMD_BIND_TO_CLAUDE_CODE, + CMD_BIND_TO_CUSTOM_AI_BY_ID, CMD_BIND_TO_GEMINI_CODE_ASSIST, CMD_BIND_TO_GITHUB_COPILOT_CHAT, CMD_UNBIND_DESTINATION, @@ -141,7 +144,7 @@ describe('wireSubscriptions', () => { expect(registeredCommands).toContain(cmd); } - expect(registeredCommands).toHaveLength(51); + expect(registeredCommands).toHaveLength(52); }); it('registers 2 terminal link providers', () => { @@ -354,6 +357,19 @@ describe('wireSubscriptions', () => { expect(services.bindToDestinationCommand.execute).toHaveBeenCalledTimes(1); }); + it('CMD_BIND_TO_CUSTOM_AI_BY_ID resolves extensionId and delegates to destinationManager.bind', async () => { + const resolveSpy = jest + .spyOn(destinationBuildersModule, 'resolveKindByExtensionId') + .mockReturnValue('custom-ai:dummy-ai-extension'); + await registrar.getHandler(CMD_BIND_TO_CUSTOM_AI_BY_ID)({ + extensionId: 'dummy-ai-extension', + }); + expect(services.destinationManager.bind).toHaveBeenCalledWith({ + kind: 'custom-ai:dummy-ai-extension', + }); + expect(resolveSpy).toHaveBeenCalledWith('dummy-ai-extension', []); + }); + it('CMD_JUMP_TO_DESTINATION delegates to jumpToDestinationCommand.execute', async () => { await registrar.getHandler(CMD_JUMP_TO_DESTINATION)(); expect(services.jumpToDestinationCommand.execute).toHaveBeenCalledTimes(1); diff --git a/packages/rangelink-vscode-extension/src/commands/createBindToCustomAiByIdCommand.ts b/packages/rangelink-vscode-extension/src/commands/createBindToCustomAiByIdCommand.ts new file mode 100644 index 00000000..0c84771c --- /dev/null +++ b/packages/rangelink-vscode-extension/src/commands/createBindToCustomAiByIdCommand.ts @@ -0,0 +1,59 @@ +import type { Logger } from 'barebone-logger'; + +import type { CustomAiAssistantConfig } from '../config/parseCustomAiAssistants'; +import { resolveKindByExtensionId } from '../destinations/destinationBuilders'; +import type { BindSuccessInfo } from '../destinations/PasteDestinationManager'; +import type { PasteDestinationManager } from '../destinations/PasteDestinationManager'; +import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors'; +import { ExtensionResult } from '../types'; + +const FN = 'createBindToCustomAiByIdCommand'; + +export const createBindToCustomAiByIdCommand = ( + customAssistants: CustomAiAssistantConfig[], + destinationManager: PasteDestinationManager, + logger: Logger, +): ((args: unknown) => Promise>) => { + return async (args: unknown): Promise> => { + const extensionId = extractExtensionId(args, logger); + if (!extensionId) { + return ExtensionResult.err( + new RangeLinkExtensionError({ + code: RangeLinkExtensionErrorCodes.CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID, + message: 'Argument must be { extensionId: string }', + functionName: FN, + }), + ); + } + + const kind = resolveKindByExtensionId(extensionId, customAssistants); + if (!kind) { + return ExtensionResult.err( + new RangeLinkExtensionError({ + code: RangeLinkExtensionErrorCodes.CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID, + message: `No AI assistant found with extension ID '${extensionId}'`, + functionName: FN, + }), + ); + } + + logger.debug({ fn: FN, extensionId, kind }, 'Binding to custom AI by ID'); + + return destinationManager.bind({ kind } as Parameters[0]); + }; +}; + +const extractExtensionId = (args: unknown, logger: Logger): string | undefined => { + if (!args || typeof args !== 'object' || Array.isArray(args)) { + logger.warn({ fn: FN }, 'Invalid or missing arguments for bindToCustomAiById'); + return undefined; + } + + const obj = args as Record; + if (typeof obj.extensionId !== 'string' || obj.extensionId.length === 0) { + logger.warn({ fn: FN, argsType: typeof args }, 'Missing or invalid extensionId in args'); + return undefined; + } + + return obj.extensionId; +}; diff --git a/packages/rangelink-vscode-extension/src/constants/commandIds.ts b/packages/rangelink-vscode-extension/src/constants/commandIds.ts index 7883655d..24cd29c2 100644 --- a/packages/rangelink-vscode-extension/src/constants/commandIds.ts +++ b/packages/rangelink-vscode-extension/src/constants/commandIds.ts @@ -6,6 +6,7 @@ export const CMD_BIND_TO_CLAUDE_CODE = 'rangelink.bindToClaudeCode'; export const CMD_BIND_TO_CURSOR_AI = 'rangelink.bindToCursorAI'; +export const CMD_BIND_TO_CUSTOM_AI_BY_ID = 'rangelink.bindToCustomAiById'; export const CMD_BIND_TO_DESTINATION = 'rangelink.bindToDestination'; export const CMD_BIND_TO_GEMINI_CODE_ASSIST = 'rangelink.bindToGeminiCodeAssist'; export const CMD_BIND_TO_GITHUB_COPILOT_CHAT = 'rangelink.bindToGitHubCopilotChat'; diff --git a/packages/rangelink-vscode-extension/src/createWiringServices.ts b/packages/rangelink-vscode-extension/src/createWiringServices.ts index 971a8937..754d546a 100644 --- a/packages/rangelink-vscode-extension/src/createWiringServices.ts +++ b/packages/rangelink-vscode-extension/src/createWiringServices.ts @@ -7,8 +7,8 @@ import { DefaultClipboardPreserver } from './clipboard'; import { AddBookmarkCommand, BindToDestinationCommand, - BindToTextEditorCommand, BindToTerminalCommand, + BindToTextEditorCommand, GoToRangeLinkCommand, JumpToDestinationCommand, ListBookmarksCommand, @@ -16,6 +16,7 @@ import { ShowVersionCommand, } from './commands'; import { ConfigReader, DelimiterCache } from './config'; +import type { CustomAiAssistantConfig } from './config/parseCustomAiAssistants'; import { parseCustomAiAssistants } from './config/parseCustomAiAssistants'; import { EligibilityCheckerFactory } from './destinations/capabilities/EligibilityCheckerFactory'; import { FocusCapabilityFactory } from './destinations/capabilities/FocusCapabilityFactory'; @@ -66,6 +67,7 @@ export interface WiringServices { terminalLinkProvider: RangeLinkTerminalProvider; documentLinkProvider: RangeLinkDocumentProvider; delimiterCache: DelimiterCache; + customAssistants: CustomAiAssistantConfig[]; } export interface ExtensionDependencies { @@ -258,5 +260,6 @@ export const createWiringServices = ( terminalLinkProvider, documentLinkProvider, delimiterCache, + customAssistants, }; }; diff --git a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts index 77029acc..2c4c3069 100644 --- a/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts +++ b/packages/rangelink-vscode-extension/src/destinations/destinationBuilders.ts @@ -24,6 +24,7 @@ import { RangeLinkExtensionError, RangeLinkExtensionErrorCodes } from '../errors import { AutoPasteResult, type AIAssistantDestinationKind, + type CustomAiAssistantKind, type DestinationKind, MessageCode, RelativePathFormat, @@ -404,6 +405,27 @@ const createOverriddenBuiltinBuilder = }); }; +// ============================================================================ +// Lookup +// ============================================================================ + +/** + * Resolve an extension ID to a destination kind. + * Searches built-in assistants by extensionId map key first, then custom configs by extensionId field. + */ +export const resolveKindByExtensionId = ( + extensionId: string, + customAssistants: CustomAiAssistantConfig[], +): AIAssistantDestinationKind | CustomAiAssistantKind | undefined => { + const builtin = BUILTIN_AI_ASSISTANTS[extensionId]; + if (builtin) return builtin.kind; + + const custom = customAssistants.find((c) => c.extensionId === extensionId); + if (custom) return custom.kind; + + return undefined; +}; + // ============================================================================ // Registration // ============================================================================ diff --git a/packages/rangelink-vscode-extension/src/errors/RangeLinkExtensionErrorCodes.ts b/packages/rangelink-vscode-extension/src/errors/RangeLinkExtensionErrorCodes.ts index 5fcb7704..a4eb1064 100644 --- a/packages/rangelink-vscode-extension/src/errors/RangeLinkExtensionErrorCodes.ts +++ b/packages/rangelink-vscode-extension/src/errors/RangeLinkExtensionErrorCodes.ts @@ -19,6 +19,7 @@ export enum RangeLinkExtensionSpecificCodes { BOOKMARK_NOT_FOUND = 'BOOKMARK_NOT_FOUND', BOOKMARK_SAVE_FAILED = 'BOOKMARK_SAVE_FAILED', BOOKMARK_STORE_NOT_AVAILABLE = 'BOOKMARK_STORE_NOT_AVAILABLE', + CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID = 'CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID', DESTINATION_BIND_FAILED = 'DESTINATION_BIND_FAILED', DESTINATION_FOCUS_FAILED = 'DESTINATION_FOCUS_FAILED', DESTINATION_NOT_AVAILABLE = 'DESTINATION_NOT_AVAILABLE', diff --git a/packages/rangelink-vscode-extension/src/wireSubscriptions.ts b/packages/rangelink-vscode-extension/src/wireSubscriptions.ts index 356be0f7..558103fa 100644 --- a/packages/rangelink-vscode-extension/src/wireSubscriptions.ts +++ b/packages/rangelink-vscode-extension/src/wireSubscriptions.ts @@ -1,9 +1,11 @@ import type * as vscode from 'vscode'; import { createBindAIAssistantCommand } from './commands/createBindAIAssistantCommand'; +import { createBindToCustomAiByIdCommand } from './commands/createBindToCustomAiByIdCommand'; import { CMD_BIND_TO_CLAUDE_CODE, CMD_BIND_TO_CURSOR_AI, + CMD_BIND_TO_CUSTOM_AI_BY_ID, CMD_BIND_TO_DESTINATION, CMD_BIND_TO_GEMINI_CODE_ASSIST, CMD_BIND_TO_GITHUB_COPILOT_CHAT, @@ -96,6 +98,7 @@ export const wireSubscriptions = ( terminalLinkProvider, documentLinkProvider, delimiterCache, + customAssistants, } = services; const bindToTerminalHandler = async (terminal?: unknown) => { @@ -175,6 +178,11 @@ export const wireSubscriptions = ( ); } + registrar.registerCommand( + CMD_BIND_TO_CUSTOM_AI_BY_ID, + createBindToCustomAiByIdCommand(customAssistants, destinationManager, logger), + ); + registrar.registerCommand(CMD_UNBIND_DESTINATION, () => { destinationManager.unbind(); }); From f62b85e5f6daf801ba769546d5806f0f47a3c305 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 21 May 2026 21:07:53 -0400 Subject: [PATCH 2/4] [PR feedback] Strengthen test assertions per CodeRabbit review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds missing logger assertions to unit tests and clipboard preservation log assertions to 22 integration tests across four files. Replaces a bare toBeErr() with toBeRangeLinkExtensionErrorErr. Benefits: - Custom-kind bind test now verifies debug logging like its built-in counterpart - Error passthrough test now asserts the specific error code, message, and functionName - Wire delegation test now verifies the debug log emitted by createBindToCustomAiByIdCommand - All 22 integration tests that call assertClipboardRestored now verify both "Clipboard saved" and "Clipboard restored" log entries, proving the full preservation pipeline ran - clipboard-preservation-009 correctly asserts the NEGATIVE case (picker dismissed, no operation ran, so no preservation logs expected) Ignored Feedback: - Asserting executeCommand return values in integration tests: the suite verifies behaviour through log capture, not command return values. Adding return-value assertions would be inconsistent with every other executeCommand call in the test suite. - Renaming CUSTOM_AI_NOT_FOUND_BY_EXTENSION_ID back to CUSTOM_AI_NOT_FOUND: the rename was intentional per prior review — the original name was too vague. This is a new error code on this branch with no existing consumers. Ref: https://github.com/couimet/rangeLink/pull/597#pullrequestreview-4341157586 --- .../helpers/clipboardHelpers.ts | 34 +++++++++++++++++++ .../suite/builtInAiAssistants.test.ts | 19 +++++++++++ .../suite/clipboardPreservation.test.ts | 19 ++++++++++- .../suite/customAiAssistants.test.ts | 3 ++ .../suite/dirtyBufferWarning.test.ts | 21 ++++++++++++ .../createBindToCustomAiByIdCommand.test.ts | 15 +++++++- .../src/__tests__/wireSubscriptions.test.ts | 8 +++++ 7 files changed, 117 insertions(+), 2 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts index d05edbea..3403e531 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts @@ -2,6 +2,8 @@ import assert from 'node:assert'; import * as vscode from 'vscode'; +import type { LogCapture } from '../../LogCapture'; + export const CLIPBOARD_SENTINEL = 'rangelink-test-sentinel-value'; export const writeClipboardSentinel = async (): Promise => { @@ -26,3 +28,35 @@ export const assertClipboardRestored = async (context: string): Promise => `${context}: clipboard should be restored to sentinel`, ); }; + +export const assertClipboardPreservationRan = ( + logCapture: LogCapture, + markName: string, + operationLabel: string, +): void => { + const lines = logCapture.getLinesSince(markName); + const savedLine = lines.find((l) => l.includes('Clipboard saved')); + const restoredLine = lines.find((l) => l.includes('Clipboard restored')); + assert.ok(savedLine, 'Expected "Clipboard saved" log entry — preservation must read clipboard'); + assert.ok( + restoredLine, + `Expected "Clipboard restored" log entry after ${operationLabel} operation`, + ); +}; + +export const assertClipboardPreservationDidNotRun = ( + logCapture: LogCapture, + markName: string, +): void => { + const lines = logCapture.getLinesSince(markName); + assert.strictEqual( + lines.find((l) => l.includes('Clipboard saved')), + undefined, + 'Expected no "Clipboard saved" — no operation ran', + ); + assert.strictEqual( + lines.find((l) => l.includes('Clipboard restored')), + undefined, + 'Expected no "Clipboard restored" — no operation ran', + ); +}; 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 3ba7ec21..bb0da45b 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 @@ -22,6 +22,7 @@ import { } from '../../utils/aiAssistants/builtInAiAssistants'; import { assertClipboardChanged, + assertClipboardPreservationRan, assertClipboardRestored, assertStatusBarMsgLogged, extractGeneratedLink, @@ -443,9 +444,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-011'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-011', 'R-L'); await assertClipboardRestored('clipboard-preservation-011: always + Claude Code cold paste'); ss.log('✓ clipboard-preservation-011: prior clipboard restored after cold paste'); }); @@ -470,9 +474,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-012'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-012', 'R-L'); await assertClipboardRestored('clipboard-preservation-012: always + Claude Code warm paste'); ss.log('✓ clipboard-preservation-012: prior clipboard restored after warm paste'); }); @@ -489,9 +496,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-013'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-013', 'R-L'); await assertClipboardRestored('clipboard-preservation-013: always + Cursor AI cold paste'); ss.log('✓ clipboard-preservation-013: cold Cursor AI paste — clipboard restored'); }); @@ -516,9 +526,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-014'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-014', 'R-L'); await assertClipboardRestored('clipboard-preservation-014: always + Cursor AI warm paste'); ss.log('✓ clipboard-preservation-014: warm Cursor AI paste — clipboard restored'); }); @@ -535,9 +548,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-015'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-015', 'R-L'); await assertClipboardRestored('clipboard-preservation-015: always + Copilot Chat cold paste'); ss.log('✓ clipboard-preservation-015: cold Copilot Chat paste — clipboard restored'); }); @@ -562,9 +578,12 @@ standardSuite('Built-in AI Assistants', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-clip-016'); await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-clip-016', 'R-L'); await assertClipboardRestored('clipboard-preservation-016: always + Copilot Chat warm paste'); ss.log('✓ clipboard-preservation-016: warm Copilot Chat paste — clipboard restored'); }); 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 90e357a2..d6ee10a0 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 @@ -11,6 +11,8 @@ import { import { VSCODE_CMD_TERMINAL_SELECT_ALL } from '../../constants/vscodeCommandIds'; import { assertClipboardChanged, + assertClipboardPreservationDidNotRun, + assertClipboardPreservationRan, assertClipboardRestored, assertTerminalBufferContains, extractGeneratedLink, @@ -44,8 +46,12 @@ standardSuite('Clipboard Preservation', (ss) => { .getConfiguration('rangelink') .update('clipboard.preserve', 'always', vscode.ConfigurationTarget.Global); + const logCapture = getLogCapture(); + logCapture.mark('before-003'); capturing.clearCaptured(); await vscode.commands.executeCommand(CMD_PASTE_CURRENT_FILE_PATH_RELATIVE); + await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-003', 'R-F'); await assertClipboardRestored('R-F with preserve=always'); assertTerminalBufferContains(capturing.getCapturedText(), 'clipboard'); }); @@ -157,6 +163,7 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { const generatedLink = extractGeneratedLink(lines); assert.ok(generatedLink, 'Expected "Generated link:" log line'); + assertClipboardPreservationRan(logCapture, 'before-001', 'R-L'); await assertClipboardRestored('clipboard-preservation-001: always + R-L'); assertTerminalBufferContains(capturing.getCapturedText(), generatedLink); ss.log('✓ Clipboard restored to sentinel after R-L; terminal received link'); @@ -184,8 +191,11 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { // Sentinel written after selectAll so copyOnSelection cannot overwrite it await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-002'); await vscode.commands.executeCommand(CMD_TERMINAL_PASTE_SELECTED_TEXT); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-002', 'R-V'); const destContent = (await vscode.workspace.openTextDocument(fileUri)).getText(); assert.ok( @@ -208,6 +218,8 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { editor.selection = new vscode.Selection(0, 0, 2, 6); await ss.settle(); + const logCapture = getLogCapture(); + logCapture.mark('before-004'); await waitForHuman( 'clipboard-preservation-004', `Press Cmd+R Cmd+D → bind "Dummy AI (Tier 1)", click back into the editor, press Cmd+R Cmd+L`, @@ -220,6 +232,7 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { ); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-004', 'R-L'); const dummyText = (await vscode.commands.executeCommand('dummyAi.getText')) as { tier1: string; tier2: string; @@ -257,6 +270,7 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { const generatedLink = extractGeneratedLink(lines); assert.ok(generatedLink, 'Expected "Generated link:" log line'); + assertClipboardPreservationRan(logCapture, 'before-005', 'R-L'); await assertClipboardRestored('clipboard-preservation-005: always + terminal paste'); assertTerminalBufferContains(capturing.getCapturedText(), generatedLink); ss.log('✓ Clipboard restored to sentinel after terminal paste (preserve=always)'); @@ -315,8 +329,11 @@ standardSuite('Clipboard Preservation — Assisted', (ss) => { await ss.settle(); await writeClipboardSentinel(); + const logCapture = getLogCapture(); + logCapture.mark('before-009'); await openAndDismiss(CMD_COPY_LINK_RELATIVE); - + await ss.settle(); + assertClipboardPreservationDidNotRun(logCapture, 'before-009'); await assertClipboardRestored('clipboard-preservation-009: always + picker dismissed'); ss.log('✓ Clipboard unchanged after picker dismissed (no operation performed)'); }); 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 6b46265b..13134e18 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 @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import { CMD_BIND_TO_DESTINATION, CMD_BIND_TO_CUSTOM_AI_BY_ID } from '../../constants/commandIds'; import { assertClipboardChanged, + assertClipboardPreservationRan, assertClipboardRestored, assertToastLogged, extractQuickPickItemsLogged, @@ -369,6 +370,8 @@ standardSuite('Custom AI Assistants — Paste Flow', (ss) => { await vscode.commands.executeCommand('rangelink.copyLinkWithRelativePath'); await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-tier1-clip', 'R-L'); + await assertClipboardRestored( 'Tier 1 should not disturb clipboard — outer preserve restores sentinel', ); 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 2df2f371..3f848bf4 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 @@ -4,6 +4,7 @@ import * as vscode from 'vscode'; import { CMD_COPY_LINK_ONLY_RELATIVE, CMD_COPY_LINK_RELATIVE } from '../../constants/commandIds'; import { + assertClipboardPreservationRan, assertClipboardRestored, assertNoToastLogged, assertStatusBarMsgLogged, @@ -206,6 +207,8 @@ standardSuite('Dirty Buffer Warning', (ss) => { assertTerminalBufferContains(capturing.getCapturedText(), 'dirty'); + assertClipboardPreservationRan(logCapture, 'before-018', 'R-L'); + await assertClipboardRestored( 'R-L with bound destination + warnOnDirtyBuffer=false: clipboard should be restored to sentinel after send', ); @@ -273,6 +276,8 @@ standardSuite('Dirty Buffer Warning', (ss) => { 'Expected document to remain dirty — bypass must not trigger save', ); + assertClipboardPreservationRan(logCapture, 'before-006', 'R-L'); + await assertClipboardRestored( 'R-L warnOnDirtyBuffer=false: clipboard should be restored after send', ); @@ -514,6 +519,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferContains(capturing.getCapturedText(), 'dirty'); + assertClipboardPreservationRan(logCapture, 'before-003', 'R-L'); + await assertClipboardRestored('R-L Save & Generate: clipboard should be restored after send'); ss.log('✓ R-L Save & Generate: file saved, link sent to terminal'); @@ -567,6 +574,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferEquals(capturing.getCapturedText(), ''); assert.ok(editor.document.isDirty, 'Expected document to remain dirty after dismiss'); + assertClipboardPreservationRan(logCapture, 'before-005', 'R-L'); + await assertClipboardRestored( 'R-L dismiss: clipboard should still have sentinel (no send occurred)', ); @@ -875,6 +884,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-rl-clipboard-preserve', 'R-L'); + await assertClipboardRestored( 'R-L with bound destination + dirty buffer dialog: clipboard should be restored after send', ); @@ -924,6 +935,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { await ss.settle(); + assertClipboardPreservationRan(logCapture, 'before-rf-clipboard-preserve', 'R-F'); + await assertClipboardRestored( 'R-F with bound destination + dirty buffer dialog: clipboard should be restored after send', ); @@ -993,6 +1006,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferContains(capturing.getCapturedText(), 'dirty'); + assertClipboardPreservationRan(logCapture, 'before-020', 'R-L'); + await assertClipboardRestored( 'R-L Save & Generate with bound destination: clipboard should be restored to sentinel after send', ); @@ -1046,6 +1061,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferContains(capturing.getCapturedText(), 'dirty'); + assertClipboardPreservationRan(logCapture, 'before-021', 'R-L'); + await assertClipboardRestored( 'R-L Generate Anyway with bound destination: clipboard should be restored to sentinel after send', ); @@ -1099,6 +1116,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { assertTerminalBufferEquals(capturing.getCapturedText(), ''); + assertClipboardPreservationRan(logCapture, 'before-022', 'R-L'); + await assertClipboardRestored( 'R-L dismiss: clipboard should still have sentinel (no send occurred)', ); @@ -1222,6 +1241,8 @@ standardSuite('Dirty Buffer Warning — Dialog Interaction', (ss) => { `Expected link to contain #L${L}C${SELECTION_START_COL}-L${L}C${postEndChar}, got: ${generatedLink}`, ); + assertClipboardPreservationRan(logCapture, 'before-023', 'R-L'); + await assertClipboardRestored( 'R-L Save & Generate (trim-on-save): clipboard should be restored to sentinel after send', ); diff --git a/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts b/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts index 844d9bee..55319902 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/commands/createBindToCustomAiByIdCommand.test.ts @@ -177,6 +177,7 @@ describe('createBindToCustomAiByIdCommand', () => { destinationName: 'Custom my-custom.extension', destinationKind: kind, }); + const mockManager = createMockDestinationManager({ bindResult }); const handler = createBindToCustomAiByIdCommand(customAssistants, mockManager, mockLogger); @@ -191,6 +192,15 @@ describe('createBindToCustomAiByIdCommand', () => { destinationKind: kind, }); }); + + expect(mockLogger.debug).toHaveBeenCalledWith( + { + fn: 'createBindToCustomAiByIdCommand', + extensionId, + kind, + }, + 'Binding to custom AI by ID', + ); }); it('passes bind error through when destinationManager.bind returns an error', async () => { @@ -209,7 +219,10 @@ describe('createBindToCustomAiByIdCommand', () => { const handler = createBindToCustomAiByIdCommand([], mockManager, mockLogger); const result = await handler({ extensionId: 'google.geminicodeassist' }); - expect(result).toBeErr(); + expect(result).toBeRangeLinkExtensionErrorErr('DESTINATION_BIND_FAILED', { + message: 'Destination not available', + functionName: 'PasteDestinationManager.bindGenericDestination', + }); expect(resolveSpy).toHaveBeenCalledWith('google.geminicodeassist', []); expect(mockManager.bind).toHaveBeenCalledWith({ kind: 'gemini-code-assist' }); }); diff --git a/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts b/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts index 0c796ce2..018f3ab4 100644 --- a/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts +++ b/packages/rangelink-vscode-extension/src/__tests__/wireSubscriptions.test.ts @@ -368,6 +368,14 @@ describe('wireSubscriptions', () => { kind: 'custom-ai:dummy-ai-extension', }); expect(resolveSpy).toHaveBeenCalledWith('dummy-ai-extension', []); + expect(services.logger.debug).toHaveBeenCalledWith( + { + fn: 'createBindToCustomAiByIdCommand', + extensionId: 'dummy-ai-extension', + kind: 'custom-ai:dummy-ai-extension', + }, + 'Binding to custom AI by ID', + ); }); it('CMD_JUMP_TO_DESTINATION delegates to jumpToDestinationCommand.execute', async () => { From 5c09fc5532891f320262c55e0ecb56bc1845511b Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Thu, 21 May 2026 21:16:09 -0400 Subject: [PATCH 3/4] [PR feedback] Assert clipboard log ordering, not just presence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assertClipboardPreservationRan` now verifies "Clipboard restored" appears after "Clipboard saved" in the log, not just that both exist. Presence-only assertions could pass if a restored line from a prior operation lingered before the saved line from the current operation. The ordering check proves the save→restore sequence ran within the same operation window. Ref: https://github.com/couimet/rangeLink/pull/597#pullrequestreview-4341766081 --- .../__integration-tests__/helpers/clipboardHelpers.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts index 3403e531..a6f652f1 100644 --- a/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts +++ b/packages/rangelink-vscode-extension/src/__integration-tests__/helpers/clipboardHelpers.ts @@ -35,11 +35,14 @@ export const assertClipboardPreservationRan = ( operationLabel: string, ): void => { const lines = logCapture.getLinesSince(markName); - const savedLine = lines.find((l) => l.includes('Clipboard saved')); - const restoredLine = lines.find((l) => l.includes('Clipboard restored')); - assert.ok(savedLine, 'Expected "Clipboard saved" log entry — preservation must read clipboard'); + const savedIdx = lines.findIndex((l) => l.includes('Clipboard saved')); + const restoredIdx = lines.findIndex((l) => l.includes('Clipboard restored')); assert.ok( - restoredLine, + savedIdx >= 0, + 'Expected "Clipboard saved" log entry — preservation must read clipboard', + ); + assert.ok( + restoredIdx > savedIdx, `Expected "Clipboard restored" log entry after ${operationLabel} operation`, ); }; From 2f045242515f29312161ae7b6e56680ba67b8da6 Mon Sep 17 00:00:00 2001 From: Charles Ouimet Date: Sun, 24 May 2026 12:04:02 -0400 Subject: [PATCH 4/4] [fix] Refocus editor before warm paste in clipboard preservation integration tests After the cold paste warmup moves focus to the AI assistant panel, vscode.window.activeTextEditor is stale. Link generation fails silently before withClipboardPreservation runs, so no preservation logs are emitted so the "Clipboard saved" log assertion fails because preservation never ran. Refocusing the editor before the warm selection fixes all three warm paste variants. Ref: https://github.com/couimet/rangeLink/pull/597#issuecomment-4514114387 --- .../suite/builtInAiAssistants.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 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 bb0da45b..25248668 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 @@ -469,7 +469,9 @@ standardSuite('Built-in AI Assistants', (ss) => { await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); - // Pre-select lines 3-4 for the warm send + // Pre-select lines 3-4 for the warm send — refocus editor first since + // the cold paste moved focus to the AI assistant panel. + await vscode.window.showTextDocument(editor.document); editor.selection = new vscode.Selection(2, 0, 3, 6); await ss.settle(); @@ -521,7 +523,9 @@ standardSuite('Built-in AI Assistants', (ss) => { await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); - // Pre-select lines 3-4 for the warm send + // Pre-select lines 3-4 for the warm send — refocus editor first since + // the cold paste moved focus to the AI assistant panel. + await vscode.window.showTextDocument(editor.document); editor.selection = new vscode.Selection(2, 0, 3, 6); await ss.settle(); @@ -573,7 +577,9 @@ standardSuite('Built-in AI Assistants', (ss) => { await vscode.commands.executeCommand(CMD_COPY_LINK_RELATIVE); await ss.settle(); - // Pre-select lines 3-4 for the warm send + // Pre-select lines 3-4 for the warm send — refocus editor first since + // the cold paste moved focus to the AI assistant panel. + await vscode.window.showTextDocument(editor.document); editor.selection = new vscode.Selection(2, 0, 3, 6); await ss.settle();