mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 19:59:50 +08:00
feat(vscodex): add remote Codex collaboration module
This commit is contained in:
@@ -4,8 +4,12 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"sync:vscodex": "npm --prefix ../aether-vscodex/web run build && node scripts/sync-vscodex.mjs",
|
||||
"predev": "npm run sync:vscodex",
|
||||
"dev": "vite --host",
|
||||
"prebuild": "npm run sync:vscodex",
|
||||
"build": "vite build",
|
||||
"prebuild:with-typecheck": "npm run sync:vscodex",
|
||||
"build:with-typecheck": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const frontendRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const moduleRoot = resolve(frontendRoot, '..', 'aether-vscodex')
|
||||
const vueBuild = resolve(moduleRoot, 'web', 'dist')
|
||||
const destination = resolve(frontendRoot, 'public', 'aether-vscodex')
|
||||
|
||||
if (!existsSync(resolve(vueBuild, 'index.html'))) {
|
||||
throw new Error(`aether-vscodex Vue build was not found at ${vueBuild}`)
|
||||
}
|
||||
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
mkdirSync(destination, { recursive: true })
|
||||
cpSync(vueBuild, destination, { recursive: true })
|
||||
@@ -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)}`)
|
||||
},
|
||||
}
|
||||
@@ -275,6 +275,37 @@ export const messages = {
|
||||
'nav.healthMonitor': '健康监控',
|
||||
'nav.modelCatalog': '模型目录',
|
||||
'nav.apiKeys': 'API 密钥',
|
||||
'nav.vscodex': 'Codex 远程控制',
|
||||
'vscodex.title': 'Codex 远程控制',
|
||||
'vscodex.devices.label': '控制设备',
|
||||
'vscodex.devices.refresh': '刷新设备',
|
||||
'vscodex.devices.loading': '正在加载设备...',
|
||||
'vscodex.devices.loadFailed': '无法加载设备,请检查网络后重试。',
|
||||
'vscodex.devices.emptyTitle': '尚未连接设备',
|
||||
'vscodex.devices.emptyDescription': '安装 aether-vscodex 插件后,在 VS Code 命令面板运行“Codex Remote: Pair with Aether”连接此账户。',
|
||||
'vscodex.devices.checkConnection': '检查连接',
|
||||
'vscodex.devices.online': '在线',
|
||||
'vscodex.devices.offline': '离线',
|
||||
'vscodex.devices.connecting': '连接中',
|
||||
'vscodex.devices.unknown': '状态未知',
|
||||
'vscodex.devices.revoke': '撤销此设备',
|
||||
'vscodex.devices.revokeConfirm': '撤销设备“{name}”后,它会立即断开,并且必须重新配对才能连接。确认撤销吗?',
|
||||
'vscodex.devices.revokeFailed': '无法撤销设备,请稍后重试。',
|
||||
'vscodex.pairing.create': '生成配对码',
|
||||
'vscodex.pairing.creating': '正在生成...',
|
||||
'vscodex.pairing.title': '连接本地插件',
|
||||
'vscodex.pairing.description': '在 VS Code 命令面板运行“Codex Remote: Pair with Aether”,并输入下方的一次性配对码。配对完成后本页会自动连接。',
|
||||
'vscodex.pairing.code': '一次性配对码',
|
||||
'vscodex.pairing.copy': '复制配对码',
|
||||
'vscodex.pairing.copied': '配对码已复制',
|
||||
'vscodex.pairing.expires': '有效期至 {time}',
|
||||
'vscodex.pairing.newCode': '生成新配对码',
|
||||
'vscodex.pairing.failed': '无法生成配对码,请稍后重试。',
|
||||
'vscodex.connection.loading': '正在加载控制台...',
|
||||
'vscodex.connection.connecting': '正在建立安全连接...',
|
||||
'vscodex.connection.ticketFailed': '安全连接凭证获取失败。',
|
||||
'vscodex.connection.retry': '重试',
|
||||
'vscodex.frame.title': 'Codex 远程控制台',
|
||||
'nav.walletCenter': '钱包中心',
|
||||
'nav.billingCenter': '套餐中心',
|
||||
'nav.myReferral': '我的邀请',
|
||||
@@ -594,6 +625,37 @@ export const messages = {
|
||||
'nav.healthMonitor': 'Health monitor',
|
||||
'nav.modelCatalog': 'Model catalog',
|
||||
'nav.apiKeys': 'API keys',
|
||||
'nav.vscodex': 'Codex remote control',
|
||||
'vscodex.title': 'Codex remote control',
|
||||
'vscodex.devices.label': 'Control device',
|
||||
'vscodex.devices.refresh': 'Refresh devices',
|
||||
'vscodex.devices.loading': 'Loading devices...',
|
||||
'vscodex.devices.loadFailed': 'Devices could not be loaded. Check your connection and retry.',
|
||||
'vscodex.devices.emptyTitle': 'No connected devices',
|
||||
'vscodex.devices.emptyDescription': 'After installing aether-vscodex, run “Codex Remote: Pair with Aether” from the VS Code Command Palette to connect this account.',
|
||||
'vscodex.devices.checkConnection': 'Check connection',
|
||||
'vscodex.devices.online': 'Online',
|
||||
'vscodex.devices.offline': 'Offline',
|
||||
'vscodex.devices.connecting': 'Connecting',
|
||||
'vscodex.devices.unknown': 'Status unknown',
|
||||
'vscodex.devices.revoke': 'Revoke this device',
|
||||
'vscodex.devices.revokeConfirm': 'Revoking “{name}” disconnects it immediately. It must be paired again before reconnecting. Revoke it?',
|
||||
'vscodex.devices.revokeFailed': 'The device could not be revoked. Try again later.',
|
||||
'vscodex.pairing.create': 'Generate pairing code',
|
||||
'vscodex.pairing.creating': 'Generating...',
|
||||
'vscodex.pairing.title': 'Connect the local plugin',
|
||||
'vscodex.pairing.description': 'Run “Codex Remote: Pair with Aether” from the VS Code Command Palette and enter this one-time code. This page connects automatically after pairing.',
|
||||
'vscodex.pairing.code': 'One-time pairing code',
|
||||
'vscodex.pairing.copy': 'Copy pairing code',
|
||||
'vscodex.pairing.copied': 'Pairing code copied',
|
||||
'vscodex.pairing.expires': 'Valid until {time}',
|
||||
'vscodex.pairing.newCode': 'Generate a new code',
|
||||
'vscodex.pairing.failed': 'A pairing code could not be generated. Try again later.',
|
||||
'vscodex.connection.loading': 'Loading control surface...',
|
||||
'vscodex.connection.connecting': 'Establishing a secure connection...',
|
||||
'vscodex.connection.ticketFailed': 'A secure connection ticket could not be issued.',
|
||||
'vscodex.connection.retry': 'Retry',
|
||||
'vscodex.frame.title': 'Codex remote control surface',
|
||||
'nav.walletCenter': 'Wallet',
|
||||
'nav.billingCenter': 'Plans',
|
||||
'nav.myReferral': 'Referrals',
|
||||
|
||||
@@ -37,6 +37,37 @@ describe('main layout navigation builder', () => {
|
||||
expect(navigation.flatMap(group => group.items.map(item => item.name))).toContain('tx:nav.myReferral')
|
||||
})
|
||||
|
||||
it('exposes the same VS Code control destination to users and administrators', () => {
|
||||
const commonOptions = {
|
||||
modules: {},
|
||||
isModuleActive: () => false,
|
||||
t: translate,
|
||||
}
|
||||
const userNavigation = buildNavigation({
|
||||
...commonOptions,
|
||||
canAccessAdmin: false,
|
||||
})
|
||||
const adminNavigation = buildNavigation({
|
||||
...commonOptions,
|
||||
canAccessAdmin: true,
|
||||
})
|
||||
|
||||
const findVscodeControl = (navigation: ReturnType<typeof buildNavigation>) => (
|
||||
navigation
|
||||
.flatMap(group => group.items)
|
||||
.find(item => item.href === '/dashboard/vscodex')
|
||||
)
|
||||
|
||||
expect(findVscodeControl(userNavigation)).toMatchObject({
|
||||
name: 'tx:nav.vscodex',
|
||||
href: '/dashboard/vscodex',
|
||||
})
|
||||
expect(findVscodeControl(adminNavigation)).toMatchObject({
|
||||
name: 'tx:nav.vscodex',
|
||||
href: '/dashboard/vscodex',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds admin navigation with dynamic module menu items sorted by menu order', () => {
|
||||
const navigation = buildNavigation({
|
||||
canAccessAdmin: true,
|
||||
@@ -99,5 +130,16 @@ describe('main layout navigation builder', () => {
|
||||
{ label: 'tx:nav.routing', href: '/admin/routing' },
|
||||
{ label: 'tx:breadcrumb.routingCreate' },
|
||||
])
|
||||
|
||||
expect(buildBreadcrumbs({
|
||||
route: route('/dashboard/vscodex'),
|
||||
navigation,
|
||||
modules: {},
|
||||
isNavActive: href => href === '/dashboard/vscodex',
|
||||
t: translate,
|
||||
})).toEqual([
|
||||
expect.objectContaining({ label: expect.any(String) }),
|
||||
{ label: 'tx:nav.vscodex' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Server,
|
||||
Shield,
|
||||
SlidersHorizontal,
|
||||
SquareTerminal,
|
||||
Users,
|
||||
Wallet,
|
||||
Zap,
|
||||
@@ -88,6 +89,7 @@ export function buildNavigation(options: {
|
||||
items: [
|
||||
{ name: t('nav.modelCatalog'), href: '/dashboard/models', icon: Box },
|
||||
{ name: t('nav.apiKeys'), href: '/dashboard/api-keys', icon: Key },
|
||||
{ name: t('nav.vscodex'), href: '/dashboard/vscodex', icon: SquareTerminal },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -115,6 +117,7 @@ export function buildNavigation(options: {
|
||||
title: t('nav.group.overview'),
|
||||
items: [
|
||||
{ name: t('nav.dashboard'), href: '/admin/dashboard', icon: Home },
|
||||
{ name: t('nav.vscodex'), href: '/dashboard/vscodex', icon: SquareTerminal },
|
||||
{ name: t('nav.operations'), href: '/admin/operations', icon: Activity },
|
||||
{ name: t('nav.healthMonitor'), href: '/admin/health-monitor', icon: Activity },
|
||||
{ name: t('nav.userStats'), href: '/admin/user-stats', icon: BarChart3 },
|
||||
|
||||
@@ -63,6 +63,11 @@ export const dashboardRoutes: RouteRecordRaw[] = [
|
||||
name: 'ModelCatalog',
|
||||
component: view(() => import('@/views/user/ModelCatalog.vue'))
|
||||
},
|
||||
{
|
||||
path: 'vscodex',
|
||||
name: 'VscodeControl',
|
||||
component: view(() => import('@/views/user/VscodeControl.vue'))
|
||||
},
|
||||
{
|
||||
path: 'async-tasks',
|
||||
name: 'UserAsyncTasks',
|
||||
|
||||
@@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<div class="mx-auto flex min-h-[calc(100vh-9rem)] w-full max-w-[1800px] flex-col gap-4 pb-2">
|
||||
<header class="flex flex-col gap-3 border-b border-border/60 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<h1 class="flex items-center gap-2 text-lg font-semibold text-foreground">
|
||||
<SquareTerminal class="h-5 w-5 text-primary" />
|
||||
{{ t('vscodex.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<label
|
||||
v-if="devices.length > 0"
|
||||
class="sr-only"
|
||||
for="vscodex-device-select"
|
||||
>{{ t('vscodex.devices.label') }}</label>
|
||||
<select
|
||||
v-if="devices.length > 0"
|
||||
id="vscodex-device-select"
|
||||
v-model="selectedDeviceId"
|
||||
data-testid="vscodex-device-select"
|
||||
class="h-9 min-w-0 max-w-64 rounded-md border border-border/70 bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
<option
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
:value="device.id"
|
||||
>
|
||||
{{ device.name }} · {{ statusLabel(device.status) }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="selectedDevice"
|
||||
type="button"
|
||||
data-testid="vscodex-revoke-device"
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive disabled:pointer-events-none disabled:opacity-50"
|
||||
:disabled="revokingDevice"
|
||||
:aria-label="t('vscodex.devices.revoke')"
|
||||
:title="t('vscodex.devices.revoke')"
|
||||
@click="revokeSelectedDevice"
|
||||
>
|
||||
<Loader2
|
||||
v-if="revokingDevice"
|
||||
class="h-4 w-4 animate-spin"
|
||||
/>
|
||||
<Trash2
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="vscodex-refresh"
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50"
|
||||
:disabled="loadingDevices"
|
||||
:aria-label="t('vscodex.devices.refresh')"
|
||||
:title="t('vscodex.devices.refresh')"
|
||||
@click="refreshDevices()"
|
||||
>
|
||||
<RefreshCcw
|
||||
class="h-4 w-4"
|
||||
:class="{ 'animate-spin': loadingDevices }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="loadError"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<AlertCircle class="h-4 w-4 shrink-0" />
|
||||
{{ t('vscodex.devices.loadFailed') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="refreshDevices()"
|
||||
>
|
||||
{{ t('vscodex.connection.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="revokeError"
|
||||
class="flex items-center gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4 shrink-0" />
|
||||
{{ t('vscodex.devices.revokeFailed') }}
|
||||
</div>
|
||||
|
||||
<LoadingState
|
||||
v-if="loadingDevices && devices.length === 0 && !pairing"
|
||||
class="flex-1"
|
||||
:message="t('vscodex.devices.loading')"
|
||||
full-height
|
||||
/>
|
||||
|
||||
<section
|
||||
v-else-if="!selectedDevice"
|
||||
data-testid="vscodex-pairing-state"
|
||||
class="flex flex-1 items-center justify-center py-8"
|
||||
>
|
||||
<div class="w-full max-w-xl rounded-lg border border-dashed border-border bg-card/40 px-5 py-8 text-center sm:px-8">
|
||||
<div class="mx-auto flex h-11 w-11 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Link2 class="h-5 w-5" />
|
||||
</div>
|
||||
<h2 class="mt-4 text-base font-semibold text-foreground">
|
||||
{{ pairing ? t('vscodex.pairing.title') : t('vscodex.devices.emptyTitle') }}
|
||||
</h2>
|
||||
<p class="mx-auto mt-2 max-w-md text-sm leading-6 text-muted-foreground">
|
||||
{{ pairing ? t('vscodex.pairing.description') : t('vscodex.devices.emptyDescription') }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="pairing"
|
||||
class="mt-6"
|
||||
>
|
||||
<div class="text-xs font-medium uppercase text-muted-foreground">
|
||||
{{ t('vscodex.pairing.code') }}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center justify-center gap-2">
|
||||
<code class="select-all rounded-md border border-border bg-background px-4 py-2 font-mono text-xl font-semibold text-foreground">
|
||||
{{ pairing.code }}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
:aria-label="codeCopied ? t('vscodex.pairing.copied') : t('vscodex.pairing.copy')"
|
||||
:title="codeCopied ? t('vscodex.pairing.copied') : t('vscodex.pairing.copy')"
|
||||
@click="copyPairingCode"
|
||||
>
|
||||
<Check
|
||||
v-if="codeCopied"
|
||||
class="h-4 w-4 text-emerald-600"
|
||||
/>
|
||||
<Copy
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="pairingExpiryLabel"
|
||||
class="mt-3 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ t('vscodex.pairing.expires', { time: pairingExpiryLabel }) }}
|
||||
</p>
|
||||
<div class="mt-5 flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="creatingPairing"
|
||||
@click="createPairing"
|
||||
>
|
||||
<RefreshCcw class="mr-2 h-4 w-4" />
|
||||
{{ t('vscodex.pairing.newCode') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="loadingDevices"
|
||||
@click="refreshDevices()"
|
||||
>
|
||||
{{ t('vscodex.devices.checkConnection') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-else
|
||||
data-testid="vscodex-create-pairing"
|
||||
class="mt-6"
|
||||
:disabled="creatingPairing"
|
||||
@click="createPairing"
|
||||
>
|
||||
<Loader2
|
||||
v-if="creatingPairing"
|
||||
class="mr-2 h-4 w-4 animate-spin"
|
||||
/>
|
||||
<Link2
|
||||
v-else
|
||||
class="mr-2 h-4 w-4"
|
||||
/>
|
||||
{{ creatingPairing ? t('vscodex.pairing.creating') : t('vscodex.pairing.create') }}
|
||||
</Button>
|
||||
|
||||
<p
|
||||
v-if="pairingError"
|
||||
class="mt-4 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{{ t('vscodex.pairing.failed') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else
|
||||
class="flex min-h-[520px] flex-1 flex-col gap-3"
|
||||
>
|
||||
<div class="flex min-h-6 flex-wrap items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span class="flex items-center gap-2">
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="statusDotClass(selectedDevice.status)"
|
||||
/>
|
||||
{{ selectedDevice.name }} · {{ statusLabel(selectedDevice.status) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="ticketLoading"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin" />
|
||||
{{ t('vscodex.connection.connecting') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="connectionError"
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-md border border-destructive/30 bg-destructive/5 px-4 py-2.5 text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<span>{{ t('vscodex.connection.ticketFailed') }}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="ticketLoading || !frameReady"
|
||||
@click="requestTicket"
|
||||
>
|
||||
{{ t('vscodex.connection.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-[480px] flex-1 overflow-hidden rounded-lg border border-border bg-background">
|
||||
<iframe
|
||||
:key="frameKey"
|
||||
ref="frameRef"
|
||||
data-testid="vscodex-frame"
|
||||
class="h-full min-h-[480px] w-full border-0 bg-background"
|
||||
:src="childFrameUrl"
|
||||
:title="t('vscodex.frame.title')"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-downloads"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
@load="frameLoaded = true"
|
||||
/>
|
||||
<div
|
||||
v-if="!frameLoaded"
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-background"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
{{ t('vscodex.connection.loading') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
Copy,
|
||||
Link2,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
SquareTerminal,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { vscodexApi, type VscodexDevice, type VscodexDeviceStatus, type VscodexPairing } from '@/api/vscodex'
|
||||
import { LoadingState } from '@/components/common'
|
||||
import { Button } from '@/components/ui'
|
||||
import { useDarkMode } from '@/composables/useDarkMode'
|
||||
import { useI18n } from '@/i18n'
|
||||
|
||||
const PROTOCOL_VERSION = 1
|
||||
const PAIRING_POLL_INTERVAL_MS = 4_000
|
||||
const childFrameUrl = `${import.meta.env.BASE_URL}aether-vscodex/index.html?embed=aether`
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
const { isDark } = useDarkMode()
|
||||
|
||||
const devices = ref<VscodexDevice[]>([])
|
||||
const selectedDeviceId = ref('')
|
||||
const loadingDevices = ref(true)
|
||||
const loadError = ref(false)
|
||||
const pairing = ref<VscodexPairing | null>(null)
|
||||
const pairingCreatedAt = ref(0)
|
||||
const creatingPairing = ref(false)
|
||||
const pairingError = ref(false)
|
||||
const codeCopied = ref(false)
|
||||
const frameRef = ref<HTMLIFrameElement | null>(null)
|
||||
const frameKey = ref(0)
|
||||
const frameLoaded = ref(false)
|
||||
const frameReady = ref(false)
|
||||
const ticketLoading = ref(false)
|
||||
const connectionError = ref(false)
|
||||
const revokingDevice = ref(false)
|
||||
const revokeError = ref(false)
|
||||
|
||||
let disposed = false
|
||||
let deviceRequestVersion = 0
|
||||
let connectionVersion = 0
|
||||
let ticketRequest: Promise<void> | null = null
|
||||
let pairingPollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const selectedDevice = computed(() => (
|
||||
devices.value.find(device => device.id === selectedDeviceId.value) ?? null
|
||||
))
|
||||
|
||||
const pairingExpiryLabel = computed(() => {
|
||||
if (!pairing.value) return ''
|
||||
if (pairing.value.expires_at) {
|
||||
const expiry = new Date(pairing.value.expires_at)
|
||||
if (!Number.isNaN(expiry.getTime())) return expiry.toLocaleString(locale.value)
|
||||
}
|
||||
if (pairing.value.expires_in_seconds) {
|
||||
const expiry = new Date(pairingCreatedAt.value + pairing.value.expires_in_seconds * 1_000)
|
||||
return expiry.toLocaleString(locale.value)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
function statusLabel(status: VscodexDeviceStatus): string {
|
||||
return t(`vscodex.devices.${status}`)
|
||||
}
|
||||
|
||||
function statusDotClass(status: VscodexDeviceStatus): string {
|
||||
if (status === 'online') return 'bg-emerald-500'
|
||||
if (status === 'connecting') return 'bg-amber-500'
|
||||
return 'bg-muted-foreground/50'
|
||||
}
|
||||
|
||||
function stopPairingPoll(): void {
|
||||
if (pairingPollTimer) {
|
||||
clearInterval(pairingPollTimer)
|
||||
pairingPollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function startPairingPoll(): void {
|
||||
stopPairingPoll()
|
||||
pairingPollTimer = setInterval(() => {
|
||||
if (!loadingDevices.value && !disposed) void refreshDevices({ silent: true })
|
||||
}, PAIRING_POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function invalidateConnection(reloadFrame = true): void {
|
||||
postToFrame({ type: 'aether-vscodex/disconnect' })
|
||||
connectionVersion += 1
|
||||
ticketRequest = null
|
||||
ticketLoading.value = false
|
||||
connectionError.value = false
|
||||
frameReady.value = false
|
||||
frameLoaded.value = false
|
||||
if (reloadFrame) frameKey.value += 1
|
||||
}
|
||||
|
||||
async function refreshDevices(options: { silent?: boolean } = {}): Promise<void> {
|
||||
const requestVersion = ++deviceRequestVersion
|
||||
if (!options.silent) {
|
||||
loadingDevices.value = true
|
||||
loadError.value = false
|
||||
}
|
||||
|
||||
try {
|
||||
const nextDevices = await vscodexApi.listDevices()
|
||||
if (disposed || requestVersion !== deviceRequestVersion) return
|
||||
|
||||
devices.value = nextDevices
|
||||
const currentStillExists = nextDevices.some(device => device.id === selectedDeviceId.value)
|
||||
if (!currentStillExists) {
|
||||
selectedDeviceId.value = nextDevices.find(device => device.status === 'online')?.id
|
||||
?? nextDevices[0]?.id
|
||||
?? ''
|
||||
}
|
||||
|
||||
if (nextDevices.length > 0) {
|
||||
pairing.value = null
|
||||
pairingError.value = false
|
||||
stopPairingPoll()
|
||||
}
|
||||
} catch {
|
||||
if (!disposed && requestVersion === deviceRequestVersion && !options.silent) {
|
||||
loadError.value = true
|
||||
}
|
||||
} finally {
|
||||
if (!disposed && requestVersion === deviceRequestVersion && !options.silent) {
|
||||
loadingDevices.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function createPairing(): Promise<void> {
|
||||
if (creatingPairing.value) return
|
||||
creatingPairing.value = true
|
||||
pairingError.value = false
|
||||
codeCopied.value = false
|
||||
|
||||
try {
|
||||
pairing.value = await vscodexApi.createPairing()
|
||||
pairingCreatedAt.value = Date.now()
|
||||
startPairingPoll()
|
||||
} catch {
|
||||
pairingError.value = true
|
||||
} finally {
|
||||
creatingPairing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSelectedDevice(): Promise<void> {
|
||||
const device = selectedDevice.value
|
||||
if (!device || revokingDevice.value) return
|
||||
if (!window.confirm(t('vscodex.devices.revokeConfirm', { name: device.name }))) return
|
||||
|
||||
revokingDevice.value = true
|
||||
revokeError.value = false
|
||||
try {
|
||||
await vscodexApi.deleteDevice(device.id)
|
||||
invalidateConnection()
|
||||
devices.value = devices.value.filter(item => item.id !== device.id)
|
||||
selectedDeviceId.value = devices.value.find(item => item.status === 'online')?.id
|
||||
?? devices.value[0]?.id
|
||||
?? ''
|
||||
await refreshDevices({ silent: true })
|
||||
} catch {
|
||||
revokeError.value = true
|
||||
} finally {
|
||||
revokingDevice.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPairingCode(): Promise<void> {
|
||||
if (!pairing.value || !navigator.clipboard) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(pairing.value.code)
|
||||
codeCopied.value = true
|
||||
window.setTimeout(() => {
|
||||
codeCopied.value = false
|
||||
}, 1_500)
|
||||
} catch {
|
||||
codeCopied.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function postToFrame(message: Record<string, unknown>, target = frameRef.value?.contentWindow): void {
|
||||
if (!target) return
|
||||
target.postMessage({ v: PROTOCOL_VERSION, ...message }, window.location.origin)
|
||||
}
|
||||
|
||||
function postContext(): void {
|
||||
postToFrame({
|
||||
type: 'aether-vscodex/context',
|
||||
locale: locale.value,
|
||||
theme: isDark.value ? 'dark' : 'light',
|
||||
})
|
||||
}
|
||||
|
||||
async function requestTicket(): Promise<void> {
|
||||
if (!selectedDevice.value || !frameReady.value) return
|
||||
if (ticketRequest) return ticketRequest
|
||||
|
||||
const requestVersion = connectionVersion
|
||||
const deviceId = selectedDevice.value.id
|
||||
const target = frameRef.value?.contentWindow
|
||||
if (!target) return
|
||||
|
||||
ticketLoading.value = true
|
||||
connectionError.value = false
|
||||
const request = (async () => {
|
||||
try {
|
||||
const result = await vscodexApi.createWsTicket(deviceId)
|
||||
if (
|
||||
disposed
|
||||
|| requestVersion !== connectionVersion
|
||||
|| selectedDeviceId.value !== deviceId
|
||||
|| frameRef.value?.contentWindow !== target
|
||||
) return
|
||||
|
||||
postToFrame({
|
||||
type: 'aether-vscodex/connect',
|
||||
ticket: result.ticket,
|
||||
wsUrl: result.ws_url,
|
||||
deviceId,
|
||||
locale: locale.value,
|
||||
theme: isDark.value ? 'dark' : 'light',
|
||||
}, target)
|
||||
} catch {
|
||||
if (disposed || requestVersion !== connectionVersion) return
|
||||
connectionError.value = true
|
||||
postToFrame({
|
||||
type: 'aether-vscodex/error',
|
||||
code: 'ticket_unavailable',
|
||||
}, target)
|
||||
} finally {
|
||||
if (!disposed && requestVersion === connectionVersion) ticketLoading.value = false
|
||||
}
|
||||
})()
|
||||
|
||||
ticketRequest = request
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
if (ticketRequest === request) ticketRequest = null
|
||||
}
|
||||
}
|
||||
|
||||
function isFrameMessage(value: unknown): value is { v: number; type: string } {
|
||||
return typeof value === 'object'
|
||||
&& value !== null
|
||||
&& (value as Record<string, unknown>).v === PROTOCOL_VERSION
|
||||
&& typeof (value as Record<string, unknown>).type === 'string'
|
||||
}
|
||||
|
||||
function handleFrameMessage(event: MessageEvent): void {
|
||||
const target = frameRef.value?.contentWindow
|
||||
if (!target || event.origin !== window.location.origin || event.source !== target) return
|
||||
if (!isFrameMessage(event.data)) return
|
||||
|
||||
if (event.data.type === 'aether-vscodex/ready') {
|
||||
frameReady.value = true
|
||||
postContext()
|
||||
void requestTicket()
|
||||
} else if (event.data.type === 'aether-vscodex/request-ticket') {
|
||||
frameReady.value = true
|
||||
void requestTicket()
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedDeviceId, async (next, previous) => {
|
||||
if (next === previous) return
|
||||
invalidateConnection()
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
watch([locale, isDark], () => {
|
||||
if (frameReady.value) postContext()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', handleFrameMessage)
|
||||
void refreshDevices()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
postToFrame({ type: 'aether-vscodex/disconnect' })
|
||||
disposed = true
|
||||
connectionVersion += 1
|
||||
deviceRequestVersion += 1
|
||||
stopPairingPoll()
|
||||
window.removeEventListener('message', handleFrameMessage)
|
||||
})
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,272 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp, defineComponent, h, nextTick, type App } from 'vue'
|
||||
|
||||
const vscodexApiMock = vi.hoisted(() => ({
|
||||
listDevices: vi.fn(),
|
||||
createPairing: vi.fn(),
|
||||
createWsTicket: vi.fn(),
|
||||
deleteDevice: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/vscodex', () => ({
|
||||
vscodexApi: vscodexApiMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/i18n')>()
|
||||
const { ref } = await import('vue')
|
||||
const locale = ref('zh-CN')
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
locale,
|
||||
t: (key: string) => key,
|
||||
legacyT: (value: string) => value,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/composables/useDarkMode', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const isDark = ref(false)
|
||||
return {
|
||||
useDarkMode: () => ({ isDark }),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/components/common', () => ({
|
||||
LoadingState: defineComponent({
|
||||
name: 'LoadingStateStub',
|
||||
setup: () => () => h('div', 'loading'),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/ui', () => ({
|
||||
Button: defineComponent({
|
||||
name: 'ButtonStub',
|
||||
inheritAttrs: false,
|
||||
setup(_, { attrs, slots }) {
|
||||
return () => h('button', attrs, slots.default?.())
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
import VscodeControl from '../VscodeControl.vue'
|
||||
|
||||
const mountedApps: Array<{ app: App; root: HTMLElement }> = []
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let index = 0; index < 6; index += 1) {
|
||||
await Promise.resolve()
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
async function mountControl() {
|
||||
const root = document.createElement('div')
|
||||
document.body.appendChild(root)
|
||||
const app = createApp(VscodeControl)
|
||||
app.mount(root)
|
||||
mountedApps.push({ app, root })
|
||||
await settle()
|
||||
|
||||
const frame = root.querySelector<HTMLIFrameElement>('[data-testid="vscodex-frame"]')
|
||||
if (!frame?.contentWindow) throw new Error('Expected VS Code control iframe to be mounted')
|
||||
return { app, root, frame, target: frame.contentWindow }
|
||||
}
|
||||
|
||||
function dispatchFrameMessage(options: {
|
||||
source: MessageEventSource | null
|
||||
origin?: string
|
||||
type: string
|
||||
version?: number
|
||||
}) {
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
data: { v: options.version ?? 1, type: options.type },
|
||||
origin: options.origin ?? window.location.origin,
|
||||
source: options.source,
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vscodexApiMock.listDevices.mockResolvedValue([
|
||||
{
|
||||
id: 'device-1',
|
||||
name: 'Studio Mac',
|
||||
status: 'online',
|
||||
last_seen_at: null,
|
||||
created_at: null,
|
||||
},
|
||||
])
|
||||
vscodexApiMock.createPairing.mockResolvedValue({
|
||||
code: 'PAIR-1234',
|
||||
expires_at: null,
|
||||
expires_in_seconds: 300,
|
||||
})
|
||||
vscodexApiMock.createWsTicket.mockResolvedValue({
|
||||
ticket: 'ticket-1',
|
||||
ws_url: 'wss://aether.example/api/vscodex/ws',
|
||||
expires_at: null,
|
||||
})
|
||||
vscodexApiMock.deleteDevice.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, root } of mountedApps.splice(0)) {
|
||||
app.unmount()
|
||||
root.remove()
|
||||
}
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('VscodeControl iframe bridge', () => {
|
||||
it('confirms and revokes a device credential from the control header', async () => {
|
||||
vscodexApiMock.listDevices
|
||||
.mockResolvedValueOnce([{
|
||||
id: 'device-1',
|
||||
name: 'Studio Mac',
|
||||
status: 'online',
|
||||
last_seen_at: null,
|
||||
created_at: null,
|
||||
}])
|
||||
.mockResolvedValueOnce([])
|
||||
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
const { root } = await mountControl()
|
||||
|
||||
const revoke = root.querySelector<HTMLButtonElement>('[data-testid="vscodex-revoke-device"]')
|
||||
revoke?.click()
|
||||
await settle()
|
||||
|
||||
expect(confirm).toHaveBeenCalledOnce()
|
||||
expect(vscodexApiMock.deleteDevice).toHaveBeenCalledWith('device-1')
|
||||
expect(root.querySelector('[data-testid="vscodex-pairing-state"]')).not.toBeNull()
|
||||
confirm.mockRestore()
|
||||
})
|
||||
|
||||
it('accepts ready messages only from the exact same-origin iframe window', async () => {
|
||||
const { frame, target } = await mountControl()
|
||||
const postMessage = vi.spyOn(target, 'postMessage')
|
||||
|
||||
dispatchFrameMessage({
|
||||
source: target,
|
||||
origin: 'https://untrusted.example',
|
||||
type: 'aether-vscodex/ready',
|
||||
})
|
||||
dispatchFrameMessage({
|
||||
source: window,
|
||||
type: 'aether-vscodex/ready',
|
||||
})
|
||||
dispatchFrameMessage({
|
||||
source: target,
|
||||
type: 'aether-vscodex/ready',
|
||||
version: 2,
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(frame.src).toContain('/aether-vscodex/index.html?embed=aether')
|
||||
expect(vscodexApiMock.createWsTicket).not.toHaveBeenCalled()
|
||||
|
||||
dispatchFrameMessage({
|
||||
source: target,
|
||||
type: 'aether-vscodex/ready',
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(vscodexApiMock.createWsTicket).toHaveBeenCalledOnce()
|
||||
expect(vscodexApiMock.createWsTicket).toHaveBeenCalledWith('device-1')
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
v: 1,
|
||||
type: 'aether-vscodex/context',
|
||||
locale: 'zh-CN',
|
||||
theme: 'light',
|
||||
}),
|
||||
window.location.origin,
|
||||
)
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
v: 1,
|
||||
type: 'aether-vscodex/connect',
|
||||
deviceId: 'device-1',
|
||||
ticket: 'ticket-1',
|
||||
wsUrl: 'wss://aether.example/api/vscodex/ws',
|
||||
}),
|
||||
window.location.origin,
|
||||
)
|
||||
})
|
||||
|
||||
it('deduplicates an in-flight ticket request and allows renewal after it settles', async () => {
|
||||
const firstTicket = deferred<{
|
||||
ticket: string
|
||||
ws_url: string
|
||||
expires_at: null
|
||||
}>()
|
||||
vscodexApiMock.createWsTicket
|
||||
.mockReturnValueOnce(firstTicket.promise)
|
||||
.mockResolvedValueOnce({
|
||||
ticket: 'ticket-2',
|
||||
ws_url: 'wss://aether.example/api/vscodex/ws',
|
||||
expires_at: null,
|
||||
})
|
||||
const { target } = await mountControl()
|
||||
|
||||
dispatchFrameMessage({ source: target, type: 'aether-vscodex/ready' })
|
||||
dispatchFrameMessage({ source: target, type: 'aether-vscodex/request-ticket' })
|
||||
await settle()
|
||||
expect(vscodexApiMock.createWsTicket).toHaveBeenCalledTimes(1)
|
||||
|
||||
firstTicket.resolve({
|
||||
ticket: 'ticket-1',
|
||||
ws_url: 'wss://aether.example/api/vscodex/ws',
|
||||
expires_at: null,
|
||||
})
|
||||
await settle()
|
||||
dispatchFrameMessage({ source: target, type: 'aether-vscodex/request-ticket' })
|
||||
await settle()
|
||||
|
||||
expect(vscodexApiMock.createWsTicket).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('disconnects on unmount and ignores a ticket that resolves afterward', async () => {
|
||||
const pendingTicket = deferred<{
|
||||
ticket: string
|
||||
ws_url: string
|
||||
expires_at: null
|
||||
}>()
|
||||
vscodexApiMock.createWsTicket.mockReturnValueOnce(pendingTicket.promise)
|
||||
const { app, root, target } = await mountControl()
|
||||
const postMessage = vi.spyOn(target, 'postMessage')
|
||||
|
||||
dispatchFrameMessage({ source: target, type: 'aether-vscodex/ready' })
|
||||
await settle()
|
||||
app.unmount()
|
||||
root.remove()
|
||||
mountedApps.splice(0, 1)
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
{ v: 1, type: 'aether-vscodex/disconnect' },
|
||||
window.location.origin,
|
||||
)
|
||||
|
||||
postMessage.mockClear()
|
||||
pendingTicket.resolve({
|
||||
ticket: 'stale-ticket',
|
||||
ws_url: 'wss://aether.example/api/vscodex/ws',
|
||||
expires_at: null,
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(postMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -80,6 +80,7 @@ export default defineConfig(({ mode }) => {
|
||||
target: gatewayTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
'/v1/': {
|
||||
target: gatewayTarget,
|
||||
|
||||
Reference in New Issue
Block a user