Release IKAR 1.0.8: fix incoming calls, cancellation and call history
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '../lib/core/transport/ikar_network.dart';
|
||||
import 'live_ikar_smoke.dart' as smoke;
|
||||
|
||||
Future<void> main() async {
|
||||
final base = Uri.parse('https://ikar.kusoft.xyz');
|
||||
final node = Platform.environment['IKAR_TEST_NODE'] ?? 'node';
|
||||
final stamp = DateTime.now().millisecondsSinceEpoch.toString().substring(5);
|
||||
final devices = <smoke.Device>[];
|
||||
final checks = <String>[];
|
||||
final sockets = <WebSocket>[];
|
||||
try {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
devices.add(
|
||||
await smoke.Device.create(
|
||||
base,
|
||||
node,
|
||||
'+999$stamp$i',
|
||||
'Call test ${i + 1}',
|
||||
),
|
||||
);
|
||||
}
|
||||
final a = devices[0], b = devices[1], outsider = devices[2];
|
||||
Future<Map> invite(String id, bool video) async {
|
||||
final incoming = b.transport.events
|
||||
.firstWhere(
|
||||
(e) =>
|
||||
e['opcode'] == 137 &&
|
||||
e['payload']?['conversationId'] == id &&
|
||||
e['payload']?['vcp'] != null,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final result = await a.ok(78, {
|
||||
'calleeIds': [b.id],
|
||||
'conversationId': id,
|
||||
'isVideo': video,
|
||||
});
|
||||
final payload = (await incoming)['payload'] as Map;
|
||||
smoke.check(
|
||||
payload['expiresAt'] > DateTime.now().millisecondsSinceEpoch,
|
||||
'Invitation already expired',
|
||||
);
|
||||
return {'result': result, 'incoming': payload};
|
||||
}
|
||||
|
||||
Future<void> committed(String id) => b.transport.events
|
||||
.firstWhere(
|
||||
(e) =>
|
||||
e['opcode'] == 137 &&
|
||||
e['payload']?['conversationId'] == id &&
|
||||
e['payload']?['event'] == 'history',
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
Future<Map> record(smoke.Device device, String id) async {
|
||||
final history = await device.ok(79, {});
|
||||
final entries = (history['history'] as List)
|
||||
.where(
|
||||
(item) => item['message']['attaches'][0]['conversationId'] == id,
|
||||
)
|
||||
.toList();
|
||||
smoke.check(entries.length == 1, 'Missing or duplicate call record');
|
||||
return entries.single['message']['attaches'][0] as Map;
|
||||
}
|
||||
|
||||
Future<WebSocket> open(String endpoint) async {
|
||||
final socket = await IkarNetwork.connectWebSocket(endpoint);
|
||||
sockets.add(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
Future<void> command(
|
||||
WebSocket ws,
|
||||
Stream<Map> stream,
|
||||
String name,
|
||||
int seq,
|
||||
) async {
|
||||
final ack = stream
|
||||
.firstWhere((e) => e['type'] == 'response' && e['sequence'] == seq)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
ws.add(jsonEncode({'command': name, 'sequence': seq}));
|
||||
await ack;
|
||||
}
|
||||
|
||||
final canceledId = 'call-canceled-$stamp';
|
||||
final call = await invite(canceledId, false);
|
||||
var forbidden = false;
|
||||
try {
|
||||
forbidden =
|
||||
(await outsider.transport.request(1001, {
|
||||
'conversationId': canceledId,
|
||||
}))['cmd'] !=
|
||||
1;
|
||||
} catch (_) {
|
||||
forbidden = true;
|
||||
}
|
||||
smoke.check(forbidden, 'An outsider canceled another call');
|
||||
checks.add('authenticated participant isolation');
|
||||
final ended = b.transport.events
|
||||
.firstWhere(
|
||||
(e) =>
|
||||
e['opcode'] == 137 &&
|
||||
e['payload']?['event'] == 'ended' &&
|
||||
e['payload']?['conversationId'] == canceledId,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final saved = committed(canceledId);
|
||||
final caller = await open(
|
||||
jsonDecode(call['result']['internalCallerParams'])['endpoint'],
|
||||
);
|
||||
final callerEvents = caller
|
||||
.map((frame) => jsonDecode(frame as String) as Map)
|
||||
.asBroadcastStream();
|
||||
final hold = callerEvents.listen((_) {});
|
||||
await command(caller, callerEvents, 'accept-call', 1);
|
||||
await command(caller, callerEvents, 'hangup', 2);
|
||||
await ended;
|
||||
await saved;
|
||||
for (final device in [a, b]) {
|
||||
final entry = await record(device, canceledId);
|
||||
smoke.check(
|
||||
entry['hangupType'] == 'CANCELED' && entry['duration'] == 0,
|
||||
'Unanswered call counted as connected',
|
||||
);
|
||||
}
|
||||
await a.ok(1001, {'conversationId': canceledId});
|
||||
await record(b, canceledId);
|
||||
await hold.cancel();
|
||||
await caller.close();
|
||||
checks.add(
|
||||
'caller cancellation reaches recipient without any recipient WS2 connection',
|
||||
);
|
||||
checks.add('durable history for both sides and idempotent cancellation');
|
||||
final spent = smoke.decodeCall(call['incoming']['vcp']);
|
||||
var revoked = false;
|
||||
try {
|
||||
final ws = await open(
|
||||
Uri.parse(
|
||||
spent['wse'],
|
||||
).replace(queryParameters: {'token': spent['tkn']}).toString(),
|
||||
);
|
||||
await ws.close();
|
||||
} catch (_) {
|
||||
revoked = true;
|
||||
}
|
||||
smoke.check(revoked, 'Canceled invitation remained joinable');
|
||||
checks.add('canceled call tokens revoked');
|
||||
|
||||
final videoId = 'call-video-$stamp';
|
||||
final video = await invite(videoId, true);
|
||||
final params = smoke.decodeCall(video['incoming']['vcp']);
|
||||
final callee = await open(
|
||||
Uri.parse(
|
||||
params['wse'],
|
||||
).replace(queryParameters: {'token': params['tkn']}).toString(),
|
||||
);
|
||||
final calleeEvents = callee
|
||||
.map((frame) => jsonDecode(frame as String) as Map)
|
||||
.asBroadcastStream();
|
||||
final calleeHold = calleeEvents.listen((_) {});
|
||||
await command(callee, calleeEvents, 'accept-call', 1);
|
||||
final videoSaved = committed(videoId);
|
||||
await command(callee, calleeEvents, 'hangup', 2);
|
||||
await videoSaved;
|
||||
final completed = await record(a, videoId);
|
||||
smoke.check(
|
||||
completed['callType'] == 'VIDEO' &&
|
||||
completed['hangupType'] == 'HUNGUP' &&
|
||||
completed['duration'] > 0,
|
||||
'Connected video history incorrect',
|
||||
);
|
||||
await record(b, videoId);
|
||||
await calleeHold.cancel();
|
||||
await callee.close();
|
||||
checks.add('accepted video call persists type and positive duration');
|
||||
|
||||
final rejectedId = 'call-rejected-$stamp';
|
||||
await invite(rejectedId, false);
|
||||
final rejectSaved = committed(rejectedId);
|
||||
await b.ok(1001, {'conversationId': rejectedId, 'reason': 'REJECTED'});
|
||||
await rejectSaved;
|
||||
smoke.check(
|
||||
(await record(a, rejectedId))['hangupType'] == 'REJECTED',
|
||||
'Reject status not saved',
|
||||
);
|
||||
checks.add('recipient rejection via independent IKAR endpoint');
|
||||
final missedId = 'call-missed-$stamp';
|
||||
final missedSaved = b.transport.events
|
||||
.firstWhere(
|
||||
(e) =>
|
||||
e['opcode'] == 137 &&
|
||||
e['payload']?['event'] == 'history' &&
|
||||
e['payload']?['conversationId'] == missedId,
|
||||
)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
await invite(missedId, false);
|
||||
await missedSaved;
|
||||
smoke.check(
|
||||
(await record(b, missedId))['hangupType'] == 'MISSED',
|
||||
'Ringing timeout not saved',
|
||||
);
|
||||
checks.add('unanswered ringing expires and becomes a missed call');
|
||||
final report = {
|
||||
'passed': checks.length,
|
||||
'checks': checks,
|
||||
'server': base.toString(),
|
||||
'physicalDeviceTested': false,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
await File(
|
||||
'artifacts/live-call-lifecycle-1.0.8.json',
|
||||
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
|
||||
stdout.writeln(jsonEncode(report));
|
||||
} finally {
|
||||
for (final socket in sockets) {
|
||||
try {
|
||||
await socket.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
for (final device in devices) {
|
||||
try {
|
||||
await device.ok(199, {});
|
||||
} catch (_) {}
|
||||
await device.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user