diff --git a/dwds/debug_extension_mv3/pubspec.yaml b/dwds/debug_extension_mv3/pubspec.yaml index 96ddfa2bf..e16dbcae3 100644 --- a/dwds/debug_extension_mv3/pubspec.yaml +++ b/dwds/debug_extension_mv3/pubspec.yaml @@ -9,12 +9,14 @@ environment: sdk: '>=2.18.0 <3.0.0' dependencies: + built_value: ^8.3.0 js: ^0.6.1+1 dev_dependencies: build: ^2.0.0 build_web_compilers: ^3.0.0 build_runner: ^2.0.6 + built_value_generator: ^8.3.0 dwds: ^16.0.0 dependency_overrides: diff --git a/dwds/debug_extension_mv3/web/background.dart b/dwds/debug_extension_mv3/web/background.dart index df06cdc93..4bd45d9a5 100644 --- a/dwds/debug_extension_mv3/web/background.dart +++ b/dwds/debug_extension_mv3/web/background.dart @@ -11,7 +11,9 @@ import 'package:dwds/data/debug_info.dart'; import 'package:js/js.dart'; import 'chrome_api.dart'; +import 'data_types.dart'; import 'messaging.dart'; +import 'storage.dart'; import 'web_api.dart'; void main() { @@ -27,7 +29,10 @@ void _registerListeners() { Future _startDebugSession(Tab _) async { // TODO(elliette): Start a debug session instead. - await _createTab('https://dart.dev/'); + final devToolsOpener = await fetchStorageObject( + type: StorageObject.devToolsOpener); + await _createTab('https://dart.dev/', + inNewWindow: devToolsOpener?.newWindow ?? false); } void _handleRuntimeMessages( @@ -59,7 +64,14 @@ Future _getTab() async { return tabs.isNotEmpty ? tabs.first : null; } -Future _createTab(String url) async { +Future _createTab(String url, {bool inNewWindow = false}) async { + if (inNewWindow) { + final windowPromise = chrome.windows.create( + WindowInfo(focused: true, url: url), + ); + final windowObj = await promiseToFuture(windowPromise); + return windowObj.tabs.first; + } final tabPromise = chrome.tabs.create(TabInfo( active: true, url: url, diff --git a/dwds/debug_extension_mv3/web/chrome_api.dart b/dwds/debug_extension_mv3/web/chrome_api.dart index 3b744417e..49a1bacc8 100644 --- a/dwds/debug_extension_mv3/web/chrome_api.dart +++ b/dwds/debug_extension_mv3/web/chrome_api.dart @@ -12,7 +12,9 @@ external Chrome get chrome; class Chrome { external Action get action; external Runtime get runtime; + external Storage get storage; external Tabs get tabs; + external Windows get windows; } /// chrome.action APIs @@ -67,6 +69,23 @@ class MessageSender { external factory MessageSender({String? id, String? url, Tab? tab}); } +/// chrome.storage APIs +/// https://developer.chrome.com/docs/extensions/reference/storage + +@JS() +@anonymous +class Storage { + external StorageArea get local; +} + +@JS() +@anonymous +class StorageArea { + external Object get(List keys, void Function(Object result) callback); + + external Object set(Object items, void Function()? callback); +} + /// chrome.tabs APIs /// https://developer.chrome.com/docs/extensions/reference/tabs @@ -102,3 +121,27 @@ class Tab { external int get id; external String get url; } + +/// chrome.windows APIs +/// https://developer.chrome.com/docs/extensions/reference/windows + +@JS() +@anonymous +class Windows { + external Object create(WindowInfo? createData); +} + +@JS() +@anonymous +class WindowInfo { + external bool? get focused; + external String? get url; + external factory WindowInfo({bool? focused, String? url}); +} + +@JS() +@anonymous +class WindowObj { + external int get id; + external List get tabs; +} diff --git a/dwds/debug_extension_mv3/web/data_serializers.dart b/dwds/debug_extension_mv3/web/data_serializers.dart new file mode 100644 index 000000000..36920270c --- /dev/null +++ b/dwds/debug_extension_mv3/web/data_serializers.dart @@ -0,0 +1,15 @@ +// Copyright (c) 2022, 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. + +import 'package:built_value/serializer.dart'; + +import 'data_types.dart'; + +part 'data_serializers.g.dart'; + +/// Serializers for all the data types used in the Dart Debug Extension. +@SerializersFor([ + DevToolsOpener, +]) +final Serializers serializers = _$serializers; diff --git a/dwds/debug_extension_mv3/web/data_serializers.g.dart b/dwds/debug_extension_mv3/web/data_serializers.g.dart new file mode 100644 index 000000000..0b4f692d8 --- /dev/null +++ b/dwds/debug_extension_mv3/web/data_serializers.g.dart @@ -0,0 +1,12 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_serializers.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +Serializers _$serializers = + (new Serializers().toBuilder()..add(DevToolsOpener.serializer)).build(); + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,no_leading_underscores_for_local_identifiers,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new,unnecessary_lambdas diff --git a/dwds/debug_extension_mv3/web/data_types.dart b/dwds/debug_extension_mv3/web/data_types.dart new file mode 100644 index 000000000..69df9d70a --- /dev/null +++ b/dwds/debug_extension_mv3/web/data_types.dart @@ -0,0 +1,21 @@ +// Copyright (c) 2022, 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. + +import 'package:built_value/built_value.dart'; +import 'package:built_value/serializer.dart'; + +part 'data_types.g.dart'; + +abstract class DevToolsOpener + implements Built { + static Serializer get serializer => + _$devToolsOpenerSerializer; + + factory DevToolsOpener([Function(DevToolsOpenerBuilder) updates]) = + _$DevToolsOpener; + + DevToolsOpener._(); + + bool get newWindow; +} diff --git a/dwds/debug_extension_mv3/web/data_types.g.dart b/dwds/debug_extension_mv3/web/data_types.g.dart new file mode 100644 index 000000000..cfdbbe5f5 --- /dev/null +++ b/dwds/debug_extension_mv3/web/data_types.g.dart @@ -0,0 +1,136 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'data_types.dart'; + +// ************************************************************************** +// BuiltValueGenerator +// ************************************************************************** + +Serializer _$devToolsOpenerSerializer = + new _$DevToolsOpenerSerializer(); + +class _$DevToolsOpenerSerializer + implements StructuredSerializer { + @override + final Iterable types = const [DevToolsOpener, _$DevToolsOpener]; + @override + final String wireName = 'DevToolsOpener'; + + @override + Iterable serialize(Serializers serializers, DevToolsOpener object, + {FullType specifiedType = FullType.unspecified}) { + final result = [ + 'newWindow', + serializers.serialize(object.newWindow, + specifiedType: const FullType(bool)), + ]; + + return result; + } + + @override + DevToolsOpener deserialize( + Serializers serializers, Iterable serialized, + {FullType specifiedType = FullType.unspecified}) { + final result = new DevToolsOpenerBuilder(); + + final iterator = serialized.iterator; + while (iterator.moveNext()) { + final key = iterator.current! as String; + iterator.moveNext(); + final Object? value = iterator.current; + switch (key) { + case 'newWindow': + result.newWindow = serializers.deserialize(value, + specifiedType: const FullType(bool))! as bool; + break; + } + } + + return result.build(); + } +} + +class _$DevToolsOpener extends DevToolsOpener { + @override + final bool newWindow; + + factory _$DevToolsOpener([void Function(DevToolsOpenerBuilder)? updates]) => + (new DevToolsOpenerBuilder()..update(updates))._build(); + + _$DevToolsOpener._({required this.newWindow}) : super._() { + BuiltValueNullFieldError.checkNotNull( + newWindow, r'DevToolsOpener', 'newWindow'); + } + + @override + DevToolsOpener rebuild(void Function(DevToolsOpenerBuilder) updates) => + (toBuilder()..update(updates)).build(); + + @override + DevToolsOpenerBuilder toBuilder() => + new DevToolsOpenerBuilder()..replace(this); + + @override + bool operator ==(Object other) { + if (identical(other, this)) return true; + return other is DevToolsOpener && newWindow == other.newWindow; + } + + @override + int get hashCode { + return $jf($jc(0, newWindow.hashCode)); + } + + @override + String toString() { + return (newBuiltValueToStringHelper(r'DevToolsOpener') + ..add('newWindow', newWindow)) + .toString(); + } +} + +class DevToolsOpenerBuilder + implements Builder { + _$DevToolsOpener? _$v; + + bool? _newWindow; + bool? get newWindow => _$this._newWindow; + set newWindow(bool? newWindow) => _$this._newWindow = newWindow; + + DevToolsOpenerBuilder(); + + DevToolsOpenerBuilder get _$this { + final $v = _$v; + if ($v != null) { + _newWindow = $v.newWindow; + _$v = null; + } + return this; + } + + @override + void replace(DevToolsOpener other) { + ArgumentError.checkNotNull(other, 'other'); + _$v = other as _$DevToolsOpener; + } + + @override + void update(void Function(DevToolsOpenerBuilder)? updates) { + if (updates != null) updates(this); + } + + @override + DevToolsOpener build() => _build(); + + _$DevToolsOpener _build() { + final _$result = _$v ?? + new _$DevToolsOpener._( + newWindow: BuiltValueNullFieldError.checkNotNull( + newWindow, r'DevToolsOpener', 'newWindow')); + replace(_$result); + return _$result; + } +} + +// ignore_for_file: always_put_control_body_on_new_line,always_specify_types,annotate_overrides,avoid_annotating_with_dynamic,avoid_as,avoid_catches_without_on_clauses,avoid_returning_this,deprecated_member_use_from_same_package,lines_longer_than_80_chars,no_leading_underscores_for_local_identifiers,omit_local_variable_types,prefer_expression_function_bodies,sort_constructors_first,test_types_in_equals,unnecessary_const,unnecessary_new,unnecessary_lambdas diff --git a/dwds/debug_extension_mv3/web/manifest.json b/dwds/debug_extension_mv3/web/manifest.json index 2492e4604..91698a367 100644 --- a/dwds/debug_extension_mv3/web/manifest.json +++ b/dwds/debug_extension_mv3/web/manifest.json @@ -6,9 +6,10 @@ "default_icon": "dart_dev.png" }, "permissions": [ + "debugger", "scripting", - "tabs", - "debugger" + "storage", + "tabs" ], "host_permissions": [ "" @@ -37,5 +38,6 @@ ], "run_at": "document_end" } - ] -} \ No newline at end of file + ], + "options_page": "settings.html" +} diff --git a/dwds/debug_extension_mv3/web/settings.dart b/dwds/debug_extension_mv3/web/settings.dart new file mode 100644 index 000000000..0f6d19b5a --- /dev/null +++ b/dwds/debug_extension_mv3/web/settings.dart @@ -0,0 +1,59 @@ +// Copyright (c) 2022, 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. + +@JS() +library settings; + +import 'dart:async'; +import 'dart:html'; + +import 'package:js/js.dart'; + +import 'data_types.dart'; +import 'storage.dart'; + +void main() { + _registerListeners(); +} + +void _registerListeners() { + document.addEventListener('DOMContentLoaded', _updateSettingsFromStorage); + final saveButton = document.getElementById('saveButton') as ButtonElement; + saveButton.addEventListener('click', _saveSettingsToStorage); +} + +void _updateSettingsFromStorage(Event _) async { + final devToolsOpener = await fetchStorageObject( + type: StorageObject.devToolsOpener); + final openInNewWindow = devToolsOpener?.newWindow ?? false; + _getRadioButton('windowOpt').checked = openInNewWindow; + _getRadioButton('tabOpt').checked = !openInNewWindow; +} + +void _saveSettingsToStorage(Event event) async { + event.preventDefault(); + final form = document.querySelector("form") as FormElement; + final data = FormData(form); + final devToolsOpenerValue = data.get('devToolsOpener') as String; + await setStorageObject( + type: StorageObject.devToolsOpener, + value: DevToolsOpener( + (b) => b..newWindow = devToolsOpenerValue == 'window')); + _showSavedMsg(); +} + +void _showSavedMsg() { + final msgContainer = document.getElementById('savedMsgEmpty'); + if (msgContainer == null) return; + msgContainer.id = 'savedMsg'; + msgContainer.innerHtml = 'Saved!'; + Timer(Duration(seconds: 3), () { + msgContainer.id = 'savedMsgEmpty'; + msgContainer.innerHtml = ''; + }); +} + +RadioButtonInputElement _getRadioButton(String id) { + return document.getElementById(id) as RadioButtonInputElement; +} diff --git a/dwds/debug_extension_mv3/web/settings.html b/dwds/debug_extension_mv3/web/settings.html new file mode 100644 index 000000000..9de14a4e9 --- /dev/null +++ b/dwds/debug_extension_mv3/web/settings.html @@ -0,0 +1,55 @@ + + + + + Dart Debug Extension Settings + + + + + + + +
+ +
+
+
+ Dart Debug Extension Settings +
+
+
+ +
+
+
+
+
Open DevTools in a:
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + + + + + diff --git a/dwds/debug_extension_mv3/web/storage.dart b/dwds/debug_extension_mv3/web/storage.dart new file mode 100644 index 000000000..7e8aa5f2b --- /dev/null +++ b/dwds/debug_extension_mv3/web/storage.dart @@ -0,0 +1,59 @@ +// Copyright (c) 2022, 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. + +@JS() +library storage; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_util'; + +import 'package:js/js.dart'; + +import 'chrome_api.dart'; +import 'data_serializers.dart'; + +enum StorageObject { + devToolsOpener; + + String get keyName { + switch (this) { + case StorageObject.devToolsOpener: + return 'devToolsOpener'; + } + } +} + +Future setStorageObject({ + required StorageObject type, + required T value, + void Function()? callback, +}) { + final storageKey = type.keyName; + final json = jsonEncode(serializers.serialize(value)); + final storageObj = {storageKey: json}; + final completer = Completer(); + chrome.storage.local.set(jsify(storageObj), allowInterop(() { + if (callback != null) { + callback(); + } + completer.complete(true); + })); + return completer.future; +} + +Future fetchStorageObject({required StorageObject type}) { + final storageKey = type.keyName; + final completer = Completer(); + chrome.storage.local.get([storageKey], allowInterop((Object storageObj) { + final json = getProperty(storageObj, storageKey) as String?; + if (json == null) { + completer.complete(null); + } else { + final value = serializers.deserialize(jsonDecode(json)) as T; + completer.complete(value); + } + })); + return completer.future; +} diff --git a/dwds/test/puppeteer/extension_test.dart b/dwds/test/puppeteer/extension_test.dart index a48ef1c6a..e97fcc5b2 100644 --- a/dwds/test/puppeteer/extension_test.dart +++ b/dwds/test/puppeteer/extension_test.dart @@ -2,62 +2,172 @@ // 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. -// TODO(elliette): Enable on Windows, see https://github.com/dart-lang/webdev/issues/1724. @OnPlatform({ - 'windows': Skip('https://github.com/dart-lang/webdev/issues/711'), + // TODO(elliette): Enable on Windows. + 'windows': Skip('https://github.com/dart-lang/webdev/issues/1724'), + // TODO(elliette): Enable on Linux. + 'linux': Skip('https://github.com/dart-lang/webdev/issues/1787'), }) +@Timeout(Duration(seconds: 60)) +import 'dart:async'; import 'dart:io'; import 'package:puppeteer/puppeteer.dart'; +import 'package:path/path.dart' as p; import 'package:test/test.dart'; import '../fixtures/context.dart'; -late Target serviceWorkerTarget; -late Browser browser; - final context = TestContext(); void main() async { - for (var useSse in [true, false]) { - group(useSse ? 'SSE' : 'WebSockets', () { - setUpAll(() async { - // TODO(elliette): Only start a TestServer, that way we can get rid of - // the launchChrome parameter: https://github.com/dart-lang/webdev/issues/1779 - await context.setUp(launchChrome: false, useSse: useSse); - final extensionPath = await _buildDebugExtension(); - browser = await puppeteer.launch( - headless: false, - args: [ - '--load-extension=$extensionPath', - '--disable-extensions-except=$extensionPath', - '--disable-features=DialMediaRouteProvider', - ], - ); + late Target serviceWorkerTarget; + late Browser browser; + late String extensionPath; - serviceWorkerTarget = await browser - .waitForTarget((target) => target.type == 'service_worker'); - }); + group('MV3 Debug Extension', () { + setUpAll(() async { + extensionPath = await _buildDebugExtension(); + }); - tearDownAll(() async { - await browser.close(); - }); + for (var useSse in [true, false]) { + group(useSse ? 'with SSE' : 'with WebSockets', () { + setUpAll(() async { + // TODO(elliette): Only start a TestServer, that way we can get rid of + // the launchChrome parameter: https://github.com/dart-lang/webdev/issues/1779 + await context.setUp(launchChrome: false, useSse: useSse); + browser = await puppeteer.launch( + headless: false, + timeout: Duration(seconds: 60), + args: [ + '--load-extension=$extensionPath', + '--disable-extensions-except=$extensionPath', + '--disable-features=DialMediaRouteProvider', + ], + ); - test('Can use the MV3 Dart Debug Extension', () async { - final appTab = await browser.newPage(); - await appTab.goto(context.appUrl, - wait: Until.all([Until.domContentLoaded])); - await appTab.bringToFront(); - final worker = (await serviceWorkerTarget.worker)!; - await worker.evaluate(clickIconJs); - // Verify that the extension opened the Dart docs: - final targetUrls = browser.targets.map((target) => target.url); - expect(targetUrls, contains('https://dart.dev/')); + serviceWorkerTarget = await browser + .waitForTarget((target) => target.type == 'service_worker'); + }); + + tearDownAll(() async { + await browser.close(); + }); + + test( + 'can configure opening DevTools in a tab/window with extension settings', + () async { + final appUrl = context.appUrl; + // TODO(elliette): Replace with the DevTools url. + final devToolsUrl = 'https://dart.dev/'; + final windowIdForAppJs = _windowIdForTabJs(appUrl); + final windowIdForDevToolsJs = _windowIdForTabJs(devToolsUrl); + // Navigate to the Dart app: + await _navigateToPage(browser, url: appUrl, isNew: true); + // Click on the Dart Debug Extension icon: + final worker = (await serviceWorkerTarget.worker)!; + // Note: The following delay is required to reduce flakiness (it makes + // sure the execution context is ready): + await Future.delayed(Duration(seconds: 1)); + await worker.evaluate(_clickIconJs); + // Verify the extension opened the Dart docs in the same window: + var devToolsTabTarget = await browser + .waitForTarget((target) => target.url.contains(devToolsUrl)); + var devToolsWindowId = + (await worker.evaluate(windowIdForDevToolsJs)) as int?; + var appWindowId = (await worker.evaluate(windowIdForAppJs)) as int?; + expect(devToolsWindowId == appWindowId, isTrue); + // Close the DevTools tab: + var devToolsTab = await devToolsTabTarget.page; + await devToolsTab.close(); + // Navigate to the extension settings page: + final extensionOrigin = _getExtensionOrigin(browser); + final settingsTab = await _navigateToPage( + browser, + url: '$extensionOrigin/settings.html', + isNew: true, + ); + // Set the settings to open DevTools in a new window: + await settingsTab.tap('#windowOpt'); + await settingsTab.tap('#saveButton'); + // Wait for the saved message to verify settings have been saved: + await settingsTab.waitForSelector('#savedMsg'); + // Close the settings tab: + await settingsTab.close(); + // Navigate to the Dart app: + await _navigateToPage(browser, url: appUrl); + // Click on the Dart Debug Extension icon: + await worker.evaluate(_clickIconJs); + // Verify the extension opened DevTools in a different window: + devToolsTabTarget = await browser + .waitForTarget((target) => target.url.contains(devToolsUrl)); + devToolsWindowId = + (await worker.evaluate(windowIdForDevToolsJs)) as int?; + appWindowId = (await worker.evaluate(windowIdForAppJs)) as int?; + expect(devToolsWindowId == appWindowId, isFalse); + // Close the DevTools tab: + devToolsTab = await devToolsTabTarget.page; + await devToolsTab.close(); + }); }); - }); + } + }); +} + +Iterable _getUrlsInBrowser(Browser browser) { + return browser.targets.map((target) => target.url); +} + +Future _getPageForUrl(Browser browser, {required String url}) { + final pageTarget = browser.targets.firstWhere((target) => target.url == url); + return pageTarget.page; +} + +String _getExtensionOrigin(Browser browser) { + final chromeExtension = 'chrome-extension:'; + final extensionUrl = _getUrlsInBrowser(browser) + .firstWhere((url) => url.contains(chromeExtension)); + final urlSegments = p.split(extensionUrl); + final extensionId = urlSegments[urlSegments.indexOf(chromeExtension) + 1]; + return '$chromeExtension//$extensionId'; +} + +Future _navigateToPage( + Browser browser, { + required String url, + bool isNew = false, +}) async { + final page = isNew + ? await browser.newPage() + : await _getPageForUrl( + browser, + url: url, + ); + if (isNew) { + await page.goto(url, wait: Until.domContentLoaded); } + await page.bringToFront(); + return page; } +String _windowIdForTabJs(String tabUrl) { + return ''' + async () => { + const matchingTabs = await chrome.tabs.query({ url: "$tabUrl" }); + const tab = matchingTabs[0]; + return tab.windowId; + } +'''; +} + +final _clickIconJs = ''' + async () => { + const activeTabs = await chrome.tabs.query({ active: true }); + const tab = activeTabs[0]; + chrome.action.onClicked.dispatch(tab); + } +'''; + Future _buildDebugExtension() async { final currentDir = Directory.current.path; if (!currentDir.endsWith('dwds')) { @@ -73,11 +183,3 @@ Future _buildDebugExtension() async { ); return '$extensionDir/compiled'; } - -final clickIconJs = ''' - async () => { - const activeTabs = await chrome.tabs.query({ active: true }); - const tab = activeTabs[0]; - chrome.action.onClicked.dispatch(tab); - } -''';