// 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 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:collection/collection.dart';
import 'package:dwds/data/debug_info.dart';
import 'package:dwds/data/extension_request.dart';
import 'package:dwds/src/servers/extension_backend.dart';
import 'package:dwds/src/utilities/server.dart';
import 'package:path/path.dart' as p;
import 'package:puppeteer/puppeteer.dart' hide Response;
import 'package:shelf/shelf.dart';
import 'package:shelf_static/shelf_static.dart';
import 'package:test/test.dart';
import 'package:test_common/test_sdk_configuration.dart';
import 'package:test_common/utilities.dart';

import '../../debug_extension/web/data_serializers.dart';
import '../fixtures/context.dart';
import '../fixtures/project.dart';
import '../fixtures/utilities.dart';
import 'test_utils.dart';

enum Panel { debugger, inspector }

late bool screenshotsEnabled;

void testAll({required bool isMV3, required bool screenshotsEnabled}) {
  screenshotsEnabled = screenshotsEnabled;

  final provider = TestSdkConfigurationProvider();
  final context = TestContext(TestProject.test, provider);
  tearDownAll(provider.dispose);

  group('${isMV3 ? 'MV3' : 'MV2'} Debug Extension', () {
    late String extensionPath;

    setUpAll(() async {
      extensionPath = await buildDebugExtension(isMV3: isMV3);
    });

    for (final useSse in [true, false]) {
      group(useSse ? 'connected with SSE:' : 'connected with WebSockets:', () {
        late Browser browser;
        Worker? worker;
        Page? backgroundPage;

        setUpAll(() async {
          browser = await setUpExtensionTest(
            context,
            extensionPath: extensionPath,
            serveDevTools: true,
            useSse: useSse,
            workspaceName: 'test-workspace',
          );

          if (isMV3) {
            worker = await getServiceWorker(browser);
          } else {
            backgroundPage = await getBackgroundPage(browser);
          }

          // 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 {
          await tearDownHelper(worker: worker, backgroundPage: backgroundPage);
        });

        tearDownAll(() async {
          await browser.close();
          await context.tearDown();
        });

        test(
          'the debug info for a Dart app is saved in session 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 _getCurrentTabId(
              worker: worker,
              backgroundPage: backgroundPage,
            );
            final debugInfoKey = '$appTabId-debugInfo';
            final debugInfo = await _fetchStorageObj<DebugInfo>(
              debugInfoKey,
              storageArea: 'session',
              worker: worker,
              backgroundPage: backgroundPage,
            );
            expect(debugInfo.appId, isNotNull);
            expect(debugInfo.appEntrypointPath, isNotNull);
            expect(debugInfo.appInstanceId, isNotNull);
            expect(debugInfo.appOrigin, isNotNull);
            expect(debugInfo.appUrl, isNotNull);
            expect(debugInfo.isInternalBuild, isNotNull);
            expect(debugInfo.isFlutterApp, isNotNull);
            expect(debugInfo.workspaceName, isNotNull);
            await appTab.close();
          },
        );

        test('the auth status is saved in session 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 _getCurrentTabId(
            worker: worker,
            backgroundPage: backgroundPage,
          );
          final authKey = '$appTabId-isAuthenticated';
          final authenticated = await _fetchStorageObj<String>(
            authKey,
            storageArea: 'session',
            worker: worker,
            backgroundPage: backgroundPage,
          );
          expect(authenticated, isNotNull);
          expect(authenticated, equals('true'));
          await appTab.close();
        });

        test('DevTools is opened with the correct query parameters', () async {
          final appUrl = context.appUrl;
          final devToolsUrlFragment =
              useSse ? 'debugger?uri=sse' : 'debugger?uri=ws';
          // Navigate to the Dart app:
          final appTab = await navigateToPage(
            browser,
            url: appUrl,
            isNew: true,
          );
          // Click on the Dart Debug Extension icon:
          await workerEvalDelay();
          await clickOnExtensionIcon(
            browser: browser,
            worker: worker,
            backgroundPage: backgroundPage,
          );
          // Wait for DevTools to open:
          final devToolsTabTarget = await browser.waitForTarget(
            (target) => target.url.contains(devToolsUrlFragment),
          );
          final devToolsUrl = devToolsTabTarget.url;
          // Expect the correct query parameters to be on the DevTools url:
          final uri = Uri.parse(devToolsUrl);
          final queryParameters = uri.queryParameters;
          expect(queryParameters.keys, unorderedMatches(['uri', 'ide']));
          expect(queryParameters, containsPair('ide', 'DebugExtension'));
          expect(queryParameters, containsPair('uri', isNotEmpty));
          // Close the DevTools tab:
          final devToolsTab = await devToolsTabTarget.page;
          await devToolsTab.close();
          await appTab.close();
        });

        test(
          'navigating away from the Dart app while debugging closes DevTools',
          () async {
            final appUrl = context.appUrl;
            final devToolsUrlFragment =
                useSse ? 'debugger?uri=sse' : 'debugger?uri=ws';
            // Navigate to the Dart app:
            final appTab = await navigateToPage(
              browser,
              url: appUrl,
              isNew: true,
            );
            // Click on the Dart Debug Extension icon:
            await workerEvalDelay();
            await clickOnExtensionIcon(
              browser: browser,
              worker: worker,
              backgroundPage: backgroundPage,
            );
            // Verify that the Dart DevTools tab is open:
            final devToolsTabTarget = await browser.waitForTarget(
              (target) => target.url.contains(devToolsUrlFragment),
            );
            expect(devToolsTabTarget.type, equals('page'));
            // Navigate away from the Dart app:
            await appTab.goto(
              'https://dart.dev/',
              wait: Until.domContentLoaded,
            );
            await appTab.bringToFront();
            // Verify that the Dart DevTools tab closes:
            await devToolsTabTarget.onClose;
            await appTab.close();
          },
        );

        test('closing the Dart app while debugging closes DevTools', () async {
          final appUrl = context.appUrl;
          final devToolsUrlFragment =
              useSse ? 'debugger?uri=sse' : 'debugger?uri=ws';
          // Navigate to the Dart app:
          final appTab = await navigateToPage(
            browser,
            url: appUrl,
            isNew: true,
          );
          // Click on the Dart Debug Extension icon:
          await workerEvalDelay();
          await clickOnExtensionIcon(
            browser: browser,
            worker: worker,
            backgroundPage: backgroundPage,
          );
          // Verify that the Dart DevTools tab is open:
          final devToolsTabTarget = await browser.waitForTarget(
            (target) => target.url.contains(devToolsUrlFragment),
          );
          expect(devToolsTabTarget.type, equals('page'));
          // Close the Dart app:
          await appTab.close();
          // Verify that the Dart DevTools tab closes:
          await devToolsTabTarget.onClose;
        });

        test('Clicking extension icon while debugging shows warning', () async {
          final appUrl = context.appUrl;
          final devToolsUrlFragment =
              useSse ? 'debugger?uri=sse' : 'debugger?uri=ws';
          // Navigate to the Dart app:
          final appTab = await navigateToPage(
            browser,
            url: appUrl,
            isNew: true,
          );
          // Click on the Dart Debug Extension icon:
          await workerEvalDelay();
          await clickOnExtensionIcon(
            browser: browser,
            worker: worker,
            backgroundPage: backgroundPage,
          );
          // Wait for Dart Devtools to open:
          final devToolsTabTarget = await browser.waitForTarget(
            (target) => target.url.contains(devToolsUrlFragment),
          );
          // There should be no warning notifications:
          var chromeNotifications = await evaluate(
            _getNotifications(),
            worker: worker,
            backgroundPage: backgroundPage,
          );
          expect(chromeNotifications, isEmpty);
          // Navigate back to Dart app:
          await navigateToPage(browser, url: appUrl, isNew: false);
          // Click on the Dart Debug Extension icon again:
          await workerEvalDelay();
          await clickOnExtensionIcon(
            browser: browser,
            worker: worker,
            backgroundPage: backgroundPage,
          );
          await workerEvalDelay();
          // There should now be a warning notification:
          chromeNotifications = await evaluate(
            _getNotifications(),
            worker: worker,
            backgroundPage: backgroundPage,
          );
          expect(chromeNotifications, isNotEmpty);
          // Close the Dart app and the associated Dart DevTools:
          await appTab.close();
          await devToolsTabTarget.onClose;
        });

        test(
          'Clicking extension icon for a non Dart app shows warning',
          () async {
            // Navigate to a page that doesn't contain a Dart app:
            final tab = await navigateToPage(
              browser,
              url: 'https://dart.dev',
              isNew: true,
            );
            // Click on the Dart Debug Extension icon:
            await workerEvalDelay();
            await clickOnExtensionIcon(
              browser: browser,
              worker: worker,
              backgroundPage: backgroundPage,
            );
            // There should now be a warning notification:
            final chromeNotifications = await evaluate(
              _getNotifications(),
              worker: worker,
              backgroundPage: backgroundPage,
            );
            await workerEvalDelay();
            expect(chromeNotifications, isNotEmpty);
            // Close the tab:
            await tab.close();
          },
        );

        test(
          'Refreshing the Dart app does not open a new Dart DevTools',
          () async {
            final appUrl = context.appUrl;
            final devToolsUrlFragment =
                useSse ? 'debugger?uri=sse' : 'debugger?uri=ws';
            // Navigate to the Dart app:
            final appTab = await navigateToPage(
              browser,
              url: appUrl,
              isNew: true,
            );
            // Click on the Dart Debug Extension icon:
            await workerEvalDelay();
            await clickOnExtensionIcon(
              browser: browser,
              worker: worker,
              backgroundPage: backgroundPage,
            );
            // Verify that the Dart DevTools tab is open:
            final devToolsTabTarget = await browser.waitForTarget(
              (target) => target.url.contains(devToolsUrlFragment),
            );
            expect(devToolsTabTarget.type, equals('page'));
            // Refresh the app tab:
            await appTab.reload();
            // Verify that we don't open a new Dart DevTools on page refresh:
            final devToolsTargets = browser.targets.where(
              (target) => target.url.contains(devToolsUrlFragment),
            );
            expect(devToolsTargets.length, equals(1));
            // Close the Dart app and the associated Dart DevTools:
            await appTab.close();
            await devToolsTabTarget.onClose;
          },
        );
      });
    }

    group('connected to an externally-built', () {
      for (final isFlutterApp in [true, false]) {
        group(isFlutterApp ? 'Flutter app:' : 'Dart app:', () {
          late Browser browser;
          Worker? worker;
          Page? backgroundPage;

          setUpAll(() async {
            browser = await setUpExtensionTest(
              context,
              extensionPath: extensionPath,
              serveDevTools: true,
              isInternalBuild: false,
              isFlutterApp: isFlutterApp,
              openChromeDevTools: true,
            );

            if (isMV3) {
              worker = await getServiceWorker(browser);
            } else {
              backgroundPage = await getBackgroundPage(browser);
            }
          });

          tearDown(() async {
            await tearDownHelper(
              worker: worker,
              backgroundPage: backgroundPage,
            );
          });

          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 _getCurrentTabId(
                worker: worker,
                backgroundPage: backgroundPage,
              );
              final debugInfoKey = '$appTabId-debugInfo';
              final debugInfo = await _fetchStorageObj<DebugInfo>(
                debugInfoKey,
                storageArea: 'session',
                worker: worker,
                backgroundPage: backgroundPage,
              );
              expect(debugInfo.isInternalBuild, equals(false));
              expect(debugInfo.isFlutterApp, equals(isFlutterApp));
              await appTab.close();
            },
          );

          test('no additional panels are added in Chrome DevTools', () async {
            final appUrl = context.appUrl;
            // This is the blank page automatically opened by Chrome:
            final blankTab = await navigateToPage(browser, url: 'about:blank');
            // Navigate to the Dart app:
            await blankTab.goto(appUrl, wait: Until.domContentLoaded);
            final appTab = blankTab;
            await appTab.bringToFront();
            final chromeDevToolsTarget = browser.targets.firstWhere(
              (target) => target.url.startsWith('devtools://devtools'),
            );
            chromeDevToolsTarget.type = 'page';
            final chromeDevToolsPage = await chromeDevToolsTarget.page;
            await _tabLeft(chromeDevToolsPage);
            await _takeScreenshot(
              chromeDevToolsPage,
              screenshotName: 'chromeDevTools_externalBuild',
            );
            final inspectorPanelTarget = browser.targets.firstWhereOrNull(
              (target) => target.url == 'inspector_panel',
            );
            expect(inspectorPanelTarget, isNull);
            final debuggerPanelTarget = browser.targets.firstWhereOrNull(
              (target) => target.url == 'debugger_panel',
            );
            expect(debuggerPanelTarget, isNull);
          });
        });
      }
    });

    group('connected to an internally-built', () {
      late Page appTab;

      for (final isFlutterApp in [true, false]) {
        group(isFlutterApp ? 'Flutter app:' : 'Dart app:', () {
          late Browser browser;
          Worker? worker;
          Page? backgroundPage;

          setUpAll(() async {
            browser = await setUpExtensionTest(
              context,
              extensionPath: extensionPath,
              serveDevTools: true,
              isInternalBuild: true,
              isFlutterApp: isFlutterApp,
              // TODO(elliette): Figure out if there is a way to close and then
              // re-open Chrome DevTools. That way we can test that a debug
              // session lasts across Chrome DevTools being opened and closed.
              openChromeDevTools: true,
            );
            if (isMV3) {
              worker = await getServiceWorker(browser);
            } else {
              backgroundPage = await getBackgroundPage(browser);
            }
          });

          setUp(() async {
            for (final page in await browser.pages) {
              await page.close().catchError((_) {});
            }
            appTab = await navigateToPage(
              browser,
              url: context.appUrl,
              isNew: true,
            );
          });

          tearDown(() async {
            await tearDownHelper(
              worker: worker,
              backgroundPage: backgroundPage,
            );
          });

          tearDownAll(() async {
            await browser.close();
          });
          test(
            'isFlutterApp=$isFlutterApp and isInternalBuild=true are saved in storage',
            () async {
              // Verify that we have debug info for the Dart app:
              await workerEvalDelay();
              final appTabId = await _getCurrentTabId(
                worker: worker,
                backgroundPage: backgroundPage,
              );
              final debugInfoKey = '$appTabId-debugInfo';
              final debugInfo = await _fetchStorageObj<DebugInfo>(
                debugInfoKey,
                storageArea: 'session',
                worker: worker,
                backgroundPage: backgroundPage,
              );
              expect(debugInfo.isInternalBuild, equals(true));
              expect(debugInfo.isFlutterApp, equals(isFlutterApp));
            },
          );

          test(
            'the correct extension panels are added to Chrome DevTools',
            () async {
              final chromeDevToolsPage = await getChromeDevToolsPage(browser);
              // There are no hooks for when a panel is added to Chrome DevTools,
              // therefore we rely on a slight delay:
              await Future.delayed(Duration(seconds: 1));
              if (isFlutterApp) {
                await _tabLeft(chromeDevToolsPage);
                final inspectorPanelElement = await _getPanelElement(
                  browser,
                  panel: Panel.inspector,
                  elementSelector: '#panelBody',
                );
                expect(inspectorPanelElement, isNotNull);
                await _takeScreenshot(
                  chromeDevToolsPage,
                  screenshotName: 'inspectorPanelLandingPage_flutterApp',
                );
              }
              await _tabLeft(chromeDevToolsPage);
              final debuggerPanelElement = await _getPanelElement(
                browser,
                panel: Panel.debugger,
                elementSelector: '#panelBody',
              );
              expect(debuggerPanelElement, isNotNull);
              await _takeScreenshot(
                chromeDevToolsPage,
                screenshotName:
                    'debuggerPanelLandingPage_${isFlutterApp ? 'flutterApp' : 'dartApp'}',
              );
            },
          );

          test('Dart DevTools is embedded for debug session lifetime', () async {
            final chromeDevToolsPage = await getChromeDevToolsPage(browser);
            // There are no hooks for when a panel is added to Chrome DevTools,
            // therefore we rely on a slight delay:
            await Future.delayed(Duration(seconds: 1));
            // Navigate to the Dart Debugger panel:
            await _tabLeft(chromeDevToolsPage);
            if (isFlutterApp) {
              await _tabLeft(chromeDevToolsPage);
            }
            await _clickLaunchButton(browser, panel: Panel.debugger);
            // Expect the Dart DevTools IFRAME to be added:
            final devToolsUrlFragment =
                'ide=ChromeDevTools&embed=true&page=debugger';
            var iframeTarget = await browser.waitForTarget(
              (target) => target.url.contains(devToolsUrlFragment),
            );
            var iframeDestroyed = false;
            unawaited(
              iframeTarget.onClose.whenComplete(() {
                iframeDestroyed = true;
              }),
            );
            // TODO(elliette): Figure out how to reliably verify that Dart
            // DevTools has loaded, and take screenshot.
            expect(iframeTarget, isNotNull);
            // Navigate away from the Dart app:
            await appTab.goto(
              'https://dart.dev/',
              wait: Until.domContentLoaded,
            );
            // Expect the Dart DevTools IFRAME to be destroyed:
            expect(iframeDestroyed, isTrue);
            // Expect the connection lost banner to be visible:
            final connectionLostBanner = await _getPanelElement(
              browser,
              panel: Panel.debugger,
              elementSelector: '#warningBanner',
            );
            expect(connectionLostBanner, isNotNull);
            await _takeScreenshot(
              chromeDevToolsPage,
              screenshotName:
                  'debuggerPanelDisconnected_${isFlutterApp ? 'flutterApp' : 'dartApp'}',
            );
            // Navigate back to the Dart app:
            await appTab.goto(context.appUrl, wait: Until.domContentLoaded);
            // Click the launch button again
            await _clickLaunchButton(browser, panel: Panel.debugger);
            // Expect the Dart DevTools IFRAME to be added again:
            iframeTarget = await browser.waitForTarget(
              (target) => target.url.contains(devToolsUrlFragment),
            );
            expect(iframeTarget, isNotNull);
          });

          // TODO(elliette): Pull TestServer out of TestContext, so we can add:
          // 1. a test case for starting another test app, loading that app in
          // the tab we were debugging, and being able to reconnect to that
          // one.
          // 2. a test case for embedding a Dart app in a tab with the same
          // origin, and being able to connect to the embedded Dart app.
          // See https://github.com/dart-lang/webdev/issues/1779

          test(
            'The Dart DevTools IFRAME has the correct query parameters and path',
            () async {
              final chromeDevToolsPage = await getChromeDevToolsPage(browser);
              // There are no hooks for when a panel is added to Chrome DevTools,
              // therefore we rely on a slight delay:
              await Future.delayed(Duration(seconds: 1));
              // Navigate to the Dart Debugger panel:
              await _tabLeft(chromeDevToolsPage);
              if (isFlutterApp) {
                await _tabLeft(chromeDevToolsPage);
              }
              await _clickLaunchButton(browser, panel: Panel.debugger);
              // Expect the Dart DevTools IFRAME to be added:
              final devToolsUrlFragment =
                  'ide=ChromeDevTools&embed=true&page=debugger';
              final iframeTarget = await browser.waitForTarget(
                (target) => target.url.contains(devToolsUrlFragment),
              );
              final iframeUrl = iframeTarget.url;
              // Expect the correct query parameters to be on the IFRAME url:
              final uri = Uri.parse(iframeUrl);
              final queryParameters = uri.queryParameters;
              expect(
                queryParameters.keys,
                unorderedMatches([
                  'uri',
                  'ide',
                  'embed',
                  'page',
                  'backgroundColor',
                ]),
              );
              expect(queryParameters, containsPair('ide', 'ChromeDevTools'));
              expect(queryParameters, containsPair('uri', isNotEmpty));
              expect(queryParameters, containsPair('page', isNotEmpty));
              expect(
                queryParameters,
                containsPair('backgroundColor', isNotEmpty),
              );
              expect(uri.path, equals('/'));
            },
          );

          test(
            'Trying to debug a page with multiple Dart apps shows warning',
            () async {
              final chromeDevToolsPage = await getChromeDevToolsPage(browser);
              // There are no hooks for when a panel is added to Chrome DevTools,
              // therefore we rely on a slight delay:
              await Future.delayed(Duration(seconds: 1));
              // Navigate to the Dart Debugger panel:
              await _tabLeft(chromeDevToolsPage);
              if (isFlutterApp) {
                await _tabLeft(chromeDevToolsPage);
              }
              // Expect there to be no warning banner:
              var warningMsg = await _evaluateInPanel<String>(
                browser,
                panel: Panel.debugger,
                jsExpression: 'document.querySelector("#warningMsg").innerHTML',
              );
              expect(
                warningMsg == 'Cannot debug multiple apps in a page.',
                isFalse,
              );
              // Set the 'data-multiple-dart-apps' attribute on the DOM.
              await appTab.evaluate(_setMultipleAppsAttributeJs);
              final appTabId = await _getCurrentTabId(
                worker: worker,
                backgroundPage: backgroundPage,
              );
              // Expect multiple apps info to be saved in storage:
              final storageKey = '$appTabId-multipleAppsDetected';
              final multipleAppsDetected = await _fetchStorageObj<String>(
                storageKey,
                storageArea: 'session',
                worker: worker,
                backgroundPage: backgroundPage,
              );
              expect(multipleAppsDetected, equals('true'));
              // Expect there to be a warning banner:
              warningMsg = await _evaluateInPanel<String>(
                browser,
                panel: Panel.debugger,
                jsExpression: 'document.querySelector("#warningMsg").innerHTML',
              );
              await _takeScreenshot(
                chromeDevToolsPage,
                screenshotName:
                    'debuggerMultipleAppsDetected_${isFlutterApp ? 'flutterApp' : 'dartApp'}',
              );
              expect(
                warningMsg,
                equals('Cannot debug multiple apps in a page.'),
              );
            },
          );
        });
      }
    });

    group('Backwards-compatible with older DWDS versions', () {
      final port = 8080;
      final hostname = 'localhost';
      final fakeAppUrl = 'http://$hostname:$port/index.html';
      final fakeAppDir = webCompatiblePath(
        p.split(
          absolutePath(pathFromDwds: p.join('test', 'puppeteer', 'fake_app')),
        ),
      );
      late Browser browser;
      late HttpServer server;
      Worker? worker;
      Page? backgroundPage;

      setUpAll(() async {
        server = await _fakeServer(
          hostname: hostname,
          port: port,
          assetPath: fakeAppDir,
        );
        browser = await puppeteer.launch(
          headless: false,
          timeout: Duration(seconds: 60),
          args: [
            '--load-extension=$extensionPath',
            '--disable-extensions-except=$extensionPath',
            '--disable-features=DialMediaRouteProvider',
          ],
        );
        if (isMV3) {
          worker = await getServiceWorker(browser);
        } else {
          backgroundPage = await getBackgroundPage(browser);
        }
        // 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 {
        await tearDownHelper(worker: worker, backgroundPage: backgroundPage);
      });

      tearDownAll(() async {
        await server.close();
        await browser.close();
      });

      // Note: This tests that the extension works for DWDS versions <17.0.0.
      // Those versions don't send the debug info with the ready event
      // (https://github.com/dart-lang/webdev/pull/1772). Therefore the values
      // are read from the Window object.
      test('reads debug info from Window and saves to storage', () async {
        // Navigate to the "Dart" app:
        final appTab = await navigateToPage(
          browser,
          url: fakeAppUrl,
          isNew: true,
        );

        // Verify that we have debug info for the fake "Dart" app:
        final appTabId = await _getCurrentTabId(
          worker: worker,
          backgroundPage: backgroundPage,
        );
        final debugInfoKey = '$appTabId-debugInfo';
        final debugInfo = await _fetchStorageObj<DebugInfo>(
          debugInfoKey,
          storageArea: 'session',
          worker: worker,
          backgroundPage: backgroundPage,
        );
        expect(debugInfo.appId, equals('DART_APP_ID'));
        expect(debugInfo.appEntrypointPath, equals('DART_ENTRYPOINT_PATH'));
        expect(debugInfo.appInstanceId, equals('DART_APP_INSTANCE_ID'));
        expect(debugInfo.isInternalBuild, isTrue);
        expect(debugInfo.isFlutterApp, isFalse);
        expect(debugInfo.appOrigin, isNotNull);
        expect(debugInfo.appUrl, isNotNull);

        // Close the tab:
        await appTab.close();
      });

      // Note: This tests that the extension works for DWDS versions <18.0.0.
      // Those versions don't support authentication from the injected client
      // (https://github.com/dart-lang/webdev/pull/1916). Therefore the auth
      // request is sent from the extension itself.
      test('clicking on extension icon authenticates the user', () async {
        // Navigate to the "Dart" app:
        final appTab = await navigateToPage(
          browser,
          url: fakeAppUrl,
          isNew: true,
        );

        // Wait for debug info to be saved:
        final appTabId = await _getCurrentTabId(
          worker: worker,
          backgroundPage: backgroundPage,
        );
        final debugInfoKey = '$appTabId-debugInfo';
        final debugInfo = await _fetchStorageObj<DebugInfo>(
          debugInfoKey,
          storageArea: 'session',
          worker: worker,
          backgroundPage: backgroundPage,
        );

        // Verify that the extension URL has been saved (this is what is used
        // by the extension to create the auth url):
        expect(debugInfo.extensionUrl, isNotNull);

        // Click on the extension icon:
        await clickOnExtensionIcon(
          browser: browser,
          worker: worker,
          backgroundPage: backgroundPage,
        );

        // Verify that the user is now authenticated:
        final authKey = '$appTabId-isAuthenticated';
        final isAuthenticated = await _fetchStorageObj<String>(
          authKey,
          storageArea: 'session',
          worker: worker,
          backgroundPage: backgroundPage,
        );
        expect(isAuthenticated, equals('true'));

        // Close the tab:
        await appTab.close();
      });
    });
  });
}

Future<bool> _clickLaunchButton(Browser browser, {required Panel panel}) async {
  try {
    final launchButton = await _getPanelElement(
      browser,
      panel: panel,
      elementSelector: '#launchDebugConnectionButton',
    );
    // Slight delay to guarantee button is clickable:
    await Future.delayed(Duration(seconds: 1));
    await launchButton!.click();
    return true;
  } catch (_) {
    return false;
  }
}

Future<Page> _getPanelPage(Browser browser, {required Panel panel}) async {
  final panelName =
      panel == Panel.inspector ? 'inspector_panel' : 'debugger_panel';
  var panelTarget = browser.targets.firstWhereOrNull(
    (target) => target.url.contains(panelName),
  );
  panelTarget ??= await browser.waitForTarget(
    (target) => target.url.contains(panelName),
  );
  panelTarget.type = 'page';
  return await panelTarget.page;
}

Future<T> _evaluateInPanel<T>(
  Browser browser, {
  required Panel panel,
  required String jsExpression,
}) async {
  final panelPage = await _getPanelPage(browser, panel: panel);
  final frames = panelPage.frames;
  final mainFrame = frames[0];
  return mainFrame.evaluate(jsExpression);
}

Future<ElementHandle?> _getPanelElement(
  Browser browser, {
  required Panel panel,
  required String elementSelector,
}) async {
  final panelPage = await _getPanelPage(browser, panel: panel);
  final frames = panelPage.frames;
  final mainFrame = frames[0];
  final panelElement = await mainFrame.$OrNull(elementSelector);
  return panelElement;
}

Future<void> _tabLeft(Page chromeDevToolsPage) async {
  // TODO(elliette): Detect which environment we are OS we are running
  // in and update modifier key accordingly. Meta key for MacOs and
  // Ctrl key for Linux/Windows.
  final modifierKey = Key.meta;
  await chromeDevToolsPage.keyboard.down(modifierKey);
  await chromeDevToolsPage.keyboard.press(Key.bracketLeft);
  await chromeDevToolsPage.keyboard.up(modifierKey);
}

Future<int> _getCurrentTabId({Worker? worker, Page? backgroundPage}) async {
  return (await evaluate(
        _currentTabIdJs,
        worker: worker,
        backgroundPage: backgroundPage,
      ))
      as int;
}

Future<T> _fetchStorageObj<T>(
  String storageKey, {
  required String storageArea,
  Worker? worker,
  Page? backgroundPage,
}) async {
  final json = await retryFnAsync<String>(() async {
    final storageObj = await evaluate(
      _fetchStorageObjJs(
        storageKey,
        // Only local storage exists for MV2:
        storageArea: worker != null ? storageArea : 'local',
      ),
      worker: worker,
      backgroundPage: backgroundPage,
    );
    return storageObj[storageKey];
  });
  if (T == String) return json as T;
  return serializers.deserialize(jsonDecode(json)) as T;
}

String _currentTabIdJs = '''
    async () => {
      return new Promise((resolve, reject) => {
        chrome.tabs.query({active: true, currentWindow: true}, (tabs) => {
          const tab = tabs[0];
          resolve(tab.id);
        });
      });
    }
''';

String _fetchStorageObjJs(String storageKey, {required String storageArea}) {
  return '''
    async () => {
      const storageKey = "$storageKey";
      return new Promise((resolve, reject) => {
        chrome.storage.$storageArea.get(storageKey, (storageObj) => {
          if (storageObj != null) {
            resolve(storageObj);
          } else {
            resolve(null);
          }
        });
      });
    }
''';
}

String _getNotifications() {
  return '''
    async () => {
      return new Promise((resolve, reject) => {
        chrome.notifications.getAll((notifications) => {
          resolve(notifications);
        });
      });
    }
''';
}

String _setMultipleAppsAttributeJs = '''
  document.documentElement.setAttribute("data-multiple-dart-apps", true);
''';

// TODO(https://github.com/dart-lang/webdev/issues/1787): Compare to golden
// images. Currently golden comparison is not set up, since this is only run
// locally, not as part of our CI test suite.
Future<void> _takeScreenshot(
  Page page, {
  required String screenshotName,
}) async {
  if (!screenshotsEnabled) return;
  // Since the DevTools panels are not real "pages" but merely targets we have
  // coerced into having a "page" type, there doesn't seem to be a way to verify
  // that the DOM has been loaded. Therefore we use a slight delay before taking
  // a screenshot. See https://github.com/puppeteer/puppeteer/issues/9371.
  await Future.delayed(Duration(seconds: 1));
  final screenshot = await page.screenshot();
  final screenshotPath = p.join(
    'test',
    'puppeteer',
    'test_images',
    '$screenshotName.png',
  );
  await File(screenshotPath).writeAsBytes(screenshot);
}

Future<HttpServer> _fakeServer({
  required String hostname,
  required int port,
  required String assetPath,
}) async {
  final server = await startHttpServer(hostname, port: port);
  final staticHandler = createStaticHandler(assetPath);
  final cascade = Cascade().add(staticHandler).add(_fakeAuthHandler);
  serveHttpRequests(server, cascade.handler, (e, s) {});
  return server;
}

Response _fakeAuthHandler(request) {
  if (request.url.path == authenticationPath) {
    return Response.ok(authenticationResponse);
  }
  return Response.notFound('Not found');
}
