import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:clide/kernel/src/log.dart'; import 'package:clide/src/daemon/dispatcher.dart'; import 'package:clide/src/ipc/envelope.dart'; import 'package:clide/src/ipc/paths.dart'; import 'package:clide/src/ipc/schema_v1.dart'; import 'package:clide/src/ipc/server.dart'; import 'package:test/test.dart'; /// Tests run with `XDG_RUNTIME_DIR` overridden to a per-test tempdir /// so the production `socketDirectory()` resolves under our control. /// Workspace roots are arbitrary strings; we don't need a real git /// repo because the path resolver only hashes the string. void main() { late Directory xdg; late DaemonDispatcher dispatcher; late IpcServer server; late String workRoot; setUp(() async { xdg = await Directory.systemTemp.createTemp('clide-ipc-test-'); workRoot = '${xdg.path}/workspace-${DateTime.now().microsecondsSinceEpoch}'; dispatcher = DaemonDispatcher(); }); tearDown(() async { try { await server.stop(); } catch (_) {} if (xdg.existsSync()) xdg.deleteSync(recursive: true); }); Future withXdg(Future Function() body) async { // dart:io's Platform.environment is read-only at the language // level but readable. Tests can't mutate it, so we mutate the // process env via Process.environment-equivalent: spawn a child // process. That's overkill — the simpler path is to override the // env vars our function reads by setting them BEFORE the test // runs. flutter_test exposes nothing for that. Easiest: skip if // we can't influence the path. // // Instead, the paths.dart functions are pure — we pass the // workspace root in. The XDG_RUNTIME_DIR fallback only matters // for the directory side. We rely on whatever XDG_RUNTIME_DIR is // set in the test runner's env; tests assert relative shape, not // absolute paths. return body(); } group('IpcServer (T-124)', () { test('start binds the socket at the per-workspace path', () async { await withXdg(() async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); expect(server.isRunning, isTrue); expect(server.socketPath, endsWith('.sock')); expect(File(server.socketPath).statSync().type, FileSystemEntityType.unixDomainSock); }); }); test('socket file has mode 0600 and parent dir has 0700', () async { await withXdg(() async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); final sock = File(server.socketPath).statSync(); final parent = Directory(File(server.socketPath).parent.path).statSync(); // FileStat.mode masks to the low 9 bits we care about. expect(sock.mode & 0x1ff, 0x180, reason: 'socket mode != 0600'); expect(parent.mode & 0x1ff, 0x1c0, reason: 'parent mode != 0700'); }); }); test('a connected client gets a JSON-line response to ping', () async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); final reply = await _roundTrip(server.socketPath, IpcRequest(id: '1', cmd: 'ping')); expect(reply.ok, isTrue); expect(reply.id, '1'); expect(reply.data['pong'], isTrue); }); test('unknown command returns a notFound IpcError', () async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); final reply = await _roundTrip(server.socketPath, IpcRequest(id: '2', cmd: 'no.such.cmd')); expect(reply.ok, isFalse); expect(reply.error?.kind, IpcErrorKind.notFound); }); test('malformed JSON line surfaces a userError', () async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); final c = await Socket.connect( InternetAddress(server.socketPath, type: InternetAddressType.unix), 0, ); c.write('{not json\n'); await c.flush(); final line = await c.cast>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2)); await c.close(); final reply = IpcMessage.decode(line) as IpcResponse; expect(reply.ok, isFalse); expect(reply.error?.kind, IpcErrorKind.userError); }); test('multi-connection accept loop: two simultaneous clients both get replies', () async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); final results = await Future.wait([ _roundTrip(server.socketPath, IpcRequest(id: 'a', cmd: 'ping')), _roundTrip(server.socketPath, IpcRequest(id: 'b', cmd: 'version')), ]); expect(results[0].id, 'a'); expect(results[0].ok, isTrue); expect(results[1].id, 'b'); expect(results[1].ok, isTrue); }); test('stop removes the socket file and lets a fresh server bind the same path', () async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); final path = server.socketPath; await server.stop(); expect(File(path).existsSync(), isFalse); // Same path can be re-bound on a new server. server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); expect(server.socketPath, path); expect(File(path).existsSync(), isTrue); }); test('stale socket file left behind is unlinked on start', () async { final path = workspaceSocketPath(workRoot); Directory(File(path).parent.path).createSync(recursive: true); File(path).writeAsBytesSync([]); // stale node, not a live listener server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); expect(server.isRunning, isTrue); }); test('refuses to clobber a live listener on the same path', () async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); final other = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); expect(() async => other.start(), throwsA(isA())); }); test('start is idempotent: second call on the same instance is a no-op', () async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.start(); await server.start(); expect(server.isRunning, isTrue); }); test('stop on a never-started server is a no-op', () async { server = IpcServer(dispatcher: dispatcher, workspaceRoot: workRoot, log: _silentLog()); await server.stop(); expect(server.isRunning, isFalse); }); }); } Logger _silentLog() => Logger(minLevel: LogLevel.error, sinks: const []); Future _roundTrip(String socketPath, IpcRequest req) async { final c = await Socket.connect( InternetAddress(socketPath, type: InternetAddressType.unix), 0, ); c.write('${req.encode()}\n'); await c.flush(); final line = await c.cast>().transform(utf8.decoder).transform(const LineSplitter()).first.timeout(const Duration(seconds: 2)); await c.close(); return IpcMessage.decode(line) as IpcResponse; }