@volter/twin-livekit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +106 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +23 -0
- package/dist/src/egress-service-cli.d.ts +2 -0
- package/dist/src/egress-service-cli.js +101 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +53 -0
- package/dist/src/livekit-budget.d.ts +57 -0
- package/dist/src/livekit-budget.js +131 -0
- package/dist/src/livekit-capabilities.d.ts +3 -0
- package/dist/src/livekit-capabilities.js +728 -0
- package/dist/src/livekit-conformance.d.ts +8 -0
- package/dist/src/livekit-conformance.js +13 -0
- package/dist/src/livekit-connector.d.ts +130 -0
- package/dist/src/livekit-connector.js +382 -0
- package/dist/src/livekit-data.d.ts +86 -0
- package/dist/src/livekit-data.js +246 -0
- package/dist/src/livekit-perform-harness.d.ts +5 -0
- package/dist/src/livekit-perform-harness.js +17 -0
- package/dist/src/livekit-server.d.ts +23 -0
- package/dist/src/livekit-server.js +45 -0
- package/dist/src/livekit-service-cli.d.ts +2 -0
- package/dist/src/livekit-service-cli.js +88 -0
- package/dist/src/livekit-token.d.ts +68 -0
- package/dist/src/livekit-token.js +76 -0
- package/dist/src/livekit-twin.d.ts +4 -0
- package/dist/src/livekit-twin.js +1161 -0
- package/dist/src/livekit-types.d.ts +203 -0
- package/dist/src/livekit-types.js +1 -0
- package/dist/src/livekit-webhook.d.ts +26 -0
- package/dist/src/livekit-webhook.js +79 -0
- package/dist/src/redis-service-cli.d.ts +2 -0
- package/dist/src/redis-service-cli.js +35 -0
- package/dist/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
- package/dist/test-fixtures/livekit-openapi-operations.json +442 -0
- package/dist/test-fixtures/protobufs/cloud_replay.proto +82 -0
- package/dist/test-fixtures/protobufs/livekit_agent.proto +185 -0
- package/dist/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
- package/dist/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
- package/dist/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
- package/dist/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
- package/dist/test-fixtures/protobufs/livekit_analytics.proto +312 -0
- package/dist/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
- package/dist/test-fixtures/protobufs/livekit_connector.proto +41 -0
- package/dist/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
- package/dist/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
- package/dist/test-fixtures/protobufs/livekit_egress.proto +641 -0
- package/dist/test-fixtures/protobufs/livekit_ingress.proto +222 -0
- package/dist/test-fixtures/protobufs/livekit_internal.proto +228 -0
- package/dist/test-fixtures/protobufs/livekit_metrics.proto +103 -0
- package/dist/test-fixtures/protobufs/livekit_models.proto +992 -0
- package/dist/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
- package/dist/test-fixtures/protobufs/livekit_room.proto +312 -0
- package/dist/test-fixtures/protobufs/livekit_rtc.proto +673 -0
- package/dist/test-fixtures/protobufs/livekit_sip.proto +1000 -0
- package/dist/test-fixtures/protobufs/livekit_token_source.proto +49 -0
- package/dist/test-fixtures/protobufs/livekit_webhook.proto +62 -0
- package/package.json +58 -0
- package/src/cli.ts +22 -0
- package/src/egress-service-cli.ts +111 -0
- package/src/index.ts +103 -0
- package/src/livekit-budget.ts +158 -0
- package/src/livekit-capabilities.ts +789 -0
- package/src/livekit-conformance.ts +15 -0
- package/src/livekit-connector.ts +406 -0
- package/src/livekit-data.ts +278 -0
- package/src/livekit-perform-harness.ts +17 -0
- package/src/livekit-server.ts +67 -0
- package/src/livekit-service-cli.ts +93 -0
- package/src/livekit-token.ts +133 -0
- package/src/livekit-twin.ts +1162 -0
- package/src/livekit-types.ts +211 -0
- package/src/livekit-webhook.ts +91 -0
- package/src/redis-service-cli.ts +39 -0
- package/test-fixtures/livekit-openapi-operations.SOURCE.md +88 -0
- package/test-fixtures/livekit-openapi-operations.json +442 -0
- package/test-fixtures/protobufs/cloud_replay.proto +82 -0
- package/test-fixtures/protobufs/livekit_agent.proto +185 -0
- package/test-fixtures/protobufs/livekit_agent_dispatch.proto +103 -0
- package/test-fixtures/protobufs/livekit_agent_simulation.proto +422 -0
- package/test-fixtures/protobufs/livekit_agent_worker.proto +29 -0
- package/test-fixtures/protobufs/livekit_agentdb.proto +226 -0
- package/test-fixtures/protobufs/livekit_analytics.proto +312 -0
- package/test-fixtures/protobufs/livekit_cloud_agent.proto +376 -0
- package/test-fixtures/protobufs/livekit_connector.proto +41 -0
- package/test-fixtures/protobufs/livekit_connector_twilio.proto +65 -0
- package/test-fixtures/protobufs/livekit_connector_whatsapp.proto +171 -0
- package/test-fixtures/protobufs/livekit_egress.proto +641 -0
- package/test-fixtures/protobufs/livekit_ingress.proto +222 -0
- package/test-fixtures/protobufs/livekit_internal.proto +228 -0
- package/test-fixtures/protobufs/livekit_metrics.proto +103 -0
- package/test-fixtures/protobufs/livekit_models.proto +992 -0
- package/test-fixtures/protobufs/livekit_phone_number.proto +151 -0
- package/test-fixtures/protobufs/livekit_room.proto +312 -0
- package/test-fixtures/protobufs/livekit_rtc.proto +673 -0
- package/test-fixtures/protobufs/livekit_sip.proto +1000 -0
- package/test-fixtures/protobufs/livekit_token_source.proto +49 -0
- package/test-fixtures/protobufs/livekit_webhook.proto +62 -0
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { applyTwinWrite, pendingActions, projectResources } from '@volter/world-core';
|
|
5
|
+
import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/world-tooling';
|
|
6
|
+
import { EGRESS_STATUS, INGRESS_INPUT, INGRESS_STATUS, TRACK_SOURCE } from './livekit-data.ts';
|
|
7
|
+
import { pullLiveKitAgentDispatch, pullLiveKitSIP, pullLiveKitSnapshot, } from './livekit-connector.ts';
|
|
8
|
+
import { performPending } from './livekit-perform-harness.ts';
|
|
9
|
+
import { handleLiveKitTwinRequest } from './livekit-twin.ts';
|
|
10
|
+
import { mintAccessToken, verifyAccessToken } from './livekit-token.ts';
|
|
11
|
+
import { buildWebhookBody, signWebhookHeader, verifyWebhook } from './livekit-webhook.ts';
|
|
12
|
+
|
|
13
|
+
type Body = Record<string, any>;
|
|
14
|
+
type Step = {
|
|
15
|
+
service?: string;
|
|
16
|
+
method?: string;
|
|
17
|
+
b?: unknown;
|
|
18
|
+
readOnly?: boolean;
|
|
19
|
+
authToken?: string;
|
|
20
|
+
apiKeys?: Record<string, string>;
|
|
21
|
+
nowSeconds?: number;
|
|
22
|
+
maxActiveEgress?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const API_KEY = 'devkey';
|
|
26
|
+
const API_SECRET = 'secretsecretsecretsecretsecretsecret';
|
|
27
|
+
|
|
28
|
+
async function withRoot(fn: (h: (step: Step) => Promise<{ status: number; body: unknown }>, root: string) => Promise<boolean>): Promise<boolean> {
|
|
29
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-cap-'));
|
|
30
|
+
const h = (step: Step) => handleLiveKitTwinRequest({
|
|
31
|
+
method: 'POST',
|
|
32
|
+
path: `/twirp/livekit.${step.service ?? 'RoomService'}/${step.method ?? 'ListRooms'}`,
|
|
33
|
+
body: step.b === undefined ? '{}' : JSON.stringify(step.b),
|
|
34
|
+
root,
|
|
35
|
+
readOnly: step.readOnly,
|
|
36
|
+
occurredAt: '1970-01-01T00:00:00.000Z',
|
|
37
|
+
...(step.authToken !== undefined ? { authToken: step.authToken } : {}),
|
|
38
|
+
...(step.apiKeys !== undefined ? { apiKeys: step.apiKeys } : {}),
|
|
39
|
+
...(step.nowSeconds !== undefined ? { nowSeconds: step.nowSeconds } : {}),
|
|
40
|
+
...(step.maxActiveEgress !== undefined ? { maxActiveEgress: step.maxActiveEgress } : {}),
|
|
41
|
+
});
|
|
42
|
+
try {
|
|
43
|
+
return await verifyBoundary('livekit.withRoot', () => fn(h, root));
|
|
44
|
+
} finally {
|
|
45
|
+
rmSync(root, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec =>
|
|
50
|
+
({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
51
|
+
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec =>
|
|
52
|
+
({ id, area, title, dimension, tier, expected: 'todo' });
|
|
53
|
+
|
|
54
|
+
export const LIVEKIT_CAPABILITIES: CapabilitySpec[] = [
|
|
55
|
+
todo('livekit.webhooks.delivery', 'webhooks', 'Outbound webhook delivery: POST the signed event bodies the twin already builds to the configured webhookUrl, with the documented retry schedule', 'api', 'common'),
|
|
56
|
+
|
|
57
|
+
done('livekit.rooms.create', 'rooms', 'RoomService CreateRoom creates a room with SDK-shaped fields', 'api', 'core', () =>
|
|
58
|
+
withRoot(async (h) => {
|
|
59
|
+
const r = await h({ method: 'CreateRoom', b: { name: 'cap-room', emptyTimeout: 600, maxParticipants: 10 } });
|
|
60
|
+
return r.status === 200 && (r.body as Body).name === 'cap-room' && (r.body as Body).emptyTimeout === 600 && (r.body as Body).maxParticipants === 10;
|
|
61
|
+
})),
|
|
62
|
+
done('livekit.rooms.create_validation', 'rooms', 'CreateRoom rejects missing room name', 'api', 'core', () =>
|
|
63
|
+
withRoot(async (h) => {
|
|
64
|
+
const r = await h({ method: 'CreateRoom', b: {} });
|
|
65
|
+
return r.status === 400 && (r.body as Body).code === 'invalid_argument';
|
|
66
|
+
})),
|
|
67
|
+
done('livekit.rooms.list_all', 'rooms', 'ListRooms returns all local active rooms', 'api', 'core', () =>
|
|
68
|
+
withRoot(async (h) => {
|
|
69
|
+
await h({ method: 'CreateRoom', b: { name: 'one' } });
|
|
70
|
+
await h({ method: 'CreateRoom', b: { name: 'two' } });
|
|
71
|
+
const r = await h({ method: 'ListRooms', b: {} });
|
|
72
|
+
return r.status === 200 && (r.body as Body).rooms?.map((room: Body) => room.name).sort().join(',') === 'one,two';
|
|
73
|
+
})),
|
|
74
|
+
done('livekit.rooms.list_filter', 'rooms', 'ListRooms filters by room names', 'api', 'core', () =>
|
|
75
|
+
withRoot(async (h) => {
|
|
76
|
+
await h({ method: 'CreateRoom', b: { name: 'one' } });
|
|
77
|
+
await h({ method: 'CreateRoom', b: { name: 'two' } });
|
|
78
|
+
const r = await h({ method: 'ListRooms', b: { names: ['two'] } });
|
|
79
|
+
return r.status === 200 && (r.body as Body).rooms?.length === 1 && (r.body as Body).rooms[0].name === 'two';
|
|
80
|
+
})),
|
|
81
|
+
done('livekit.rooms.update_metadata', 'rooms', 'UpdateRoomMetadata persists metadata', 'api', 'core', () =>
|
|
82
|
+
withRoot(async (h) => {
|
|
83
|
+
await h({ method: 'CreateRoom', b: { name: 'meta-room' } });
|
|
84
|
+
const updated = await h({ method: 'UpdateRoomMetadata', b: { room: 'meta-room', metadata: '{"egressActive":true}' } });
|
|
85
|
+
const listed = await h({ method: 'ListRooms', b: { names: ['meta-room'] } });
|
|
86
|
+
return updated.status === 200 && (updated.body as Body).metadata === '{"egressActive":true}' && (listed.body as Body).rooms?.[0]?.metadata === '{"egressActive":true}';
|
|
87
|
+
})),
|
|
88
|
+
done('livekit.rooms.update_missing', 'rooms', 'UpdateRoomMetadata returns not_found for unknown room', 'api', 'common', () =>
|
|
89
|
+
withRoot(async (h) => {
|
|
90
|
+
const r = await h({ method: 'UpdateRoomMetadata', b: { room: 'missing', metadata: '{}' } });
|
|
91
|
+
return r.status === 404 && (r.body as Body).code === 'not_found';
|
|
92
|
+
})),
|
|
93
|
+
done('livekit.rooms.delete', 'rooms', 'DeleteRoom removes room from ListRooms', 'api', 'core', () =>
|
|
94
|
+
withRoot(async (h) => {
|
|
95
|
+
await h({ method: 'CreateRoom', b: { name: 'delete-me' } });
|
|
96
|
+
const deleted = await h({ method: 'DeleteRoom', b: { room: 'delete-me' } });
|
|
97
|
+
const listed = await h({ method: 'ListRooms', b: { names: ['delete-me'] } });
|
|
98
|
+
return deleted.status === 200 && (listed.body as Body).rooms?.length === 0;
|
|
99
|
+
})),
|
|
100
|
+
done('livekit.rooms.recreate_after_delete', 'rooms', 'CreateRoom can recreate a deleted room name', 'api', 'core', () =>
|
|
101
|
+
withRoot(async (h) => {
|
|
102
|
+
await h({ method: 'CreateRoom', b: { name: 'recreate-me' } });
|
|
103
|
+
await h({ method: 'DeleteRoom', b: { room: 'recreate-me' } });
|
|
104
|
+
await h({ method: 'CreateRoom', b: { name: 'recreate-me' } });
|
|
105
|
+
const listed = await h({ method: 'ListRooms', b: { names: ['recreate-me'] } });
|
|
106
|
+
return (listed.body as Body).rooms?.length === 1 && (listed.body as Body).rooms[0].name === 'recreate-me';
|
|
107
|
+
})),
|
|
108
|
+
done('livekit.readonly.rejects_room_write', 'honesty', 'readOnly rejects room writes', 'api', 'common', () =>
|
|
109
|
+
withRoot(async (h) => {
|
|
110
|
+
const r = await h({ method: 'CreateRoom', b: { name: 'ro' }, readOnly: true });
|
|
111
|
+
return r.status === 405 && (r.body as Body).code === 'permission_denied';
|
|
112
|
+
})),
|
|
113
|
+
|
|
114
|
+
done('livekit.participants.list', 'participants', 'ListParticipants returns locally seeded participants', 'api', 'core', () =>
|
|
115
|
+
withRoot(async (h) => {
|
|
116
|
+
await h({ method: 'CreateRoom', b: { name: 'p-room' } });
|
|
117
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'p-room', identity: 'tester-1' } });
|
|
118
|
+
const r = await h({ method: 'ListParticipants', b: { room: 'p-room' } });
|
|
119
|
+
return r.status === 200 && (r.body as Body).participants?.[0]?.identity === 'tester-1';
|
|
120
|
+
})),
|
|
121
|
+
done('livekit.participants.counts_room', 'participants', 'Room numParticipants reflects locally seeded participants', 'api', 'core', () =>
|
|
122
|
+
withRoot(async (h) => {
|
|
123
|
+
await h({ method: 'CreateRoom', b: { name: 'count-room' } });
|
|
124
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'count-room', identity: 'tester-1' } });
|
|
125
|
+
const r = await h({ method: 'ListRooms', b: { names: ['count-room'] } });
|
|
126
|
+
return (r.body as Body).rooms?.[0]?.numParticipants === 1;
|
|
127
|
+
})),
|
|
128
|
+
done('livekit.participants.get', 'participants', 'GetParticipant returns one locally seeded participant by identity', 'api', 'common', () =>
|
|
129
|
+
withRoot(async (h) => {
|
|
130
|
+
await h({ method: 'CreateRoom', b: { name: 'get-room' } });
|
|
131
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'get-room', identity: 'tester-1', name: 'Tester One' } });
|
|
132
|
+
const r = await h({ method: 'GetParticipant', b: { room: 'get-room', identity: 'tester-1' } });
|
|
133
|
+
return r.status === 200 && (r.body as Body).name === 'Tester One';
|
|
134
|
+
})),
|
|
135
|
+
done('livekit.participants.screen_share', 'participants', 'Locally seeded participant tracks expose SCREEN_SHARE source and muted state', 'api', 'core', () =>
|
|
136
|
+
withRoot(async (h) => {
|
|
137
|
+
await h({ method: 'CreateRoom', b: { name: 'share-room' } });
|
|
138
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'share-room', identity: 'tester-1', tracks: [{ source: 'SCREEN_SHARE', muted: false }] } });
|
|
139
|
+
const r = await h({ method: 'ListParticipants', b: { room: 'share-room' } });
|
|
140
|
+
const track = (r.body as Body).participants?.[0]?.tracks?.[0];
|
|
141
|
+
return r.status === 200 && track?.source === TRACK_SOURCE.SCREEN_SHARE && track?.muted === false;
|
|
142
|
+
})),
|
|
143
|
+
done('livekit.participants.mute_track', 'participants', 'MutePublishedTrack toggles a published track', 'api', 'common', () =>
|
|
144
|
+
withRoot(async (h) => {
|
|
145
|
+
await h({ method: 'CreateRoom', b: { name: 'mute-room' } });
|
|
146
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'mute-room', identity: 'tester-1', tracks: [{ sid: 'TR_screen', source: 'SCREEN_SHARE', muted: false }] } });
|
|
147
|
+
const muted = await h({ method: 'MutePublishedTrack', b: { room: 'mute-room', identity: 'tester-1', trackSid: 'TR_screen', muted: true } });
|
|
148
|
+
const got = await h({ method: 'GetParticipant', b: { room: 'mute-room', identity: 'tester-1' } });
|
|
149
|
+
return muted.status === 200 && (muted.body as Body).track?.muted === true && (got.body as Body).tracks?.[0]?.muted === true;
|
|
150
|
+
})),
|
|
151
|
+
done('livekit.participants.remove', 'participants', 'RemoveParticipant hides participant from ListParticipants', 'api', 'common', () =>
|
|
152
|
+
withRoot(async (h) => {
|
|
153
|
+
await h({ method: 'CreateRoom', b: { name: 'remove-room' } });
|
|
154
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'remove-room', identity: 'tester-1' } });
|
|
155
|
+
const removed = await h({ method: 'RemoveParticipant', b: { room: 'remove-room', identity: 'tester-1' } });
|
|
156
|
+
const listed = await h({ method: 'ListParticipants', b: { room: 'remove-room' } });
|
|
157
|
+
return removed.status === 200 && (listed.body as Body).participants?.length === 0;
|
|
158
|
+
})),
|
|
159
|
+
|
|
160
|
+
done('livekit.egress.start_room_composite', 'egress', 'StartRoomCompositeEgress records an active egress', 'api', 'core', () =>
|
|
161
|
+
withRoot(async (h) => {
|
|
162
|
+
await h({ method: 'CreateRoom', b: { name: 'egress-room' } });
|
|
163
|
+
const r = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'egress-room', fileOutputs: [{ filepath: 'recordings/job/eg.mp4' }], videoOnly: true } });
|
|
164
|
+
const second = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'egress-room', fileOutputs: [{ filepath: 'recordings/job/eg.mp4' }], videoOnly: true } });
|
|
165
|
+
return r.status === 200 && typeof (r.body as Body).egressId === 'string' && (r.body as Body).status === EGRESS_STATUS.ACTIVE && (r.body as Body).roomName === 'egress-room'
|
|
166
|
+
&& (second.body as Body).egressId !== (r.body as Body).egressId;
|
|
167
|
+
})),
|
|
168
|
+
done('livekit.egress.list_by_id', 'egress', 'ListEgress filters by egressId', 'api', 'core', () =>
|
|
169
|
+
withRoot(async (h) => {
|
|
170
|
+
await h({ method: 'CreateRoom', b: { name: 'egress-list' } });
|
|
171
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'egress-list' } });
|
|
172
|
+
const egressId = (started.body as Body).egressId;
|
|
173
|
+
// guard against a sabotaged/empty handler: the started egressId must actually be a real id,
|
|
174
|
+
// not compare undefined-to-undefined.
|
|
175
|
+
if (started.status !== 200 || typeof egressId !== 'string' || egressId.length === 0) return false;
|
|
176
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { egressId } });
|
|
177
|
+
const items = (listed.body as Body).items as Body[] | undefined;
|
|
178
|
+
return listed.status === 200 && Array.isArray(items) && items.length === 1 && items[0]!.egressId === egressId;
|
|
179
|
+
})),
|
|
180
|
+
done('livekit.egress.stop_complete', 'egress', 'StopEgress transitions egress to COMPLETE', 'api', 'core', () =>
|
|
181
|
+
withRoot(async (h) => {
|
|
182
|
+
await h({ method: 'CreateRoom', b: { name: 'stop-room' } });
|
|
183
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'stop-room' } });
|
|
184
|
+
const stopped = await h({ service: 'Egress', method: 'StopEgress', b: { egressId: (started.body as Body).egressId } });
|
|
185
|
+
return stopped.status === 200 && (stopped.body as Body).status === EGRESS_STATUS.COMPLETE && typeof (stopped.body as Body).endedAt === 'string';
|
|
186
|
+
})),
|
|
187
|
+
done('livekit.egress.active_filter', 'egress', 'ListEgress active filter excludes completed egresses', 'api', 'common', () =>
|
|
188
|
+
withRoot(async (h) => {
|
|
189
|
+
await h({ method: 'CreateRoom', b: { name: 'active-room' } });
|
|
190
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'active-room' } });
|
|
191
|
+
await h({ service: 'Egress', method: 'StopEgress', b: { egressId: (started.body as Body).egressId } });
|
|
192
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { active: true } });
|
|
193
|
+
return listed.status === 200 && (listed.body as Body).items?.length === 0;
|
|
194
|
+
})),
|
|
195
|
+
done('livekit.egress.update_layout', 'egress', 'UpdateLayout persists egress layout', 'api', 'common', () =>
|
|
196
|
+
withRoot(async (h) => {
|
|
197
|
+
await h({ method: 'CreateRoom', b: { name: 'layout-room' } });
|
|
198
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'layout-room' } });
|
|
199
|
+
const updated = await h({ service: 'Egress', method: 'UpdateLayout', b: { egressId: (started.body as Body).egressId, layout: 'speaker' } });
|
|
200
|
+
return updated.status === 200 && (updated.body as Body).roomComposite?.layout === 'speaker';
|
|
201
|
+
})),
|
|
202
|
+
done('livekit.unmodeled.404', 'honesty', 'Unmodeled Twirp methods fail with not_found', 'api', 'core', () =>
|
|
203
|
+
withRoot(async (h) => {
|
|
204
|
+
const r = await h({ method: 'TeleportRoom', b: {} });
|
|
205
|
+
return r.status === 404 && (r.body as Body).code === 'not_found';
|
|
206
|
+
})),
|
|
207
|
+
|
|
208
|
+
done('livekit.connector.pull', 'connector', 'Connector pulls rooms, participants, and egress through injected client', 'connector', 'common', async () => {
|
|
209
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-connector-cap-'));
|
|
210
|
+
try {
|
|
211
|
+
await pullLiveKitSnapshot(async (req) => {
|
|
212
|
+
if (req.service === 'RoomService' && req.method === 'ListRooms') return { status: 200, data: { rooms: [{ name: 'remote-room', sid: 'RM_remote', metadata: '', emptyTimeout: 300 }] } };
|
|
213
|
+
if (req.service === 'RoomService' && req.method === 'ListParticipants') return { status: 200, data: { participants: [{ identity: 'remote-user', tracks: [] }] } };
|
|
214
|
+
return { status: 200, data: { items: [{ egressId: 'EG_remote', roomName: 'remote-room', status: EGRESS_STATUS.ACTIVE }] } };
|
|
215
|
+
}, ['remote-room'], { root });
|
|
216
|
+
await pullLiveKitSnapshot(async (req) => {
|
|
217
|
+
if (req.service === 'RoomService' && req.method === 'ListRooms') return { status: 200, data: { rooms: [{ name: 'remote-room', sid: 'RM_remote', metadata: '', emptyTimeout: 300 }] } };
|
|
218
|
+
if (req.service === 'RoomService' && req.method === 'ListParticipants') return { status: 200, data: { participants: [{ identity: 'remote-user', tracks: [] }] } };
|
|
219
|
+
return { status: 200, data: { items: [{ egressId: 'EG_remote', roomName: 'remote-room', status: EGRESS_STATUS.ACTIVE }] } };
|
|
220
|
+
}, ['remote-room'], { root });
|
|
221
|
+
const rows = projectResources('livekit', root);
|
|
222
|
+
return rows.some((r) => r.type === 'room' && (r as Body).name === 'remote-room')
|
|
223
|
+
&& rows.some((r) => r.type === 'participant' && (r as Body).identity === 'remote-user')
|
|
224
|
+
&& rows.filter((r) => r.type === 'egress' && (r as Body).egressId === 'EG_remote').length === 1;
|
|
225
|
+
} finally {
|
|
226
|
+
rmSync(root, { recursive: true, force: true });
|
|
227
|
+
}
|
|
228
|
+
}),
|
|
229
|
+
done('livekit.connector.push', 'connector', 'Connector confirms pending room metadata pushes idempotently', 'connector', 'common', async () => {
|
|
230
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-connector-push-cap-'));
|
|
231
|
+
try {
|
|
232
|
+
await applyTwinWrite('livekit', {
|
|
233
|
+
operation: 'room.update_metadata',
|
|
234
|
+
subjectType: 'room',
|
|
235
|
+
subjectId: 'room_push',
|
|
236
|
+
fields: { id: 'room_push', name: 'push-room', metadata: '{"ok":true}' },
|
|
237
|
+
occurredAt: '1970-01-01T00:00:00.000Z',
|
|
238
|
+
actor: { kind: 'system' },
|
|
239
|
+
}, root);
|
|
240
|
+
const pushed = await performPending(async () => ({ status: 200, data: { name: 'push-room', metadata: '{"ok":true}' } }), { root });
|
|
241
|
+
const again = await performPending(async () => ({ status: 200, data: {} }), { root });
|
|
242
|
+
return pushed === 1 && again === 0 && pendingActions('livekit', root).length === 0;
|
|
243
|
+
} finally {
|
|
244
|
+
rmSync(root, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
}),
|
|
247
|
+
|
|
248
|
+
done('livekit.rooms.forward_participant', 'rooms', 'ForwardParticipant mirrors a participant into a destination room (source retained, mirror cannot publish)', 'api', 'niche', () =>
|
|
249
|
+
withRoot(async (h) => {
|
|
250
|
+
await h({ method: 'CreateRoom', b: { name: 'fwd-src' } });
|
|
251
|
+
await h({ method: 'CreateRoom', b: { name: 'fwd-dst' } });
|
|
252
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'fwd-src', identity: 'caller', tracks: [{ sid: 'TR_a', source: 'CAMERA' }] } });
|
|
253
|
+
const fwd = await h({ method: 'ForwardParticipant', b: { room: 'fwd-src', identity: 'caller', destinationRoom: 'fwd-dst' } });
|
|
254
|
+
// original retained in source, mirror appears in destination
|
|
255
|
+
const src = await h({ method: 'GetParticipant', b: { room: 'fwd-src', identity: 'caller' } });
|
|
256
|
+
const dst = await h({ method: 'GetParticipant', b: { room: 'fwd-dst', identity: 'caller' } });
|
|
257
|
+
const noDst = await h({ method: 'ForwardParticipant', b: { room: 'fwd-src', identity: 'caller' } });
|
|
258
|
+
const ghost = await h({ method: 'ForwardParticipant', b: { room: 'fwd-src', identity: 'nobody', destinationRoom: 'fwd-dst' } });
|
|
259
|
+
const dup = await h({ method: 'ForwardParticipant', b: { room: 'fwd-src', identity: 'caller', destinationRoom: 'fwd-dst' } });
|
|
260
|
+
return fwd.status === 200 && src.status === 200 && dst.status === 200
|
|
261
|
+
&& (dst.body as Body).isPublisher === false && (dst.body as Body).permission?.canPublish === false
|
|
262
|
+
&& noDst.status === 400 && ghost.status === 404 && dup.status === 409;
|
|
263
|
+
})),
|
|
264
|
+
done('livekit.rooms.move_participant', 'rooms', 'MoveParticipant relocates a participant (left source, present in destination)', 'api', 'niche', () =>
|
|
265
|
+
withRoot(async (h) => {
|
|
266
|
+
await h({ method: 'CreateRoom', b: { name: 'mv-src' } });
|
|
267
|
+
await h({ method: 'CreateRoom', b: { name: 'mv-dst' } });
|
|
268
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'mv-src', identity: 'mover', name: 'Mover' } });
|
|
269
|
+
const mv = await h({ method: 'MoveParticipant', b: { room: 'mv-src', identity: 'mover', destinationRoom: 'mv-dst' } });
|
|
270
|
+
const srcList = await h({ method: 'ListParticipants', b: { room: 'mv-src' } });
|
|
271
|
+
const dst = await h({ method: 'GetParticipant', b: { room: 'mv-dst', identity: 'mover' } });
|
|
272
|
+
const noDst = await h({ method: 'MoveParticipant', b: { room: 'mv-src', identity: 'mover' } });
|
|
273
|
+
const ghost = await h({ method: 'MoveParticipant', b: { room: 'mv-src', identity: 'gone', destinationRoom: 'mv-dst' } });
|
|
274
|
+
return mv.status === 200 && (srcList.body as Body).participants?.length === 0
|
|
275
|
+
&& dst.status === 200 && (dst.body as Body).name === 'Mover'
|
|
276
|
+
&& noDst.status === 400 && ghost.status === 404;
|
|
277
|
+
})),
|
|
278
|
+
|
|
279
|
+
done('livekit.rooms.update_subscriptions', 'rooms', 'UpdateSubscriptions adds/removes participant track subscriptions', 'api', 'common', () =>
|
|
280
|
+
withRoot(async (h) => {
|
|
281
|
+
await h({ method: 'CreateRoom', b: { name: 'sub-room' } });
|
|
282
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'sub-room', identity: 'sub-1' } });
|
|
283
|
+
const sub = await h({ method: 'UpdateSubscriptions', b: { room: 'sub-room', identity: 'sub-1', trackSids: ['TR_a', 'TR_b'], subscribe: true } });
|
|
284
|
+
const afterSub = await h({ method: 'GetParticipant', b: { room: 'sub-room', identity: 'sub-1' } });
|
|
285
|
+
const subbed = ((afterSub.body as Body).subscribedTracks ?? []).slice().sort().join(',');
|
|
286
|
+
const unsub = await h({ method: 'UpdateSubscriptions', b: { room: 'sub-room', identity: 'sub-1', trackSids: ['TR_a'], subscribe: false } });
|
|
287
|
+
const afterUnsub = await h({ method: 'GetParticipant', b: { room: 'sub-room', identity: 'sub-1' } });
|
|
288
|
+
const missing = await h({ method: 'UpdateSubscriptions', b: { room: 'sub-room', identity: 'ghost', trackSids: ['TR_a'], subscribe: true } });
|
|
289
|
+
return sub.status === 200 && subbed === 'TR_a,TR_b' && unsub.status === 200
|
|
290
|
+
&& ((afterUnsub.body as Body).subscribedTracks ?? []).join(',') === 'TR_b' && missing.status === 404;
|
|
291
|
+
})),
|
|
292
|
+
done('livekit.rooms.send_data', 'rooms', 'SendData validates room+payload and acks; unknown room/empty payload fail', 'api', 'common', () =>
|
|
293
|
+
withRoot(async (h) => {
|
|
294
|
+
await h({ method: 'CreateRoom', b: { name: 'data-room' } });
|
|
295
|
+
const ok = await h({ method: 'SendData', b: { room: 'data-room', data: 'aGVsbG8=', kind: 0, topic: 'chat' } });
|
|
296
|
+
const noRoom = await h({ method: 'SendData', b: { room: 'missing', data: 'aGVsbG8=' } });
|
|
297
|
+
const noData = await h({ method: 'SendData', b: { room: 'data-room' } });
|
|
298
|
+
return ok.status === 200 && noRoom.status === 404 && (noRoom.body as Body).code === 'not_found'
|
|
299
|
+
&& noData.status === 400 && (noData.body as Body).code === 'invalid_argument';
|
|
300
|
+
})),
|
|
301
|
+
done('livekit.rooms.permission_update', 'participants', 'UpdateParticipant atomically replaces permission grants', 'api', 'common', () =>
|
|
302
|
+
withRoot(async (h) => {
|
|
303
|
+
await h({ method: 'CreateRoom', b: { name: 'perm-room' } });
|
|
304
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'perm-room', identity: 'perm-1' } });
|
|
305
|
+
const updated = await h({ method: 'UpdateParticipant', b: { room: 'perm-room', identity: 'perm-1', permission: { canSubscribe: true, canPublish: false, canPublishData: false, hidden: true } } });
|
|
306
|
+
const perm = (updated.body as Body).permission;
|
|
307
|
+
return updated.status === 200 && perm?.canSubscribe === true && perm?.canPublish === false && perm?.hidden === true && (updated.body as Body).isPublisher === false;
|
|
308
|
+
})),
|
|
309
|
+
|
|
310
|
+
done('livekit.rooms.subscription_permissions', 'participants', 'Subscription set is tracked per participant across toggles', 'api', 'common', () =>
|
|
311
|
+
withRoot(async (h) => {
|
|
312
|
+
await h({ method: 'CreateRoom', b: { name: 'subperm-room' } });
|
|
313
|
+
await h({ service: 'Twin', method: 'UpsertParticipant', b: { roomName: 'subperm-room', identity: 'sp-1' } });
|
|
314
|
+
await h({ method: 'UpdateSubscriptions', b: { room: 'subperm-room', identity: 'sp-1', trackSids: ['TR_x'], subscribe: true } });
|
|
315
|
+
await h({ method: 'UpdateSubscriptions', b: { room: 'subperm-room', identity: 'sp-1', trackSids: ['TR_x'], subscribe: true } });
|
|
316
|
+
const got = await h({ method: 'GetParticipant', b: { room: 'subperm-room', identity: 'sp-1' } });
|
|
317
|
+
// idempotent: re-subscribing the same sid does not duplicate it
|
|
318
|
+
return ((got.body as Body).subscribedTracks ?? []).filter((s: string) => s === 'TR_x').length === 1;
|
|
319
|
+
})),
|
|
320
|
+
|
|
321
|
+
done('livekit.egress.web', 'egress', 'StartWebEgress records a web-source egress with no backing room', 'api', 'common', () =>
|
|
322
|
+
withRoot(async (h) => {
|
|
323
|
+
const started = await h({ service: 'Egress', method: 'StartWebEgress', b: { url: 'https://example.com/live', streamOutputs: [{ urls: ['rtmp://a/b'] }] } });
|
|
324
|
+
const noUrl = await h({ service: 'Egress', method: 'StartWebEgress', b: {} });
|
|
325
|
+
return started.status === 200 && (started.body as Body).web?.url === 'https://example.com/live'
|
|
326
|
+
&& (started.body as Body).status === EGRESS_STATUS.ACTIVE && noUrl.status === 400;
|
|
327
|
+
})),
|
|
328
|
+
done('livekit.egress.participant', 'egress', 'StartParticipantEgress requires identity and tags request case', 'api', 'common', () =>
|
|
329
|
+
withRoot(async (h) => {
|
|
330
|
+
await h({ method: 'CreateRoom', b: { name: 'pe-room' } });
|
|
331
|
+
const started = await h({ service: 'Egress', method: 'StartParticipantEgress', b: { roomName: 'pe-room', identity: 'pe-1' } });
|
|
332
|
+
const noId = await h({ service: 'Egress', method: 'StartParticipantEgress', b: { roomName: 'pe-room' } });
|
|
333
|
+
return started.status === 200 && (started.body as Body).participant?.identity === 'pe-1' && noId.status === 400;
|
|
334
|
+
})),
|
|
335
|
+
done('livekit.egress.track_composite', 'egress', 'StartTrackCompositeEgress requires a track id and tags request case', 'api', 'common', () =>
|
|
336
|
+
withRoot(async (h) => {
|
|
337
|
+
await h({ method: 'CreateRoom', b: { name: 'tc-room' } });
|
|
338
|
+
const started = await h({ service: 'Egress', method: 'StartTrackCompositeEgress', b: { roomName: 'tc-room', videoTrackId: 'TR_v', audioTrackId: 'TR_a' } });
|
|
339
|
+
const noTrack = await h({ service: 'Egress', method: 'StartTrackCompositeEgress', b: { roomName: 'tc-room' } });
|
|
340
|
+
return started.status === 200 && (started.body as Body).trackComposite?.videoTrackId === 'TR_v' && noTrack.status === 400;
|
|
341
|
+
})),
|
|
342
|
+
done('livekit.egress.track', 'egress', 'StartTrackEgress requires a track id and tags request case', 'api', 'common', () =>
|
|
343
|
+
withRoot(async (h) => {
|
|
344
|
+
await h({ method: 'CreateRoom', b: { name: 'te-room' } });
|
|
345
|
+
const started = await h({ service: 'Egress', method: 'StartTrackEgress', b: { roomName: 'te-room', trackId: 'TR_only' } });
|
|
346
|
+
const noTrack = await h({ service: 'Egress', method: 'StartTrackEgress', b: { roomName: 'te-room' } });
|
|
347
|
+
return started.status === 200 && (started.body as Body).track?.trackId === 'TR_only' && noTrack.status === 400;
|
|
348
|
+
})),
|
|
349
|
+
done('livekit.egress.update_stream', 'egress', 'UpdateStream adds and removes output URLs on an active egress', 'api', 'common', () =>
|
|
350
|
+
withRoot(async (h) => {
|
|
351
|
+
await h({ method: 'CreateRoom', b: { name: 'us-room' } });
|
|
352
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'us-room', streamOutputs: [{ urls: ['rtmp://a/1'] }] } });
|
|
353
|
+
const id = (started.body as Body).egressId;
|
|
354
|
+
const added = await h({ service: 'Egress', method: 'UpdateStream', b: { egressId: id, addOutputUrls: ['rtmp://b/2'] } });
|
|
355
|
+
const urls1 = ((added.body as Body).streamResults ?? []).map((s: Body) => s.url).sort().join(',');
|
|
356
|
+
const removed = await h({ service: 'Egress', method: 'UpdateStream', b: { egressId: id, removeOutputUrls: ['rtmp://a/1'] } });
|
|
357
|
+
const urls2 = ((removed.body as Body).streamResults ?? []).map((s: Body) => s.url).join(',');
|
|
358
|
+
await h({ service: 'Egress', method: 'StopEgress', b: { egressId: id } });
|
|
359
|
+
const afterStop = await h({ service: 'Egress', method: 'UpdateStream', b: { egressId: id, addOutputUrls: ['rtmp://c/3'] } });
|
|
360
|
+
return added.status === 200 && urls1 === 'rtmp://a/1,rtmp://b/2' && urls2 === 'rtmp://b/2' && afterStop.status === 412;
|
|
361
|
+
})),
|
|
362
|
+
done('livekit.egress.failure_states', 'egress', 'Egress can transition to FAILED/ABORTED/LIMIT_REACHED terminal states', 'api', 'common', () =>
|
|
363
|
+
withRoot(async (h) => {
|
|
364
|
+
await h({ method: 'CreateRoom', b: { name: 'fail-room' } });
|
|
365
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'fail-room' } });
|
|
366
|
+
const id = (started.body as Body).egressId;
|
|
367
|
+
const failed = await h({ service: 'Egress', method: 'FailEgress', b: { egressId: id, status: EGRESS_STATUS.FAILED, error: 'compositor crashed', errorCode: 2 } });
|
|
368
|
+
const listActive = await h({ service: 'Egress', method: 'ListEgress', b: { active: true } });
|
|
369
|
+
const cannotStop = await h({ service: 'Egress', method: 'StopEgress', b: { egressId: id } });
|
|
370
|
+
const badState = await h({ service: 'Egress', method: 'FailEgress', b: { egressId: id, status: EGRESS_STATUS.COMPLETE } });
|
|
371
|
+
return failed.status === 200 && (failed.body as Body).status === EGRESS_STATUS.FAILED
|
|
372
|
+
&& (failed.body as Body).error === 'compositor crashed' && (listActive.body as Body).items?.length === 0
|
|
373
|
+
&& cannotStop.status === 412 && badState.status === 400;
|
|
374
|
+
})),
|
|
375
|
+
done('livekit.egress.segment_outputs', 'egress', 'Segment outputs are persisted on the egress request', 'api', 'common', () =>
|
|
376
|
+
withRoot(async (h) => {
|
|
377
|
+
await h({ method: 'CreateRoom', b: { name: 'seg-room' } });
|
|
378
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'seg-room', segmentOutputs: [{ filenamePrefix: 'seg/out', playlistName: 'index.m3u8', segmentDuration: 6 }] } });
|
|
379
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { egressId: (started.body as Body).egressId } });
|
|
380
|
+
const seg = (listed.body as Body).items?.[0]?.roomComposite?.segmentOutputs?.[0];
|
|
381
|
+
return started.status === 200 && seg?.playlistName === 'index.m3u8' && seg?.segmentDuration === 6;
|
|
382
|
+
})),
|
|
383
|
+
done('livekit.egress.image_outputs', 'egress', 'Image (thumbnail) outputs are persisted on the egress request and round-trip via ListEgress', 'api', 'niche', () =>
|
|
384
|
+
withRoot(async (h) => {
|
|
385
|
+
await h({ method: 'CreateRoom', b: { name: 'img-room' } });
|
|
386
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'img-room', imageOutputs: [{ captureInterval: 5, width: 1280, height: 720, filenamePrefix: 'thumb/img', imagePrefix: 'thumb' }] } });
|
|
387
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { egressId: (started.body as Body).egressId } });
|
|
388
|
+
const img = (listed.body as Body).items?.[0]?.roomComposite?.imageOutputs?.[0];
|
|
389
|
+
// imageResults is always present (empty offline — no rendered snapshot bytes).
|
|
390
|
+
return started.status === 200 && img?.captureInterval === 5 && img?.filenamePrefix === 'thumb/img'
|
|
391
|
+
&& Array.isArray((listed.body as Body).items?.[0]?.imageResults);
|
|
392
|
+
})),
|
|
393
|
+
done('livekit.egress.webhook_config', 'egress', 'Per-egress webhook config (webhookUrl) is persisted on the request and round-trips via ListEgress', 'api', 'niche', () =>
|
|
394
|
+
withRoot(async (h) => {
|
|
395
|
+
await h({ method: 'CreateRoom', b: { name: 'wh-room' } });
|
|
396
|
+
const started = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'wh-room', webhookUrl: 'https://hooks.example.com/egress' } });
|
|
397
|
+
const listed = await h({ service: 'Egress', method: 'ListEgress', b: { egressId: (started.body as Body).egressId } });
|
|
398
|
+
return started.status === 200 && (listed.body as Body).items?.[0]?.roomComposite?.webhookUrl === 'https://hooks.example.com/egress';
|
|
399
|
+
})),
|
|
400
|
+
|
|
401
|
+
done('livekit.ingress.create', 'ingress', 'CreateIngress mints stream key/url and requires participant identity', 'api', 'common', () =>
|
|
402
|
+
withRoot(async (h) => {
|
|
403
|
+
const created = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'live', participantIdentity: 'streamer', name: 'My Ingress' } });
|
|
404
|
+
const noId = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'live' } });
|
|
405
|
+
const noUrl = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'URL_INPUT', roomName: 'live', participantIdentity: 'streamer' } });
|
|
406
|
+
const b = created.body as Body;
|
|
407
|
+
return created.status === 200 && typeof b.ingressId === 'string' && b.ingressId.startsWith('IN_')
|
|
408
|
+
&& b.streamKey && b.url.startsWith('rtmp://') && b.inputType === INGRESS_INPUT.RTMP_INPUT
|
|
409
|
+
&& b.state?.status === INGRESS_STATUS.ENDPOINT_INACTIVE && noId.status === 400 && noUrl.status === 400;
|
|
410
|
+
})),
|
|
411
|
+
done('livekit.ingress.update', 'ingress', 'UpdateIngress mutates fields and toggles transcoding', 'api', 'common', () =>
|
|
412
|
+
withRoot(async (h) => {
|
|
413
|
+
const created = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'r1', participantIdentity: 'p1' } });
|
|
414
|
+
const id = (created.body as Body).ingressId;
|
|
415
|
+
const updated = await h({ service: 'Ingress', method: 'UpdateIngress', b: { ingressId: id, name: 'renamed', roomName: 'r2', enableTranscoding: false } });
|
|
416
|
+
const missing = await h({ service: 'Ingress', method: 'UpdateIngress', b: { ingressId: 'IN_nope', name: 'x' } });
|
|
417
|
+
const b = updated.body as Body;
|
|
418
|
+
return updated.status === 200 && b.name === 'renamed' && b.roomName === 'r2' && b.enableTranscoding === false && b.bypassTranscoding === true && missing.status === 404;
|
|
419
|
+
})),
|
|
420
|
+
done('livekit.ingress.list', 'ingress', 'ListIngress filters by room and ingress id, excludes deleted', 'api', 'common', () =>
|
|
421
|
+
withRoot(async (h) => {
|
|
422
|
+
await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'a', participantIdentity: 'p1' } });
|
|
423
|
+
const c2 = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'WHIP_INPUT', roomName: 'b', participantIdentity: 'p2' } });
|
|
424
|
+
const all = await h({ service: 'Ingress', method: 'ListIngress', b: {} });
|
|
425
|
+
const byRoom = await h({ service: 'Ingress', method: 'ListIngress', b: { roomName: 'b' } });
|
|
426
|
+
const byId = await h({ service: 'Ingress', method: 'ListIngress', b: { ingressId: (c2.body as Body).ingressId } });
|
|
427
|
+
return (all.body as Body).items?.length === 2 && (byRoom.body as Body).items?.length === 1
|
|
428
|
+
&& (byRoom.body as Body).items[0].roomName === 'b' && (byId.body as Body).items?.length === 1;
|
|
429
|
+
})),
|
|
430
|
+
done('livekit.ingress.delete', 'ingress', 'DeleteIngress removes ingress from ListIngress and completes its state', 'api', 'common', () =>
|
|
431
|
+
withRoot(async (h) => {
|
|
432
|
+
const created = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'a', participantIdentity: 'p1' } });
|
|
433
|
+
const id = (created.body as Body).ingressId;
|
|
434
|
+
const deleted = await h({ service: 'Ingress', method: 'DeleteIngress', b: { ingressId: id } });
|
|
435
|
+
const listed = await h({ service: 'Ingress', method: 'ListIngress', b: {} });
|
|
436
|
+
const missing = await h({ service: 'Ingress', method: 'DeleteIngress', b: { ingressId: id } });
|
|
437
|
+
return deleted.status === 200 && (deleted.body as Body).state?.status === INGRESS_STATUS.ENDPOINT_COMPLETE
|
|
438
|
+
&& (listed.body as Body).items?.length === 0 && missing.status === 404;
|
|
439
|
+
})),
|
|
440
|
+
done('livekit.ingress.rtmp_whip', 'ingress', 'RTMP defaults to transcoding+streamKey; WHIP defaults to bypass', 'api', 'common', () =>
|
|
441
|
+
withRoot(async (h) => {
|
|
442
|
+
const rtmp = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'RTMP_INPUT', roomName: 'r', participantIdentity: 'p1' } });
|
|
443
|
+
const whip = await h({ service: 'Ingress', method: 'CreateIngress', b: { inputType: 'WHIP_INPUT', roomName: 'r', participantIdentity: 'p2' } });
|
|
444
|
+
const r = rtmp.body as Body;
|
|
445
|
+
const w = whip.body as Body;
|
|
446
|
+
return r.inputType === INGRESS_INPUT.RTMP_INPUT && r.enableTranscoding === true && r.url.startsWith('rtmp://') && r.streamKey
|
|
447
|
+
&& w.inputType === INGRESS_INPUT.WHIP_INPUT && w.enableTranscoding === false && w.bypassTranscoding === true && w.url.startsWith('https://');
|
|
448
|
+
})),
|
|
449
|
+
done('livekit.sip.inbound_trunks', 'sip', 'CreateSIPInboundTrunk + list/delete; create requires a number, list filters by id/number', 'api', 'niche', () =>
|
|
450
|
+
withRoot(async (h) => {
|
|
451
|
+
const created = await h({ service: 'SIP', method: 'CreateSIPInboundTrunk', b: { trunk: { name: 'main', numbers: ['+15551230000'], allowedNumbers: ['+1*'], authUsername: 'u', authPassword: 'p' } } });
|
|
452
|
+
const id = (created.body as Body).sipTrunkId;
|
|
453
|
+
const noNumber = await h({ service: 'SIP', method: 'CreateSIPInboundTrunk', b: { trunk: { name: 'bad', numbers: [] } } });
|
|
454
|
+
const listed = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: {} });
|
|
455
|
+
const byNumber = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: { numbers: ['+15551230000'] } });
|
|
456
|
+
const byMissNum = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: { numbers: ['+19999999999'] } });
|
|
457
|
+
const deleted = await h({ service: 'SIP', method: 'DeleteSIPTrunk', b: { sipTrunkId: id } });
|
|
458
|
+
const afterDelete = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: {} });
|
|
459
|
+
const deleteMissing = await h({ service: 'SIP', method: 'DeleteSIPTrunk', b: { sipTrunkId: id } });
|
|
460
|
+
return created.status === 200 && typeof id === 'string' && id.startsWith('ST_')
|
|
461
|
+
&& (created.body as Body).numbers?.[0] === '+15551230000' && (created.body as Body).authUsername === 'u'
|
|
462
|
+
&& noNumber.status === 400 && (listed.body as Body).items?.length === 1
|
|
463
|
+
&& (byNumber.body as Body).items?.length === 1 && (byMissNum.body as Body).items?.length === 0
|
|
464
|
+
&& deleted.status === 200 && (afterDelete.body as Body).items?.length === 0 && deleteMissing.status === 404;
|
|
465
|
+
})),
|
|
466
|
+
done('livekit.sip.outbound_trunks', 'sip', 'CreateSIPOutboundTrunk + list/delete; create requires an address, transport normalizes', 'api', 'niche', () =>
|
|
467
|
+
withRoot(async (h) => {
|
|
468
|
+
const created = await h({ service: 'SIP', method: 'CreateSIPOutboundTrunk', b: { trunk: { name: 'pstn', address: 'sip.provider.com', numbers: ['+15557654321'], transport: 'SIP_TRANSPORT_TCP', destinationCountry: 'US' } } });
|
|
469
|
+
const id = (created.body as Body).sipTrunkId;
|
|
470
|
+
const noAddr = await h({ service: 'SIP', method: 'CreateSIPOutboundTrunk', b: { trunk: { name: 'bad' } } });
|
|
471
|
+
const listed = await h({ service: 'SIP', method: 'ListSIPOutboundTrunk', b: {} });
|
|
472
|
+
const byId = await h({ service: 'SIP', method: 'ListSIPOutboundTrunk', b: { trunkIds: [id] } });
|
|
473
|
+
const deleted = await h({ service: 'SIP', method: 'DeleteSIPTrunk', b: { sipTrunkId: id } });
|
|
474
|
+
const afterDelete = await h({ service: 'SIP', method: 'ListSIPOutboundTrunk', b: {} });
|
|
475
|
+
return created.status === 200 && (created.body as Body).address === 'sip.provider.com'
|
|
476
|
+
&& (created.body as Body).transport === 'SIP_TRANSPORT_TCP' && (created.body as Body).destinationCountry === 'US'
|
|
477
|
+
&& noAddr.status === 400 && (listed.body as Body).items?.length === 1 && (byId.body as Body).items?.length === 1
|
|
478
|
+
&& deleted.status === 200 && (afterDelete.body as Body).items?.length === 0;
|
|
479
|
+
})),
|
|
480
|
+
done('livekit.sip.dispatch_rules', 'sip', 'CreateSIPDispatchRule (direct + individual oneof) + list/delete; direct requires a roomName', 'api', 'niche', () =>
|
|
481
|
+
withRoot(async (h) => {
|
|
482
|
+
const direct = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: { rule: { dispatchRuleDirect: { roomName: 'support', pin: '1234' } }, trunkIds: ['ST_x'], name: 'd1' } });
|
|
483
|
+
const individual = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: { rule: { dispatchRuleIndividual: { roomPrefix: 'call-' } } } });
|
|
484
|
+
const noRoom = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: { rule: { dispatchRuleDirect: { pin: '1' } } } });
|
|
485
|
+
const noRule = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: {} });
|
|
486
|
+
const listed = await h({ service: 'SIP', method: 'ListSIPDispatchRule', b: {} });
|
|
487
|
+
const byTrunk = await h({ service: 'SIP', method: 'ListSIPDispatchRule', b: { trunkIds: ['ST_x'] } });
|
|
488
|
+
const deleted = await h({ service: 'SIP', method: 'DeleteSIPDispatchRule', b: { sipDispatchRuleId: (direct.body as Body).sipDispatchRuleId } });
|
|
489
|
+
const afterDelete = await h({ service: 'SIP', method: 'ListSIPDispatchRule', b: {} });
|
|
490
|
+
const deleteMissing = await h({ service: 'SIP', method: 'DeleteSIPDispatchRule', b: { sipDispatchRuleId: 'SDR_nope' } });
|
|
491
|
+
return direct.status === 200 && (direct.body as Body).rule?.dispatchRuleDirect?.roomName === 'support'
|
|
492
|
+
&& individual.status === 200 && (individual.body as Body).rule?.dispatchRuleIndividual?.roomPrefix === 'call-'
|
|
493
|
+
&& noRoom.status === 400 && noRule.status === 400 && (listed.body as Body).items?.length === 2
|
|
494
|
+
// byTrunk matches the rule scoped to ST_x AND the wildcard (no-trunk) individual rule
|
|
495
|
+
&& (byTrunk.body as Body).items?.length === 2
|
|
496
|
+
&& deleted.status === 200 && (afterDelete.body as Body).items?.length === 1 && deleteMissing.status === 404;
|
|
497
|
+
})),
|
|
498
|
+
done('livekit.sip.participants', 'sip', 'CreateSIPParticipant dials over a known outbound trunk (adds a room participant) + TransferSIPParticipant', 'api', 'niche', () =>
|
|
499
|
+
withRoot(async (h) => {
|
|
500
|
+
const trunk = await h({ service: 'SIP', method: 'CreateSIPOutboundTrunk', b: { trunk: { name: 't', address: 'sip.p.com', numbers: ['+15550000000'] } } });
|
|
501
|
+
const trunkId = (trunk.body as Body).sipTrunkId;
|
|
502
|
+
const dialed = await h({ service: 'SIP', method: 'CreateSIPParticipant', b: { sipTrunkId: trunkId, sipCallTo: '+15551112222', roomName: 'room-call', participantIdentity: 'caller-1', sipNumber: '+15550000000' } });
|
|
503
|
+
const noRoom = await h({ service: 'SIP', method: 'CreateSIPParticipant', b: { sipTrunkId: trunkId, sipCallTo: '+15551112222' } });
|
|
504
|
+
const noNumber = await h({ service: 'SIP', method: 'CreateSIPParticipant', b: { sipTrunkId: trunkId, roomName: 'room-call' } });
|
|
505
|
+
const badTrunk = await h({ service: 'SIP', method: 'CreateSIPParticipant', b: { sipTrunkId: 'ST_missing', sipCallTo: '+1', roomName: 'room-call' } });
|
|
506
|
+
// the dialed caller appears as a room participant
|
|
507
|
+
const inRoom = await h({ method: 'GetParticipant', b: { room: 'room-call', identity: 'caller-1' } });
|
|
508
|
+
const transferred = await h({ service: 'SIP', method: 'TransferSIPParticipant', b: { roomName: 'room-call', participantIdentity: 'caller-1', transferTo: 'tel:+15559998888' } });
|
|
509
|
+
const afterTransfer = await h({ method: 'GetParticipant', b: { room: 'room-call', identity: 'caller-1' } });
|
|
510
|
+
const transferGhost = await h({ service: 'SIP', method: 'TransferSIPParticipant', b: { roomName: 'room-call', participantIdentity: 'ghost', transferTo: 'tel:+1' } });
|
|
511
|
+
return dialed.status === 200 && typeof (dialed.body as Body).sipCallId === 'string' && (dialed.body as Body).sipCallId.startsWith('SCL_')
|
|
512
|
+
&& (dialed.body as Body).participantIdentity === 'caller-1'
|
|
513
|
+
&& noRoom.status === 400 && noNumber.status === 400 && badTrunk.status === 404
|
|
514
|
+
&& inRoom.status === 200 && (inRoom.body as Body).kind === 3
|
|
515
|
+
&& transferred.status === 200 && (afterTransfer.body as Body).attributes?.['sip.transferTo'] === 'tel:+15559998888'
|
|
516
|
+
&& transferGhost.status === 404;
|
|
517
|
+
})),
|
|
518
|
+
done('livekit.sip.update_trunk', 'sip', 'UpdateSIPInboundTrunk/UpdateSIPOutboundTrunk — replace overwrites whole Info; update field-merges scalars (optional) + lists (ListUpdate set/add/remove/clear)', 'api', 'niche', () =>
|
|
519
|
+
withRoot(async (h) => {
|
|
520
|
+
// --- inbound trunk -------------------------------------------------
|
|
521
|
+
const inCreated = await h({ service: 'SIP', method: 'CreateSIPInboundTrunk', b: { trunk: { name: 'main', numbers: ['+15551230000'], allowedNumbers: ['+1*'], metadata: 'orig', authUsername: 'u', authPassword: 'p' } } });
|
|
522
|
+
const inId = (inCreated.body as Body).sipTrunkId;
|
|
523
|
+
// field-merge: rename + ListUpdate add a number + set allowedNumbers; leave authUsername/password untouched
|
|
524
|
+
const inMerged = await h({ service: 'SIP', method: 'UpdateSIPInboundTrunk', b: { sipTrunkId: inId, update: { name: 'renamed', numbers: { add: ['+15559990000'] }, allowedNumbers: { set: ['+44*'] } } } });
|
|
525
|
+
// full-replace: a complete Info wholly replaces (authUsername must be cleared since absent)
|
|
526
|
+
const inReplaced = await h({ service: 'SIP', method: 'UpdateSIPInboundTrunk', b: { sipTrunkId: inId, replace: { name: 'fresh', numbers: ['+10000000000'] } } });
|
|
527
|
+
const inAfter = await h({ service: 'SIP', method: 'ListSIPInboundTrunk', b: { trunkIds: [inId] } });
|
|
528
|
+
const inRow = (inAfter.body as Body).items?.[0] as Body;
|
|
529
|
+
const inMissing = await h({ service: 'SIP', method: 'UpdateSIPInboundTrunk', b: { sipTrunkId: 'ST_nope', update: { name: 'x' } } });
|
|
530
|
+
// --- outbound trunk ------------------------------------------------
|
|
531
|
+
const outCreated = await h({ service: 'SIP', method: 'CreateSIPOutboundTrunk', b: { trunk: { name: 'pstn', address: 'a.com', numbers: ['+15557654321'], destinationCountry: 'US' } } });
|
|
532
|
+
const outId = (outCreated.body as Body).sipTrunkId;
|
|
533
|
+
const outMerged = await h({ service: 'SIP', method: 'UpdateSIPOutboundTrunk', b: { sipTrunkId: outId, update: { address: 'b.com', numbers: { remove: ['+15557654321'], add: ['+15550001111'] } } } });
|
|
534
|
+
const outMissing = await h({ service: 'SIP', method: 'UpdateSIPOutboundTrunk', b: { sipTrunkId: 'ST_nope', replace: { address: 'x' } } });
|
|
535
|
+
return inCreated.status === 200
|
|
536
|
+
// field-merge asserted VALUES: renamed, numbers appended, allowedNumbers set-replaced, untouched scalar preserved
|
|
537
|
+
&& inMerged.status === 200 && (inMerged.body as Body).name === 'renamed'
|
|
538
|
+
&& Array.isArray((inMerged.body as Body).numbers) && (inMerged.body as Body).numbers.length === 2
|
|
539
|
+
&& (inMerged.body as Body).numbers.includes('+15559990000')
|
|
540
|
+
&& JSON.stringify((inMerged.body as Body).allowedNumbers) === JSON.stringify(['+44*'])
|
|
541
|
+
&& (inMerged.body as Body).authUsername === 'u' && (inMerged.body as Body).metadata === 'orig'
|
|
542
|
+
// full-replace asserted: name fresh, numbers replaced, absent authUsername cleared
|
|
543
|
+
&& inReplaced.status === 200 && (inReplaced.body as Body).name === 'fresh'
|
|
544
|
+
&& JSON.stringify((inReplaced.body as Body).numbers) === JSON.stringify(['+10000000000'])
|
|
545
|
+
&& (inReplaced.body as Body).authUsername === '' && (inReplaced.body as Body).metadata === ''
|
|
546
|
+
// state folded from kernel log (read-back via List)
|
|
547
|
+
&& inRow?.name === 'fresh' && JSON.stringify(inRow?.numbers) === JSON.stringify(['+10000000000'])
|
|
548
|
+
// negative path: unknown id => Twirp not_found
|
|
549
|
+
&& inMissing.status === 404 && (inMissing.body as Body).code === 'not_found'
|
|
550
|
+
// outbound field-merge: address overwritten, list remove+add applied
|
|
551
|
+
&& outMerged.status === 200 && (outMerged.body as Body).address === 'b.com'
|
|
552
|
+
&& JSON.stringify((outMerged.body as Body).numbers) === JSON.stringify(['+15550001111'])
|
|
553
|
+
&& (outMerged.body as Body).destinationCountry === 'US'
|
|
554
|
+
&& outMissing.status === 404;
|
|
555
|
+
})),
|
|
556
|
+
done('livekit.sip.update_dispatch_rule', 'sip', 'UpdateSIPDispatchRule — replace overwrites whole Info; update field-merges name/metadata/attributes + trunkIds (ListUpdate) + the rule oneof', 'api', 'niche', () =>
|
|
557
|
+
withRoot(async (h) => {
|
|
558
|
+
const created = await h({ service: 'SIP', method: 'CreateSIPDispatchRule', b: { rule: { dispatchRuleDirect: { roomName: 'support', pin: '1234' } }, trunkIds: ['ST_a'], name: 'd1', metadata: 'm' } });
|
|
559
|
+
const id = (created.body as Body).sipDispatchRuleId;
|
|
560
|
+
// field-merge: rename, ListUpdate add a trunk, swap the rule oneof to individual; metadata untouched
|
|
561
|
+
const merged = await h({ service: 'SIP', method: 'UpdateSIPDispatchRule', b: { sipDispatchRuleId: id, update: { name: 'd1-renamed', trunkIds: { add: ['ST_b'] }, rule: { dispatchRuleIndividual: { roomPrefix: 'call-' } } } } });
|
|
562
|
+
// full-replace: complete Info wholly replaces (metadata cleared, trunkIds reset)
|
|
563
|
+
const replaced = await h({ service: 'SIP', method: 'UpdateSIPDispatchRule', b: { sipDispatchRuleId: id, replace: { rule: { dispatchRuleDirect: { roomName: 'sales' } }, name: 'fresh' } } });
|
|
564
|
+
const after = await h({ service: 'SIP', method: 'ListSIPDispatchRule', b: { dispatchRuleIds: [id] } });
|
|
565
|
+
const row = (after.body as Body).items?.[0] as Body;
|
|
566
|
+
const badRule = await h({ service: 'SIP', method: 'UpdateSIPDispatchRule', b: { sipDispatchRuleId: id, replace: { name: 'x' } } });
|
|
567
|
+
const missing = await h({ service: 'SIP', method: 'UpdateSIPDispatchRule', b: { sipDispatchRuleId: 'SDR_nope', update: { name: 'x' } } });
|
|
568
|
+
return created.status === 200
|
|
569
|
+
&& merged.status === 200 && (merged.body as Body).name === 'd1-renamed' && (merged.body as Body).metadata === 'm'
|
|
570
|
+
&& JSON.stringify((merged.body as Body).trunkIds) === JSON.stringify(['ST_a', 'ST_b'])
|
|
571
|
+
&& (merged.body as Body).rule?.dispatchRuleIndividual?.roomPrefix === 'call-'
|
|
572
|
+
&& !(merged.body as Body).rule?.dispatchRuleDirect
|
|
573
|
+
// full-replace asserted VALUES: rule back to direct/sales, metadata cleared, trunkIds reset
|
|
574
|
+
&& replaced.status === 200 && (replaced.body as Body).rule?.dispatchRuleDirect?.roomName === 'sales'
|
|
575
|
+
&& (replaced.body as Body).name === 'fresh' && (replaced.body as Body).metadata === ''
|
|
576
|
+
&& JSON.stringify((replaced.body as Body).trunkIds) === JSON.stringify([])
|
|
577
|
+
// state folded from kernel log
|
|
578
|
+
&& row?.name === 'fresh' && row?.rule?.dispatchRuleDirect?.roomName === 'sales'
|
|
579
|
+
// negative paths: replace without a rule => invalid_argument; unknown id => not_found
|
|
580
|
+
&& badRule.status === 400 && missing.status === 404 && (missing.body as Body).code === 'not_found';
|
|
581
|
+
})),
|
|
582
|
+
|
|
583
|
+
// Honestly-still-undone SIP surfaces (real Twirp methods, not yet modeled):
|
|
584
|
+
// the singular get-by-id reads. Unmodeled ops fail like the vendor (not_found
|
|
585
|
+
// Twirp error) — never faked.
|
|
586
|
+
todo('livekit.sip.get_trunk', 'sip', 'GetSIPInboundTrunk/GetSIPOutboundTrunk — fetch a single trunk by id', 'api', 'niche'),
|
|
587
|
+
|
|
588
|
+
done('livekit.agents.dispatch_create', 'agents', 'CreateDispatch records an explicit agent dispatch; room+agentName required', 'api', 'niche', () =>
|
|
589
|
+
withRoot(async (h) => {
|
|
590
|
+
await h({ method: 'CreateRoom', b: { name: 'agent-room' } });
|
|
591
|
+
const created = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'agent-room', agentName: 'transcriber', metadata: '{"lang":"en"}' } });
|
|
592
|
+
const noRoom = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { agentName: 'x' } });
|
|
593
|
+
const noAgent = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'agent-room' } });
|
|
594
|
+
const b = created.body as Body;
|
|
595
|
+
return created.status === 200 && typeof b.id === 'string' && b.id.startsWith('AD_')
|
|
596
|
+
&& b.agentName === 'transcriber' && b.room === 'agent-room' && b.metadata === '{"lang":"en"}'
|
|
597
|
+
&& noRoom.status === 400 && noAgent.status === 400;
|
|
598
|
+
})),
|
|
599
|
+
done('livekit.agents.dispatch_list', 'agents', 'ListDispatch filters by room and dispatch id, excludes deleted', 'api', 'niche', () =>
|
|
600
|
+
withRoot(async (h) => {
|
|
601
|
+
const a = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'r1', agentName: 'a1' } });
|
|
602
|
+
await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'r2', agentName: 'a2' } });
|
|
603
|
+
const all = await h({ service: 'AgentDispatchService', method: 'ListDispatch', b: {} });
|
|
604
|
+
const byRoom = await h({ service: 'AgentDispatchService', method: 'ListDispatch', b: { room: 'r1' } });
|
|
605
|
+
const byId = await h({ service: 'AgentDispatchService', method: 'ListDispatch', b: { room: 'r1', dispatchId: (a.body as Body).id } });
|
|
606
|
+
return (all.body as Body).agentDispatches?.length === 2 && (byRoom.body as Body).agentDispatches?.length === 1
|
|
607
|
+
&& (byRoom.body as Body).agentDispatches[0].room === 'r1' && (byId.body as Body).agentDispatches?.length === 1;
|
|
608
|
+
})),
|
|
609
|
+
done('livekit.agents.dispatch_delete', 'agents', 'DeleteDispatch removes a dispatch from ListDispatch; deleting twice is not_found', 'api', 'niche', () =>
|
|
610
|
+
withRoot(async (h) => {
|
|
611
|
+
const created = await h({ service: 'AgentDispatchService', method: 'CreateDispatch', b: { room: 'del-room', agentName: 'a' } });
|
|
612
|
+
const id = (created.body as Body).id;
|
|
613
|
+
const deleted = await h({ service: 'AgentDispatchService', method: 'DeleteDispatch', b: { dispatchId: id, room: 'del-room' } });
|
|
614
|
+
const listed = await h({ service: 'AgentDispatchService', method: 'ListDispatch', b: { room: 'del-room' } });
|
|
615
|
+
const again = await h({ service: 'AgentDispatchService', method: 'DeleteDispatch', b: { dispatchId: id, room: 'del-room' } });
|
|
616
|
+
return deleted.status === 200 && (listed.body as Body).agentDispatches?.length === 0 && again.status === 404;
|
|
617
|
+
})),
|
|
618
|
+
done('livekit.tokens.access_grants', 'tokens', 'AccessToken mint+verify with full video-grant parity (round-trips and rejects forgery)', 'api', 'core', async () => {
|
|
619
|
+
// Mint a join token with rich video grants, verify it round-trips with the same grants.
|
|
620
|
+
const video = { roomJoin: true, room: 'r1', canPublish: true, canSubscribe: false, canPublishData: true, hidden: true, roomRecord: false };
|
|
621
|
+
const jwt = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, identity: 'alice', name: 'Alice', nowSeconds: 1000, ttlSeconds: 3600, video, metadata: '{"x":1}' });
|
|
622
|
+
const ok = verifyAccessToken(jwt, (k) => (k === API_KEY ? API_SECRET : undefined), { nowSeconds: 1000 });
|
|
623
|
+
if (!ok.ok) return false;
|
|
624
|
+
const g = ok.grants;
|
|
625
|
+
if (g.sub !== 'alice' || g.name !== 'Alice' || g.metadata !== '{"x":1}') return false;
|
|
626
|
+
if (g.video?.roomJoin !== true || g.video?.room !== 'r1' || g.video?.canPublish !== true || g.video?.canSubscribe !== false || g.video?.hidden !== true) return false;
|
|
627
|
+
// Forged signature must fail.
|
|
628
|
+
const forged = verifyAccessToken(jwt.slice(0, -3) + 'AAA', (k) => (k === API_KEY ? API_SECRET : undefined), { nowSeconds: 1000 });
|
|
629
|
+
// Wrong secret must fail.
|
|
630
|
+
const wrongSecret = verifyAccessToken(jwt, () => 'x'.repeat(36), { nowSeconds: 1000 });
|
|
631
|
+
// Expired must fail.
|
|
632
|
+
const expired = verifyAccessToken(jwt, (k) => (k === API_KEY ? API_SECRET : undefined), { nowSeconds: 1000 + 4000 });
|
|
633
|
+
// Unknown key must fail.
|
|
634
|
+
const unknown = verifyAccessToken(jwt, () => undefined, { nowSeconds: 1000 });
|
|
635
|
+
return forged.ok === false && wrongSecret.ok === false && expired.ok === false && expired.reason === 'expired' && unknown.ok === false && unknown.reason === 'unknown_key';
|
|
636
|
+
}),
|
|
637
|
+
done('livekit.tokens.sip_grants', 'tokens', 'SIP grants are carried and verified in access tokens', 'api', 'niche', async () => {
|
|
638
|
+
const jwt = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, identity: 'caller', nowSeconds: 0, ttlSeconds: 600, sip: { admin: true, call: true } });
|
|
639
|
+
const ok = verifyAccessToken(jwt, (k) => (k === API_KEY ? API_SECRET : undefined), { nowSeconds: 0 });
|
|
640
|
+
return ok.ok && ok.grants.sip?.admin === true && ok.grants.sip?.call === true;
|
|
641
|
+
}),
|
|
642
|
+
done('livekit.webhooks.receiver_verify', 'webhooks', 'Webhook signing+verification (HMAC header + body sha256) round-trips and rejects tampering', 'api', 'common', async () => {
|
|
643
|
+
const body = buildWebhookBody({ event: 'room_started', id: 'EV_1', createdAtSeconds: 100, room: { name: 'r1', sid: 'RM_1' } });
|
|
644
|
+
const header = signWebhookHeader(body, API_KEY, API_SECRET, 100);
|
|
645
|
+
const ok = verifyWebhook(body, header, API_SECRET, { nowSeconds: 100 });
|
|
646
|
+
const tamperedBody = verifyWebhook(body + ' ', header, API_SECRET, { nowSeconds: 100 });
|
|
647
|
+
const badSig = verifyWebhook(body, header.slice(0, -3) + 'AAA', API_SECRET, { nowSeconds: 100 });
|
|
648
|
+
const noHeader = verifyWebhook(body, undefined, API_SECRET, { nowSeconds: 100 });
|
|
649
|
+
return ok.ok && ok.event.event === 'room_started' && tamperedBody.ok === false && tamperedBody.reason === 'hash_mismatch'
|
|
650
|
+
&& badSig.ok === false && noHeader.ok === false && noHeader.reason === 'missing_header';
|
|
651
|
+
}),
|
|
652
|
+
done('livekit.webhooks.room_events', 'webhooks', 'room_started/room_finished/participant_joined webhook bodies carry the right shape', 'api', 'common', async () => {
|
|
653
|
+
for (const event of ['room_started', 'room_finished', 'participant_joined', 'participant_left'] as const) {
|
|
654
|
+
const body = buildWebhookBody({ event, createdAtSeconds: 5, room: { name: 'r', sid: 'RM_r' }, participant: { identity: 'u' } });
|
|
655
|
+
const v = verifyWebhook(body, signWebhookHeader(body, API_KEY, API_SECRET, 5), API_SECRET, { nowSeconds: 5 });
|
|
656
|
+
if (!v.ok || v.event.event !== event || (v.event.room as Body)?.name !== 'r') return false;
|
|
657
|
+
}
|
|
658
|
+
return true;
|
|
659
|
+
}),
|
|
660
|
+
done('livekit.webhooks.egress_events', 'webhooks', 'egress_started/updated/ended webhook bodies carry egressInfo and verify', 'api', 'common', async () => {
|
|
661
|
+
for (const event of ['egress_started', 'egress_updated', 'egress_ended'] as const) {
|
|
662
|
+
const body = buildWebhookBody({ event, createdAtSeconds: 9, egressInfo: { egressId: 'EG_1', roomName: 'r', status: 1 } });
|
|
663
|
+
const v = verifyWebhook(body, signWebhookHeader(body, API_KEY, API_SECRET, 9), API_SECRET, { nowSeconds: 9 });
|
|
664
|
+
if (!v.ok || v.event.event !== event || (v.event.egressInfo as Body)?.egressId !== 'EG_1') return false;
|
|
665
|
+
}
|
|
666
|
+
return true;
|
|
667
|
+
}),
|
|
668
|
+
done('livekit.errors.auth_required', 'errors', 'Missing/invalid/expired JWT returns unauthenticated when auth is enforced', 'api', 'common', () =>
|
|
669
|
+
withRoot(async (h) => {
|
|
670
|
+
const apiKeys = { [API_KEY]: API_SECRET };
|
|
671
|
+
const noToken = await h({ method: 'ListRooms', b: {}, apiKeys, nowSeconds: 1000 });
|
|
672
|
+
const garbage = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: 'not.a.jwt', nowSeconds: 1000 });
|
|
673
|
+
const wrongKeyToken = mintAccessToken({ apiKey: 'other', apiSecret: API_SECRET, nowSeconds: 1000, ttlSeconds: 600, video: { roomList: true } });
|
|
674
|
+
const unknownKey = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: wrongKeyToken, nowSeconds: 1000 });
|
|
675
|
+
const expiredToken = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, nowSeconds: 1000, ttlSeconds: 10, video: { roomList: true } });
|
|
676
|
+
const expired = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: expiredToken, nowSeconds: 5000 });
|
|
677
|
+
// a valid token still works (auth layer is not blanket-deny)
|
|
678
|
+
const goodToken = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, nowSeconds: 1000, ttlSeconds: 600, video: { roomList: true } });
|
|
679
|
+
const good = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: goodToken, nowSeconds: 1000 });
|
|
680
|
+
return noToken.status === 401 && (noToken.body as Body).code === 'unauthenticated'
|
|
681
|
+
&& garbage.status === 401 && unknownKey.status === 401 && expired.status === 401 && good.status === 200;
|
|
682
|
+
})),
|
|
683
|
+
done('livekit.errors.permission_denied', 'errors', 'Valid token lacking the required grant returns permission_denied', 'api', 'common', () =>
|
|
684
|
+
withRoot(async (h) => {
|
|
685
|
+
const apiKeys = { [API_KEY]: API_SECRET };
|
|
686
|
+
// token has roomList but not roomCreate
|
|
687
|
+
const listOnly = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, nowSeconds: 0, ttlSeconds: 600, video: { roomList: true } });
|
|
688
|
+
const create = await h({ method: 'CreateRoom', b: { name: 'x' }, apiKeys, authToken: listOnly, nowSeconds: 0 });
|
|
689
|
+
const list = await h({ method: 'ListRooms', b: {}, apiKeys, authToken: listOnly, nowSeconds: 0 });
|
|
690
|
+
// recorder-scoped token cannot create rooms but can start egress
|
|
691
|
+
const recorder = mintAccessToken({ apiKey: API_KEY, apiSecret: API_SECRET, nowSeconds: 0, ttlSeconds: 600, video: { roomCreate: true, roomRecord: true } });
|
|
692
|
+
await h({ method: 'CreateRoom', b: { name: 'rec' }, apiKeys, authToken: recorder, nowSeconds: 0 });
|
|
693
|
+
const egress = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'rec' }, apiKeys, authToken: recorder, nowSeconds: 0 });
|
|
694
|
+
const egressDenied = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'rec' }, apiKeys, authToken: listOnly, nowSeconds: 0 });
|
|
695
|
+
return create.status === 403 && (create.body as Body).code === 'permission_denied' && list.status === 200
|
|
696
|
+
&& egress.status === 200 && egressDenied.status === 403;
|
|
697
|
+
})),
|
|
698
|
+
done('livekit.errors.quota_capacity', 'errors', 'Egress start beyond the active-egress quota returns resource_exhausted', 'api', 'common', () =>
|
|
699
|
+
withRoot(async (h) => {
|
|
700
|
+
await h({ method: 'CreateRoom', b: { name: 'q-room' } });
|
|
701
|
+
const first = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'q-room' }, maxActiveEgress: 1 });
|
|
702
|
+
const second = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'q-room' }, maxActiveEgress: 1 });
|
|
703
|
+
// stopping one frees capacity again
|
|
704
|
+
await h({ service: 'Egress', method: 'StopEgress', b: { egressId: (first.body as Body).egressId } });
|
|
705
|
+
const third = await h({ service: 'Egress', method: 'StartRoomCompositeEgress', b: { roomName: 'q-room' }, maxActiveEgress: 1 });
|
|
706
|
+
return first.status === 200 && second.status === 429 && (second.body as Body).code === 'resource_exhausted' && third.status === 200;
|
|
707
|
+
})),
|
|
708
|
+
done('livekit.connector.push_egress_stop', 'connector', 'Connector pushes pending egress stop actions idempotently', 'connector', 'common', async () => {
|
|
709
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-egress-stop-cap-'));
|
|
710
|
+
try {
|
|
711
|
+
await applyTwinWrite('livekit', {
|
|
712
|
+
operation: 'egress.stop',
|
|
713
|
+
subjectType: 'egress',
|
|
714
|
+
subjectId: 'egress_EG_push',
|
|
715
|
+
fields: { id: 'egress_EG_push', egressId: 'EG_push' },
|
|
716
|
+
occurredAt: '1970-01-01T00:00:00.000Z',
|
|
717
|
+
actor: { kind: 'system' },
|
|
718
|
+
}, root);
|
|
719
|
+
let calls = 0;
|
|
720
|
+
const pushed = await performPending(async (req) => {
|
|
721
|
+
calls++;
|
|
722
|
+
if (req.service !== 'Egress' || req.method !== 'StopEgress') return { status: 400, data: {} };
|
|
723
|
+
return { status: 200, data: { egressId: 'EG_push', status: EGRESS_STATUS.COMPLETE } };
|
|
724
|
+
}, { root });
|
|
725
|
+
const again = await performPending(async () => ({ status: 200, data: {} }), { root });
|
|
726
|
+
return pushed === 1 && calls === 1 && again === 0 && pendingActions('livekit', root).length === 0;
|
|
727
|
+
} finally {
|
|
728
|
+
rmSync(root, { recursive: true, force: true });
|
|
729
|
+
}
|
|
730
|
+
}),
|
|
731
|
+
done('livekit.connector.ingress_pull', 'connector', 'Connector pulls ingress resources through the injected client', 'connector', 'common', async () => {
|
|
732
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-ingress-pull-cap-'));
|
|
733
|
+
try {
|
|
734
|
+
const exec = async (req: { service: string; method: string }) => {
|
|
735
|
+
if (req.service === 'Ingress' && req.method === 'ListIngress') {
|
|
736
|
+
return { status: 200, data: { items: [{ ingressId: 'IN_remote', name: 'live', roomName: 'r', inputType: INGRESS_INPUT.RTMP_INPUT, participantIdentity: 'p' }] } };
|
|
737
|
+
}
|
|
738
|
+
if (req.service === 'RoomService' && req.method === 'ListRooms') return { status: 200, data: { rooms: [] } };
|
|
739
|
+
return { status: 200, data: { items: [] } };
|
|
740
|
+
};
|
|
741
|
+
await pullLiveKitSnapshot(exec, [], { root });
|
|
742
|
+
await pullLiveKitSnapshot(exec, [], { root });
|
|
743
|
+
const rows = projectResources('livekit', root);
|
|
744
|
+
return rows.filter((r) => r.type === 'ingress' && (r as Body).ingressId === 'IN_remote').length === 1;
|
|
745
|
+
} finally {
|
|
746
|
+
rmSync(root, { recursive: true, force: true });
|
|
747
|
+
}
|
|
748
|
+
}),
|
|
749
|
+
done('livekit.connector.sip_pull', 'connector', 'Connector pulls SIP inbound/outbound trunks + dispatch rules through the injected client (idempotent)', 'connector', 'niche', async () => {
|
|
750
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-sip-pull-cap-'));
|
|
751
|
+
try {
|
|
752
|
+
const exec = async (req: { service: string; method: string }) => {
|
|
753
|
+
if (req.service === 'SIP' && req.method === 'ListSIPInboundTrunk') return { status: 200, data: { items: [{ sipTrunkId: 'ST_in', name: 'in', numbers: ['+1'] }] } };
|
|
754
|
+
if (req.service === 'SIP' && req.method === 'ListSIPOutboundTrunk') return { status: 200, data: { items: [{ sipTrunkId: 'ST_out', name: 'out', address: 'sip.x' }] } };
|
|
755
|
+
if (req.service === 'SIP' && req.method === 'ListSIPDispatchRule') return { status: 200, data: { items: [{ sipDispatchRuleId: 'SDR_1', rule: { case: 'dispatchRuleDirect', value: { roomName: 'r' } } }] } };
|
|
756
|
+
return { status: 200, data: { items: [] } };
|
|
757
|
+
};
|
|
758
|
+
await pullLiveKitSIP(exec, { root });
|
|
759
|
+
await pullLiveKitSIP(exec, { root });
|
|
760
|
+
const rows = projectResources('livekit', root);
|
|
761
|
+
return rows.filter((r) => r.type === 'sip_inbound_trunk' && (r as Body).sipTrunkId === 'ST_in').length === 1
|
|
762
|
+
&& rows.filter((r) => r.type === 'sip_outbound_trunk' && (r as Body).sipTrunkId === 'ST_out').length === 1
|
|
763
|
+
&& rows.filter((r) => r.type === 'sip_dispatch_rule' && (r as Body).sipDispatchRuleId === 'SDR_1').length === 1;
|
|
764
|
+
} finally {
|
|
765
|
+
rmSync(root, { recursive: true, force: true });
|
|
766
|
+
}
|
|
767
|
+
}),
|
|
768
|
+
done('livekit.connector.agent_pull', 'connector', 'Connector pulls agent dispatch resources per-room through the injected client (idempotent)', 'connector', 'niche', async () => {
|
|
769
|
+
const root = mkdtempSync(join(tmpdir(), 'livekit-agent-pull-cap-'));
|
|
770
|
+
try {
|
|
771
|
+
const exec = async (req: { service: string; method: string }) => {
|
|
772
|
+
if (req.service === 'AgentDispatchService' && req.method === 'ListDispatch') {
|
|
773
|
+
return { status: 200, data: { agentDispatches: [{ id: 'AD_remote', agentName: 'a', room: 'remote-room', metadata: '', state: { createdAt: '5' } }] } };
|
|
774
|
+
}
|
|
775
|
+
return { status: 200, data: { agentDispatches: [] } };
|
|
776
|
+
};
|
|
777
|
+
await pullLiveKitAgentDispatch(exec, ['remote-room'], { root });
|
|
778
|
+
await pullLiveKitAgentDispatch(exec, ['remote-room'], { root });
|
|
779
|
+
const rows = projectResources('livekit', root);
|
|
780
|
+
return rows.filter((r) => r.type === 'agent_dispatch' && (r as Body).dispatchId === 'AD_remote').length === 1;
|
|
781
|
+
} finally {
|
|
782
|
+
rmSync(root, { recursive: true, force: true });
|
|
783
|
+
}
|
|
784
|
+
}),
|
|
785
|
+
];
|
|
786
|
+
|
|
787
|
+
export async function livekitCapabilities(): Promise<CapabilityReport> {
|
|
788
|
+
return checkCapabilities('livekit', LIVEKIT_CAPABILITIES);
|
|
789
|
+
}
|