import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:sm_mobile/core/app_controller.dart'; import 'package:sm_mobile/main.dart'; import 'package:sm_mobile/models/server_profile.dart'; import 'package:sm_mobile/services/secure_store.dart'; import 'package:sm_mobile/ui/terminal_page.dart'; /// Native window changes on a real device, emulator or simulator. /// /// After connecting to the isolated OpenSSH fixture the test watches the /// window for SM_ADAPTIVE_SECONDS. Rotate, fold, unfold, resize or split the /// window meanwhile (by hand, or by a script driving the emulator). Every new /// window shape must keep the same SSH client and terminal page, and the /// server's PTY must report the terminal's new rows and columns. void main() { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); const seconds = int.fromEnvironment('SM_ADAPTIVE_SECONDS', defaultValue: 90); const minShapes = int.fromEnvironment('SM_ADAPTIVE_MIN_SHAPES'); testWidgets( 'native resize, rotation and folding keep the SSH session and PTY size', (tester) async { final namespace = 'sm_adaptive_${newId()}'; final store = DeviceStore( FlutterSecureStorage( aOptions: AndroidOptions( resetOnError: false, storageNamespace: namespace, ), iOptions: IOSOptions( accountName: namespace, accessibility: KeychainAccessibility.unlocked_this_device, ), ), ); final c = AppController(store); // Frames are produced by the real engine; waiting must not depend on // them, because a hidden window (for example after folding) has none. binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; Future idle(int ms) => Future.delayed(Duration(milliseconds: ms)); Future until(bool Function() predicate, String what) async { for (var i = 0; i < 300 && !predicate(); i++) { await idle(100); } expect(predicate(), true, reason: what); } final lifecycle = AppLifecycleListener( onStateChange: (state) { // ignore: avoid_print print('ADAPT lifecycle ${state.name}'); }, ); await c.initialize(); c.locale = const Locale('en'); // The background lock stays on: if folding or resizing were reported as // leaving the app, it would lock, disconnect and fail this test. await c.acceptLicense(); await c.openVault('native adaptive master password', create: true); final profile = ServerProfile( name: 'Isolated adaptive test', host: const String.fromEnvironment( 'SM_TEST_HOST', defaultValue: '127.0.0.1', ), username: const String.fromEnvironment('SM_TEST_USER'), port: 22229, auth: AuthMethod.privateKey, ); const fingerprint = String.fromEnvironment('SM_TEST_FINGERPRINT'); await c.ssh.connect( profile, credential: utf8.decode( base64Decode(const String.fromEnvironment('SM_TEST_KEY_B64')), ), passphrase: 'isolated-test-passphrase', prompt: (_, _, shown) async => shown == fingerprint, ); expect(c.ssh.error, null); expect(c.ssh.connected, true); final client = c.ssh.client, terminal = c.ssh.terminal; await tester.pumpWidget(SmMobileApp(c)); await idle(1500); await tester.tap(find.text('Terminal').last); await idle(1500); final page = tester.state(find.byType(TerminalPage)); String shape() { final v = tester.view; final s = v.physicalSize / v.devicePixelRatio; return '${s.width.toStringAsFixed(1)}x${s.height.toStringAsFixed(1)}dp' ' @${v.devicePixelRatio}x physical ${v.physicalSize.width.toInt()}' 'x${v.physicalSize.height.toInt()} features ' '${v.displayFeatures.map((f) => '${f.type.name}/${f.state.name}').join(',')}'; } final seen = {}; var probe = 0; Future check(String now) async { if (!c.vault.unlocked || !c.ssh.connected) { // ignore: avoid_print print('ADAPT session lost at $now (locked: ${!c.vault.unlocked})'); } expect(tester.takeException(), null, reason: now); expect(c.ssh.connected, true, reason: now); expect(c.ssh.client, same(client), reason: '$now: no reconnect'); expect(c.ssh.terminal, same(terminal), reason: now); expect(tester.state(find.byType(TerminalPage)), same(page)); final rows = terminal.viewHeight, cols = terminal.viewWidth; final step = probe++; terminal.onOutput!( "printf 'SZ%s_%s_\\n' $step \"\$(stty size | tr ' ' x)\"\r", ); final expected = 'SZ${step}_${rows}x${cols}_'; await until( () => terminal.buffer.getText().contains(expected), '$now: remote PTY ${rows}x$cols', ); // ignore: avoid_print print('ADAPT ok $now terminal ${rows}x$cols'); } var last = shape(); seen.add(last); await check(last); // ignore: avoid_print print('ADAPT ready ${seconds}s'); final end = DateTime.now().add(const Duration(seconds: seconds)); while (DateTime.now().isBefore(end)) { await idle(250); final now = shape(); if (now == last && c.vault.unlocked) continue; // Let the window settle before measuring the new terminal size. await idle(800); last = shape(); seen.add(last); await check(last); } // ignore: avoid_print print('ADAPT shapes ${seen.length}'); expect(seen.length, greaterThanOrEqualTo(minShapes)); lifecycle.dispose(); await tester.pumpWidget(const SizedBox()); c.dispose(); for (final key in ['vault', 'consent', 'preferences']) { await store.delete(key); } }, timeout: const Timeout(Duration(seconds: seconds + 180)), ); }