Files
clide/test/pty/windows_pty_test.dart
jpmschweitzerandClaude Opus 4.8 04bb512cb7 test: green the first Windows CI run + skip non-portable goldens on CI
Two fixes from the first real CI execution of these suites:
- windows_pty_test: the non-existent-executable test asserted errno==2
  (ERROR_FILE_NOT_FOUND), but Dart FFI doesn't reliably preserve GetLastError
  across the lookupFunction boundary (CI Windows returned 0). Assert the
  PtyException op instead. The ConPTY suite otherwise passed 21/22 on real
  Windows with no stall — supporting the accumulation (not single-run) freeze
  theory.
- golden_harness: platform goldens are font-render-dependent across machines
  (dev Fedora vs GitHub ubuntu-latest), so run them locally only and skip on CI
  (the CI env var). Goldens stay a local pre-merge check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 22:43:13 +02:00

155 lines
5.9 KiB
Dart

/// WindowsPty (ConPTY) smoke tests — the Windows sibling of
/// `session_test.dart`. Windows only; skipped elsewhere.
///
/// Same `tags: ['pty']` discipline as the POSIX suite: tests that
/// depend on the reader isolate delivering ConPTY output run serially
/// via `dart test` per `ci/test.sh`; only the synchronous-throw test
/// stays untagged.
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/src/pty/errors.dart';
import 'package:clide/src/pty/windows_pty.dart';
import 'package:test/test.dart';
import '../helpers/timeouts.dart';
void main() {
if (!Platform.isWindows) return;
group('WindowsPty', () {
test('spawns cmd /c echo and reads output', tags: ['pty'], () async {
final s = WindowsPty.start(
executable: 'cmd.exe',
arguments: ['/c', 'echo hello-pty'],
columns: 80,
rows: 24,
environment: {...Platform.environment, 'TERM': 'xterm-256color'},
);
addTearDown(s.close);
final got = await _readUntil(s, 'hello-pty', ioTimeout);
expect(got, contains('hello-pty'));
});
test('write sends keystrokes to child', tags: ['pty'], () async {
final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment, 'TERM': 'xterm-256color'});
addTearDown(s.close);
final buf = StringBuffer();
final firstByte = Completer<void>();
final sub = s.output.listen((bytes) {
buf.write(utf8.decode(bytes, allowMalformed: true));
if (!firstByte.isCompleted) firstByte.complete();
});
addTearDown(sub.cancel);
await firstByte.future.timeout(ioTimeout, onTimeout: () => fail('shell never produced its first byte within ${ioTimeout.inSeconds}s'));
s.write(utf8.encode('echo write-test-ok\r\n'));
final result = await _waitForBuffer(buf, 'write-test-ok', ioTimeout);
expect(result, contains('write-test-ok'));
});
test('child exit closes the output stream without close()', tags: ['pty'], () async {
// The waiter isolate must ClosePseudoConsole on child exit, or the
// reader blocks forever and pane.exit never fires.
final s = WindowsPty.start(executable: 'cmd.exe', arguments: ['/c', 'echo bye'], columns: 80, rows: 24, environment: {...Platform.environment});
addTearDown(s.close);
final done = Completer<void>();
s.output.listen((_) {}, onDone: () => done.complete());
await done.future.timeout(ioTimeout, onTimeout: () => fail('output stream did not close within ${ioTimeout.inSeconds}s of child exit'));
expect(s.isClosed, isTrue);
});
test('close kills child and closes output', tags: ['pty'], () async {
final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment});
final done = Completer<void>();
s.output.listen((_) {}, onDone: () => done.complete());
await s.close();
await done.future.timeout(ioTimeout, onTimeout: () => fail('output stream did not close within ${ioTimeout.inSeconds}s after s.close()'));
expect(s.isClosed, isTrue);
});
test('bare command name resolves via PATH + PATHEXT', tags: ['pty'], () async {
// 'cmd' is bare and extension-less; resolution must find cmd.exe.
final s = WindowsPty.start(
executable: 'cmd',
arguments: ['/c', 'echo path-resolution-ok'],
columns: 80,
rows: 24,
environment: {...Platform.environment},
);
addTearDown(s.close);
final got = await _readUntil(s, 'path-resolution-ok', ioTimeout);
expect(got, contains('path-resolution-ok'));
});
test('resize survives a live session', tags: ['pty'], () async {
final s = WindowsPty.start(executable: 'cmd.exe', arguments: [], columns: 80, rows: 24, environment: {...Platform.environment});
addTearDown(s.close);
s.resize(cols: 120, rows: 40);
expect(s.isClosed, isFalse);
});
test('non-existent executable surfaces a PtyException at spawn time', () {
// CreateProcessW fails (the file doesn't exist). The GetLastError code
// (ERROR_FILE_NOT_FOUND, 2) is intentionally NOT asserted: Dart FFI does
// not reliably preserve GetLastError across the lookupFunction boundary —
// CI Windows observed errno 0 here — so only the PtyException and the
// failing op are dependable. (Same reason the errno-based broken-pipe
// check in write() is best-effort; see T-424.)
expect(
() => WindowsPty.start(
executable: 'C:\\clide-no-such-binary-${DateTime.now().microsecondsSinceEpoch}.exe',
arguments: const [],
columns: 80,
rows: 24,
environment: {...Platform.environment},
),
throwsA(isA<PtyException>().having((e) => e.op, 'op', 'CreateProcessW')),
);
});
});
}
/// Collect output until [needle] appears or [limit] elapses.
Future<String> _readUntil(WindowsPty s, String needle, Duration limit) async {
final buf = StringBuffer();
final found = Completer<String>();
final sub = s.output.listen(
(bytes) {
buf.write(utf8.decode(bytes, allowMalformed: true));
if (!found.isCompleted && buf.toString().contains(needle)) {
found.complete(buf.toString());
}
},
onDone: () {
if (!found.isCompleted) found.complete(buf.toString());
},
);
try {
return await found.future.timeout(limit, onTimeout: () => buf.toString());
} finally {
await sub.cancel();
}
}
/// Poll [buf] until it contains [needle] or [limit] elapses.
Future<String> _waitForBuffer(StringBuffer buf, String needle, Duration limit) async {
final deadline = DateTime.now().add(limit);
while (DateTime.now().isBefore(deadline)) {
if (buf.toString().contains(needle)) return buf.toString();
await Future<void>.delayed(const Duration(milliseconds: 50));
}
return buf.toString();
}