File size: 4,621 Bytes
a1b6014
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import type { HermesConnection } from '@/global'

// pluginSocket must dial the ACTIVE gateway's backend — resolved through the
// same (connectionId, profile) source of truth ensureGatewayProfile /
// ensureGatewayAgent maintain for $connection — not the unscoped primary
// (#73044). Exercises the REAL hermes + store/gateway + store/profile chain:
//  1. A profile switch routes the plugin socket to the pooled profile backend
//     (getConnection(profile), like pluginRest's profileScoped()).
//  2. A registry-agent activation routes it to the agent's SOURCE connection
//     (getConnectionFor), not the local pool — the post-#87600 shape.

vi.mock('@/hermes', async importOriginal => {
  const actual = await importOriginal<Record<string, unknown>>()

  return {
    ...actual,
    // Stub only the socket class so gateway activations don't dial real WS.
    HermesGateway: class {
      connectionState = 'closed'
      connect = async (_wsUrl: string): Promise<void> => {
        this.connectionState = 'open'
      }
      close = (): void => {
        this.connectionState = 'closed'
      }
      onEvent = vi.fn(() => () => {})
      onState = vi.fn(() => () => {})
    }
  }
})
vi.mock('@/lib/query-client', () => ({ invalidateProfileScopedQueries: vi.fn() }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))

const { pluginSocket, setApiRequestConnection, setApiRequestProfile } = await import('@/hermes')
const { closeSecondaryGateways, configureGatewayRegistry, setPrimaryGateway } = await import('@/store/gateway')
const { $activeGatewayProfile, ensureGatewayAgent, ensureGatewayProfile } = await import('@/store/profile')

// authMode 'oauth' makes pluginSocket stop after resolving the connection
// (polling fallback), so the assertions cover resolution without a WS dial.
const conn = (over: Partial<HermesConnection> = {}): HermesConnection =>
  ({
    authMode: 'oauth',
    baseUrl: 'https://pool.invalid',
    mode: 'remote',
    token: 'fake-test-token',
    wsUrl: 'wss://pool.invalid/api/ws?token=fake-test-token',
    ...over
  }) as HermesConnection

let getConnection: ReturnType<typeof vi.fn>
let getConnectionFor: ReturnType<typeof vi.fn>

beforeEach(() => {
  getConnection = vi.fn(async (profile?: null | string) => conn({ profile: profile ?? 'default' }))
  getConnectionFor = vi.fn(async ({ profile }: { connectionId?: null | string; profile?: null | string }) =>
    conn({ baseUrl: 'https://homelab.invalid', profile: profile ?? 'default' })
  )
  Object.defineProperty(window, 'hermesDesktop', {
    configurable: true,
    value: {
      getConnection,
      getConnectionFor,
      getGatewayWsUrl: vi.fn(async () => 'wss://pool.invalid/api/ws?ticket=fake'),
      getGatewayWsUrlFor: vi.fn(async () => 'wss://homelab.invalid/api/ws?ticket=fake'),
      touchBackend: vi.fn(async () => ({ ok: true }))
    }
  })
  configureGatewayRegistry({ onEvent: vi.fn() })
  setPrimaryGateway({ connectionState: 'open' } as never, 'default')
})

afterEach(() => {
  closeSecondaryGateways()
  $activeGatewayProfile.set('default')
  setApiRequestProfile(null)
  setApiRequestConnection(null)
  Reflect.deleteProperty(window, 'hermesDesktop')
  vi.restoreAllMocks()
})

describe('pluginSocket active-backend scoping (#73044)', () => {
  it('dials the active profile backend after a profile switch', async () => {
    await ensureGatewayProfile('work')
    getConnection.mockClear()
    getConnectionFor.mockClear()

    const dispose = pluginSocket('kanban', '/events', () => {})

    await vi.waitFor(() => expect(getConnection).toHaveBeenCalled())
    expect(getConnection).toHaveBeenCalledWith('work')
    expect(getConnectionFor).not.toHaveBeenCalled()

    dispose()
  })

  it("dials the agent's SOURCE connection after a registry-agent activation", async () => {
    await ensureGatewayAgent('homelab', 'research')
    getConnection.mockClear()
    getConnectionFor.mockClear()

    const dispose = pluginSocket('kanban', '/events', () => {})

    await vi.waitFor(() => expect(getConnectionFor).toHaveBeenCalled())
    expect(getConnectionFor).toHaveBeenCalledWith({ connectionId: 'homelab', profile: 'research' })
    expect(getConnection).not.toHaveBeenCalled()

    dispose()
  })

  it('falls back to the primary when no profile or connection is active', async () => {
    const dispose = pluginSocket('kanban', '/events', () => {})

    await vi.waitFor(() => expect(getConnection).toHaveBeenCalled())
    expect(getConnection).toHaveBeenCalledWith(null)

    dispose()
  })
})