diff --git a/dwds/debug_extension_mv3/web/chrome_api.dart b/dwds/debug_extension_mv3/web/chrome_api.dart index e9025ac8d..b3468f7f3 100644 --- a/dwds/debug_extension_mv3/web/chrome_api.dart +++ b/dwds/debug_extension_mv3/web/chrome_api.dart @@ -2,6 +2,8 @@ // 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 'dart:html'; + import 'package:js/js.dart'; @JS() @@ -12,6 +14,7 @@ external Chrome get chrome; class Chrome { external Action get action; external Debugger get debugger; + external Devtools get devtools; external Notifications get notifications; external Runtime get runtime; external Scripting get scripting; @@ -87,6 +90,50 @@ class Debuggee { external factory Debuggee({int tabId, String? extensionId, String? targetId}); } +/// chrome.devtools APIs: + +@JS() +@anonymous +class Devtools { + // https://developer.chrome.com/docs/extensions/reference/devtools_inspectedWindow + external InspectedWindow get inspectedWindow; + + // https://developer.chrome.com/docs/extensions/reference/devtools_panels/ + external Panels get panels; +} + +@JS() +@anonymous +class InspectedWindow { + external int get tabId; +} + +@JS() +@anonymous +class Panels { + external void create(String title, String iconPath, String pagePath, + void Function(ExtensionPanel)? callback); +} + +@JS() +@anonymous +class ExtensionPanel { + external OnHiddenHandler get onHidden; + external OnShownHandler get onShown; +} + +@JS() +@anonymous +class OnHiddenHandler { + external void addListener(void Function() callback); +} + +@JS() +@anonymous +class OnShownHandler { + external void addListener(void Function(Window window) callback); +} + /// chrome.notification APIs: /// https://developer.chrome.com/docs/extensions/reference/notifications @@ -211,6 +258,8 @@ class Storage { external StorageArea get local; external StorageArea get session; + + external OnChangedHandler get onChanged; } @JS() @@ -223,6 +272,14 @@ class StorageArea { external Object remove(List keys, void Function()? callback); } +@JS() +@anonymous +class OnChangedHandler { + external void addListener( + void Function(Object changes, String areaName) callback, + ); +} + /// chrome.tabs APIs /// https://developer.chrome.com/docs/extensions/reference/tabs diff --git a/dwds/debug_extension_mv3/web/devtools.dart b/dwds/debug_extension_mv3/web/devtools.dart new file mode 100644 index 000000000..0b0b4674f --- /dev/null +++ b/dwds/debug_extension_mv3/web/devtools.dart @@ -0,0 +1,72 @@ +// 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 devtools; + +import 'dart:html'; +import 'package:js/js.dart'; +import 'package:dwds/data/debug_info.dart'; + +import 'chrome_api.dart'; +import 'logger.dart'; +import 'storage.dart'; +import 'utils.dart'; + +bool panelsExist = false; + +void main() async { + _registerListeners(); + _maybeCreatePanels(); +} + +void _registerListeners() { + chrome.storage.onChanged.addListener(allowInterop(( + Object _, + String storageArea, + ) { + if (storageArea != 'session') return; + _maybeCreatePanels(); + })); +} + +void _maybeCreatePanels() async { + if (panelsExist) return; + final tabId = chrome.devtools.inspectedWindow.tabId; + final debugInfo = await fetchStorageObject( + type: StorageObject.debugInfo, + tabId: tabId, + ); + if (debugInfo == null) return; + final isInternalBuild = debugInfo.isInternalBuild ?? false; + if (!isInternalBuild) return; + // Create a Debugger panel for all internal apps: + chrome.devtools.panels.create( + isDevMode() ? '[DEV] Dart Debugger' : 'Dart Debugger', + '', + 'panel.html', + allowInterop((ExtensionPanel panel) => _onPanelAdded(panel, debugInfo)), + ); + // Create an inspector panel for internal Flutter apps: + final isFlutterApp = debugInfo.isFlutterApp ?? false; + if (isFlutterApp) { + chrome.devtools.panels.create( + isDevMode() ? '[DEV] Flutter Inspector' : 'Flutter Inspector', + '', + 'panel.html', + allowInterop((ExtensionPanel panel) => _onPanelAdded(panel, debugInfo)), + ); + } + panelsExist = true; +} + +void _onPanelAdded(ExtensionPanel panel, DebugInfo debugInfo) { + panel.onShown.addListener(allowInterop((Window window) { + if (window.origin != debugInfo.appOrigin) { + debugWarn('Page at ${window.origin} is no longer a Dart app.'); + // TODO(elliette): Display banner that panel is not applicable. See: + // https://stackoverflow.com/questions/18927147/how-to-close-destroy-chrome-devtools-extensionpanel-programmatically + } + })); +} diff --git a/dwds/debug_extension_mv3/web/devtools.html b/dwds/debug_extension_mv3/web/devtools.html new file mode 100644 index 000000000..50038348f --- /dev/null +++ b/dwds/debug_extension_mv3/web/devtools.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dwds/debug_extension_mv3/web/manifest.json b/dwds/debug_extension_mv3/web/manifest.json index fdec60af5..333e43e07 100644 --- a/dwds/debug_extension_mv3/web/manifest.json +++ b/dwds/debug_extension_mv3/web/manifest.json @@ -2,6 +2,7 @@ "name": "MV3 Dart Debug Extension", "version": "1.0", "manifest_version": 3, + "devtools_page": "devtools.html", "action": { "default_icon": "dart_dev.png" }, diff --git a/dwds/debug_extension_mv3/web/panel.html b/dwds/debug_extension_mv3/web/panel.html new file mode 100644 index 000000000..f7e8d6924 --- /dev/null +++ b/dwds/debug_extension_mv3/web/panel.html @@ -0,0 +1,11 @@ + + + + + + + +

Panel

+ + + diff --git a/dwds/test/fixtures/context.dart b/dwds/test/fixtures/context.dart index 7c0562665..2fbaef572 100644 --- a/dwds/test/fixtures/context.dart +++ b/dwds/test/fixtures/context.dart @@ -162,6 +162,8 @@ class TestContext { SdkConfigurationProvider? sdkConfigurationProvider, bool useDebuggerModuleNames = false, bool launchChrome = true, + bool isFlutterApp = false, + bool isInternalBuild = false, }) async { sdkConfigurationProvider ??= DefaultSdkConfigurationProvider(); @@ -370,6 +372,8 @@ class TestContext { expressionCompiler, spawnDds, ddcService, + isFlutterApp, + isInternalBuild, ); _appUrl = basePath.isEmpty diff --git a/dwds/test/fixtures/server.dart b/dwds/test/fixtures/server.dart index 01211379e..0f1a97f46 100644 --- a/dwds/test/fixtures/server.dart +++ b/dwds/test/fixtures/server.dart @@ -80,6 +80,8 @@ class TestServer { ExpressionCompiler? expressionCompiler, bool spawnDds, ExpressionCompilerService? ddcService, + bool isFlutterApp, + bool isInternalBuild, ) async { var pipeline = const Pipeline(); @@ -113,6 +115,8 @@ class TestServer { hostname: hostname, urlEncoder: urlEncoder, expressionCompiler: expressionCompiler, + isInternalBuild: isInternalBuild, + isFlutterApp: isFlutterApp, devtoolsLauncher: serveDevTools ? (hostname) async { final server = await DevToolsServer().serveDevTools( diff --git a/dwds/test/puppeteer/extension_test.dart b/dwds/test/puppeteer/extension_test.dart index 918697e01..a95398603 100644 --- a/dwds/test/puppeteer/extension_test.dart +++ b/dwds/test/puppeteer/extension_test.dart @@ -24,39 +24,31 @@ import 'test_utils.dart'; final context = TestContext(); void main() async { - late Worker worker; - late Browser browser; - late String extensionPath; - group('MV3 Debug Extension', () { + late String extensionPath; + setUpAll(() async { extensionPath = await buildDebugExtension(); }); for (var useSse in [true, false]) { - group(useSse ? 'with SSE' : 'with WebSockets', () { + group(useSse ? 'connected with SSE:' : 'connected with WebSockets:', () { + late Browser browser; + late Worker worker; + 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( + browser = await setUpExtensionTest( + context, + extensionPath: extensionPath, serveDevTools: true, - launchChrome: false, useSse: useSse, - enableDebugExtension: true, - ); - browser = await puppeteer.launch( - headless: false, - timeout: Duration(seconds: 60), - args: [ - '--load-extension=$extensionPath', - '--disable-extensions-except=$extensionPath', - '--disable-features=DialMediaRouteProvider', - ], ); + worker = await getServiceWorker(browser); - final serviceWorkerTarget = await browser - .waitForTarget((target) => target.type == 'service_worker'); - worker = (await serviceWorkerTarget.worker)!; + // Navigate to the Chrome extension page instead of the blank tab + // opened by Chrome. This is helpful for local debugging. + final blankTab = await navigateToPage(browser, url: 'about:blank'); + await blankTab.goto('chrome://extensions/'); }); tearDown(() async { @@ -178,7 +170,7 @@ void main() async { }); test( - 'Navigating away from the Dart app while debugging closes DevTools', + 'navigating away from the Dart app while debugging closes DevTools', () async { final appUrl = context.appUrl; final devToolsUrlFragment = @@ -201,7 +193,7 @@ void main() async { await appTab.close(); }); - test('Closing the Dart app while debugging closes DevTools', () async { + test('closing the Dart app while debugging closes DevTools', () async { final appUrl = context.appUrl; final devToolsUrlFragment = useSse ? 'debugger?uri=sse' : 'debugger?uri=ws'; @@ -222,6 +214,125 @@ void main() async { }); }); } + + group('connected to an externally-built', () { + for (var isFlutterApp in [true, false]) { + group(isFlutterApp ? 'Flutter app:' : 'Dart app:', () { + late Browser browser; + late Worker worker; + + setUpAll(() async { + browser = await setUpExtensionTest( + context, + extensionPath: extensionPath, + serveDevTools: true, + isInternalBuild: false, + isFlutterApp: isFlutterApp, + ); + worker = await getServiceWorker(browser); + }); + + tearDown(() async { + await workerEvalDelay(); + await worker.evaluate(_clearStorageJs()); + }); + + tearDownAll(() async { + await browser.close(); + }); + test( + 'isFlutterApp=$isFlutterApp and isInternalBuild=false are saved in storage', + () async { + final appUrl = context.appUrl; + // Navigate to the Dart app: + final appTab = + await navigateToPage(browser, url: appUrl, isNew: true); + // Verify that we have debug info for the Dart app: + await workerEvalDelay(); + final appTabId = await _getTabId(appUrl, worker: worker); + final debugInfoKey = '$appTabId-debugInfo'; + final debugInfo = await _fetchStorageObj( + debugInfoKey, + storageArea: 'session', + worker: worker, + ); + expect(debugInfo.isInternalBuild, equals(false)); + expect(debugInfo.isFlutterApp, equals(isFlutterApp)); + await appTab.close(); + }); + + test('no additional panels are added in Chrome DevTools', () async { + // TODO(elliette): Requires either of the following to be resolved: + // - https://github.com/puppeteer/puppeteer/issues/9371 + // - https://github.com/xvrh/puppeteer-dart/issues/201 + }, skip: true); + }); + } + }); + + group('connected to an internally-built', () { + for (var isFlutterApp in [true, false]) { + group(isFlutterApp ? 'Flutter app:' : 'Dart app:', () { + late Browser browser; + late Worker worker; + + setUpAll(() async { + browser = await setUpExtensionTest( + context, + extensionPath: extensionPath, + serveDevTools: true, + isInternalBuild: true, + isFlutterApp: isFlutterApp, + ); + worker = await getServiceWorker(browser); + }); + + tearDown(() async { + await workerEvalDelay(); + await worker.evaluate(_clearStorageJs()); + }); + + tearDownAll(() async { + await browser.close(); + }); + test( + 'isFlutterApp=$isFlutterApp and isInternalBuild=true are saved in storage', + () async { + final appUrl = context.appUrl; + // Navigate to the Dart app: + final appTab = + await navigateToPage(browser, url: appUrl, isNew: true); + // Verify that we have debug info for the Dart app: + await workerEvalDelay(); + final appTabId = await _getTabId(appUrl, worker: worker); + final debugInfoKey = '$appTabId-debugInfo'; + final debugInfo = await _fetchStorageObj( + debugInfoKey, + storageArea: 'session', + worker: worker, + ); + expect(debugInfo.isInternalBuild, equals(true)); + expect(debugInfo.isFlutterApp, equals(isFlutterApp)); + await appTab.close(); + }); + + test('the Dart Debugger panel is added to Chrome DevTools', () async { + // TODO(elliette): Requires either of the following to be resolved: + // - https://github.com/puppeteer/puppeteer/issues/9371 + // - https://github.com/xvrh/puppeteer-dart/issues/201 + }, skip: true); + + if (isFlutterApp) { + test('the Flutter Inspector panel is added to Chrome DevTools', + () async { + // TODO(elliette): Requires either of the following to be resolved: + // - https://github.com/puppeteer/puppeteer/issues/9371 + // - https://github.com/xvrh/puppeteer-dart/issues/201 + }, skip: true); + } + }); + } + }); }); } diff --git a/dwds/test/puppeteer/lifeline_test.dart b/dwds/test/puppeteer/lifeline_test.dart index aed47d747..600db189b 100644 --- a/dwds/test/puppeteer/lifeline_test.dart +++ b/dwds/test/puppeteer/lifeline_test.dart @@ -24,23 +24,12 @@ void main() async { group('MV3 Debug Extension Lifeline Connection', () { setUpAll(() async { extensionPath = await buildDebugExtension(); - await context.setUp( - launchChrome: false, - enableDebugExtension: true, + browser = await setUpExtensionTest( + context, + extensionPath: extensionPath, + serveDevTools: true, ); - browser = await puppeteer.launch( - headless: false, - timeout: Duration(seconds: 60), - args: [ - '--load-extension=$extensionPath', - '--disable-extensions-except=$extensionPath', - '--disable-features=DialMediaRouteProvider', - ], - ); - - final serviceWorkerTarget = await browser - .waitForTarget((target) => target.type == 'service_worker'); - worker = (await serviceWorkerTarget.worker)!; + worker = await getServiceWorker(browser); }); tearDownAll(() async { diff --git a/dwds/test/puppeteer/test_utils.dart b/dwds/test/puppeteer/test_utils.dart index 7d8c046b1..46b5ef2f6 100644 --- a/dwds/test/puppeteer/test_utils.dart +++ b/dwds/test/puppeteer/test_utils.dart @@ -8,6 +8,8 @@ import 'dart:io'; import 'package:path/path.dart' as p; import 'package:puppeteer/puppeteer.dart'; +import '../fixtures/context.dart'; + Future buildDebugExtension() async { final currentDir = Directory.current.path; if (!currentDir.endsWith('dwds')) { @@ -24,6 +26,43 @@ Future buildDebugExtension() async { return '$extensionDir/compiled'; } +Future setUpExtensionTest( + TestContext context, { + required String extensionPath, + bool serveDevTools = true, + bool useSse = false, + bool isInternalBuild = false, + bool isFlutterApp = false, + bool openChromeDevTools = false, +}) 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, + serveDevTools: serveDevTools, + useSse: useSse, + enableDebugExtension: true, + isInternalBuild: isInternalBuild, + isFlutterApp: isFlutterApp, + ); + return await puppeteer.launch( + devTools: openChromeDevTools, + headless: false, + timeout: Duration(seconds: 60), + args: [ + '--load-extension=$extensionPath', + '--disable-extensions-except=$extensionPath', + '--disable-features=DialMediaRouteProvider', + ], + ); +} + +Future getServiceWorker(Browser browser) async { + final serviceWorkerTarget = + await browser.waitForTarget((target) => target.type == 'service_worker'); + return (await serviceWorkerTarget.worker)!; +} + Future clickOnExtensionIcon(Worker worker) async { return worker.evaluate(_clickIconJs); }