From bab1a4536c2ab83f1b816f4acd09bd95d250c436 Mon Sep 17 00:00:00 2001 From: Kairi Yasumatsu Date: Tue, 21 Jul 2026 00:43:53 +0900 Subject: [PATCH 1/2] feat: add connection duplication #4034 --- .../lib/Instance/Connection/TrpcRouter.ts | 13 ++ companion/lib/Instance/Controller.ts | 45 ++++ .../Instance/Connection/Duplicate.test.ts | 192 ++++++++++++++++++ .../ConnectionList/ConnectionsTableRow.tsx | 34 +++- .../__tests__/ConnectionsTableRow.test.tsx | 88 ++++++++ 5 files changed, 363 insertions(+), 9 deletions(-) create mode 100644 companion/test/Instance/Connection/Duplicate.test.ts create mode 100644 webui/src/Connections/ConnectionList/__tests__/ConnectionsTableRow.test.tsx diff --git a/companion/lib/Instance/Connection/TrpcRouter.ts b/companion/lib/Instance/Connection/TrpcRouter.ts index 3f52b4e0c2..30d43f3441 100644 --- a/companion/lib/Instance/Connection/TrpcRouter.ts +++ b/companion/lib/Instance/Connection/TrpcRouter.ts @@ -68,6 +68,19 @@ export function createConnectionsTrpcRouter( return connectionInfo[0] }), + duplicate: publicProcedure + .input( + z.object({ + connectionId: z.string(), + }) + ) + .mutation(({ input }) => { + const connectionId = instanceController.duplicateConnection(input.connectionId) + if (!connectionId) throw new Error('Connection not found') + + return connectionId + }), + delete: publicProcedure .input( z.object({ diff --git a/companion/lib/Instance/Controller.ts b/companion/lib/Instance/Controller.ts index d032ba70a6..0012dd6b56 100644 --- a/companion/lib/Instance/Controller.ts +++ b/companion/lib/Instance/Controller.ts @@ -821,6 +821,51 @@ export class InstanceController extends EventEmitter { return [id, config] } + duplicateConnection(sourceId: string): string | undefined { + const sourceConfig = this.#configStore.getConfigOfTypeForId(sourceId, ModuleInstanceType.Connection) + if (!sourceConfig) return undefined + + const sourceIndex = this.#configStore + .getAllInstanceConfigs() + .entries() + .filter( + ([, config]) => + config.moduleInstanceType === ModuleInstanceType.Connection && + config.collectionId === sourceConfig.collectionId + ) + .toArray() + .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) + .findIndex(([id]) => id === sourceId) + + const [newId] = this.addConnectionWithLabel({ type: sourceConfig.moduleId }, sourceConfig.label, { + versionId: sourceConfig.moduleVersionId, + updatePolicy: sourceConfig.updatePolicy, + disabled: true, + collectionId: sourceConfig.collectionId, + }) + + const updateResult = this.setConnectionLabelAndConfig(newId, { + label: null, + enabled: null, + config: structuredClone(sourceConfig.config), + secrets: structuredClone(sourceConfig.secrets ?? {}), + updatePolicy: null, + upgradeIndex: sourceConfig.lastUpgradeIndex, + }) + if (!updateResult.ok) throw new Error(updateResult.message) + + this.#configStore.moveInstance( + sourceConfig.collectionId ?? null, + ModuleInstanceType.Connection, + newId, + sourceIndex + 1 + ) + + if (sourceConfig.enabled) this.enableDisableConnection(newId, true) + + return newId + } + getLabelForConnection(id: string): string | undefined { return this.#configStore.getConfigOfTypeForId(id, ModuleInstanceType.Connection)?.label } diff --git a/companion/test/Instance/Connection/Duplicate.test.ts b/companion/test/Instance/Connection/Duplicate.test.ts new file mode 100644 index 0000000000..dc55339d34 --- /dev/null +++ b/companion/test/Instance/Connection/Duplicate.test.ts @@ -0,0 +1,192 @@ +import express from 'express' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { + InstanceVersionUpdatePolicy, + ModuleInstanceType, + type InstanceConfig, +} from '@companion-app/shared/Model/Instance.js' +import type { IControlStore } from '../../../lib/Controls/IControlStore.js' +import { DataCache } from '../../../lib/Data/Cache.js' +import { DataDatabase } from '../../../lib/Data/Database.js' +import type { MetricsRegistry } from '../../../lib/Data/Metrics.js' +import { InstanceController } from '../../../lib/Instance/Controller.js' +import type { AppInfo } from '../../../lib/Registry.js' +import type { ServiceOscSender } from '../../../lib/Service/OscSender.js' +import type { SurfaceController } from '../../../lib/Surface/Controller.js' +import type { VariablesController } from '../../../lib/Variables/Controller.js' + +const SOURCE_ID = 'source-connection' +const COLLECTION_ID = 'camera-collection' + +function createConnection(overrides: Partial = {}): InstanceConfig { + return { + moduleInstanceType: ModuleInstanceType.Connection, + moduleId: 'test-camera', + moduleVersionId: '1.2.3', + label: 'camera', + config: { host: '192.0.2.10', nested: { port: 1234 } }, + secrets: { password: 'secret' }, + isFirstInit: false, + lastUpgradeIndex: 7, + enabled: true, + sortOrder: 20, + updatePolicy: InstanceVersionUpdatePolicy.Beta, + collectionId: COLLECTION_ID, + ...overrides, + } +} + +describe('InstanceController duplicateConnection', () => { + let db: DataDatabase + let cache: DataCache + let controlsStore: ReturnType> + let controller: InstanceController + + beforeEach(() => { + vi.useFakeTimers() + + db = new DataDatabase(':memory:') + cache = new DataCache(':memory:') + controlsStore = mockDeep() + + const instances = db.getTableView>('instances') + instances.set('before', createConnection({ label: 'before', sortOrder: 10 })) + instances.set(SOURCE_ID, createConnection()) + instances.set('after', createConnection({ label: 'after', sortOrder: 40 })) + instances.set('ungrouped', createConnection({ label: 'ungrouped', collectionId: undefined, sortOrder: 50 })) + instances.set( + 'disabled-source', + createConnection({ label: 'disabled', collectionId: undefined, enabled: false, sortOrder: 60 }) + ) + + const appInfo = { + configDir: ':memory:', + logsDir: undefined, + modulesDirs: { + [ModuleInstanceType.Connection]: '', + [ModuleInstanceType.Surface]: '', + }, + builtinModuleDirs: { + [ModuleInstanceType.Connection]: null, + [ModuleInstanceType.Surface]: null, + }, + udevRulesDir: '', + machineId: 'test-machine', + appVersion: 'test', + appBuild: 'test', + pkgInfo: {}, + options: { + notifications: false, + enableShellCommandSupport: false, + enableRestrictedModules: false, + trustedProxies: undefined, + installNameOverride: undefined, + }, + } as AppInfo + + controller = new InstanceController( + appInfo, + db, + cache, + express.Router(), + controlsStore, + mockDeep(), + mockDeep(), + mockDeep(), + mockDeep() + ) + vi.spyOn(controller.userModulesManager, 'ensureModuleIsInstalled').mockImplementation(() => undefined) + vi.clearAllMocks() + }) + + afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + cache.close() + db.close() + }) + + it('copies the connection configuration and inserts it immediately after the source', () => { + const newId = controller.duplicateConnection(SOURCE_ID) + expect(newId).toBeTypeOf('string') + expect(newId).not.toBe(SOURCE_ID) + expect(controller.userModulesManager.ensureModuleIsInstalled).toHaveBeenCalledWith( + ModuleInstanceType.Connection, + 'test-camera', + '1.2.3' + ) + + const instances = db.getTableView>('instances').all() + const source = instances[SOURCE_ID] + const duplicate = instances[newId!] + + expect(duplicate).toMatchObject({ + moduleInstanceType: ModuleInstanceType.Connection, + moduleId: source.moduleId, + moduleVersionId: source.moduleVersionId, + label: 'camera_2', + config: source.config, + secrets: source.secrets, + isFirstInit: false, + lastUpgradeIndex: source.lastUpgradeIndex, + enabled: source.enabled, + updatePolicy: source.updatePolicy, + collectionId: source.collectionId, + }) + + const collectionOrder = Object.entries(instances) + .filter(([, config]) => config.collectionId === COLLECTION_ID) + .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) + .map(([id]) => id) + expect(collectionOrder).toEqual(['before', SOURCE_ID, newId, 'after']) + expect(instances.ungrouped.sortOrder).toBe(50) + }) + + it('does not copy or rewrite controls that reference the source connection', () => { + controller.duplicateConnection(SOURCE_ID) + + expect(controlsStore.renameVariables).not.toHaveBeenCalled() + expect(controlsStore.forgetConnection).not.toHaveBeenCalled() + expect(controlsStore.clearConnectionState).not.toHaveBeenCalled() + expect(controlsStore.updateFeedbackValues).not.toHaveBeenCalled() + }) + + it('keeps a disabled source disabled', () => { + const instances = db.getTableView>('instances') + const newId = controller.duplicateConnection('disabled-source') + const duplicate = instances.get(newId!) + expect(duplicate?.enabled).toBe(false) + }) + + it('duplicates an ungrouped connection immediately after its source', () => { + const newId = controller.duplicateConnection('ungrouped') + const instances = db.getTableView>('instances').all() + const ungroupedOrder = Object.entries(instances) + .filter(([, config]) => !config.collectionId) + .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) + .map(([id]) => id) + + expect(ungroupedOrder).toEqual(['ungrouped', newId, 'disabled-source']) + }) + + it('uses stable labels and ordering when the same connection is duplicated repeatedly', () => { + const firstId = controller.duplicateConnection(SOURCE_ID) + const secondId = controller.duplicateConnection(SOURCE_ID) + const instances = db.getTableView>('instances').all() + + expect(instances[firstId!].label).toBe('camera_2') + expect(instances[secondId!].label).toBe('camera_3') + + const collectionOrder = Object.entries(instances) + .filter(([, config]) => config.collectionId === COLLECTION_ID) + .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) + .map(([id]) => id) + expect(collectionOrder).toEqual(['before', SOURCE_ID, secondId, firstId, 'after']) + }) + + it('returns undefined when the source connection does not exist', () => { + expect(controller.duplicateConnection('missing')).toBeUndefined() + expect(controller.userModulesManager.ensureModuleIsInstalled).not.toHaveBeenCalled() + }) +}) diff --git a/webui/src/Connections/ConnectionList/ConnectionsTableRow.tsx b/webui/src/Connections/ConnectionList/ConnectionsTableRow.tsx index e8e3ab93e3..2a15ef1e11 100644 --- a/webui/src/Connections/ConnectionList/ConnectionsTableRow.tsx +++ b/webui/src/Connections/ConnectionList/ConnectionsTableRow.tsx @@ -1,4 +1,4 @@ -import { faDollarSign } from '@fortawesome/free-solid-svg-icons' +import { faClone, faDollarSign } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { observer } from 'mobx-react-lite' import { useCallback, useContext } from 'react' @@ -23,8 +23,18 @@ export const ConnectionsTableRow = observer(function ConnectionsTableRow({ const id = connection.id const deleteMutation = useMutationExt(trpc.instances.connections.delete.mutationOptions()) + const duplicateMutation = useMutationExt(trpc.instances.connections.duplicate.mutationOptions()) const setEnabledMutation = useMutationExt(trpc.instances.connections.setEnabled.mutationOptions()) + const doDuplicate = useCallback(() => { + duplicateMutation + .mutateAsync({ connectionId: id }) + .then((connectionId) => configureConnection(connectionId)) + .catch((e) => { + console.error('Duplicate failed', e) + }) + }, [duplicateMutation, id, configureConnection]) + const doDelete = useCallback(() => { deleteModalRef.current?.show( 'Delete connection', @@ -62,14 +72,20 @@ export const ConnectionsTableRow = observer(function ConnectionsTableRow({ instance={connection} instanceStatus={connection.status} extraMenuItems={ - 0)} - > - - Variables - + <> + + + Duplicate + + 0)} + > + + Variables + + } labelStr="connection" doDelete={doDelete} diff --git a/webui/src/Connections/ConnectionList/__tests__/ConnectionsTableRow.test.tsx b/webui/src/Connections/ConnectionList/__tests__/ConnectionsTableRow.test.tsx new file mode 100644 index 0000000000..8bb06f901e --- /dev/null +++ b/webui/src/Connections/ConnectionList/__tests__/ConnectionsTableRow.test.tsx @@ -0,0 +1,88 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { InstanceVersionUpdatePolicy, ModuleInstanceType } from '@companion-app/shared/Model/Instance.js' +import { RootAppStoreContext, type RootAppStore } from '~/Stores/RootAppStore.js' +import type { ClientConnectionConfigWithId } from '../ConnectionList.js' +import { ConnectionListContextProvider, type ConnectionListContextType } from '../ConnectionListContext.js' +import { ConnectionsTableRow } from '../ConnectionsTableRow.js' + +const mutationMocks = vi.hoisted(() => ({ + delete: vi.fn(), + duplicate: vi.fn(), + setEnabled: vi.fn(), +})) + +vi.mock('~/Resources/TRPC.js', () => ({ + trpc: { + instances: { + connections: { + delete: { mutationOptions: () => 'delete' }, + duplicate: { mutationOptions: () => 'duplicate' }, + setEnabled: { mutationOptions: () => 'setEnabled' }, + }, + }, + }, + useMutationExt: (mutation: keyof typeof mutationMocks) => ({ mutateAsync: mutationMocks[mutation] }), +})) + +vi.mock('~/Components/Popover.js', () => ({ + Popover: { + Item: ({ children, ...props }: { children: ReactNode; onClick: () => void; title: string; disabled?: boolean }) => ( + + ), + }, +})) + +vi.mock('~/Instances/List/InstancesListTableRow.js', () => ({ + InstancesListTableRow: ({ extraMenuItems }: { extraMenuItems: ReactNode }) =>
{extraMenuItems}
, +})) + +const connection: ClientConnectionConfigWithId = { + id: 'source-connection', + label: 'camera', + moduleType: ModuleInstanceType.Connection, + moduleId: 'test-camera', + moduleVersionId: '1.2.3', + updatePolicy: InstanceVersionUpdatePolicy.Stable, + enabled: true, + sortOrder: 0, + collectionId: null, + hasRecordActionsHandler: false, + status: undefined, +} + +function renderRow(configureConnection: ConnectionListContextType['configureConnection']) { + const rootStore = { + connections: {}, + variablesStore: { variables: new Map() }, + } as unknown as RootAppStore + + render( + + + + + + ) +} + +describe('ConnectionsTableRow duplicate', () => { + it('duplicates the connection and opens the new connection', async () => { + const user = userEvent.setup() + const configureConnection = vi.fn() + mutationMocks.duplicate.mockResolvedValueOnce('duplicated-connection') + renderRow(configureConnection) + + await user.click(screen.getByRole('button', { name: 'Duplicate' })) + + expect(mutationMocks.duplicate).toHaveBeenCalledWith({ connectionId: 'source-connection' }) + await waitFor(() => expect(configureConnection).toHaveBeenCalledWith('duplicated-connection')) + }) +}) From aee82159a02468b27aa7c6e5ba1fdaa11ddc3b0f Mon Sep 17 00:00:00 2001 From: Kairi Yasumatsu Date: Sun, 26 Jul 2026 23:24:26 +0900 Subject: [PATCH 2/2] fix: preserve legacy connection enabled state --- companion/lib/Instance/Controller.ts | 2 +- .../test/Instance/Connection/Duplicate.test.ts | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/companion/lib/Instance/Controller.ts b/companion/lib/Instance/Controller.ts index 0012dd6b56..d30f9f1b44 100644 --- a/companion/lib/Instance/Controller.ts +++ b/companion/lib/Instance/Controller.ts @@ -861,7 +861,7 @@ export class InstanceController extends EventEmitter { sourceIndex + 1 ) - if (sourceConfig.enabled) this.enableDisableConnection(newId, true) + if (sourceConfig.enabled !== false) this.enableDisableConnection(newId, true) return newId } diff --git a/companion/test/Instance/Connection/Duplicate.test.ts b/companion/test/Instance/Connection/Duplicate.test.ts index dc55339d34..f0472a4e29 100644 --- a/companion/test/Instance/Connection/Duplicate.test.ts +++ b/companion/test/Instance/Connection/Duplicate.test.ts @@ -59,6 +59,10 @@ describe('InstanceController duplicateConnection', () => { 'disabled-source', createConnection({ label: 'disabled', collectionId: undefined, enabled: false, sortOrder: 60 }) ) + instances.set( + 'legacy-enabled-source', + createConnection({ label: 'legacy-enabled', collectionId: undefined, enabled: undefined, sortOrder: 70 }) + ) const appInfo = { configDir: ':memory:', @@ -159,6 +163,13 @@ describe('InstanceController duplicateConnection', () => { expect(duplicate?.enabled).toBe(false) }) + it('keeps a legacy source without an enabled field enabled', () => { + const instances = db.getTableView>('instances') + const newId = controller.duplicateConnection('legacy-enabled-source') + const duplicate = instances.get(newId!) + expect(duplicate?.enabled).toBe(true) + }) + it('duplicates an ungrouped connection immediately after its source', () => { const newId = controller.duplicateConnection('ungrouped') const instances = db.getTableView>('instances').all() @@ -167,7 +178,7 @@ describe('InstanceController duplicateConnection', () => { .sort(([, a], [, b]) => a.sortOrder - b.sortOrder) .map(([id]) => id) - expect(ungroupedOrder).toEqual(['ungrouped', newId, 'disabled-source']) + expect(ungroupedOrder).toEqual(['ungrouped', newId, 'disabled-source', 'legacy-enabled-source']) }) it('uses stable labels and ordering when the same connection is duplicated repeatedly', () => {