Files
IKAR/tool/live_call_lifecycle_smoke.dart
T
sevenhill 0508b70732
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
Release IKAR 1.0.8: fix incoming calls, cancellation and call history
2026-10-08 21:34:15 +03:00

229 lines
7.5 KiB
Dart

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();
}
}
}