mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-06 17:37:47 +08:00
feat(vscodex): add remote Codex collaboration module
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { deleteMock, getMock, postMock } = vi.hoisted(() => ({
|
||||
deleteMock: vi.fn(),
|
||||
getMock: vi.fn(),
|
||||
postMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/client', () => ({
|
||||
default: {
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
delete: deleteMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import { vscodexApi } from '@/api/vscodex'
|
||||
|
||||
describe('vscodexApi', () => {
|
||||
beforeEach(() => {
|
||||
getMock.mockReset()
|
||||
postMock.mockReset()
|
||||
deleteMock.mockReset()
|
||||
})
|
||||
|
||||
it('lists and normalizes the current user devices', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
data: {
|
||||
devices: [
|
||||
{
|
||||
device_id: 'device-online',
|
||||
display_name: 'Studio Mac',
|
||||
connected: true,
|
||||
last_seen_at: '2026-08-31T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'device-unknown',
|
||||
name: 'Laptop',
|
||||
status: 'unexpected-status',
|
||||
},
|
||||
{ name: 'missing id' },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await expect(vscodexApi.listDevices()).resolves.toEqual([
|
||||
{
|
||||
id: 'device-online',
|
||||
name: 'Studio Mac',
|
||||
status: 'online',
|
||||
last_seen_at: '2026-08-31T10:00:00Z',
|
||||
created_at: null,
|
||||
},
|
||||
{
|
||||
id: 'device-unknown',
|
||||
name: 'Laptop',
|
||||
status: 'unknown',
|
||||
last_seen_at: null,
|
||||
created_at: null,
|
||||
},
|
||||
])
|
||||
expect(getMock).toHaveBeenCalledWith('/api/users/me/vscodex/devices')
|
||||
})
|
||||
|
||||
it('creates a pairing using an explicit empty body and normalizes its code', async () => {
|
||||
postMock.mockResolvedValue({
|
||||
data: {
|
||||
pairing_code: 'PAIR-1234',
|
||||
expires_in_seconds: 300,
|
||||
},
|
||||
})
|
||||
|
||||
await expect(vscodexApi.createPairing()).resolves.toEqual({
|
||||
code: 'PAIR-1234',
|
||||
expires_at: null,
|
||||
expires_in_seconds: 300,
|
||||
})
|
||||
expect(postMock).toHaveBeenCalledWith('/api/users/me/vscodex/pairings', {})
|
||||
})
|
||||
|
||||
it('requests a scoped WebSocket ticket for the selected device', async () => {
|
||||
postMock.mockResolvedValue({
|
||||
data: {
|
||||
ticket: 'single-use-ticket',
|
||||
wsUrl: 'wss://aether.example/api/vscodex/ws',
|
||||
},
|
||||
})
|
||||
|
||||
await expect(vscodexApi.createWsTicket('device-online')).resolves.toEqual({
|
||||
ticket: 'single-use-ticket',
|
||||
ws_url: 'wss://aether.example/api/vscodex/ws',
|
||||
expires_at: null,
|
||||
})
|
||||
expect(postMock).toHaveBeenCalledWith('/api/users/me/vscodex/ws-tickets', {
|
||||
device_id: 'device-online',
|
||||
})
|
||||
})
|
||||
|
||||
it('revokes the selected device using an encoded path segment', async () => {
|
||||
deleteMock.mockResolvedValue({ status: 204 })
|
||||
|
||||
await expect(vscodexApi.deleteDevice('device/one')).resolves.toBeUndefined()
|
||||
expect(deleteMock).toHaveBeenCalledWith('/api/users/me/vscodex/devices/device%2Fone')
|
||||
})
|
||||
|
||||
it('rejects incomplete pairing and ticket responses', async () => {
|
||||
postMock
|
||||
.mockResolvedValueOnce({ data: {} })
|
||||
.mockResolvedValueOnce({ data: { ticket: 'missing-url' } })
|
||||
|
||||
await expect(vscodexApi.createPairing()).rejects.toThrow('Pairing response did not include a code')
|
||||
await expect(vscodexApi.createWsTicket('device-online')).rejects.toThrow(
|
||||
'WebSocket ticket response was incomplete',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import apiClient from '@/api/client'
|
||||
|
||||
const BASE_PATH = '/api/users/me/vscodex'
|
||||
|
||||
export type VscodexDeviceStatus = 'online' | 'offline' | 'connecting' | 'unknown'
|
||||
|
||||
export interface VscodexDevice {
|
||||
id: string
|
||||
name: string
|
||||
status: VscodexDeviceStatus
|
||||
last_seen_at: string | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export interface VscodexPairing {
|
||||
code: string
|
||||
expires_at: string | null
|
||||
expires_in_seconds: number | null
|
||||
}
|
||||
|
||||
export interface VscodexWsTicket {
|
||||
ticket: string
|
||||
ws_url: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
type DevicePayload = Partial<VscodexDevice> & {
|
||||
device_id?: string
|
||||
display_name?: string
|
||||
connected?: boolean
|
||||
}
|
||||
|
||||
type DevicesPayload = DevicePayload[] | {
|
||||
devices?: DevicePayload[]
|
||||
items?: DevicePayload[]
|
||||
}
|
||||
|
||||
type PairingPayload = Partial<VscodexPairing> & {
|
||||
pairing_code?: string
|
||||
}
|
||||
|
||||
type WsTicketPayload = Partial<VscodexWsTicket> & {
|
||||
wsUrl?: string
|
||||
}
|
||||
|
||||
function normalizeStatus(device: DevicePayload): VscodexDeviceStatus {
|
||||
if (device.connected === true) return 'online'
|
||||
if (device.connected === false) return 'offline'
|
||||
|
||||
switch (device.status) {
|
||||
case 'online':
|
||||
case 'offline':
|
||||
case 'connecting':
|
||||
return device.status
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDevice(device: DevicePayload): VscodexDevice | null {
|
||||
const id = device.id || device.device_id
|
||||
if (!id) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
name: device.name || device.display_name || id,
|
||||
status: normalizeStatus(device),
|
||||
last_seen_at: device.last_seen_at ?? null,
|
||||
created_at: device.created_at ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export const vscodexApi = {
|
||||
async listDevices(): Promise<VscodexDevice[]> {
|
||||
const response = await apiClient.get<DevicesPayload>(`${BASE_PATH}/devices`)
|
||||
const payload = response.data
|
||||
const devices = Array.isArray(payload) ? payload : payload.devices ?? payload.items ?? []
|
||||
return devices.map(normalizeDevice).filter((device): device is VscodexDevice => device !== null)
|
||||
},
|
||||
|
||||
async createPairing(): Promise<VscodexPairing> {
|
||||
const response = await apiClient.post<PairingPayload>(`${BASE_PATH}/pairings`, {})
|
||||
const code = response.data.code || response.data.pairing_code
|
||||
if (!code) throw new Error('Pairing response did not include a code')
|
||||
|
||||
return {
|
||||
code,
|
||||
expires_at: response.data.expires_at ?? null,
|
||||
expires_in_seconds: response.data.expires_in_seconds ?? null,
|
||||
}
|
||||
},
|
||||
|
||||
async createWsTicket(deviceId: string): Promise<VscodexWsTicket> {
|
||||
const response = await apiClient.post<WsTicketPayload>(`${BASE_PATH}/ws-tickets`, {
|
||||
device_id: deviceId,
|
||||
})
|
||||
const { ticket } = response.data
|
||||
const wsUrl = response.data.ws_url || response.data.wsUrl
|
||||
if (!ticket || !wsUrl) throw new Error('WebSocket ticket response was incomplete')
|
||||
|
||||
return {
|
||||
ticket,
|
||||
ws_url: wsUrl,
|
||||
expires_at: response.data.expires_at ?? null,
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDevice(deviceId: string): Promise<void> {
|
||||
await apiClient.delete(`${BASE_PATH}/devices/${encodeURIComponent(deviceId)}`)
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user