// Copyright (c) 2023, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. @Timeout(Duration(minutes: 2)) library; import 'dart:async'; import 'dart:convert'; import 'package:dwds/data/devtools_request.dart'; import 'package:dwds/data/extension_request.dart'; import 'package:dwds/data/serializers.dart'; import 'package:dwds/src/debugging/execution_context.dart'; import 'package:dwds/src/servers/extension_debugger.dart'; import 'package:test/test.dart'; import 'package:test_common/logging.dart'; import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart'; import 'fixtures/fakes.dart'; void main() async { const debug = false; group('ExecutionContext', () { setUpAll(() { setCurrentLogWriter(debug: debug); }); TestDebuggerConnection? debugger; TestDebuggerConnection getDebugger() => debugger!; setUp(() async { setCurrentLogWriter(debug: debug); debugger = TestDebuggerConnection(); }); tearDown(() async { await debugger?.close(); }); test('is created on devtools request', () async { final debugger = getDebugger(); await debugger.createDebuggerExecutionContext(TestContextId.dartDefault); // Expect the context ID to be set. expect(await debugger.defaultContextId(), TestContextId.dartDefault); }); test('clears context ID', () async { final debugger = getDebugger(); await debugger.createDebuggerExecutionContext(TestContextId.dartDefault); debugger.sendContextsClearedEvent(); // Expect non-dart context. expect(await debugger.defaultContextId(), TestContextId.none); }); test('finds dart context ID', () async { final debugger = getDebugger(); await debugger.createDebuggerExecutionContext(TestContextId.none); debugger.sendContextCreatedEvent(TestContextId.dartNormal); // Expect dart context. expect(await debugger.dartContextId(), TestContextId.dartNormal); }); test('does not find dart context ID if not available', () async { final debugger = getDebugger(); await debugger.createDebuggerExecutionContext(TestContextId.none); // No context IDs received yet. expect(await debugger.defaultContextId(), TestContextId.none); debugger.sendContextCreatedEvent(TestContextId.dartLate); // Expect no dart context. // This mocks injected client still loading. expect(await debugger.noContextId(), TestContextId.none); // Expect dart context. // This mocks injected client loading later for previously // received context ID. expect(await debugger.dartContextId(), TestContextId.dartLate); }); test('works with stale contexts', () async { final debugger = getDebugger(); await debugger.createDebuggerExecutionContext(TestContextId.none); debugger.sendContextCreatedEvent(TestContextId.stale); // Expect no dart context. expect(await debugger.noContextId(), TestContextId.none); debugger.sendContextsClearedEvent(); debugger.sendContextCreatedEvent(TestContextId.dartNormal); // Expect dart context. expect(await debugger.dartContextId(), TestContextId.dartNormal); }); }); } enum TestContextId { none, dartDefault, dartNormal, dartLate, nonDart, stale; factory TestContextId.from(int? value) { return switch (value) { null => none, 0 => dartDefault, 1 => dartNormal, 2 => dartLate, 3 => nonDart, 4 => stale, _ => throw StateError('$value is not a TestContextId'), }; } int? get id { return switch (this) { none => null, dartDefault => 0, dartNormal => 1, dartLate => 2, nonDart => 3, stale => 4, }; } } class TestExtensionDebugger extends ExtensionDebugger { TestExtensionDebugger(FakeSseConnection super.sseConnection); @override Future sendCommand( String command, { Map? params, }) { final id = params?['contextId']; final response = super.sendCommand(command, params: params); /// Mock stale contexts that cause the evaluation to throw. if (command == 'Runtime.evaluate' && TestContextId.from(id) == TestContextId.stale) { throw Exception('Stale execution context'); } return response; } } class TestDebuggerConnection { late final TestExtensionDebugger extensionDebugger; late final FakeSseConnection connection; int _evaluateRequestId = 0; TestDebuggerConnection() { connection = FakeSseConnection(); extensionDebugger = TestExtensionDebugger(connection); } /// Create a new execution context in the debugger. Future createDebuggerExecutionContext(TestContextId contextId) { _sendDevToolsRequest(contextId: contextId.id); return _executionContext(); } /// Flush the streams and close debugger connection. Future close() async { unawaited(connection.controllerOutgoing.stream.any((e) => false)); unawaited(extensionDebugger.devToolsRequestStream.any((e) => false)); await connection.controllerIncoming.sink.close(); await connection.controllerOutgoing.sink.close(); await extensionDebugger.close(); } /// Return the initial context ID from the DevToolsRequest. Future defaultContextId() async { // Give the previous events time to propagate. await Future.delayed(Duration(milliseconds: 100)); return TestContextId.from(await extensionDebugger.executionContext!.id); } /// Mock receiving dart context ID in the execution context. /// /// Note: dart context is detected by evaluation of /// `window.$dartAppInstanceId` in that context returning /// a non-null value. Future dartContextId() async { // Try getting execution id. final executionContextId = extensionDebugger.executionContext!.id; // Give it time to send the evaluate request. await Future.delayed(Duration(milliseconds: 100)); // Respond to the evaluate request. _sendEvaluationResponse({ 'result': {'value': 'dart'}, }); return TestContextId.from(await executionContextId); } /// Mock receiving non-dart context ID in the execution context. /// /// Note: dart context is detected by evaluation of /// `window.$dartAppInstanceId` in that context returning /// a null value. Future noContextId() async { // Try getting execution id. final executionContextId = extensionDebugger.executionContext!.id; // Give it time to send the evaluate request. await Future.delayed(Duration(milliseconds: 100)); // Respond to the evaluate request. _sendEvaluationResponse({ 'result': {'value': null}, }); return TestContextId.from(await executionContextId); } /// Send `Runtime.executionContextsCleared` event to the execution /// context in the extension debugger. void sendContextsClearedEvent() { final extensionEvent = ExtensionEvent( (b) => b ..method = jsonEncode('Runtime.executionContextsCleared') ..params = jsonEncode({}), ); connection.controllerIncoming.sink.add( jsonEncode(serializers.serialize(extensionEvent)), ); } /// Send `Runtime.executionContextCreated` event to the execution /// context in the extension debugger. void sendContextCreatedEvent(TestContextId contextId) { final extensionEvent = ExtensionEvent( (b) => b ..method = jsonEncode('Runtime.executionContextCreated') ..params = jsonEncode({ 'context': {'id': '${contextId.id}'}, }), ); connection.controllerIncoming.sink.add( jsonEncode(serializers.serialize(extensionEvent)), ); } void _sendEvaluationResponse(Map response) { // Respond to the evaluate request. final extensionResponse = ExtensionResponse( (b) => b ..result = jsonEncode(response) ..id = _evaluateRequestId++ ..success = true, ); connection.controllerIncoming.sink.add( jsonEncode(serializers.serialize(extensionResponse)), ); } void _sendDevToolsRequest({int? contextId}) { final devToolsRequest = DevToolsRequest( (b) => b ..contextId = contextId ..appId = 'app' ..instanceId = '0', ); connection.controllerIncoming.sink.add( jsonEncode(serializers.serialize(devToolsRequest)), ); } Future _executionContext() async { final executionContext = await _waitForExecutionContext().timeout( const Duration(milliseconds: 100), onTimeout: () { expect(fail, 'Timeout getting execution context'); return null; }, ); expect(executionContext, isNotNull); } Future _waitForExecutionContext() async { while (extensionDebugger.executionContext == null) { await Future.delayed(Duration(milliseconds: 20)); } return extensionDebugger.executionContext; } }