import 'dart:io'; import 'dart:ui' show DisplayFeature, DisplayFeatureState, DisplayFeatureType; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_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/ui/files_page.dart'; import 'package:sm_mobile/ui/terminal_page.dart'; import 'package:xterm/xterm.dart'; import '../support.dart'; /// Real OpenSSH (tool/test_openssh.py): layout changes must keep the same SSH /// client, PTY and SFTP state, and the remote PTY must follow the new size. void main() { final enabled = Platform.environment['SM_OPENSSH_TEST'] == '1'; DisplayFeature fold(Rect r) => DisplayFeature( bounds: r, type: DisplayFeatureType.fold, state: DisplayFeatureState.postureHalfOpened, ); // Logical sizes; physical = logical × ratio. final shapes = <(String, Size, double, double, List)>[ ('phone portrait', const Size(390, 844), 3, 0, []), ('phone landscape', const Size(844, 390), 3, 0, []), ('phone landscape + keyboard', const Size(844, 390), 3, 170, []), ('phone portrait + keyboard', const Size(390, 844), 3, 336, []), ('small phone', const Size(320, 568), 2, 0, []), ('iPhone Duo outer @3x (assumed)', const Size(466, 678), 3, 0, []), ('iPhone Duo inner @3x (assumed)', const Size(626, 890), 3, 0, []), ( 'iPhone Duo inner half folded', const Size(626, 890), 3, 0, [fold(const Rect.fromLTWH(305, 0, 16, 890))], ), ('tablet portrait', const Size(820, 1180), 2, 0, []), ('tablet landscape', const Size(1180, 820), 2, 0, []), ('tablet split 1/3', const Size(375, 820), 2, 0, []), ('desktop-class', const Size(2560, 1440), 1, 0, []), ('minimum canvas fallback', const Size(200, 260), 2, 0, []), ('phone portrait again', const Size(390, 844), 3, 0, []), ]; Future apply( WidgetTester tester, Size s, double ratio, double keyboard, List features, ) async { tester.view.devicePixelRatio = ratio; tester.view.physicalSize = s * ratio; tester.view.viewInsets = FakeViewPadding(bottom: keyboard * ratio); tester.view.displayFeatures = features; await tester.pump(); await tester.pump(); } Future waitFor( WidgetTester tester, bool Function() done, String what, ) async { final deadline = DateTime.now().add(const Duration(seconds: 15)); while (!done()) { if (DateTime.now().isAfter(deadline)) fail('timeout: $what'); await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50)), ); await tester.pump(); } } testWidgets( 'real OpenSSH session, PTY size and SFTP directory survive every layout', (tester) async { addTearDown(tester.view.reset); final c = AppController(MemoryStore()); final key = await tester.runAsync( () => File(Platform.environment['SM_TEST_KEY']!).readAsString(), ); final root = await tester.runAsync( () => Directory( Platform.environment['SM_TEST_REMOTE_DIR']!, ).resolveSymbolicLinks(), ); final dir = '$root/adaptive-${newId()}'; await tester.runAsync(() async { await Directory(dir).create(recursive: true); await c.initialize(); await c.acceptLicense(); await c.openVault('isolated adaptive password', create: true); await c.ssh.connect( ServerProfile( name: 'Isolated OpenSSH', host: '127.0.0.1', username: Platform.environment['USER']!, port: 22229, auth: AuthMethod.privateKey, ), credential: key!, passphrase: 'isolated-test-passphrase', prompt: (_, _, _) async => true, ); }); expect(c.ssh.error, null); expect(c.ssh.connected, true); final client = c.ssh.client, terminal = c.ssh.terminal; await apply(tester, const Size(390, 844), 3, 0, const []); await tester.pumpWidget(SmMobileApp(c)); await tester.pump(); await tester.tap(find.byIcon(Icons.terminal).first); await tester.pump(); final page = tester.state(find.byType(TerminalPage)); var n = 0; for (final (name, size, ratio, keyboard, features) in shapes) { await apply(tester, size, ratio, keyboard, features); expect(tester.takeException(), null, reason: name); expect(c.ssh.connected, true, reason: name); expect(c.ssh.client, same(client), reason: '$name: no reconnect'); expect(c.ssh.terminal, same(terminal), reason: name); expect( tester.state(find.byType(TerminalPage)), same(page), reason: name, ); // Ask the server for the PTY size it now has. final rows = terminal.viewHeight, cols = terminal.viewWidth; final step = n++; // The echoed command contains only the format string; the expanded // marker can come from the server's answer alone. terminal.onOutput!( "printf 'SZ%s_%s_\\n' $step \"\$(stty size | tr ' ' x)\"\r", ); final expected = 'SZ${step}_${rows}x${cols}_'; await waitFor( tester, () => terminal.buffer.getText().contains(expected), '$name: remote PTY ${rows}x$cols', ); } // Long output and UTF-8: scrollback keeps it, the view follows the // newest line, and painting is clipped to the terminal area. await apply(tester, const Size(390, 844), 3, 0, const []); terminal.onOutput!( "seq 1 3000 | sed 's/^/řádek-/'; printf 'Žluťoučký kůň %s\\n' KONEC\r", ); await waitFor( tester, () => terminal.buffer.getText().contains('Žluťoučký kůň KONEC'), 'long UTF-8 output', ); expect(terminal.buffer.getText(), contains('řádek-3000')); expect(terminal.buffer.lines.length, greaterThan(1000)); expect( find.ancestor( of: find.byType(TerminalView), matching: find.byType(ClipRect), ), findsWidgets, ); expect(tester.takeException(), null); // SFTP: open a directory, then move through the layouts again. await apply(tester, const Size(390, 844), 3, 0, const []); await tester.tap(find.byIcon(Icons.folder_outlined).first); await tester.pump(); await waitFor( tester, () => find.byType(LinearProgressIndicator).evaluate().isEmpty, 'SFTP home listing', ); final files = tester.state(find.byType(FilesPage)) as dynamic; // The SFTP client lives in the test's fake zone, so its replies are // processed by pumping (waitFor), not inside runAsync. files.load(directory: dir); await waitFor(tester, () => find.text(dir).evaluate().isNotEmpty, dir); for (final (name, size, ratio, keyboard, features) in shapes) { await apply(tester, size, ratio, keyboard, features); expect(tester.takeException(), null, reason: name); expect(c.ssh.client, same(client), reason: '$name: no reconnect'); expect(tester.state(find.byType(FilesPage)), same(files), reason: name); expect(find.text(dir), findsOneWidget, reason: '$name: SFTP path'); } await tester.pumpWidget(const SizedBox()); c.dispose(); await tester.runAsync(() => Directory(dir).delete(recursive: true)); }, skip: !enabled, ); }