diff --git a/.agents/hooks.json b/.agents/hooks.json new file mode 100644 index 00000000000..c4fa91dffc6 --- /dev/null +++ b/.agents/hooks.json @@ -0,0 +1,23 @@ +{ + "skill-linter-and-review-hook": { + "PostToolUse": [ + { + "matcher": "write_to_file|replace_file_content|multi_replace_file_content", + "hooks": [ + { + "type": "command", + "command": "./.agents/scripts/validate_skills_hook.sh", + "timeout": 30 + } + ] + } + ], + "PostInvocation": [ + { + "type": "command", + "command": "./.agents/scripts/skill_post_invocation_hook.sh", + "timeout": 30 + } + ] + } +} diff --git a/.agents/scripts/skill_post_invocation_hook.sh b/.agents/scripts/skill_post_invocation_hook.sh new file mode 100755 index 00000000000..be7d614983f --- /dev/null +++ b/.agents/scripts/skill_post_invocation_hook.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# Copyright 2026 The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +# Check if any skill file was modified in git working directory +MODIFIED_SKILLS=$(git diff --name-only HEAD 2>/dev/null | grep "\.agents/skills/.*SKILL\.md$") + +if [ -n "$MODIFIED_SKILLS" ]; then + # Inject ephemeral message instructing AGY to review the modified skill + cat </dev/null) + +# Check if a skill file was modified +if [[ "$TARGET_FILE" == *"SKILL.md"* ]] || [[ "$TARGET_FILE" == *".agents/skills"* ]]; then + echo "Skill edit detected: $TARGET_FILE" >&2 + echo "Running skills_lint..." >&2 + + # Run the skills linter from tool/ directory + (cd tool && dart run skills_lint) +fi + +# PostToolUse expects an empty JSON object on stdout +echo "{}" diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 00000000000..8555a78eeac --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,40 @@ + + +# Agent Skills + +This directory contains AI agent skills for this repository. + +## Validation + +To ensure skills meet the required specification, they are automatically validated in pre-submit checks. +You should also validate your skills locally before submitting a review. + +### Running the Linter Locally + +To validate skills locally before review, run the linter from the `tool` directory: + +```bash +cd tool && dart run dart_skills_lint:cli +``` + +This will use the configuration in `tool/dart_skills_lint.yaml` to validate all skills in the `.agents/skills` directory. + +Or for a single skill (also from the `tool` directory): + +```bash +cd tool && dart run dart_skills_lint:cli --skill ../.agents/skills/my-skill +``` + +### Running via Dart Test + +Alternatively, you can run the validation as a test from the `tool` directory: + +```bash +cd tool && dart test test/validate_skills_test.dart +``` + +This ensures that the validation logic is executed in the same way as in CI. diff --git a/.agents/skills/adding-changelog-entries/SKILL.md b/.agents/skills/adding-changelog-entries/SKILL.md new file mode 100644 index 00000000000..2b4dc848530 --- /dev/null +++ b/.agents/skills/adding-changelog-entries/SKILL.md @@ -0,0 +1,14 @@ +--- +name: adding-changelog-entries +description: Use when documenting changes in CHANGELOG.md files for devtools_shared, devtools_app_shared, or devtools_extensions. +--- + +# Adding Changelog Entries + +When adding changelog entries to published packages (`devtools_shared`, `devtools_app_shared`, and `devtools_extensions`), follow these rules: + +- **Check Publication State**: Never add an entry to an already published version (e.g. `# 12.1.0`). You MUST check which versions are published on pub.dev (e.g., at `https://pub.dev/packages//versions`) before determining section headers! If a version is not yet published, it should be suffixed with the next numeric `-wip` tag (e.g., `## 0.5.1-wip`) or follow the existing convention in the file, such as `(not released)`. You should also match the existing header level (H1 or H2) used for versions in that file. Avoid generic placeholder headers like `# WIP`! +- **New Version Headers**: If a version bump is required, you MUST first use the instructions in [updating-package-versions](../updating-package-versions/SKILL.md) to update the package version in `pubspec.yaml`, and then add a new header to the changelog file with the new version (e.g., `## 0.5.1-wip`) before adding your entries. +- **Accurately Distinguish Packages**: Ensure that the cleanups or edits applied to a specific package's changelog entries belong strictly to that package path (e.g., changes in `devtools_app` do not warrant entries in `devtools_shared`). +- **Conciseness and Accuracy**: Describe clearly what was changed and why. Avoid generic descriptions without context, such as "Fixes missing deprecation message". Indicate specifically what was added or removed. +- **User visible changes ONLY**: Only add entries in the changelog if the change is meaningful to consumers of the package. Do not include internal refactoring notes or other trivial changes that users do not care about. diff --git a/.agents/skills/adding-copyright-headers/SKILL.md b/.agents/skills/adding-copyright-headers/SKILL.md new file mode 100644 index 00000000000..6ca723a3484 --- /dev/null +++ b/.agents/skills/adding-copyright-headers/SKILL.md @@ -0,0 +1,74 @@ +--- +name: adding-copyright-headers +description: Adds copyright headers to files based on file type. Use when creating new files or when asked to verify copyright headers. +--- + +# Adding Copyright Headers + +## Formats + +### Dart Files (`.dart`) + +```dart +// Copyright 20?? The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +``` + +### YAML Files (`.yaml`) + +```yaml +# Copyright 20?? The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +``` + +### HTML Files (`.html`) and Markdown Files (`.md`) + +```html + +``` + +### Shell Scripts (`.sh`) + +```bash +# Copyright 20?? The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +``` + +## Instructions + +1. **New Files**: When creating a new file, you MUST add the appropriate copyright header based on its file extension. + - Replace `20??` with the current year (e.g., 2026). +2. **Placement**: + - The copyright header should be at the very top of the file by default. + - **Exception**: If the file requires a shebang (e.g., `#!/bin/bash` in `.sh` files) or other necessary frontmatter, the copyright header must come **after** the frontmatter, separated by a blank line. +3. **Exclusions**: Copyright headers are **not necessary** in directories that begin with a dot (`.`), such as: + - `.agents/` + - `.gemini/` + - `.github/` +4. **Existing Files**: When editing an existing file, do NOT modify the year in the copyright header. Leave it as it is. +5. **Subsequent Edits**: Subsequent edits should not modify the year. + +## Examples (for 2026) + +### Shell Script with Shebang +```bash +#!/bin/bash + +# Copyright 2026 The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +``` + +### Dart File +```dart +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +``` diff --git a/.agents/skills/adding-release-notes/SKILL.md b/.agents/skills/adding-release-notes/SKILL.md index c47b3e699b1..59853bf5d89 100644 --- a/.agents/skills/adding-release-notes/SKILL.md +++ b/.agents/skills/adding-release-notes/SKILL.md @@ -84,4 +84,4 @@ Add images to `packages/devtools_app/release_notes/images/` and reference them: **Constraint**: Use **dark mode** for screenshots. ## Resources -- [README.md](../../packages/devtools_app/release_notes/README.md): Official project guidance. +- [README.md](../../../packages/devtools_app/release_notes/README.md): Official project guidance. diff --git a/.agents/skills/authoring-skills/SKILL.md b/.agents/skills/authoring-skills/SKILL.md index ab871197713..da0bdcfea13 100644 --- a/.agents/skills/authoring-skills/SKILL.md +++ b/.agents/skills/authoring-skills/SKILL.md @@ -17,6 +17,7 @@ When creating or modifying skills in this repository, follow these best practice - **Naming**: Use the gerund form (**verb-ing-noun**) or **noun-phrase** (e.g., `authoring-skills`, `adding-release-notes`). Use only lowercase letters, numbers, and hyphens. - **Conciseness**: Prioritize brevity in `SKILL.md`. Agents are already highly capable; only provide context they don't already have. - **Automation**: Any utility scripts placed in the `scripts/` directory MUST be written in **Dart**. +- **Validation**: Skills are automatically validated using `dart_skills_lint`. Ensure your skills pass the linter before submitting. See the `README.md` in `.agents/skills/` for instructions on how to run the linter locally. - **Progressive Disclosure**: Use the patterns below to organize instructions effectively: - [CHECKLIST.md](CHECKLIST.md): Template for tracking skill development progress. - [EXAMPLES.md](EXAMPLES.md): Local examples and anti-patterns. diff --git a/.agents/skills/closing-obsolete-issues/SKILL.md b/.agents/skills/closing-obsolete-issues/SKILL.md new file mode 100644 index 00000000000..f1683a088ed --- /dev/null +++ b/.agents/skills/closing-obsolete-issues/SKILL.md @@ -0,0 +1,49 @@ +--- +name: closing-obsolete-issues +description: Find and close obsolete, stale, or not reproducible issues in the flutter/devtools repository. +--- + +# Closing Obsolete Issues + +Use this skill to find old, outdated issues in the `flutter/devtools` repository that can be closed because they have been fixed, are stale, obsolete, or not reproducible. + +## Instructions + +1. **Identify Target Issues**: + - Use the GitHub CLI (`gh`) to search for the oldest open issues. + - Use any label that the user gives you, or none if the user does not specify any issue labels. + - Sort by creation date (`created-asc`) or last update (`updated-asc`) to find the most likely candidates for being outdated. + - Fetch at least 20-30 candidates. + - Example command (with label): `gh issue list --repo flutter/devtools --search "label:bug is:open sort:created-asc" --limit 30 | cat` + - Example command (without label): `gh issue list --repo flutter/devtools --search "is:open sort:created-asc" --limit 30 | cat` + +2. **Investigate Status**: + - For each candidate, analyze its description and comments. + - **Pro Tip**: Use the bundled script `dart scripts/fetch_issues.dart ` or `dart scripts/fetch_issues.dart --page ` to get a comprehensive view of issues and their comments. + - Compare the issue's request or reported bug with the current state of the codebase. + - Refer to `references/rationale_templates.md` for a library of common reasons issues become outdated in DevTools. + - **Safety Rule**: Do not assume a bug is fixed or obsolete just because the screen has been updated or the file modified. Verify if the specific bug behavior is still possible. Valid bugs or feature requests should not be closed as stale just because they are old or have no activity. Inactivity alone does not invalidate a feature request or bug report. + +3. **Draft and Review Closing Comments (CRITICAL MANDATE)**: + - For issues identified as candidates for closing, draft a detailed comment for each explaining *why* it can be closed. + - **Style Constraint**: DO NOT use em dashes (—) in the comments. Use hyphens (-) or colons (:) instead. + - **Template**: Consult `references/rationale_templates.md` for wording inspiration. + - Each comment MUST end with: "If there is more work to do here, please let us know by filing a new issue with up to date information. Thanks!" + - **User Approval Required**: You MUST present the identified issues (including URLs to the issues for easy navigation) and their drafted comments to the user and obtain explicit approval BEFORE running any command that closes an issue. + +4. **Iterate on Skill Knowledge (Learning Loop)**: + - If you discover a new, distinct category of closing rationale that is not covered in `references/rationale_templates.md`, **update the reference file** to include it. + +5. **Execute and Summarize**: + - Once approved, use `gh issue close` with the `-c` flag to post the comment and close the issue. + - Provide the user with a clean bulleted list of links to each closing comment. + +## Tips + +- Use available file and content search tools (such as `grep`, `ripgrep`, or environment-specific +search tools) to check the current codebase for references to the issue or relevant code. +- Look for related PRs that might have fixed the issue but didn't close it automatically. +- **Pro Tip**: Use the bundled script `dart scripts/search_prs.dart ` to search for PRs in the repository. +- For issues reporting specific versions, check the current DevTools version in `packages/devtools_app/pubspec.yaml` +to determine if the reported version is very old. If the reported version is 1 or more major versions behind or 12 or more +minor versions behind the current version, this issue is a good candidate for being obsolete. diff --git a/.agents/skills/closing-obsolete-issues/references/rationale_templates.md b/.agents/skills/closing-obsolete-issues/references/rationale_templates.md new file mode 100644 index 00000000000..ba7c6c27fd9 --- /dev/null +++ b/.agents/skills/closing-obsolete-issues/references/rationale_templates.md @@ -0,0 +1,41 @@ +# Common Closing Rationales for DevTools + +When investigating old issues in the Flutter DevTools repository, look for these common reasons they may be eligible for closing. Use these as templates for your closing comments. + +## 1. Superseded by New DevTools Features +DevTools has evolved significantly. Many old requests for features are now solved by newer implementations or entire new screens. +- **Example**: Requests for specific memory allocation tracking features that are covered by the new Tracing or Diff panes. +- **Rationale**: Point to the new feature or screen that fulfills the need (e.g., "This is now supported in the Memory screen's Tracing pane."). + +## 2. Observatory Deprecation +With the deprecation and removal of the Observatory UI in favor of DevTools, issues specifically requesting feature parity or fixing bugs in Observatory integration may be obsolete. +- **Rationale**: Note that Observatory is deprecated/removed and DevTools is the supported solution. + +## 3. Tooling Daemon (DTD) and IDE Integration +Issues about IDE integration or multi-package support might be resolved by the introduction of the Dart Tooling Daemon (DTD). +- **Rationale**: Explain that DTD now handles this integration or that workspace support has improved. + +## 4. UI Refactoring and Legacy Screens +Requests related to old UI patterns or legacy screens that have been completely rewritten or removed are obsolete. +- **Example**: The "Analysis" pane in the Memory screen no longer exists. +- **Rationale**: Note that the feature or screen has been refactored or removed. + +## 5. Resolved by Flutter SDK Updates +Some issues are caused by or fixed by changes in the Flutter SDK rather than DevTools itself. +- **Rationale**: If a bug was fixed in a specific Flutter version, mention it. + +## 6. Stale Feature Requests +Proposals or feature requests from several years ago with no recent activity or community interest may be closed if they no longer align with current priorities or have been superseded by general improvements. +- **Rationale**: Note that the issue is a stale feature request with no recent activity and that DevTools has evolved significantly since then. + +## 7. Very Old DevTools Version +Issues reported on very old versions of DevTools (check current version in `packages/devtools_app/pubspec.yaml` for comparison) may be closed if they are likely fixed or no longer relevant. +- **Rationale**: "This issue looks like it occurred on an old version of DevTools. Are you still experiencing this issue with DevTools on the latest Flutter stable? If so, please reopen and we will take a look. Thanks." + +## 8. Insufficient Information +Issues that lack clear descriptions, reproduction steps, or logs make it impossible to investigate. +- **Rationale**: "Without additional information, we cannot debug this issue. Please re-open if you can provide a description of your issue and repro steps. Thanks." + +--- +**Reminder**: Every closing comment MUST end with: +"If there is more work to do here, please let us know by filing a new issue with up to date information. Thanks!" diff --git a/.agents/skills/closing-obsolete-issues/scripts/fetch_issue_details.sh b/.agents/skills/closing-obsolete-issues/scripts/fetch_issue_details.sh new file mode 100755 index 00000000000..729efccedd0 --- /dev/null +++ b/.agents/skills/closing-obsolete-issues/scripts/fetch_issue_details.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +# A script to fetch and format comprehensive issue details for investigation. +# Usage: ./fetch_issue_details.sh + +ISSUE_NUMBER=$1 + +if [ -z "$ISSUE_NUMBER" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "--- INVESTIGATION FOR ISSUE #$ISSUE_NUMBER ---" +# Fetching all comments to ensure full context is captured. +gh issue view "$ISSUE_NUMBER" --repo flutter/devtools --json number,title,author,createdAt,labels,body,comments -t ' +Title: {{.title}} +Author: {{if .author}}{{.author.login}}{{else}}ghost{{end}} +Created: {{.createdAt}} +Labels: {{range .labels}}{{.name}}, {{end}} + +Description: +{{.body}} + +--- ALL COMMENTS --- +{{range .comments}} +{{if .author}}{{.author.login}}{{else}}ghost{{end}} ({{.createdAt}}): +{{.body}} +------------------------------------------------------------ +{{end}} +' \ No newline at end of file diff --git a/.agents/skills/closing-obsolete-issues/scripts/fetch_issues.dart b/.agents/skills/closing-obsolete-issues/scripts/fetch_issues.dart new file mode 100644 index 00000000000..c84dfcf65b5 --- /dev/null +++ b/.agents/skills/closing-obsolete-issues/scripts/fetch_issues.dart @@ -0,0 +1,268 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +// ignore_for_file: avoid_print, avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +/// A tool to fetch and format comprehensive details for GitHub issues in `flutter/devtools`. +/// +/// Usage: +/// # Fetch specific issues: +/// dart fetch_issues.dart 4152 4072 4071 +/// +/// # Fetch a specific page of open issues (default 25 per page, sort:updated-desc): +/// dart fetch_issues.dart --page 31 +/// +/// # Fetch with custom query or limit: +/// dart fetch_issues.dart --page 31 --query "is:issue state:open sort:updated-desc" +/// dart fetch_issues.dart --query "label:bug is:open sort:created-asc" --limit 20 +void main(List args) async { + if (args.isEmpty || args.contains('-h') || args.contains('--help')) { + _printUsage(); + return; + } + + String repo = 'flutter/devtools'; + String? query; + int? page; + int perPage = 25; + int? limit; + final issueNumbers = []; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == '--repo' && i + 1 < args.length) { + repo = args[++i]; + } else if (arg == '--query' && i + 1 < args.length) { + query = args[++i]; + } else if (arg == '--page' && i + 1 < args.length) { + page = int.tryParse(args[++i]); + } else if (arg == '--per-page' && i + 1 < args.length) { + perPage = int.tryParse(args[++i]) ?? 25; + } else if (arg == '--limit' && i + 1 < args.length) { + limit = int.tryParse(args[++i]); + } else if (arg.startsWith('#')) { + final num = int.tryParse(arg.substring(1)); + if (num != null) issueNumbers.add(num); + } else { + final num = int.tryParse(arg); + if (num != null) { + issueNumbers.add(num); + } else { + stderr.writeln('Unrecognized argument: $arg'); + _printUsage(); + exitCode = 1; + return; + } + } + } + + if (issueNumbers.isEmpty) { + if (page != null) { + final defaultQuery = query ?? 'is:issue is:open sort:updated-desc'; + issueNumbers.addAll( + await _fetchIssueNumbersForPage( + repo: repo, + query: defaultQuery, + page: page, + perPage: perPage, + ), + ); + } else if (query != null || limit != null) { + final effectiveQuery = query ?? 'is:issue is:open sort:created-asc'; + final effectiveLimit = limit ?? 25; + issueNumbers.addAll( + await _fetchIssueNumbersForQuery( + repo: repo, + query: effectiveQuery, + limit: effectiveLimit, + ), + ); + } + } + + if (issueNumbers.isEmpty) { + print('No issues found matching criteria.'); + return; + } + + print( + 'Fetching details for ${issueNumbers.length} issues: ${issueNumbers.join(', ')}...\n', + ); + + // Fetch issue details with bounded concurrency (5 concurrent requests). + final results = await _fetchAllIssueDetails(repo, issueNumbers); + + results.forEach(_printFormattedIssue); +} + +void _printUsage() { + print(''' +Usage: + dart fetch_issues.dart [ ...] + dart fetch_issues.dart --page [--per-page ] [--query ] + dart fetch_issues.dart --query "" [--limit ] + +Options: + --repo GitHub repository (default: flutter/devtools) + --page Page number from search results + --per-page Number of results per page (default: 25) + --query Search query string + --limit Total number of issues to fetch + -h, --help Show this help message +'''); +} + +Future> _fetchIssueNumbersForPage({ + required String repo, + required String query, + required int page, + required int perPage, +}) async { + final encodedQuery = 'repo:$repo $query'; + final result = await Process.run('gh', [ + 'api', + 'search/issues?q=${Uri.encodeQueryComponent(encodedQuery)}&per_page=$perPage&page=$page', + '--jq', + '.items[].number', + ]); + + if (result.exitCode != 0) { + stderr.writeln('Error fetching issues: ${result.stderr}'); + return []; + } + + final lines = (result.stdout as String).trim().split('\n'); + return lines.map((l) => int.tryParse(l.trim())).whereType().toList(); +} + +Future> _fetchIssueNumbersForQuery({ + required String repo, + required String query, + required int limit, +}) async { + final result = await Process.run('gh', [ + 'issue', + 'list', + '--repo', + repo, + '--search', + query, + '--limit', + limit.toString(), + '--json', + 'number', + '--jq', + '.[].number', + ]); + + if (result.exitCode != 0) { + stderr.writeln('Error fetching issues: ${result.stderr}'); + return []; + } + + final lines = (result.stdout as String).trim().split('\n'); + return lines.map((l) => int.tryParse(l.trim())).whereType().toList(); +} + +Future>> _fetchAllIssueDetails( + String repo, + List numbers, { + int concurrency = 5, +}) async { + final results = ?>[]; + results.length = numbers.length; + + var index = 0; + Future worker() async { + while (true) { + if (index >= numbers.length) return; + final currentIdx = index++; + final num = numbers[currentIdx]; + results[currentIdx] = await _fetchSingleIssueDetails(repo, num); + } + } + + final workers = List.generate(concurrency, (_) => worker()); + await Future.wait(workers); + + return results.whereType>().toList(); +} + +Future?> _fetchSingleIssueDetails( + String repo, + int number, +) async { + final result = await Process.run('gh', [ + 'issue', + 'view', + number.toString(), + '--repo', + repo, + '--json', + 'number,title,author,createdAt,updatedAt,labels,body,comments,state,url', + ]); + + if (result.exitCode != 0) { + stderr.writeln('Error fetching issue #$number: ${result.stderr}'); + return null; + } + + try { + return jsonDecode(result.stdout as String) as Map; + } catch (e) { + stderr.writeln('Error parsing JSON for issue #$number: $e'); + return null; + } +} + +void _printFormattedIssue(Map data) { + final number = data['number']; + final title = data['title'] ?? ''; + final author = data['author']?['login'] ?? 'unknown'; + final createdAt = data['createdAt'] ?? ''; + final state = data['state'] ?? ''; + final url = + data['url'] ?? 'https://github.com/flutter/devtools/issues/$number'; + final labels = (data['labels'] as List? ?? []) + .map((l) => l['name'] as String) + .toList(); + final body = (data['body'] as String? ?? '').trim(); + final comments = data['comments'] as List? ?? []; + + print('=' * 70); + print('ISSUE #$number: $title'); + print('URL: $url'); + print('Author: $author | Created: $createdAt | State: $state'); + print('Labels: $labels'); + print('\n--- BODY ---'); + if (body.isEmpty) { + print('(No description provided)'); + } else if (body.length > 1000) { + print('${body.substring(0, 1000)}\n... (truncated)'); + } else { + print(body); + } + + print('\n--- COMMENTS (${comments.length}) ---'); + if (comments.isEmpty) { + print('(No comments)'); + } else { + for (final c in comments) { + final cAuthor = c['author']?['login'] ?? 'unknown'; + final cDate = c['createdAt'] ?? ''; + final cBody = (c['body'] as String? ?? '') + .replaceAll('\r\n', '\n') + .trim(); + final preview = cBody.length > 300 + ? '${cBody.substring(0, 300)}...' + : cBody; + final formattedPreview = preview.replaceAll('\n', '\n '); + print(' [$cAuthor at $cDate]:\n $formattedPreview'); + } + } + print(''); +} diff --git a/.agents/skills/closing-obsolete-issues/scripts/search_prs.dart b/.agents/skills/closing-obsolete-issues/scripts/search_prs.dart new file mode 100644 index 00000000000..dca3e5ffa80 --- /dev/null +++ b/.agents/skills/closing-obsolete-issues/scripts/search_prs.dart @@ -0,0 +1,91 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +// ignore_for_file: avoid_print, avoid_dynamic_calls + +import 'dart:convert'; +import 'dart:io'; + +/// A script to search for PRs in `flutter/devtools`. +/// +/// Usage: +/// `dart search_prs.dart [--limit ] [--state ]` +void main(List args) async { + if (args.isEmpty || args.contains('-h') || args.contains('--help')) { + print(''' +Usage: + dart search_prs.dart [--limit ] [--state ] + +Options: + --limit Maximum number of PRs to return (default: 20) + --state Filter by state: open, closed, merged, all (default: all) + --repo GitHub repository (default: flutter/devtools) + -h, --help Show this help message +'''); + return; + } + + String repo = 'flutter/devtools'; + int limit = 20; + String? state; + final queryTerms = []; + + for (var i = 0; i < args.length; i++) { + final arg = args[i]; + if (arg == '--limit' && i + 1 < args.length) { + limit = int.tryParse(args[++i]) ?? 20; + } else if (arg == '--state' && i + 1 < args.length) { + state = args[++i]; + } else if (arg == '--repo' && i + 1 < args.length) { + repo = args[++i]; + } else { + queryTerms.add(arg); + } + } + + var query = queryTerms.join(' '); + if (state != null) { + query += ' state:$state'; + } + + print('--- SEARCHING PRs IN $repo FOR: $query ---\n'); + + final result = await Process.run('gh', [ + 'search', + 'prs', + query, + '--repo', + repo, + '--limit', + limit.toString(), + '--json', + 'number,title,state,url,createdAt,closedAt', + ]); + + if (result.exitCode != 0) { + stderr.writeln('Error searching PRs: ${result.stderr}'); + exitCode = 1; + return; + } + + final prs = (jsonDecode(result.stdout as String) as List) + .cast>(); + + if (prs.isEmpty) { + print('No PRs found matching query.'); + return; + } + + for (final pr in prs) { + final number = pr['number']; + final title = pr['title'] ?? ''; + final prState = pr['state'] ?? ''; + final url = pr['url'] ?? ''; + final createdAt = pr['createdAt'] ?? ''; + print('#$number $title ($prState)'); + print('Url: $url'); + print('Created: $createdAt'); + print('-' * 60); + } +} diff --git a/.agents/skills/closing-obsolete-issues/scripts/search_prs.sh b/.agents/skills/closing-obsolete-issues/scripts/search_prs.sh new file mode 100755 index 00000000000..5f27ec36e2d --- /dev/null +++ b/.agents/skills/closing-obsolete-issues/scripts/search_prs.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# A script to search for PRs in flutter/devtools. +# Usage: ./search_prs.sh + +QUERY=$1 + +if [ -z "$QUERY" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "--- SEARCHING PRs FOR: $QUERY ---" +gh search prs "$QUERY" --repo flutter/devtools --limit 20 --json number,title,state,url,createdAt -t ' +{{range .}} +#{{.number}} {{.title}} ({{.state}}) +Url: {{.url}} +Created: {{.createdAt}} +------------------------------------------------------------ +{{end}} +' diff --git a/.agents/skills/debugging-devtools-extensions/SKILL.md b/.agents/skills/debugging-devtools-extensions/SKILL.md new file mode 100644 index 00000000000..5fbb2e4e881 --- /dev/null +++ b/.agents/skills/debugging-devtools-extensions/SKILL.md @@ -0,0 +1,63 @@ +--- +name: debugging-devtools-extensions +description: Guidelines and step-by-step workflow for debugging DevTools extensions locally, including stub mode, fixed-port launching, browser auto-opening, URL query parameters, target app connection, and human-in-the-loop interaction. Use when debugging or testing DevTools extension behavior. +--- + +# Debugging DevTools Extensions + +Follow this workflow to test and debug DevTools extensions locally. + +## 1. Local Stub Extensions Mode (No Server Needed) + +When running DevTools in standalone web mode (`flutter run -d chrome`), DevTools does not run the `devtools_server` backend by default. To test extensions without a running server backend: + +1. Open [`packages/devtools_app/lib/src/shared/development_helpers.dart`](file:///Users/ryjohn/code/github/flutter/devtools/packages/devtools_app/lib/src/shared/development_helpers.dart#L57). +2. Set `const _debugDevToolsExtensions = true;`. + +> [!WARNING] +> Never commit `_debugDevToolsExtensions = true;` to git. A repository unit test (`development_helpers_test.dart`) enforces that this flag remains `false`. + +Activating stub mode registers the following mock extensions: +- `foo_ext` (`package:foo`) +- `bar_ext` (`package:bar`) +- `provider_ext` (`package:provider`) + +## 2. Automated Launch & Browser Navigation + +The agent can automate running DevTools AND launching the browser directly to the target URL: + +### Step 2a: Launch DevTools on a Fixed Port +In `packages/devtools_app`, launch DevTools specifying a fixed `--web-port`: +```bash +flutter run -d chrome --web-port=52941 +``` + +### Step 2b: Open Browser to Target URL Automatically +Use the system OS open command to launch Chrome/browser directly to the desired test URL: + +- **macOS**: `open "http://localhost:52941/foo_ext?embedMode=one"` +- **Linux**: `xdg-open "http://localhost:52941/foo_ext?embedMode=one"` +- **Windows**: `start "http://localhost:52941/foo_ext?embedMode=one"` + +## 3. Connecting to an End-User Target App + +To test against real pub package extensions: + +1. Run the sample app in `packages/devtools_extensions/example/app_that_uses_foo`: + ```bash + cd packages/devtools_extensions/example/app_that_uses_foo + flutter run -d chrome + ``` +2. Ask the user to copy/paste the VM Service URI from the terminal output (e.g. `ws://127.0.0.1:8181/xxx=/ws`). +3. Open the browser automatically with the `uri` parameter using the appropriate OS command (as described in Step 2b): + - **macOS**: `open "http://localhost:52941/foo_ext?embedMode=one&uri="` + - **Linux**: `xdg-open "http://localhost:52941/foo_ext?embedMode=one&uri="` + - **Windows**: `start "http://localhost:52941/foo_ext?embedMode=one&uri="` + +## 4. Human Interaction & User Prompting Steps + +When an AI agent is performing this workflow: + +- **Obtaining VM Service URI**: When connecting to a target app, ask the user to provide the VM Service URI printed in the target app's console output (using `ask_question` or a direct prompt). +- **Automated Browser Opening**: The agent should launch DevTools and execute the appropriate OS command (`open`, `xdg-open`, or `start` as described in Step 2b) to launch the browser automatically. +- **Manual Visual Verification**: Ask the user to inspect the opened browser window and confirm whether the expected extension UI or behavior is visible. diff --git a/.agents/skills/preparing-pr/SKILL.md b/.agents/skills/preparing-pr/SKILL.md new file mode 100644 index 00000000000..f50206d8d3c --- /dev/null +++ b/.agents/skills/preparing-pr/SKILL.md @@ -0,0 +1,27 @@ +--- +name: preparing-pr +description: Prepare a pull request by running presubmit with fixes. Use when preparing to commit or send a PR. +--- + +# Preparing a Pull Request + +Follow these steps to prepare a pull request for DevTools: + +1. **Verify `dt` configuration**: + - Ensure `dt` is available on the path by running `dt --help` or verifying its alias. + - If not available, refer to the [CONTRIBUTING guide](../../../CONTRIBUTING.md#L64-L80) for instructions on how to set up the `dt` executable on your path. +2. **Update Flutter SDK**: + - Run `dt update-flutter-sdk` to ensure the SDK is up to date. +3. **Run Presubmit with Fixes**: + - Run `dt presubmit --fix` to address automated fixes. + - If any issues remain or if the command fails, fix the issues manually and run `dt presubmit --fix` again to verify the fix. +4. **Run Tests for Affected Code**: + - Use `git status` or `git diff upstream/master` to find changed files. + - Run tests that are associated with the changed code. For example, if you edited `packages/devtools_app/lib/src/screens/logging/logging_screen.dart`, run the logging tests under `packages/devtools_app/test/screens/logging/`. + +## Verification + +If any step fails, stop and address the issue, and then verify the issue is fixed before proceeding. + +> [!IMPORTANT] +> Do NOT commit or push changes without getting explicit user approval first. diff --git a/.agents/skills/reviewing-devtools-prs/SKILL.md b/.agents/skills/reviewing-devtools-prs/SKILL.md new file mode 100644 index 00000000000..0b9276b0f68 --- /dev/null +++ b/.agents/skills/reviewing-devtools-prs/SKILL.md @@ -0,0 +1,46 @@ +--- +name: reviewing-devtools-prs +description: DevTools repository-specific PR review workflow enforcing DevTools style guidelines and common review patterns. Use when reviewing pull requests in the flutter/devtools repository. +--- + +# Reviewing DevTools Pull Requests + +Extends [reviewing-prs](../reviewing-prs/SKILL.md) for pull requests in `flutter/devtools`. Follow [reviewing-prs](../reviewing-prs/SKILL.md) for GitHub CLI data retrieval and the strict user approval workflow. + +## References & Style Guidelines + +Read and enforce: +- **Style Guide**: [styleguide.md](../../../.gemini/styleguide.md) (severity tags `[MUST-FIX]`, `[CONCERN]`, `[NIT]`, zero-formatting policy, copyright headers, DRY rules, magic values) +- **Repository Constraints**: [AGENTS.md](../../../AGENTS.md) +- **Code Style**: [STYLE.md](../../../STYLE.md) + +## Common Review Patterns + +1. **Listener & Resource Disposals**: + - Ensure controller and notifier listeners use `addAutoDisposeListener(...)`. + +2. **Helper Widgets over Helper Methods**: + - Prefer small composable `Widget` classes over helper methods returning `Widget` (`_buildFoo()`). + +3. **Reuse Shared Components & Test Helpers**: + - Use standard shared widgets (e.g. `CenteredMessage`) and test mocks (e.g. `mockConnectedApp`) instead of re-creating them inline. + +4. **TODO Formatting**: + - Link TODOs to a GitHub issue or LDAP: `// TODO(https://github.com/flutter/devtools/issues/): `. + +5. **Async & Unawaited Futures**: + - Audit unawaited futures and suggest `unawaited(...)` or `safeUnawaited(...)` where appropriate. + +6. **Feature Flags**: + - Default feature flags to `false` with explicit test expectations in `feature_flags_test.dart`. + +7. **Test File Structure & PR Scope**: + - Place test fakes/helpers below `main()`. + - Ask authors to revert unrelated file changes or commented-out test code. + +8. **Constant Scoping**: + - Keep single-use constants local to the component, but extract user-facing UI strings into shared constants when used across multiple places. + +9. **Release Notes Scope (`NEXT_RELEASE_NOTES.md`)**: + - Release notes are strictly for end-user facing changes (e.g. Inspector, Memory UI/UX). Internal tools (`dt` / `devtools_tool`), CI, and refactors are NOT user-facing. + - Request removing release notes added for developer tools like `dt`, or suggest a `* . [#](https://github.com/flutter/devtools/pull/)` entry via [adding-release-notes](../adding-release-notes/SKILL.md) if a user-facing PR lacks one. diff --git a/.agents/skills/reviewing-prs/SKILL.md b/.agents/skills/reviewing-prs/SKILL.md new file mode 100644 index 00000000000..10da0d1a4ad --- /dev/null +++ b/.agents/skills/reviewing-prs/SKILL.md @@ -0,0 +1,58 @@ +--- +name: reviewing-prs +description: General workflow for fetching, inspecting, reviewing GitHub Pull Requests using the gh CLI, drafting user-aligned review comments, and securing approval before posting. Use when asked to review a GitHub PR or pull request. +--- + +# Reviewing Pull Requests + +This skill outlines the workflow for inspecting GitHub Pull Requests using the `gh` CLI, drafting review feedback, and securing user approval before posting review comments. + +## Approval Safeguard (Strict Requirement) + +> [!IMPORTANT] +> **NEVER post comments or reviews to GitHub without explicit prior user approval.** +> Always present draft review comments to the user in natural language first. Only execute write commands (`gh pr comment`, `gh pr review`) after the user approves. + +## Workflow + +### 1. Request Information via GitHub CLI + +- **PR Details**: + ```bash + gh pr view --repo --json title,body,author,state,headRefName,baseRefName,comments,reviews,files + ``` +- **Code Diff**: + ```bash + gh pr diff --repo + ``` +- **Existing Inline Comments**: + ```bash + gh api repos//pulls//comments + ``` +- **CI / Status Checks**: + ```bash + gh pr checks --repo + ``` + +### 2. Inspect Context & Prior Feedback + +- Read the PR description, linked issues, and full diff. +- Verify whether existing bot or human comments have already been addressed in subsequent commits. + +### 3. Draft Review Comments + +- Keep comments direct, concise, and focused on code quality and correctness. +- **Approvals**: Keep comments concise (`LGTM` or `A couple comments but lgtm.`). Avoid fluffy praise or re-summarizing the PR. +- **Actionable Feedback**: Reference specific files, line numbers, and rationale when leaving suggestions. + +### 4. Present Draft & Post Only Upon User Approval + +> [!IMPORTANT] +> **NEVER post comments or reviews to GitHub without explicit prior user approval.** + +1. Present the drafted review comments to the user in your response window. +2. Ask for confirmation: *"Would you like me to submit this review to GitHub?"* +3. Once explicitly approved by the user, post the review: + ```bash + gh pr review --repo --comment --body "" + ``` diff --git a/.agents/skills/skills_lint_ignore.json b/.agents/skills/skills_lint_ignore.json new file mode 100644 index 00000000000..a35a7895575 --- /dev/null +++ b/.agents/skills/skills_lint_ignore.json @@ -0,0 +1,10 @@ +{ + "skills": { + "adding-release-notes": [ + { + "rule_id": "check-relative-paths", + "file_name": "SKILL.md" + } + ] + } +} \ No newline at end of file diff --git a/.agents/skills/updating-package-versions/SKILL.md b/.agents/skills/updating-package-versions/SKILL.md new file mode 100644 index 00000000000..7ef4ef1e9d4 --- /dev/null +++ b/.agents/skills/updating-package-versions/SKILL.md @@ -0,0 +1,22 @@ +--- +name: updating-package-versions +description: Guides updating dependency versions in published packages to avoid mono-repo resolution failures. +--- + +# Updating Package Versions + +When updating dependency versions in published package `pubspec.yaml` files (`devtools_shared`, `devtools_app_shared`, and `devtools_extensions`), follow these rules to protect the local mono-repo workspace graph: + +- **Version Decisions**: Decide if it should be a major, minor, or patch version based on your edits: + - **Breaking changes**: Major bump (+1 to first component, others reset to 0). E.g., removals of public constants, properties, getters/setters, or APIs. + - **New features or deprecation start**: Minor bump (+1 to second component, patch resets to 0). + - **Bug fixes and non-breaking changes**: Patch bump (+1 to third component). +- **Match Exact Versions**: Always use the **actual** version specified in the target package's `pubspec.yaml` file. +- **Suffix Preservation**: If a requested version contains a suffix like `-wip` (e.g., `13.0.0-wip`), the full string MUST be used in dependency constraints (e.g., `devtools_shared: ^13.0.0-wip`). +- **Prevent Graph Failures**: Do not drop the suffix or estimate the base version tags. Version solver operations will fail in the local workspace if dependencies point to published strings that can't be resolved in non-published repositories. +- **No Dependencies on Unpublished Packages**: Published packages (`devtools_shared`, `devtools_app_shared`, and `devtools_extensions`) MUST NOT depend on unpublished packages like `devtools_app` or `devtools_test`. +- **Resolution Testing**: After updating versions, run `flutter pub get` in the repository to ensure version solving is satisfied. If this returns errors, you should fix the errors and try again. +- **Updating devtools_app**: In `packages/devtools_app/pubspec.yaml`: + - Dependencies on published packages do not have version constraints. This is intentional; do not change this when updating versions. + - Always use the `dt update-version` tool to update the `devtools_app` version. + - This should only be updated for monthly releases and cherry pick releases, so perform this update only when explicitly asked to. diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 00000000000..25c4a8475a1 --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,28 @@ +# Customize Gemini Code Assist behavior for DevTools. +# +# Schema: https://developers.google.com/gemini-code-assist/docs/customize-repo-review#config.yaml-schema +# Based on Flutter config: https://github.com/flutter/flutter/blob/master/.gemini/config.yaml + +# Minimize verbosity. +have_fun: false + +code_review: + # (DEFAULT) - Include field in the config for easy shut off if needed. + disable: false + # LOW, MEDIUM, HIGH or CRITICAL. + comment_severity_threshold: MEDIUM + # (DEFAULT) -1 is unlimited. + max_review_comments: -1 + pull_request_opened: + # (DEFAULT) Explicitly set help to false in case the default changes in the + # future, as having a help message on every PR would be spammy. + help: false + # These tend to be verbose, and since we expect PR authors to clearly + # describe their PRs this would be at best duplicative. + summary: false + # Avoid comments on draft PRs. + include_drafts: false + +ignore_patterns: + # Ignore third party files by default + - "**/third_party/**" diff --git a/.gemini/styleguide.md b/.gemini/styleguide.md new file mode 100644 index 00000000000..624c4c8cb94 --- /dev/null +++ b/.gemini/styleguide.md @@ -0,0 +1,75 @@ +# DevTools Style Guide for Gemini Code Assist + +This style guide outlines the coding conventions for the DevTools repository to help Gemini Code Assist provide effective code reviews. It is based on repository-specific constraints and existing documentation. + +**Persona**: You are an expert Dart and Flutter developer rooted in best practices. Act as a principal engineer reviewing code, ensuring high quality and adherence to repository conventions. + +## 1. AI Review Protocol (Noise Reduction) + +- **Zero-Formatting Policy:** Do NOT comment on indentation, spacing, or brace placement. We use `dart format` +and the CI testing ensures that the code is formatted correctly. +- **Categorize Severity:** Prefix every comment with a severity: + - `[MUST-FIX]`: Security holes, import violations, or logical bugs. + - `[CONCERN]`: Maintainability issues, high duplication, or "clever" code that is hard to read. + - `[NIT]`: Idiomatic improvements or minor naming suggestions. +- **Focus:** Prioritize logic, performance on the UI thread, and architectural consistency. +- **No Empty Praise:** Do not leave "Looks good" or "Nice change" comments. If there are no issues, leave no comments. +- **Copyright Headers:** Ensure all new files (except for agent skills and generated files) have a proper copyright header with the current year. For example: + ``` + // Copyright 2026 The Flutter Authors + // Use of this source code is governed by a BSD-style license that can be + // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + ``` + Flag missing copyright headers as `[MUST-FIX]`. + +## 2. Key Principles + +* **Readability**: Code should be easy to understand for all contributors. +* **Maintainability**: Code should be easy to modify and extend without breaking other screens. +* **Consistency**: Adhering to consistent style across all DevTools packages improves collaboration and reduces errors. +* **Code Reuse & DRY**: Use shared primitives and components rather than recreating them from scratch. Flag duplicated code blocks (90%+ identical) and repeated sub-expressions or function calls within the same method as `[CONCERN]` or `[NIT]`, and suggest extracting them into local variables or constants. + Prefer these DevTools helpers when the diff re-creates them: + * `CenteredMessage` instead of `Center` + `Text`, and `NotifierSwitch` instead of a raw `Switch` + nested inside a `ValueListenableBuilder` (both from + `packages/devtools_app/lib/src/shared/ui/common_widgets.dart`). + * `MultiValueListenableBuilder` instead of nested `ValueListenableBuilder`s; a `ValueNotifier` + plus a builder instead of calling `markNeedsBuild`/`setState` by hand. + * `safeUnawaited(...)` for fire-and-forget futures; `addAutoDisposeListener(...)` for listeners. + * `Logger` instead of `debugPrint`. + * Constants and shared styles from `packages/devtools_app_shared/lib/src/ui/theme/theme.dart` + instead of raw numbers or raw `TextStyle`s: sizes and spacing such as `smallFontSize`, + `defaultSpacing`, and `defaultIconSize`, and text styles such as + `Theme.of(context).regularTextStyle`, `boldTextStyle`, and `subtleTextStyle`. + * In tests, existing helpers such as `mockConnectedApp` instead of hand-built mocks. + * A small `StatelessWidget` instead of a helper method that returns a widget. +* **Constants & Magic Values**: Avoid using raw/magic strings or numbers. Extract them into descriptive named constants so the intent and meaning of the values are clear. +* **Testing**: All changes should include automated tests to ensure correctness and prevent regressions. +* **Release notes** (`packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md`): + * A PR should only add its own entry. Flag edits to other entries, removed blank lines or + punctuation, and leftover text from merge conflicts. + * Links in this file are intentionally relative (the notes are copied to the Flutter + website). Flag changes that make them absolute. + * Entries are past tense (e.g. "Added ..."), and the sentence ends with a period. The PR + link that follows the sentence (e.g. `[#1234](...)`) does not need a period. + * Only user-facing changes belong here. Changes to the `dt` tool, CI, tests, or internal + refactors should use the `release-notes-not-required` label instead. + +## 3. Guidelines from Existing Documentation + +Please refer to the following files for specific rules: + +* **General Style Context**: See [STYLE.md](../STYLE.md) for rules on: + * Getter and setter order. + * Naming for typedefs and function variables. + * Overriding equality. + * Windows/POSIX path handling conventions. + * Default text styles. +* **Repository Constraints**: See [AGENTS.md](../AGENTS.md) for rules on: + * Constraints on `packages/devtools_shared` (no Flutter dependency). + * Import restrictions (no importing `devtools_app.dart` from `src/`). + * Published packages management (CHANGELOGs, versions, breaking changes). + * Code quality (DRY, Meaningful Naming). + * Strict avoidance of raw values in UI (use named constants). + * Usage of established themes and text styles. + * Usage of shared components and utilities. + * Widget structure (avoiding long build methods, widgets vs methods). diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e62b58fab90..717fb7f81de 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -11,7 +11,8 @@ on: - master # Declare default permissions as read only. -permissions: read-all +permissions: + contents: read defaults: run: @@ -36,9 +37,9 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -57,11 +58,11 @@ jobs: fail-fast: false steps: - name: Clone Flutter DevTools - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: ref: "${{ github.event.pull_request.head.sha }}" - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -74,14 +75,18 @@ jobs: wget -qO- https://dcm.dev/pgp-key.public | sudo gpg --dearmor -o /usr/share/keyrings/dcm.gpg echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | sudo tee /etc/apt/sources.list.d/dart_stable.list sudo apt-get update - sudo apt-get install dcm=1.30.0-1 # To avoid errors add `-1` (build number) to the version + sudo apt-get install dcm=1.40.4-1 # To avoid errors add `-1` (build number) to the version sudo chmod +x /usr/bin/dcm echo "$(dcm --version)" - name: Setup Dart SDK - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c - - name: Run DCM on root + uses: dart-lang/setup-dart@7654d458321ee25acccccfdb86cd48bd95768ff1 + - name: Run dcm analyze on root run: | dcm analyze packages/devtools_app packages/devtools_app_shared packages/devtools_extensions packages/devtools_shared packages/devtools_test + - name: Run dcm checks on packages + run: | + dcm check-unused-code packages/devtools_app packages/devtools_app_shared packages/devtools_extensions packages/devtools_shared packages/devtools_test --exclude-public-api + dcm check-dependencies packages/devtools_app packages/devtools_app_shared packages/devtools_extensions packages/devtools_shared packages/devtools_test test-packages: name: ${{ matrix.os }} ${{ matrix.package }} test @@ -97,9 +102,9 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -119,9 +124,9 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -144,9 +149,9 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -171,9 +176,9 @@ jobs: steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -186,7 +191,7 @@ jobs: run: ./tool/ci/bots.sh - name: Upload Golden Failure Artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: failure() with: name: golden_image_failures.${{ matrix.bot }} @@ -208,7 +213,7 @@ jobs: # TODO(https://github.com/flutter/devtools/issues/9357): Re-enable tests on windows-latest. os: [macos-latest] # Consider running integration tests in ddc mode, too. - bot: [integration_dart2js] + bot: [integration_dart2js, integration_dart2wasm] device: [flutter, flutter-web, dart-cli] # Option 1/1 will run all tests for a device in a single shard. # Option 1/2 and 2/2 should be enabled to run tests for a device in 2 shards. @@ -243,9 +248,9 @@ jobs: shard: 3/3 steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -259,7 +264,7 @@ jobs: run: ./tool/ci/bots.sh - name: Upload Golden Failure Artifacts - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a if: failure() with: name: golden_image_failures.${{ matrix.bot }} @@ -273,13 +278,13 @@ jobs: fail-fast: false matrix: # Consider running integration tests in ddc mode, too. - bot: [integration_dart2js] + bot: [integration_dart2js, integration_wasm] os: [ubuntu-latest, windows-latest] steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -302,9 +307,9 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -323,9 +328,9 @@ jobs: fail-fast: false steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -341,9 +346,9 @@ jobs: fail-fast: false steps: - name: git clone - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk @@ -364,7 +369,7 @@ jobs: # - test_dart2js # steps: # - name: git clone -# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd +# uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # # - name: tool/ci/bots.sh # env: @@ -384,7 +389,7 @@ jobs: # - integration_dart2js # steps: # - name: git clone -# uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd +# uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # - name: tool/ci/bots.sh # env: # BOT: ${{ matrix.bot }} diff --git a/.github/workflows/daily-dev-bump.yaml b/.github/workflows/daily-dev-bump.yaml index 7e57c9d62ed..2c6e5fefb90 100644 --- a/.github/workflows/daily-dev-bump.yaml +++ b/.github/workflows/daily-dev-bump.yaml @@ -37,12 +37,12 @@ jobs: runs-on: ubuntu-latest steps: - name: git clone devtools - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: ref: master - name: Load Cached Flutter SDK - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk diff --git a/.github/workflows/flutter-candidate-update.yaml b/.github/workflows/flutter-candidate-update.yaml index 7ae0757d047..51136edf48c 100644 --- a/.github/workflows/flutter-candidate-update.yaml +++ b/.github/workflows/flutter-candidate-update.yaml @@ -21,11 +21,11 @@ jobs: runs-on: ubuntu-latest steps: - name: git clone devtools - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: ref: master - - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c + - uses: dart-lang/setup-dart@7654d458321ee25acccccfdb86cd48bd95768ff1 - name: setup git config run: | diff --git a/.github/workflows/flutter-prep.yaml b/.github/workflows/flutter-prep.yaml index 6a1155d843c..7520c95112b 100644 --- a/.github/workflows/flutter-prep.yaml +++ b/.github/workflows/flutter-prep.yaml @@ -35,7 +35,7 @@ jobs: # TODO(https://github.com/flutter/devtools/issues/5729) Consider caching DevTools so that we # don't check it out again is subsequent workflows. - name: Checkout DevTools (default) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 - name: Get Latest Flutter Candidate id: flutter-candidate @@ -45,7 +45,7 @@ jobs: - name: Load Cached Flutter SDK id: cache-flutter - uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: path: | ./tool/flutter-sdk diff --git a/.github/workflows/release-notes.yaml b/.github/workflows/release-notes.yaml index 32511d05926..463f5c29833 100644 --- a/.github/workflows/release-notes.yaml +++ b/.github/workflows/release-notes.yaml @@ -26,10 +26,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PULL_NUMBER: ${{steps.get-pull-request-number.outputs.PULL_REQUEST_NUMBER}} run: | - FILES_RESPONSE=$(gh api /repos/$GITHUB_REPOSITORY/pulls/$PULL_NUMBER/files) - echo "FILES_RESPONSE: $FILES_RESPONSE" + FILES_RESPONSE=$(gh api --paginate /repos/$GITHUB_REPOSITORY/pulls/$PULL_NUMBER/files) - HAS_CHANGED_RELEASE_NOTES=$(echo $FILES_RESPONSE | jq '.[].filename' | jq -s '. | any(. == env.CURRENT_RELEASE_FILE_PATH)') + HAS_CHANGED_RELEASE_NOTES=$(echo "$FILES_RESPONSE" | jq -r --arg path "$CURRENT_RELEASE_FILE_PATH" '[.[].filename == $path] | any') echo "HAS_CHANGED_RELEASE_NOTES=$HAS_CHANGED_RELEASE_NOTES" >> $GITHUB_OUTPUT - name: Check Release Preparedness requirements diff --git a/.github/workflows/workflow-bot-cleanup.yaml b/.github/workflows/workflow-bot-cleanup.yaml index fee8ef0789f..0828f09cb0c 100644 --- a/.github/workflows/workflow-bot-cleanup.yaml +++ b/.github/workflows/workflow-bot-cleanup.yaml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Sparse checkout of the repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 with: sparse-checkout: | README.md diff --git a/.gitignore b/.gitignore index 2a119978514..ff6678e7488 100644 --- a/.gitignore +++ b/.gitignore @@ -43,9 +43,6 @@ DEPENDENCIES.md **/logs/** *.stager_app.g.dart -# Ignore Gemini logs -**/.gemini/ - # Ignore VS code workspaces that may be configured per-user *.code-workspace @@ -55,3 +52,5 @@ DEPENDENCIES.md # macOS generated Flutter files **/macos/Flutter/ephemeral/ **/macos/Flutter/GeneratedPluginRegistrant.swift + +build/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..218ad3bf918 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# DevTools Repo Guidelines for AI Assistants + +Follow these rules when working in this repository. + +## Repository Rules + +### Dependencies +- **`packages/devtools_shared`**: Never introduce a Flutter dependency or a dependency on web-only Dart libraries. +- **Imports**: Never import `packages/devtools_app/lib/devtools_app.dart` in code under `packages/devtools_app/lib/src/`. It is acceptable in test code. + +### Published Packages (`packages/devtools_shared`, `packages/devtools_app_shared`, `packages/devtools_extensions`) +- Document file changes in the respective `CHANGELOG.md` files. +- Ensure version numbers in `pubspec.yaml` are updated accordingly for required changes. +- Ensure no breaking changes are introduced. If introduced, they must be intentional and documented. +- The published packages should not depend on unpublished packages like `packages/devtools_app` and `packages/devtools_test`. + +### Code Style +- **Style Guide**: Follow the DevTools style guide in [STYLE.md](STYLE.md). +- **Formatting & Analysis**: Always ensure code is formatted properly and has no analysis errors or warnings before completing a task. + +### Code Quality & Maintainability +- **DRY (Don't Repeat Yourself)**: Identify blocks of code that are 90%+ identical to existing utility methods in this repo and flag them for duplication. +- **Meaningful Naming**: Variables should describe their intent (e.g., `timeoutInMs` instead of `t`). +- **Descriptive Pull Request**: Contributors should include the information recommended in the pull request template (in `.github/PULL_REQUEST_TEMPLATE.md`). + +## Documentation +- All public members should have documentation. +- **Answer your own questions**: If you have a question, find the answer, and then document it where you first looked. +- **Documentation should be useful**: Explain the *why* and the *how*. +- **Introduce terms**: Assume the reader does not know everything. Link to definitions. +- Use `///` for public-quality documentation, even on private members. + +## Running Tests +- **Standard Packages**: Run unit and widget tests with `flutter test`. +- **`packages/devtools_shared`**: Run tests with `dart test`. +- **Integration Tests**: Refer to the [Integration Test README](packages/devtools_app/integration_test/README.md) for instructions on running integration tests. + +## UI Development Guidelines + +### Constants and Magic Values +- **Avoid Raw Values**: Avoid using raw strings or numbers in UI code. Use named constants instead. + +### Themes and Styles +- **Use Existing Themes**: Use existing theme values and text styles from `packages/devtools_app_shared/lib/src/ui/theme/theme.dart` (e.g., `Theme.of(context).regularTextStyle`). +- **Reuse Patterns**: Reuse common patterns and styles used in other UI code in the repository. + +### Code Reuse +- **Use Shared Components & Utils**: Prefer using reusable components from `shared/ui/` (such as `packages/devtools_app/lib/src/shared/ui/common_widgets.dart`), primitives from `shared/primitives/`, and utilities from `shared/utils/` rather than creating things from scratch. + +### Helper Widgets and Methods +- **Avoid Long Build Methods**: Use separate helper widgets instead of writing excessively long build methods to keep the structure clear. +- **Prefer Widgets Over Methods**: Create small, composable helper widgets rather than helper methods that return a widget at build time. This improves readability and allows Flutter to optimize tree updates better. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..ba90b7b7e46 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Claude Code Guidelines + +Before starting any work in this repository, you MUST read and follow the instructions in the `AGENTS.md` file. diff --git a/CODEOWNERS b/CODEOWNERS index a0d64f867b8..879a1c82714 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,28 +1,28 @@ * @flutter/devtools-reviewers # Inspector Files -inspector/ @elliette +inspector/ @kenzieschmoll @srawlins # Network Files -network/ @bkonyi @kenzieschmoll +network/ @srawlins @kenzieschmoll # Performance Files performance/ @kenzieschmoll # CPU Profiler files -profiler/ @kenzieschmoll @bkonyi +profiler/ @kenzieschmoll # Memory files -memory/ @bkonyi @kenzieschmoll +memory/ @kenzieschmoll # Debugger files -debugger/ @elliette +debugger/ @srawlins # VM Developer files vm_developer/ @bkonyi # Tooling files -/tool/ @kenzieschmoll +/tool/ @kenzieschmoll @srawlins # DevTools extensions and related files src/extensions/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86ffc9f1714..80293f223ff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,7 +24,7 @@ repo as well. We prioritize reviewing PRs that address issues with the following labels: * [`good-first-issue`](https://github.com/flutter/devtools/issues?q=state%3Aopen%20label%3Agood-first-issue): Small and easy issues that can be tackled by a first-time contributor. -* [`contributions-welcome`](https://github.com/flutter/devtools/issues?q=state%3Aopen%20label%3Acontributions-welcome): Issues of any size or difficulty that we consider high-priority fixes. +* [`contributions-welcome`](https://github.com/flutter/devtools/issues?q=state%3Aopen%20label%3A%22contributions%20welcome%22): Issues of any size or difficulty that we consider high-priority fixes. We communicate primarily over GitHub and [Discord](https://github.com/flutter/flutter/blob/master/docs/contributing/Chat.md) on the [#hackers-devtools](https://discord.com/channels/608014603317936148/1106667330093723668) channel. @@ -154,13 +154,19 @@ not want to merge `upstream/master` or update your Flutter SDK version, instead If the version of DCM used on the bots is outdated, consider contributing a PR to update the version on the bots to the latest. +## Companion app + +For most DevTools features, we recommend using the [DevTools companion +app](https://github.com/flutter/demos/tree/main/devexp_demos/devtools_companion) +as the example Flutter app to connect to when working on DevTools. + ## Running and debugging DevTools -Please see [DEBUGGING.md]() for guidance on running and debugging DevTools. +Please see [DEBUGGING.md](DEBUGGING.md) for guidance on running and debugging DevTools. ## Testing for DevTools -Please see [TESTING.md]() for guidance on running and writing tests. +Please see [TESTING.md](TESTING.md) for guidance on running and writing tests. ## Appendix diff --git a/GEMINI.md b/GEMINI.md deleted file mode 100644 index 4cc9c4ca9e8..00000000000 --- a/GEMINI.md +++ /dev/null @@ -1,24 +0,0 @@ -# Repository rules - -When making changes to this code base, follow these rules, which are listed -in no particular order: -- `packages/devtools_shared` should never introduce a Flutter dependency or a -dependency on web-only Dart libraries. -- never import the `packages/devtools_app/lib/devtools_app.dart` file in code -that lives under `packages/devtools_app/lib/src/`. This file is okay to import -in code that lives under `packages/devtools_app/test/`. - -# Running tests - -Unit test and widget tests are all contained under a package's `test/` -directory. These tests should be run with `flutter test` in all packages except -for `packages/devtools_shared`, whose tests should be run with `dart test`. - -# Gemini Guidelines - -- Prefer to use MCP server tools over shell commands whenever possible. -- When you are done making code changes, ensure the code does not have analysis -errors or warnings. Also ensure it is formatted properly. You should have MCP -server tools available to you to perform these tasks. If not, you can get -analysis errors and warnings by running the `dart analyze` shell command, and -you can perform Dart formatting with the `dart format` shell command. diff --git a/STYLE.md b/STYLE.md index 878461888fb..d898886528f 100644 --- a/STYLE.md +++ b/STYLE.md @@ -6,14 +6,14 @@ found in the LICENSE file or at https://developers.google.com/open-source/licens # DevTools style guide We fully follow [Effective Dart](https://dart.dev/effective-dart) -and some items of -[Style guide for Flutter repo](https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md): +and certain guidelines from the +[Flutter repository style guide](https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md): ## Order of getters and setters -When an object owns and exposes a (listenable) value, -more complicated than just public field -we declare the related class members always in the same order, +When an object owns and exposes a (listenable) value +that is more complex than a simple public field, +we always declare the related class members in the same order, without new lines separating the members, in compliance with [Flutter repo style guide](https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md#order-other-class-members-in-a-way-that-makes-sense): @@ -41,10 +41,10 @@ converted to paths when required to interact with the file system. Additionally: -- `Uri.parse()` should not be used for converting file paths to URIs, instead - `Uri.file()` should be used -- `Uri.path` should not be used for extracting a file path from a URI, instead - `uri.toFilePath()` should be used +- `Uri.parse()` should not be used for converting file paths to URIs; instead, + `Uri.file()` should be used. +- `Uri.path` should not be used for extracting a file path from a URI; instead, + `uri.toFilePath()` should be used. - In code compiled to run in the browser, `Uri.file()` and `uri.toFilePath()` will assume POSIX-style paths even on Windows, so care should be taken to handle these correctly (if possible, avoid converting between URIs and file diff --git a/TRIAGE.md b/TRIAGE.md index 331ff6a1d87..f1daaa2ab5f 100644 --- a/TRIAGE.md +++ b/TRIAGE.md @@ -14,7 +14,7 @@ The triager is assigned by an automatic rotation of DevTools team members. - [Untriaged issues](https://github.com/flutter/devtools/issues?q=is%3Aopen+is%3Aissue+-label%3AP0%2CP1%2CP2%2CP3) - [Reproduce to verify issues](https://github.com/flutter/devtools/labels/reproduce%20to%20verify) (issues that need to be manually reproduced in order to verify validity) -- [flutter/flutter issues related to DevTools](https://github.com/flutter/flutter/labels/d%3A%20devtools) +- [flutter/flutter issues related to DevTools](https://github.com/flutter/flutter/labels/a%3A%20devtools) ## Triager responsibilities @@ -27,7 +27,7 @@ by applying [proper labels](#label-the-issue) and [assigning priority](#prioriti Transfer any issues to the `flutter/devtools` repo that should be tracked on our own issue tracker, and close issues you find that are obsolete. 4. Try to reproduce any issues with the [reproduce to verify](https://github.com/flutter/devtools/labels/reproduce%20to%20verify) label. -5. Spend at least 20 minutes [cleaning up the issue backlog](#clean-up-the-issue-backlog). +5. Spend at least 20 minutes [cleaning up the issue backlog](#clean-up-the-issue-backlog) and/or labeling backlog issues with "contributions welcome" or "good first issue". 6. Look through the DevTools discord [channel](https://discord.com/channels/608014603317936148/958862085297672282) for any recent user questions or concerns that require a response. @@ -36,9 +36,11 @@ questions or concerns that require a response. * Add labels for its proper category or categories ( “screen: inspector", “screen: network", “bug”, etc.) * Add cost labels ("cost: low", "cost: medium", etc.) if you have a good idea of how much work it will take to resolve this issue. Leave the cost label off if you do not know. -* Add label “waiting for customer response” if you requested more details from reporter -* Add label “fix it friday” if the issue should be fixed and looks easy to fix -* Add label "good first issue" if the issue looks like an easy starter bug for a new contributor +* Add label "waiting for customer response" if you requested more details from reporter. +* Add label "contributions welcome" for issues that can be tackled by the community.* +* Add label "good first issue" if the issue looks like an easy starter bug for a new contributor.* + +> [*] In our contribution guidelines we suggest that contributors use the "contributions welcome" and "good first issue" labels to find issues to work on. Therefore, it is important to keep these lists well-populated. ### Prioritize the issue @@ -48,14 +50,14 @@ If the issue is actively being worked on or if it needs immediate / almost-immed to a product area owner. Here are some suggested owners by product area: -* **Flutter Inspector**: @elliette +* **Flutter Inspector**: @kenzieschmoll or @srawlins * **Performance**: @kenzieschmoll * **CPU Profiler**: @kenzieschmoll or @bkonyi * **Memory**: @kenzieschmoll or @bkonyi -* **Network**: @elliette or @bkonyi -* **Logging**: @elliette or @bkonyi +* **Network**: @srawlins or @bkonyi +* **Logging**: @kenzieschmoll or @bkonyi * **VM Tools**: @bkonyi -* **Debugger**: @elliette +* **Debugger**: @srawlins * **DevTools extensions**: @kenzieschmoll * **Tooling integrations with VS Code**: @DanTup * **Tooling integrations with IntelliJ or Android Studio**: @helin24 or @jwren @@ -71,6 +73,10 @@ about issues marked “severe: …” or “P0”.** This step is to ensure the health of the [DevTools issue backlog](https://github.com/flutter/devtools/issues) over time. There are a couple of things to do as part of the backlog clean up work: - Close any obsolete issues. Recommendation: start with the oldest issues first since these are the most likely to be stale. + - **Tip**: You can use an AI assistant (agent) to help with this. Try a prompt like: + ``` + Identify and close obsolete issues in the backlog. + ``` - Add good candidates for product excellence / quality work to the [DevTools Product Excellence project](https://github.com/orgs/flutter/projects/157). This project feeds monthly milestone planning for ongoing P.E. work. diff --git a/analysis_options.yaml b/analysis_options.yaml index f219cfddeae..207a8e959bb 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -18,6 +18,18 @@ analyzer: - '**/build/**' - '**.freezed.dart' - tool/flutter-sdk/ + - '**/lib/generated_plugin_registrant.dart' + - '**/android/**' + - '**/ios/**' + - '**/web/**' + - '**/windows/**' + - '**/macos/**' + - '**/linux/**' + # Syntax highlighting test files may include or lint-violating code + # for testing so should be ignored. Uncomment this while modifying + # these files if you want to ensure code is not invalid except where + # expected. + - packages/devtools_app/test/test_infra/test_data/syntax_highlighting/** linter: rules: @@ -149,13 +161,21 @@ linter: - valid_regexps # - void_checks # not yet tested -dart_code_metrics: +dcm: metrics: cyclomatic-complexity: 20 number-of-parameters: 4 maximum-nesting-level: 5 - metrics-exclude: - - test/** + exclude: + dependencies: + - "**/packages/devtools_extensions/example/**" + metrics: + - packages/devtools_app/test/test_infra/test_data/** + unused-code: + # This fixture has unused code for testing the debugger. + - packages/devtools_app/test/test_infra/fixtures/flutter_app/** + # Ignore unused code in package examples. + - packages/devtools_app_shared/example/** rules: # - arguments-ordering Too strict # - avoid-banned-imports # TODO(polina-c): add configuration @@ -186,6 +206,8 @@ dart_code_metrics: - avoid-explicit-type-declaration # - ban-name # TODO(polina-c): add configuration # - binary-expression-operand-order Some nice catches but too many false positives to enable. + - dispose-class-fields + - dispose-fields - double-literal-format # - format-comment TODO(jacobr): enable this one after fixing violations. # TODO(jacobr): enable member-ordering. This catches a bunch of real style diff --git a/flutter-candidate.txt b/flutter-candidate.txt index 366d50243d0..c863d3e45de 100644 --- a/flutter-candidate.txt +++ b/flutter-candidate.txt @@ -1 +1 @@ -c6cec79f1f5d4ea1242936c7574af02bd39207c4 +de37392db476791bb9570b5c910cb309bf5845a0 diff --git a/packages/devtools_app/analysis_options.yaml b/packages/devtools_app/analysis_options.yaml deleted file mode 100644 index e85c2e11cf6..00000000000 --- a/packages/devtools_app/analysis_options.yaml +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2025 The Flutter Authors -# Use of this source code is governed by a BSD-style license that can be -# found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -include: ../../analysis_options.yaml - -analyzer: - exclude: - - build/** - - lib/generated_plugin_registrant.dart - - macos/ - # Syntax highlighting test files may include or lint-violating code - # for testing so should be ignored. Uncomment this while modifying - # these files if you want to ensure code is not invalid except where - # expected. - - test/test_infra/test_data/syntax_highlighting/** - -dart_code_metrics: - metrics-exclude: - - test/** - - test/test_infra/test_data/** diff --git a/packages/devtools_app/assets/dart_syntax.json b/packages/devtools_app/assets/dart_syntax.json index 2f3492cea94..f746ff4e338 100644 --- a/packages/devtools_app/assets/dart_syntax.json +++ b/packages/devtools_app/assets/dart_syntax.json @@ -1,6 +1,6 @@ { "name": "Dart", - "version": "1.4.1", + "version": "1.8.0", "fileTypes": [ "dart" ], @@ -60,6 +60,12 @@ { "include": "#constants-and-special-vars" }, + { + "include": "#class-identifier-with-optional-factory-method" + }, + { + "include": "#function-identifier" + }, { "include": "#operators" }, @@ -70,13 +76,13 @@ "repository": { "dartdoc-codeblock-triple": { - "begin": "^\\s*///\\s*(?!\\s*```)", - "end": "\n", + "begin": "^\\s*///\\s*", + "end": "$", "contentName": "variable.other.source.dart" }, "dartdoc-codeblock-block": { - "begin": "^\\s*\\*\\s*(?!(\\s*```|\/))", - "end": "\n", + "begin": "^\\s*\\*\\s*", + "end": "$", "contentName": "variable.other.source.dart" }, "dartdoc": { @@ -90,8 +96,8 @@ } }, { - "begin": "^\\s*///\\s*(```)", - "end": "^\\s*///\\s*(```)|^(?!\\s*///)", + "begin": "^\\s*///\\s*(`{3,})", + "end": "^\\s*///\\s*(?=\\1)`*|^(?!\\s*///)", "patterns": [ { "include": "#dartdoc-codeblock-triple" @@ -99,8 +105,8 @@ ] }, { - "begin": "^\\s*\\*\\s*(```)", - "end": "^\\s*\\*\\s*(```)|^(?=\\s*\\*\/)", + "begin": "^\\s*\\*\\s*(`{3,})", + "end": "^\\s*\\*\\s*(?=\\1)`*|^(?=\\s*\\*\/)", "patterns": [ { "include": "#dartdoc-codeblock-block" @@ -112,7 +118,7 @@ "name": "variable.other.source.dart" }, { - "match": "(?:\\*|\\/\\/)\\s{4,}(.*?)(?=($|\\*\\/))", + "match": "(?:\\*|\\/\\/)\\s{5,}(.*?)(?=($|\\*\\/))", "captures": { "1": { "name": "variable.other.source.dart" @@ -227,14 +233,50 @@ { "name": "constant.numeric.dart", "match": "(? _runBenchmarks({bool useWasm = false}) async { stdout.writeln('Web benchmark tests finished.'); - expect( - const JsonEncoder.withIndent(' ').convert(taskResult.toJson()), - isA(), - ); + expect(prettyPrintJson(taskResult.toJson()), isA()); expect(taskResult.scores.keys, hasLength(DevToolsBenchmark.values.length)); for (final devToolsBenchmark in DevToolsBenchmark.values) { diff --git a/packages/devtools_app/benchmark/scripts/compare_benchmarks.dart b/packages/devtools_app/benchmark/scripts/compare_benchmarks.dart index 6b919c910ef..6b159451afb 100644 --- a/packages/devtools_app/benchmark/scripts/compare_benchmarks.dart +++ b/packages/devtools_app/benchmark/scripts/compare_benchmarks.dart @@ -61,6 +61,6 @@ void compareBenchmarks( stdout.writeln('Baseline comparison finished.'); stdout ..writeln('==== Comparison with baseline $baselineSource ====') - ..writeln(const JsonEncoder.withIndent(' ').convert(delta.toJson())) + ..writeln(prettyPrintJson(delta.toJson())) ..writeln('==== End of baseline comparison ===='); } diff --git a/packages/devtools_app/benchmark/scripts/dart2wasm_performance_diff.dart b/packages/devtools_app/benchmark/scripts/dart2wasm_performance_diff.dart index d0fb75ce1d2..8a8ba98908f 100644 --- a/packages/devtools_app/benchmark/scripts/dart2wasm_performance_diff.dart +++ b/packages/devtools_app/benchmark/scripts/dart2wasm_performance_diff.dart @@ -96,7 +96,7 @@ class CsvBuilder { void writeHeaders({required int averageOf}) { writeLines([ - 'Flutter DevTools performance benchmarks diff: dart2wasm diffed against dart2js.', + 'Flutter DevTools performance benchmarks diff: dart2js (baseline) vs dart2wasm (test).', 'Benchmark results were averaged over $averageOf benchmark run(s).', '', 'These results were auto-generated by a script:', @@ -105,9 +105,8 @@ class CsvBuilder { ]); // Write the Flutter and DevTools commit hash for the benchmark run. - // TODO(kenz): automatically detect these and write them to the CSV. - const flutter = ''; - const devtools = ''; + final flutter = _detectFlutterVersion(); + final devtools = _detectDevToolsCommit(); writeLines([ 'Version info:', 'Flutter: $flutter', @@ -120,7 +119,8 @@ class CsvBuilder { writeLine([ 'Benchmark Name', 'Metric', - 'Value (micros)', + 'Baseline (micros)', + 'Test (micros)', 'Delta (micros)', 'Delta (%)', ]); @@ -178,6 +178,49 @@ class CsvBuilder { '(Google Sheets, Excel, etc.) for viewing.', ); } + + String _detectFlutterVersion() { + try { + final result = Process.runSync('flutter', [ + '--version', + '--machine', + ], runInShell: true); + if (result.exitCode == 0) { + final json = + jsonDecode(result.stdout.toString()) as Map; + final flutterVersion = json['flutterVersion']; + final frameworkRevision = json['frameworkRevision']; + if (flutterVersion != null && frameworkRevision != null) { + return '$flutterVersion (revision $frameworkRevision)'; + } + return result.stdout.toString().trim(); + } + } catch (_) {} + + try { + final result = Process.runSync('flutter', [ + '--version', + ], runInShell: true); + if (result.exitCode == 0) { + return result.stdout.toString().trim().split('\n').first; + } + } catch (_) {} + + return ''; + } + + String _detectDevToolsCommit() { + try { + final result = Process.runSync('git', [ + 'rev-parse', + 'HEAD', + ], runInShell: true); + if (result.exitCode == 0) { + return result.stdout.toString().trim(); + } + } catch (_) {} + return ''; + } } Future runBenchmarkOrUseExisting( diff --git a/packages/devtools_app/benchmark/scripts/utils.dart b/packages/devtools_app/benchmark/scripts/utils.dart index 5e015fe4b34..cb557c5a514 100644 --- a/packages/devtools_app/benchmark/scripts/utils.dart +++ b/packages/devtools_app/benchmark/scripts/utils.dart @@ -2,10 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:convert'; import 'dart:io'; import 'package:web_benchmarks/analysis.dart'; +/// Returns a pretty-printed representation of a JSON payload. +String prettyPrintJson(Object? json) => + const JsonEncoder.withIndent(' ').convert(json); + File? checkFileExists(String path) { final testFile = File.fromUri(Uri.parse(path)); if (!testFile.existsSync()) { @@ -40,14 +45,22 @@ extension BenchmarkResultsExtension on BenchmarkResults { extension BenchmarkScoreExtension on BenchmarkScore { List toCsvLine() { + final deltaValue = delta; + final baselineValue = deltaValue != null ? value - deltaValue : null; + final String deltaPercent; + if (baselineValue == null) { + deltaPercent = ''; + } else if (baselineValue == 0) { + deltaPercent = 'N/A'; + } else { + deltaPercent = (deltaValue! / baselineValue).toString(); + } return [ metric, // Metric name - value.toString(), // Value - delta?.toString() ?? '', // Delta value - // value - delta represents the baseline score. - delta != null - ? (delta! / (value - delta!)).toString() - : '', // Delta % value + baselineValue?.toString() ?? '', // Baseline value + value.toString(), // Test value + deltaValue?.toString() ?? '', // Delta value + deltaPercent, // Delta % value ]; } } diff --git a/packages/devtools_app/benchmark/test_infra/automators/_cpu_profiler_automator.dart b/packages/devtools_app/benchmark/test_infra/automators/_cpu_profiler_automator.dart index 565d51b2297..64d26fc03fd 100644 --- a/packages/devtools_app/benchmark/test_infra/automators/_cpu_profiler_automator.dart +++ b/packages/devtools_app/benchmark/test_infra/automators/_cpu_profiler_automator.dart @@ -10,8 +10,8 @@ import 'package:devtools_app/src/screens/profiler/panes/cpu_flame_chart.dart'; import 'package:devtools_app/src/screens/profiler/panes/method_table/method_table.dart'; import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/test_data.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; class CpuProfilerScreenAutomator { const CpuProfilerScreenAutomator(this.controller); diff --git a/packages/devtools_app/benchmark/test_infra/automators/_performance_automator.dart b/packages/devtools_app/benchmark/test_infra/automators/_performance_automator.dart index fee360f5bdd..d334c15d836 100644 --- a/packages/devtools_app/benchmark/test_infra/automators/_performance_automator.dart +++ b/packages/devtools_app/benchmark/test_infra/automators/_performance_automator.dart @@ -7,8 +7,8 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/test_data.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; class PerformanceScreenAutomator { const PerformanceScreenAutomator(this.controller); diff --git a/packages/devtools_app/benchmark/test_infra/automators/devtools_automator.dart b/packages/devtools_app/benchmark/test_infra/automators/devtools_automator.dart index 2dfd7e91302..cb9133be2c7 100644 --- a/packages/devtools_app/benchmark/test_infra/automators/devtools_automator.dart +++ b/packages/devtools_app/benchmark/test_infra/automators/devtools_automator.dart @@ -8,8 +8,8 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/test_data.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:web_benchmarks/client.dart'; import '../common.dart'; diff --git a/packages/devtools_app/benchmark/test_infra/common.dart b/packages/devtools_app/benchmark/test_infra/common.dart index 32ab6438d34..ec978f085bb 100644 --- a/packages/devtools_app/benchmark/test_infra/common.dart +++ b/packages/devtools_app/benchmark/test_infra/common.dart @@ -10,11 +10,9 @@ /// found" in DevTools. const _benchmarkInitialPage = ''; -const _wasmQueryParameters = {'compiler': 'wasm'}; - String benchmarkPath({required bool useWasm}) => Uri( path: _benchmarkInitialPage, - queryParameters: useWasm ? _wasmQueryParameters : null, + queryParameters: {'compiler': useWasm ? 'wasm' : 'js'}, ).toString(); String generateBenchmarkEntryPoint({required bool useWasm}) { diff --git a/packages/devtools_app/benchmark/test_infra/devtools_recorder.dart b/packages/devtools_app/benchmark/test_infra/devtools_recorder.dart index f28a87162a9..568cae46ec0 100644 --- a/packages/devtools_app/benchmark/test_infra/devtools_recorder.dart +++ b/packages/devtools_app/benchmark/test_infra/devtools_recorder.dart @@ -5,7 +5,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/initialization.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:web_benchmarks/client.dart'; import 'automators/devtools_automator.dart'; diff --git a/packages/devtools_app/benchmark/web_bundle_size_test.dart b/packages/devtools_app/benchmark/web_bundle_size_test.dart index 1e17beb7506..bf73a9005df 100644 --- a/packages/devtools_app/benchmark/web_bundle_size_test.dart +++ b/packages/devtools_app/benchmark/web_bundle_size_test.dart @@ -11,8 +11,8 @@ import 'package:path/path.dart' as path; import 'package:test/test.dart'; // Benchmark size in kB. -const bundleSizeBenchmark = 5550; -const gzipBundleSizeBenchmark = 1650; +const bundleSizeBenchmark = 6200; +const gzipBundleSizeBenchmark = 1900; void main() { group('Web Compile', () { @@ -31,7 +31,6 @@ void main() { await _runProcess('flutter', [ 'build', 'web', - '--pwa-strategy=offline-first', '--release', '--no-tree-shake-icons', ]); diff --git a/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart b/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart index 5321b55ba02..7f6a7a5676d 100644 --- a/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/debugger_panel_test.dart @@ -9,9 +9,9 @@ import 'package:devtools_app/src/screens/debugger/codeview.dart'; import 'package:devtools_app/src/service/service_extension_widgets.dart'; import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/integration_test.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:material_ui/material_ui.dart'; // To run the test while connected to a flutter-tester device: // dart run integration_test/run_tests.dart --target=integration_test/test/live_connection/debugger_panel_test.dart diff --git a/packages/devtools_app/integration_test/test/live_connection/devtools_extensions_test.dart b/packages/devtools_app/integration_test/test/live_connection/devtools_extensions_test.dart index 2792f16398d..7003f0517f1 100644 --- a/packages/devtools_app/integration_test/test/live_connection/devtools_extensions_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/devtools_extensions_test.dart @@ -19,9 +19,9 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_shared/devtools_extensions.dart'; import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/integration_test.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:material_ui/material_ui.dart'; // To run: // dart run integration_test/run_tests.dart --target=integration_test/test/live_connection/devtools_extensions_test.dart diff --git a/packages/devtools_app/integration_test/test/live_connection/eval_utils.dart b/packages/devtools_app/integration_test/test/live_connection/eval_utils.dart index 83043cdb869..7e554e86bea 100644 --- a/packages/devtools_app/integration_test/test/live_connection/eval_utils.dart +++ b/packages/devtools_app/integration_test/test/live_connection/eval_utils.dart @@ -73,7 +73,7 @@ class EvalTester { Future selectWidgetTreeNode(Finder finder) async { await tapAndPump( find.descendant( - of: find.byKey(InspectorScreenBodyState.summaryTreeKey), + of: find.byKey(InspectorScreenBodyState.inspectorTreeKey), matching: finder, ), ); diff --git a/packages/devtools_app/integration_test/test/live_connection/memory_screen_helpers.dart b/packages/devtools_app/integration_test/test/live_connection/memory_screen_helpers.dart index 2753d9704ad..8699bf30429 100644 --- a/packages/devtools_app/integration_test/test/live_connection/memory_screen_helpers.dart +++ b/packages/devtools_app/integration_test/test/live_connection/memory_screen_helpers.dart @@ -7,8 +7,8 @@ import 'package:devtools_app/src/framework/scaffold/bottom_pane.dart'; import 'package:devtools_app/src/screens/memory/panes/control/widgets/primary_controls.dart'; import 'package:devtools_app/src/screens/memory/panes/diff/widgets/snapshot_list.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; /// Prepares the UI of the memory screen for an integration test. Future prepareMemoryUI( diff --git a/packages/devtools_app/integration_test/test/live_connection/performance_screen_event_recording_test.dart b/packages/devtools_app/integration_test/test/live_connection/performance_screen_event_recording_test.dart index dd34a1c8811..f311f4348c7 100644 --- a/packages/devtools_app/integration_test/test/live_connection/performance_screen_event_recording_test.dart +++ b/packages/devtools_app/integration_test/test/live_connection/performance_screen_event_recording_test.dart @@ -6,9 +6,9 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/performance/panes/timeline_events/timeline_events_view.dart'; import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/integration_test.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service_protos/vm_service_protos.dart'; // To run: diff --git a/packages/devtools_app/integration_test/test/offline/perfetto_test.dart b/packages/devtools_app/integration_test/test/offline/perfetto_test.dart index f2fb3c30ed7..185b266bbe8 100644 --- a/packages/devtools_app/integration_test/test/offline/perfetto_test.dart +++ b/packages/devtools_app/integration_test/test/offline/perfetto_test.dart @@ -7,9 +7,9 @@ import 'package:devtools_app/src/screens/performance/panes/timeline_events/perfe import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/integration_test.dart'; import 'package:devtools_test/test_data.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:material_ui/material_ui.dart'; // To run: // dart run integration_test/run_tests.dart --target=integration_test/test/offline/perfetto_test.dart diff --git a/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart b/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart index cd3c843a291..95e2582dbf3 100644 --- a/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart +++ b/packages/devtools_app/integration_test/test_infra/run/_test_app_driver.dart @@ -15,7 +15,12 @@ class TestFlutterApp extends IntegrationTestApp { TestFlutterApp({ String appPath = 'test/test_infra/fixtures/flutter_app', TestAppDevice appDevice = TestAppDevice.flutterTester, - }) : super(appPath, appDevice); + bool headless = false, + }) : _headless = headless, + super(appPath, appDevice); + + /// Whether to run web tests headlessly. + final bool _headless; String? _currentRunningAppId; @@ -30,6 +35,8 @@ class TestFlutterApp extends IntegrationTestApp { testAppDevice.argName, // Do not serve DevTools from Flutter Tools. '--no-devtools', + if (_headless && testAppDevice == TestAppDevice.flutterChrome) + '--web-run-headless', ], workingDirectory: testAppPath, ); @@ -389,11 +396,9 @@ final class FlutterDaemonConstants { static const paramsKey = 'params'; static const traceKey = 'trace'; static const wsUriKey = 'wsUri'; - static const pidKey = 'pid'; static const appStopKey = 'app.stop'; static const appStartedKey = 'app.started'; static const appDebugPortKey = 'app.debugPort'; - static const daemonConnectedKey = 'daemon.connected'; } enum TestAppDevice { diff --git a/packages/devtools_app/integration_test/test_infra/run/run_test.dart b/packages/devtools_app/integration_test/test_infra/run/run_test.dart index 3daeee093f2..39c2c2ccef3 100644 --- a/packages/devtools_app/integration_test/test_infra/run/run_test.dart +++ b/packages/devtools_app/integration_test/test_infra/run/run_test.dart @@ -58,6 +58,7 @@ Future runFlutterIntegrationTest( testApp = TestFlutterApp( appPath: testFileArgs.appPath, appDevice: testRunnerArgs.testAppDevice, + headless: testRunnerArgs.headless, ); } debugLog('starting the test app'); @@ -82,6 +83,7 @@ Future runFlutterIntegrationTest( testTarget, testDriver: 'test_driver/integration_test.dart', headless: testRunnerArgs.headless, + useWasm: testRunnerArgs.useWasm, dartDefineArgs: [ 'test_args=${jsonEncode(testArgs)}', if (testFileArgs.experimentsOn) 'enable_experiments=true', diff --git a/packages/devtools_app/lib/devtools.dart b/packages/devtools_app/lib/devtools.dart index 44658c325e1..1cd63916957 100644 --- a/packages/devtools_app/lib/devtools.dart +++ b/packages/devtools_app/lib/devtools.dart @@ -10,4 +10,4 @@ /// Note: a regexp in the `dt update-version' command logic matches the constant /// declaration `const version =`. If you change the declaration you must also /// modify the regex in the `dt update-version' command logic. -const version = '2.57.0-dev.0'; +const version = '2.63.0-dev.0'; diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart index 5917216e754..ca45ced8a68 100644 --- a/packages/devtools_app/lib/devtools_app.dart +++ b/packages/devtools_app/lib/devtools_app.dart @@ -10,6 +10,10 @@ export 'src/framework/notifications_view.dart'; export 'src/framework/release_notes.dart'; export 'src/framework/scaffold/app_bar.dart'; export 'src/framework/scaffold/status_line.dart'; +export 'src/screens/accessibility/accessibility_controller.dart'; +export 'src/screens/accessibility/accessibility_screen.dart'; +export 'src/screens/accessibility/semantics_node_details_pane.dart'; +export 'src/screens/accessibility/semantics_node_model.dart'; export 'src/screens/app_size/app_size_controller.dart'; export 'src/screens/app_size/app_size_screen.dart'; export 'src/screens/debugger/breakpoint_manager.dart'; @@ -25,10 +29,11 @@ export 'src/screens/deep_link_validation/deep_links_screen.dart'; export 'src/screens/dtd/dtd_tools_controller.dart'; export 'src/screens/dtd/dtd_tools_screen.dart'; export 'src/screens/inspector/inspector_controller.dart'; +export 'src/screens/inspector/inspector_errors.dart'; +export 'src/screens/inspector/inspector_screen.dart'; export 'src/screens/inspector/inspector_screen_body.dart'; +export 'src/screens/inspector/inspector_screen_controller.dart'; export 'src/screens/inspector/inspector_tree_controller.dart'; -export 'src/screens/inspector_shared/inspector_screen.dart'; -export 'src/screens/inspector_shared/inspector_screen_controller.dart'; export 'src/screens/logging/log_details_controller.dart'; export 'src/screens/logging/logging_controller.dart'; export 'src/screens/logging/logging_screen.dart'; @@ -53,7 +58,6 @@ export 'src/screens/profiler/cpu_profile_model.dart'; export 'src/screens/profiler/profiler_screen.dart'; export 'src/screens/profiler/profiler_screen_controller.dart'; export 'src/screens/profiler/sampling_rate.dart'; -export 'src/screens/provider/provider_screen.dart'; export 'src/screens/vm_developer/object_inspector/class_hierarchy_explorer.dart'; export 'src/screens/vm_developer/object_inspector/class_hierarchy_explorer_controller.dart'; export 'src/screens/vm_developer/object_inspector/object_inspector_view_controller.dart'; diff --git a/packages/devtools_app/lib/initialization.dart b/packages/devtools_app/lib/initialization.dart index 1696215fab3..d721615d872 100644 --- a/packages/devtools_app/lib/initialization.dart +++ b/packages/devtools_app/lib/initialization.dart @@ -5,8 +5,8 @@ import 'dart:async'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:material_ui/material_ui.dart'; import 'src/app.dart'; import 'src/framework/framework_core.dart'; diff --git a/packages/devtools_app/lib/src/app.dart b/packages/devtools_app/lib/src/app.dart index 43c72cd6b7c..73797dd0d99 100644 --- a/packages/devtools_app/lib/src/app.dart +++ b/packages/devtools_app/lib/src/app.dart @@ -12,7 +12,7 @@ import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'extensions/extension_screen.dart'; @@ -23,6 +23,8 @@ import 'framework/notifications_view.dart'; import 'framework/observer/disconnect_observer.dart'; import 'framework/release_notes.dart'; import 'framework/scaffold/scaffold.dart'; +import 'screens/accessibility/accessibility_controller.dart'; +import 'screens/accessibility/accessibility_screen.dart'; import 'screens/app_size/app_size_controller.dart'; import 'screens/app_size/app_size_screen.dart'; import 'screens/debugger/debugger_controller.dart'; @@ -31,8 +33,8 @@ import 'screens/deep_link_validation/deep_links_controller.dart'; import 'screens/deep_link_validation/deep_links_screen.dart'; import 'screens/dtd/dtd_tools_controller.dart'; import 'screens/dtd/dtd_tools_screen.dart'; -import 'screens/inspector_shared/inspector_screen.dart'; -import 'screens/inspector_shared/inspector_screen_controller.dart'; +import 'screens/inspector/inspector_screen.dart'; +import 'screens/inspector/inspector_screen_controller.dart'; import 'screens/logging/logging_controller.dart'; import 'screens/logging/logging_screen.dart'; import 'screens/memory/framework/memory_controller.dart'; @@ -43,7 +45,6 @@ import 'screens/performance/performance_controller.dart'; import 'screens/performance/performance_screen.dart'; import 'screens/profiler/profiler_screen.dart'; import 'screens/profiler/profiler_screen_controller.dart'; -import 'screens/provider/provider_screen.dart'; import 'screens/vm_developer/vm_developer_tools_controller.dart'; import 'screens/vm_developer/vm_developer_tools_screen.dart'; import 'service/service_extension_widgets.dart'; @@ -87,20 +88,7 @@ class DevToolsApp extends StatefulWidget { class DevToolsAppState extends State with AutoDisposeMixin { List get _screens { if (FeatureFlags.devToolsExtensions.isEnabled) { - // TODO(https://github.com/flutter/devtools/issues/6273): stop special - // casing the package:provider extension. - final containsProviderExtension = extensionService - .currentExtensions - .value - .visibleExtensions - .where((e) => e.name == 'provider') - .isNotEmpty; - final devToolsScreens = containsProviderExtension - ? _originalScreens - .where((s) => s.screenId != ScreenMetaData.provider.id) - .toList() - : _originalScreens; - return [...devToolsScreens, ..._extensionScreens]; + return [..._originalScreens, ..._extensionScreens]; } return _originalScreens; } @@ -202,6 +190,7 @@ class DevToolsAppState extends State with AutoDisposeMixin { FrameworkCore.dispose(); // Workaround for https://github.com/flutter/flutter/issues/155265. removeTextFieldFocusFixHandler(); + routerDelegate.dispose(); super.dispose(); } @@ -239,12 +228,11 @@ class DevToolsAppState extends State with AutoDisposeMixin { // Provide the appropriate page route. if (pages.containsKey(page)) { Widget widget = pages[page]!(context, page, params, state); - assert(() { + if (kDebugMode) { widget = _AlternateCheckedModeBanner( builder: (context) => pages[page]!(context, page, params, state), ); - return true; - }()); + } return MaterialPage(child: widget); } @@ -473,22 +461,27 @@ class DevToolsAppState extends State with AutoDisposeMixin { message: 'Uh-oh, something went wrong. Please refresh the page.', ); } - return MultiProvider( - providers: [ - Provider.value( - value: widget.analyticsController, - ), - Provider.value(value: hoverCardController), - Provider.value( - value: releaseNotesController, - ), - ], - child: NotificationsView( - child: ReleaseNotesViewer( - controller: releaseNotesController, - child: DisconnectObserver( - routerDelegate: routerDelegate, - child: child, + // TODO(ryjohn): Upgrade to package:flutter_markdown_plus and stop using + // MaterialUiCompatibilityBridge when it switches to package:material_ui. + // ignore: deprecated_member_use + return MaterialUiCompatibilityBridge( + child: MultiProvider( + providers: [ + Provider.value( + value: widget.analyticsController, + ), + Provider.value(value: hoverCardController), + Provider.value( + value: releaseNotesController, + ), + ], + child: NotificationsView( + child: ReleaseNotesViewer( + controller: releaseNotesController, + child: DisconnectObserver( + routerDelegate: routerDelegate, + child: child, + ), ), ), ), @@ -615,7 +608,9 @@ typedef UrlParametersBuilder = /// /// This avoids issues with widgets in the appbar being hidden by the banner /// in a web or desktop app. +// ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9907): false positive. class _AlternateCheckedModeBanner extends StatelessWidget { + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9907): false positive. const _AlternateCheckedModeBanner({required this.builder}); final WidgetBuilder builder; @@ -683,8 +678,6 @@ List defaultScreens({ DevToolsScreen( HomeScreen(sampleData: sampleData), ), - // TODO(https://github.com/flutter/devtools/issues/7860): Clean-up after - // Inspector V2 has been released. DevToolsScreen( InspectorScreen(), createController: (_) => InspectorScreenController(), @@ -714,7 +707,11 @@ List defaultScreens({ LoggingScreen(), createController: (_) => LoggingController(), ), - DevToolsScreen(ProviderScreen()), + if (FeatureFlags.accessibility.isEnabled) + DevToolsScreen( + AccessibilityScreen(), + createController: (_) => AccessibilityController(), + ), DevToolsScreen( AppSizeScreen(), createController: (_) => AppSizeController(), diff --git a/packages/devtools_app/lib/src/extensions/embedded/_controller_web.dart b/packages/devtools_app/lib/src/extensions/embedded/_controller_web.dart index 388fe7ab9d8..9d281858d9d 100644 --- a/packages/devtools_app/lib/src/extensions/embedded/_controller_web.dart +++ b/packages/devtools_app/lib/src/extensions/embedded/_controller_web.dart @@ -30,6 +30,45 @@ import 'controller.dart'; /// [ui_web.PlatformViewRegistry], which [_viewIdIncrementer] is used to create. var _viewIdIncrementer = 0; +/// HTML template for the placeholder view used when debugging extensions without +/// a running DevTools server. +String _debugExtensionPlaceholderHtml(String name) { + return ''' + + + + + + +

DevTools Extension Placeholder ($name)

+

Local debugging placeholder view.

+ + +'''; +} + +/// The sandbox permissions granted to embedded extension iframes. +/// +/// Configures the iframe sandbox to allow: +/// - Script execution (`allow-scripts`) +/// - Origin-based features like local storage and service workers (`allow-same-origin`) +/// - Form submissions and downloads (`allow-forms`, `allow-downloads`) +/// - Unrestricted popup windows and links (`allow-popups`, `allow-popups-to-escape-sandbox`) +const _extensionSandboxRules = + 'allow-scripts allow-same-origin allow-forms allow-downloads allow-popups allow-popups-to-escape-sandbox'; + class EmbeddedExtensionControllerImpl extends EmbeddedExtensionController with AutoDisposeControllerMixin { EmbeddedExtensionControllerImpl(super.extensionConfig); @@ -42,7 +81,8 @@ class EmbeddedExtensionControllerImpl extends EmbeddedExtensionController String get extensionUrl { if (debugDevToolsExtensions && !isDevToolsServerAvailable) { - return 'https://flutter.dev/'; + final html = _debugExtensionPlaceholderHtml(extensionConfig.name); + return 'data:text/html;charset=utf-8,${Uri.encodeComponent(html)}'; } final basePath = devtoolsAssetsBasePath( @@ -78,6 +118,7 @@ class EmbeddedExtensionControllerImpl extends EmbeddedExtensionController final extensionPostEventStream = StreamController.broadcast(); + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9907): false positive. bool _initialized = false; @override @@ -92,7 +133,8 @@ class EmbeddedExtensionControllerImpl extends EmbeddedExtensionController // This url is safe because we built it ourselves and it does not include // any user input. ..src = extensionUrl - ..allow = 'usb'; + ..allow = 'usb' + ..sandbox.value = _extensionSandboxRules; _extensionIFrame.style ..border = 'none' ..height = '100%' diff --git a/packages/devtools_app/lib/src/extensions/embedded/_view_desktop.dart b/packages/devtools_app/lib/src/extensions/embedded/_view_desktop.dart index 5d9403f6ce8..bdada546e0c 100644 --- a/packages/devtools_app/lib/src/extensions/embedded/_view_desktop.dart +++ b/packages/devtools_app/lib/src/extensions/embedded/_view_desktop.dart @@ -2,14 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'controller.dart'; class EmbeddedExtension extends StatelessWidget { - const EmbeddedExtension({super.key, required this.controller}); - - final EmbeddedExtensionController controller; + const EmbeddedExtension({ + super.key, + required EmbeddedExtensionController controller, + }); @override Widget build(BuildContext context) { diff --git a/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart b/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart index 3135be38703..fd7ffde8538 100644 --- a/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart +++ b/packages/devtools_app/lib/src/extensions/embedded/_view_web.dart @@ -10,7 +10,7 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_extensions/api.dart'; import 'package:devtools_extensions/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:web/web.dart'; import '../../shared/config_specific/copy_to_clipboard/copy_to_clipboard.dart'; @@ -46,6 +46,7 @@ class _EmbeddedExtensionState extends State @override void dispose() { + _embeddedExtensionController.dispose(); iFrameController.dispose(); super.dispose(); } diff --git a/packages/devtools_app/lib/src/extensions/embedded/view.dart b/packages/devtools_app/lib/src/extensions/embedded/view.dart index f8563f42830..6fa5e13484b 100644 --- a/packages/devtools_app/lib/src/extensions/embedded/view.dart +++ b/packages/devtools_app/lib/src/extensions/embedded/view.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/extensions/extension_screen.dart b/packages/devtools_app/lib/src/extensions/extension_screen.dart index 7102119f487..b899197e5c1 100644 --- a/packages/devtools_app/lib/src/extensions/extension_screen.dart +++ b/packages/devtools_app/lib/src/extensions/extension_screen.dart @@ -5,7 +5,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_extensions/api.dart'; import 'package:devtools_shared/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../shared/analytics/analytics.dart' as ga; import '../shared/analytics/constants.dart' as gac; @@ -112,7 +112,9 @@ class ExtensionView extends StatelessWidget { const SizedBox(height: intermediateSpacing), Expanded( child: ValueListenableBuilder( - valueListenable: extensionService.enabledStateListenable(ext.name), + valueListenable: extensionService.enabledStateListenable( + ext.packageName, + ), builder: (context, activationState, _) { if (activationState == ExtensionEnabledState.enabled) { return KeepAliveWrapper( diff --git a/packages/devtools_app/lib/src/extensions/extension_screen_controls.dart b/packages/devtools_app/lib/src/extensions/extension_screen_controls.dart index 5635266ba6b..a235f911cfb 100644 --- a/packages/devtools_app/lib/src/extensions/extension_screen_controls.dart +++ b/packages/devtools_app/lib/src/extensions/extension_screen_controls.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_shared/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import '../shared/analytics/analytics.dart' as ga; @@ -29,7 +29,7 @@ class EmbeddedExtensionHeader extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final extensionName = ext.displayName; + final extensionPackage = ext.packageName; return SizedBox( width: double.infinity, child: Wrap( @@ -40,7 +40,7 @@ class EmbeddedExtensionHeader extends StatelessWidget { padding: const EdgeInsets.only(left: borderPadding), child: RichText( text: TextSpan( - text: 'package:$extensionName extension', + text: 'package:$extensionPackage extension', style: theme.regularTextStyle.copyWith( fontWeight: FontWeight.bold, ), @@ -96,7 +96,7 @@ class _ExtensionContextMenuButton extends StatelessWidget { @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: extensionService.enabledStateListenable(ext.displayName), + valueListenable: extensionService.enabledStateListenable(ext.packageName), builder: (context, activationState, _) { if (activationState != ExtensionEnabledState.enabled) { return const SizedBox.shrink(); @@ -168,7 +168,10 @@ class DisableExtensionDialog extends StatelessWidget { text: 'Are you sure you want to disable the ', style: theme.regularTextStyle, children: [ - TextSpan(text: ext.displayName, style: theme.fixedFontStyle), + TextSpan( + text: 'package:${ext.packageName}', + style: theme.fixedFontStyle, + ), const TextSpan(text: ' extension?'), ], ), @@ -233,7 +236,10 @@ class EnableExtensionPrompt extends StatelessWidget { text: 'The ', style: theme.regularTextStyle, children: [ - TextSpan(text: ext.name, style: theme.fixedFontStyle), + TextSpan( + text: 'package:${ext.packageName}', + style: theme.fixedFontStyle, + ), const TextSpan( text: ' extension has not been enabled. Do you want to enable' diff --git a/packages/devtools_app/lib/src/extensions/extension_service.dart b/packages/devtools_app/lib/src/extensions/extension_service.dart index 2c7513b48ca..2ba343c34cd 100644 --- a/packages/devtools_app/lib/src/extensions/extension_service.dart +++ b/packages/devtools_app/lib/src/extensions/extension_service.dart @@ -70,6 +70,7 @@ class ExtensionService extends DisposableController /// /// This set of extensions will include one version of a DevTools extension /// per package. + @visibleForTesting List get availableExtensions => _currentExtensions.value.availableExtensions; @@ -110,12 +111,12 @@ class ExtensionService extends DisposableController final _ignoredStaticExtensionsByHashCode = {}; /// Returns the [ValueListenable] that stores the [ExtensionEnabledState] for - /// the DevTools Extension with [extensionName]. + /// the DevTools Extension provided by [extensionPackageName]. ValueListenable enabledStateListenable( - String extensionName, + String extensionPackageName, ) { return _extensionEnabledStates.putIfAbsent( - extensionName.toLowerCase(), + extensionPackageName.toLowerCase(), () => ValueNotifier(ExtensionEnabledState.none), ); } @@ -231,7 +232,7 @@ class ExtensionService extends DisposableController // not always be true for extensions that are not published on pub or // extensions that do not follow best practices for naming. final isRuntimeDuplicate = runtimeExtensions.any( - (ext) => ext.name == staticExtension.name, + (ext) => ext.packageName == staticExtension.packageName, ); if (isRuntimeDuplicate) { _log.fine( @@ -255,9 +256,10 @@ class ExtensionService extends DisposableController final stateFromOptionsFile = await server.extensionEnabledState( devtoolsOptionsFileUri: extension.devtoolsOptionsUri, extensionName: extension.name, + extensionPackage: extension.packageName, ); final stateNotifier = _extensionEnabledStates.putIfAbsent( - extension.name, + extension.packageName.toLowerCase(), () => ValueNotifier(stateFromOptionsFile), ); stateNotifier.value = stateFromOptionsFile; @@ -294,12 +296,13 @@ class ExtensionService extends DisposableController final allMatchingExtensions = [ ...runtimeExtensions, ...staticExtensions, - ].where((e) => e.name == extension.name); + ].where((e) => e.packageName == extension.packageName); await [ for (final ext in allMatchingExtensions) server.extensionEnabledState( devtoolsOptionsFileUri: ext.devtoolsOptionsUri, extensionName: ext.name, + extensionPackage: ext.packageName, enable: enable, ), ].wait; diff --git a/packages/devtools_app/lib/src/extensions/extension_service_helpers.dart b/packages/devtools_app/lib/src/extensions/extension_service_helpers.dart index 1e5f54379cf..3133ac7a795 100644 --- a/packages/devtools_app/lib/src/extensions/extension_service_helpers.dart +++ b/packages/devtools_app/lib/src/extensions/extension_service_helpers.dart @@ -19,14 +19,17 @@ void deduplicateExtensionsAndTakeLatest( }) { final deduped = {}; for (final ext in extensions) { - if (deduped.contains(ext.name)) continue; - deduped.add(ext.name); + final dedupeKey = ext.packageName; + if (deduped.contains(dedupeKey)) continue; + deduped.add(dedupeKey); // This includes [ext] itself. - final matchingExtensions = extensions.where((e) => e.name == ext.name); + final matchingExtensions = extensions.where( + (e) => e.packageName == ext.packageName, + ); if (matchingExtensions.length > 1) { logger?.fine( - 'detected duplicate $extensionType extensions for ${ext.name}', + 'detected duplicate $extensionType extensions for package:${ext.packageName}', ); // Ignore all matching extensions and then mark the [latest] as @@ -45,7 +48,7 @@ void deduplicateExtensionsAndTakeLatest( ); } else { logger?.fine( - 'no duplicates found for $extensionType extension ${ext.name}', + 'no duplicates found for $extensionType extension package:${ext.packageName}', ); } } diff --git a/packages/devtools_app/lib/src/extensions/extension_settings.dart b/packages/devtools_app/lib/src/extensions/extension_settings.dart index 4ebfdfae6d9..6bd82fd2539 100644 --- a/packages/devtools_app/lib/src/extensions/extension_settings.dart +++ b/packages/devtools_app/lib/src/extensions/extension_settings.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_shared/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../shared/analytics/analytics.dart' as ga; import '../shared/analytics/constants.dart' as gac; @@ -172,9 +172,9 @@ class ExtensionSetting extends StatelessWidget { ), ]; final theme = Theme.of(context); - final extensionName = extension.name.toLowerCase(); + final packageName = extension.packageName.toLowerCase(); return ValueListenableBuilder( - valueListenable: extensionService.enabledStateListenable(extensionName), + valueListenable: extensionService.enabledStateListenable(packageName), builder: (context, enabledState, _) { return Padding( padding: const EdgeInsets.only(bottom: denseSpacing), @@ -182,7 +182,7 @@ class ExtensionSetting extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'package:$extensionName', + 'package:$packageName', overflow: TextOverflow.ellipsis, style: theme.fixedFontStyle, ), diff --git a/packages/devtools_app/lib/src/framework/framework_core.dart b/packages/devtools_app/lib/src/framework/framework_core.dart index 28bb4d4e18d..08f50bf419c 100644 --- a/packages/devtools_app/lib/src/framework/framework_core.dart +++ b/packages/devtools_app/lib/src/framework/framework_core.dart @@ -18,6 +18,7 @@ import 'package:vm_service/vm_service.dart'; import '../extensions/extension_service.dart'; import '../screens/debugger/breakpoint_manager.dart'; +import '../service/message_port_connection.dart'; import '../service/service_manager.dart'; import '../service/vm_service_wrapper.dart'; import '../shared/analytics/analytics.dart' as ga; @@ -136,32 +137,38 @@ extension FrameworkCore on Never { final uri = normalizeVmServiceUri(serviceUriAsString); if (uri != null) { vmServiceInitializationInProgress = true; + _lastServiceUriAsString = serviceUriAsString; final finishedCompleter = Completer(); try { - final service = await connect( - uri: uri, - finishedCompleter: finishedCompleter, - serviceFactory: - ({ - // ignore: avoid-dynamic, mirrors types of [VmServiceFactory]. - required Stream /*String|List*/ inStream, - required void Function(String message) writeMessage, - Log? log, - DisposeHandler? disposeHandler, - Future? streamClosed, - String? wsUri, - bool trackFutures = false, - }) => VmServiceWrapper.defaultFactory( - inStream: inStream, - writeMessage: writeMessage, - log: log, - disposeHandler: disposeHandler, - streamClosed: streamClosed, - wsUri: wsUri, - trackFutures: integrationTestMode, - ), - ); + final service = isMessagePortUri(uri) + ? await connectWithMessagePort( + uri: uri, + finishedCompleter: finishedCompleter, + ) + : await connect( + uri: uri, + finishedCompleter: finishedCompleter, + serviceFactory: + ({ + // ignore: avoid-dynamic, mirrors types of [VmServiceFactory]. + required Stream /*String|List*/ inStream, + required void Function(String message) writeMessage, + Log? log, + DisposeHandler? disposeHandler, + Future? streamClosed, + String? wsUri, + bool trackFutures = false, + }) => VmServiceWrapper.defaultFactory( + inStream: inStream, + writeMessage: writeMessage, + log: log, + disposeHandler: disposeHandler, + streamClosed: streamClosed, + wsUri: wsUri, + trackFutures: integrationTestMode, + ), + ); await serviceConnection.serviceManager.vmServiceOpened( service, @@ -187,6 +194,32 @@ extension FrameworkCore on Never { static void _defaultErrorReporter(String title, Object error) { notificationService.pushError('$title, $error', isReportable: false); } + + /// The URI of the last VM service connection, used for reconnection. + static String? _lastServiceUriAsString; + + /// Attempts to reconnect to the last known VM service. + /// + /// Returns true if reconnection was successful. + static Future reconnectVmService() async { + if (vmServiceInitializationInProgress) { + _log.warning( + 'Reconnection attempt ignored: initialization already in progress.', + ); + return false; + } + final lastUri = _lastServiceUriAsString; + if (lastUri == null) return false; + + _log.info('Attempting to reconnect to VM service at: $lastUri'); + final success = await initVmService(serviceUriAsString: lastUri); + if (success) { + _log.info('VM service reconnection successful'); + } else { + _log.warning('VM service reconnection failed'); + } + return success; + } } Future _initDTDConnection() async { diff --git a/packages/devtools_app/lib/src/framework/home_screen.dart b/packages/devtools_app/lib/src/framework/home_screen.dart index 7b02646f24e..08b99ab55ca 100644 --- a/packages/devtools_app/lib/src/framework/home_screen.dart +++ b/packages/devtools_app/lib/src/framework/home_screen.dart @@ -8,7 +8,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../service/connected_app/connection_info.dart'; @@ -32,8 +32,6 @@ class HomeScreen extends Screen { titleGenerator: () => devToolsTitle.value, ); - static final id = ScreenMetaData.home.id; - final List sampleData; @override @@ -165,10 +163,7 @@ class _ConnectInputState extends State with BlockingActionMixin { void initState() { super.initState(); connectDialogController = TextEditingController(); - assert(() { - _debugInitVmServiceCache(); - return true; - }()); + if (kDebugMode) _debugInitVmServiceCache(); } void _debugInitVmServiceCache() async { @@ -176,7 +171,7 @@ class _ConnectInputState extends State with BlockingActionMixin { // developers who tend to repeatedly restart DevTools to debug the same // test application. final uri = await storage.getValue(_debugVmServiceUriKey); - if (uri != null) { + if (uri != null && mounted) { setState(() { connectDialogController.text = uri; }); @@ -258,10 +253,9 @@ class _ConnectInputState extends State with BlockingActionMixin { return; } - assert(() { + if (kDebugMode) { safeUnawaited(storage.setValue(_debugVmServiceUriKey, uri)); - return true; - }()); + } // Cache the routerDelegate and notifications providers before the async // gap as the landing screen may not be displayed by the time the async gap diff --git a/packages/devtools_app/lib/src/framework/initializer.dart b/packages/devtools_app/lib/src/framework/initializer.dart index 8bc2760a6a4..817efc678ba 100644 --- a/packages/devtools_app/lib/src/framework/initializer.dart +++ b/packages/devtools_app/lib/src/framework/initializer.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../service/connected_app/connection_info.dart'; import '../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/framework/notifications_view.dart b/packages/devtools_app/lib/src/framework/notifications_view.dart index 83c5b69a6d3..23e8a1439e8 100644 --- a/packages/devtools_app/lib/src/framework/notifications_view.dart +++ b/packages/devtools_app/lib/src/framework/notifications_view.dart @@ -6,8 +6,8 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:material_ui/material_ui.dart'; import '../shared/globals.dart'; import '../shared/managers/notifications.dart'; @@ -86,7 +86,10 @@ class _NotificationsState extends State<_Notifications> with AutoDisposeMixin { @override void dispose() { - _overlayEntry!.remove(); + _overlayEntry + ?..remove() + ..dispose(); + _overlayEntry = null; super.dispose(); } @@ -218,6 +221,7 @@ class _NotificationState extends State<_Notification> void dispose() { controller.dispose(); _dismissTimer?.cancel(); + curve.dispose(); super.dispose(); } diff --git a/packages/devtools_app/lib/src/framework/observer/_memory_web.dart b/packages/devtools_app/lib/src/framework/observer/_memory_web.dart index c6e56a3bbd3..251cfb64071 100644 --- a/packages/devtools_app/lib/src/framework/observer/_memory_web.dart +++ b/packages/devtools_app/lib/src/framework/observer/_memory_web.dart @@ -65,7 +65,8 @@ extension type _UserAgentSpecificMemoryBreakdownAttributionElement._(JSObject _) @JS() extension type _UserAgentSpecificMemoryBreakdownAttributionContainerElement._( JSObject _ -) implements JSObject { +) + implements JSObject { external String get id; external String get url; diff --git a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart index bf415e4b3c8..bef72ffe7bd 100644 --- a/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/disconnect_observer.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../service/connected_app/connection_info.dart'; import '../../shared/analytics/analytics.dart' as ga; @@ -16,6 +16,7 @@ import '../../shared/config_specific/import_export/import_export.dart'; import '../../shared/framework/routing.dart'; import '../../shared/globals.dart'; import '../../shared/primitives/query_parameters.dart'; +import '../framework_core.dart'; class DisconnectObserver extends StatefulWidget { const DisconnectObserver({ @@ -34,9 +35,10 @@ class DisconnectObserver extends StatefulWidget { class DisconnectObserverState extends State with AutoDisposeMixin { OverlayEntry? currentDisconnectedOverlay; - late ConnectedState currentConnectionState; + bool _isReconnecting = false; + @override void initState() { super.initState(); @@ -52,6 +54,7 @@ class DisconnectObserverState extends State if (currentConnectionState.connected && currentDisconnectedOverlay != null) { setState(() { + _isReconnecting = false; hideDisconnectedOverlay(); }); } else if (!currentConnectionState.connected) { @@ -95,6 +98,38 @@ class DisconnectObserverState extends State currentDisconnectedOverlay = null; } + Future _attemptReconnect() async { + if (_isReconnecting) return; + _isReconnecting = true; + currentDisconnectedOverlay?.markNeedsBuild(); + + bool success = false; + try { + success = await FrameworkCore.reconnectVmService().timeout( + const Duration(seconds: 5), + ); + } catch (_) { + success = false; + } + + if (mounted) { + _isReconnecting = false; + if (success) { + final uri = serviceConnection.serviceManager.serviceUri; + if (uri != null) { + unawaited( + widget.routerDelegate.updateArgsIfChanged({ + DevToolsQueryParams.vmServiceUriKey: uri, + }), + ); + } + hideDisconnectedOverlay(); + } else { + currentDisconnectedOverlay?.markNeedsBuild(); + } + } + } + Future _reviewHistory() async { assert(offlineDataController.offlineDataJson.isNotEmpty); @@ -124,16 +159,29 @@ class DisconnectObserverState extends State child: Column( children: [ const Spacer(), - Text('Disconnected', style: theme.textTheme.headlineMedium), - const SizedBox(height: defaultSpacing), - if (!isEmbedded()) - ConnectToNewAppButton( - routerDelegate: widget.routerDelegate, - onPressed: hideDisconnectedOverlay, - gaScreen: gac.devToolsMain, - ) - else - const Text('Run a new debug session to reconnect.'), + if (_isReconnecting) ...[ + const CircularProgressIndicator(), + const SizedBox(height: defaultSpacing), + Text( + 'Reconnecting...', + style: theme.textTheme.headlineMedium, + ), + ] else ...[ + Text('Disconnected', style: theme.textTheme.headlineMedium), + const SizedBox(height: defaultSpacing), + ElevatedButton( + onPressed: _attemptReconnect, + child: const Text('Reconnect'), + ), + if (!isEmbedded()) ...[ + const SizedBox(height: denseSpacing), + ConnectToNewAppButton( + routerDelegate: widget.routerDelegate, + onPressed: hideDisconnectedOverlay, + gaScreen: gac.devToolsMain, + ), + ], + ], const Spacer(), if (offlineDataController.offlineDataJson.isNotEmpty) ...[ ElevatedButton( diff --git a/packages/devtools_app/lib/src/framework/observer/memory_observer.dart b/packages/devtools_app/lib/src/framework/observer/memory_observer.dart index 9618a6d57d5..b1e2d267ac8 100644 --- a/packages/devtools_app/lib/src/framework/observer/memory_observer.dart +++ b/packages/devtools_app/lib/src/framework/observer/memory_observer.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/framework/release_notes.dart b/packages/devtools_app/lib/src/framework/release_notes.dart index f6ea52ca9aa..a4d6a8d9688 100644 --- a/packages/devtools_app/lib/src/framework/release_notes.dart +++ b/packages/devtools_app/lib/src/framework/release_notes.dart @@ -8,9 +8,9 @@ import 'dart:convert'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; -import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import '../shared/primitives/url_utils.dart'; import '../shared/server/server.dart' as server; @@ -28,6 +28,7 @@ bool debugTestReleaseNotes = false; // from the flutter/website PR, which has a GitHub action that automatically // stages commits to firebase. Example: // https://flutter-docs-prod--pr12652-devtools-release-notes-2-52-3bbb8c0u.web.app/tools/devtools/release-notes/release-notes-2.52.0.md. +// ignore: unused-code, debug-only feature. String? _debugReleaseNotesUrl; const releaseNotesKey = Key('release_notes'); diff --git a/packages/devtools_app/lib/src/framework/scaffold/about_dialog.dart b/packages/devtools_app/lib/src/framework/scaffold/about_dialog.dart index 2a31c09ed53..b86833d9337 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/about_dialog.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/about_dialog.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/framework/scaffold/app_bar.dart b/packages/devtools_app/lib/src/framework/scaffold/app_bar.dart index 55ba3916926..72baad32a73 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/app_bar.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/app_bar.dart @@ -5,7 +5,7 @@ import 'dart:math' as math; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import '../../shared/framework/screen.dart'; diff --git a/packages/devtools_app/lib/src/framework/scaffold/bottom_pane.dart b/packages/devtools_app/lib/src/framework/scaffold/bottom_pane.dart index bba85e2633b..8b1e5bb7cb5 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/bottom_pane.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/bottom_pane.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/ui/tab.dart'; diff --git a/packages/devtools_app/lib/src/framework/scaffold/report_feedback_button.dart b/packages/devtools_app/lib/src/framework/scaffold/report_feedback_button.dart index 447fc32c0d0..252c62cb8ea 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/report_feedback_button.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/report_feedback_button.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart b/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart index 8c34b71eb82..db8a3a6750a 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/scaffold.dart @@ -5,13 +5,13 @@ import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../../app.dart'; +import '../../extensions/extension_screen.dart'; import '../../extensions/extension_settings.dart'; import '../../screens/debugger/debugger_screen.dart'; -import '../../shared/ai_assistant/widgets/ai_assistant_pane.dart'; import '../../shared/analytics/prompt.dart'; import '../../shared/config_specific/drag_and_drop/drag_and_drop.dart'; import '../../shared/config_specific/import_export/import_export.dart'; @@ -24,6 +24,7 @@ import '../../shared/globals.dart'; import '../../shared/managers/banner_messages.dart'; import '../../shared/primitives/query_parameters.dart'; import '../../shared/title.dart'; +import '../../shared/ui/common_widgets.dart'; import 'about_dialog.dart'; import 'app_bar.dart'; import 'bottom_pane.dart'; @@ -58,14 +59,32 @@ class DevToolsScaffold extends StatefulWidget { embedMode: embedMode, ); - static List defaultActions({Color? color}) => [ - OpenSettingsAction(color: color), - if (FeatureFlags.devToolsExtensions.isEnabled && - !DevToolsQueryParams.load().hideExtensions) - ExtensionSettingsAction(color: color), - ReportFeedbackButton(color: color), - OpenAboutAction(color: color), - ]; + /// Returns the list of ScaffoldAction widgets. + static List defaultActions({ + Color? color, + Screen? currentScreen, + DevToolsQueryParams? queryParams, + }) { + queryParams ??= DevToolsQueryParams.load(); + // Whether DevTools is embedded in an IDE displaying extension(s). + final isEmbeddedExtensionScreen = + isEmbedded() && + (currentScreen is ExtensionScreen || + queryParams.hideAllExceptExtensions); + // Show extension settings if extensions are enabled and not hidden, AND: + // - DevTools is running standalone in the browser (!isEmbedded), OR + // - DevTools is embedded in an IDE, but specifically showing an extension. + final showExtensionSettings = + FeatureFlags.devToolsExtensions.isEnabled && + !queryParams.hideExtensions && + (!isEmbedded() || isEmbeddedExtensionScreen); + return [ + OpenSettingsAction(color: color), + if (showExtensionSettings) ExtensionSettingsAction(color: color), + ReportFeedbackButton(color: color), + OpenAboutAction(color: color), + ]; + } /// The padding around the content in the DevTools UI. EdgeInsets get appPadding => EdgeInsets.fromLTRB( @@ -317,11 +336,6 @@ class DevToolsScaffoldState extends State !offlineDataController.showingOfflineData.value; final showConsole = isConnectedAppView && _currentScreen.showConsole(widget.embedMode); - final showAiAssistant = - FeatureFlags.aiAssistant.isEnabled && - isConnectedAppView && - _currentScreen.showAiAssistant(); - final showBottomPane = showConsole || showAiAssistant; final containsSingleSimpleScreen = widget.screens.length == 1 && widget.screens.first is SimpleScreen; final showAppBar = @@ -353,7 +367,7 @@ class DevToolsScaffoldState extends State body: OutlineDecoration.onlyTop( child: Padding( padding: widget.appPadding, - child: showBottomPane + child: showConsole ? SplitPane( axis: Axis.vertical, initialFractions: const [0.8, 0.2], @@ -367,10 +381,7 @@ class DevToolsScaffoldState extends State content, BottomPane( screenId: _currentScreen.screenId, - tabs: [ - if (showConsole) const ConsolePane(), - if (showAiAssistant) const AiAssistantPane(), - ], + tabs: const [ConsolePane()], ), ], ) diff --git a/packages/devtools_app/lib/src/framework/scaffold/settings_dialog.dart b/packages/devtools_app/lib/src/framework/scaffold/settings_dialog.dart index 6bef59f0d58..b957df2f7f7 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/settings_dialog.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/settings_dialog.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../../shared/analytics/analytics_controller.dart'; diff --git a/packages/devtools_app/lib/src/framework/scaffold/status_line.dart b/packages/devtools_app/lib/src/framework/scaffold/status_line.dart index 16133d36a29..4a7238c5a55 100644 --- a/packages/devtools_app/lib/src/framework/scaffold/status_line.dart +++ b/packages/devtools_app/lib/src/framework/scaffold/status_line.dart @@ -2,11 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:async'; + import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; +import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; import '../../shared/framework/screen.dart'; import '../../shared/globals.dart'; @@ -37,8 +40,6 @@ class StatusLine extends StatelessWidget { /// Whether to highlight the footer when DevTools is connected to an app. final bool highlightForConnection; - static const deviceInfoTooltip = 'Device Info'; - /// The padding around the footer in the DevTools UI. EdgeInsets get padding => const EdgeInsets.symmetric( horizontal: defaultSpacing, @@ -59,8 +60,12 @@ class StatusLine extends StatelessWidget { valueListenable: currentScreen.showIsolateSelector, builder: (context, showIsolateSelector, _) { showIsolateSelector = showIsolateSelector && isConnected; + // Keep every footer item on a single line. Items that do not fit are + // truncated rather than overflowing the status line. return DefaultTextStyle.merge( style: TextStyle(color: foregroundColor), + maxLines: 1, + overflow: TextOverflow.ellipsis, child: Container( decoration: BoxDecoration( color: backgroundColor, @@ -92,7 +97,27 @@ class StatusLine extends StatelessWidget { final pageStatus = currentScreen.buildStatus(context); final widerThanXxs = screenWidth > MediaSize.xxs; final screenMetaData = ScreenMetaData.lookup(currentScreen.screenId); - final showVideoTutorial = screenMetaData?.tutorialVideoTimestamp != null; + final showVideoTutorial = + screenMetaData?.tutorialVideoTimestamp != null || + screenMetaData?.tutorialVideoUrl != null; + // Secondary items (the tutorial link and, when embedded, the DevTools + // actions) are shown inline only on wide screens. Below [MediaSize.s] they + // move into one overflow menu, so they stay reachable and the device name + // and page status are not squeezed. + final showSecondaryItemsInline = screenWidth > MediaSize.s; + final showTutorialInline = showVideoTutorial && showSecondaryItemsInline; + final embeddedActions = isEmbedded + ? DevToolsScaffold.defaultActions( + color: foregroundColor, + currentScreen: currentScreen, + ) + : const []; + final overflowTutorial = showVideoTutorial && !showTutorialInline + ? screenMetaData + : null; + final overflowActions = showSecondaryItemsInline + ? const [] + : embeddedActions; return [ Row( mainAxisSize: MainAxisSize.min, @@ -102,11 +127,10 @@ class StatusLine extends StatelessWidget { screenWidth: screenWidth, highlightForConnection: highlightForConnection, ), - if (showVideoTutorial) ...[ + if (showTutorialInline) ...[ BulletSpacer(color: foregroundColor), VideoTutorialLink( screenMetaData: screenMetaData!, - screenWidth: screenWidth, highlightForConnection: highlightForConnection, ), ], @@ -118,15 +142,28 @@ class StatusLine extends StatelessWidget { BulletSpacer(color: foregroundColor), ], if (screenWidth > MediaSize.xs && pageStatus != null) ...[ - pageStatus, + // The page status needs to be flexible so that the status line can + // shrink to fit narrow screens without overflowing. + Flexible(child: pageStatus), BulletSpacer(color: foregroundColor), ], - buildConnectionStatus(context, screenWidth), - if (widerThanXxs && isEmbedded) ...[ + // The connection status needs to be flexible so that the status line + // can shrink to fit narrow screens without overflowing. + Flexible(child: buildConnectionStatus(context, screenWidth)), + if (showSecondaryItemsInline && embeddedActions.isNotEmpty) ...[ BulletSpacer(color: foregroundColor), Row( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, - children: DevToolsScaffold.defaultActions(color: foregroundColor), + children: embeddedActions, + ), + ], + if (overflowTutorial != null || overflowActions.isNotEmpty) ...[ + BulletSpacer(color: foregroundColor), + _StatusLineOverflowMenu( + tutorial: overflowTutorial, + actions: overflowActions, + color: foregroundColor, ), ], ]; @@ -171,16 +208,18 @@ class StatusLine extends StatelessWidget { }, ), const SizedBox(width: denseSpacing), - DevToolsTooltip( - message: 'Connected device', - child: Text( - description, - style: highlightForConnection - ? theme.regularTextStyle.copyWith( - color: theme.colorScheme.onPrimary, - ) - : theme.regularTextStyle, - overflow: TextOverflow.clip, + Flexible( + child: DevToolsTooltip( + message: 'Connected device', + child: Text( + description, + style: highlightForConnection + ? theme.regularTextStyle.copyWith( + color: theme.colorScheme.onPrimary, + ) + : theme.regularTextStyle, + overflow: TextOverflow.ellipsis, + ), ), ), ], @@ -189,7 +228,7 @@ class StatusLine extends StatelessWidget { return child!; } }, - child: screenWidth <= MediaSize.xxs + child: screenWidth <= MediaSize.xs ? const DevToolsTooltip( message: noConnectionMsg, child: Icon(Icons.warning_amber_rounded, size: actionsIconSize), @@ -239,21 +278,41 @@ class DocumentationLink extends StatelessWidget { /// A widget that links to the "Dive in to DevTools" YouTube video at the /// chapter for the given [screenMetaData]. class VideoTutorialLink extends StatelessWidget { - const VideoTutorialLink({ + VideoTutorialLink({ super.key, required this.screenMetaData, - required this.screenWidth, required this.highlightForConnection, - }); + }) : assert( + screenMetaData.tutorialVideoTimestamp != null || + screenMetaData.tutorialVideoUrl != null, + ); final ScreenMetaData screenMetaData; - final MediaSize screenWidth; - final bool highlightForConnection; static const _devToolsYouTubeVideoUrl = 'https://youtu.be/_EYk-E29edo'; + static GaLink _linkFor(ScreenMetaData screenMetaData) { + return GaLink( + display: 'Watch tutorial', + url: + screenMetaData.tutorialVideoUrl ?? + '$_devToolsYouTubeVideoUrl${screenMetaData.tutorialVideoTimestamp}', + gaScreenName: screenMetaData.id, + gaSelectedItemDescription: + '${gac.videoTutorialLink}-${screenMetaData.id}', + ); + } + + /// Opens the tutorial video for [screenMetaData] and records the same + /// analytics event as this link, for use from the overflow menu. + static void open(ScreenMetaData screenMetaData) { + final link = _linkFor(screenMetaData); + unawaited(launchUrlWithErrorHandling(link.url)); + ga.select(link.gaScreenName!, link.gaSelectedItemDescription!); + } + @override Widget build(BuildContext context) { final color = highlightForConnection @@ -261,19 +320,60 @@ class VideoTutorialLink extends StatelessWidget { : null; return LinkIconLabel( icon: Icons.ondemand_video_rounded, - link: GaLink( - display: screenWidth <= MediaSize.xs ? 'Tutorial' : 'Watch tutorial', - url: - '$_devToolsYouTubeVideoUrl${screenMetaData.tutorialVideoTimestamp}', - gaScreenName: screenMetaData.id, - gaSelectedItemDescription: - '${gac.videoTutorialLink}-${screenMetaData.id}', - ), + link: _linkFor(screenMetaData), color: color, ); } } +/// The footer items that do not fit at the current width: the video tutorial +/// link and, for embedded DevTools, the DevTools actions (Settings, Feedback, +/// About, and extension settings), in one menu. +/// +/// Each item runs the same behavior as its inline counterpart. +class _StatusLineOverflowMenu extends StatelessWidget { + const _StatusLineOverflowMenu({ + required this.tutorial, + required this.actions, + required this.color, + }); + + /// The metadata of the screen whose tutorial should be offered, if any. + final ScreenMetaData? tutorial; + + final List actions; + + final Color? color; + + @override + Widget build(BuildContext context) { + final tutorial = this.tutorial; + return PopupMenuButton( + tooltip: 'More actions', + padding: EdgeInsets.zero, + onSelected: (onSelected) => onSelected(), + itemBuilder: (_) => [ + if (tutorial != null) + PopupMenuItem( + value: () => VideoTutorialLink.open(tutorial), + child: const Text('Watch tutorial'), + ), + for (final action in actions) + PopupMenuItem( + value: () => action.onPressed(context), + child: Text(action.tooltip), + ), + ], + child: SizedBox( + width: actionWidgetSize, + child: Center( + child: Icon(Icons.more_horiz, size: actionsIconSize, color: color), + ), + ), + ); + } +} + class IsolateSelector extends StatelessWidget { const IsolateSelector({super.key, required this.foregroundColor}); diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart new file mode 100644 index 00000000000..679bdccd399 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_controller.dart @@ -0,0 +1,347 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; + +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart' show ScrollController; +import 'package:logging/logging.dart'; + +import '../../service/service_extensions.dart' as extensions; +import '../../service/service_registrations.dart' as registrations; +import '../../shared/framework/screen.dart'; +import '../../shared/framework/screen_controllers.dart'; +import '../../shared/globals.dart'; +import 'semantics_node_model.dart'; + +final _log = Logger('accessibility_controller'); + +/// Modes for brightness override in the accessibility controls. +enum BrightnessOverride { + system('System Default', 'system'), + light('Light Mode', 'Brightness.light'), + dark('Dark Mode', 'Brightness.dark'); + + const BrightnessOverride(this.display, this.value); + + /// The user-facing display label for this override option. + final String display; + + /// The raw value associated with this override option sent to or received + /// from the VM service extension. + final String value; +} + +/// Controller for the Accessibility screen. +class AccessibilityController extends DevToolsScreenController + with AutoDisposeControllerMixin { + AccessibilityController() { + _initListeners(); + } + + @override + void init() { + super.init(); + _initServiceExtensionStates(); + _initSemanticsTree(); + } + + void _initListeners() { + addAutoDisposeListener(brightness, _onBrightnessChanged); + addAutoDisposeListener(textScale, _onTextScaleChanged); + addAutoDisposeListener(boldText, _onBoldTextChanged); + addAutoDisposeListener(screenReader, _onScreenReaderChanged); + addAutoDisposeListener(highContrast, _onHighContrastChanged); + addAutoDisposeListener(semanticsRoots, _onSemanticsRootsChanged); + } + + void _onSemanticsRootsChanged() { + selectSemanticsNode(semanticsRoots.value.firstOrNull); + } + + void _initSemanticsTree() { + if (serviceConnection.serviceManager.isolateManager.mainIsolate.value != + null) { + unawaited(_autoLoadSemanticsTreeIfNeeded()); + } + addAutoDisposeListener( + serviceConnection.serviceManager.isolateManager.mainIsolate, + () { + if (serviceConnection.serviceManager.isolateManager.mainIsolate.value != + null) { + // Clear stale data from a previous isolate so the guard in + // _autoLoadSemanticsTreeIfNeeded doesn't skip the new load. + semanticsRoots.value = []; + semanticsTreeError.value = null; + unawaited(_autoLoadSemanticsTreeIfNeeded()); + } else { + semanticsRoots.value = []; + semanticsTreeError.value = null; + } + }, + ); + } + + Future _autoLoadSemanticsTreeIfNeeded() async { + if (semanticsRoots.value.isEmpty && + semanticsTreeError.value == null && + !semanticsTreeLoading.value) { + await loadSemanticsTree(); + } + } + + void _initServiceExtensionStates() { + final state = serviceConnection.serviceManager.serviceExtensionManager + .getServiceExtensionState(extensions.brightnessMode.extension); + + void updateFromDeviceState(ServiceExtensionState state) { + final newBrightness = !state.enabled || state.value == null + ? BrightnessOverride.system + : BrightnessOverride.values.firstWhere( + (b) => b.value == state.value, + orElse: () => BrightnessOverride.system, + ); + brightness.value = newBrightness; + } + + updateFromDeviceState(state.value); + addAutoDisposeListener(state, () => updateFromDeviceState(state.value)); + } + + void _onBrightnessChanged() { + final value = brightness.value; + unawaited( + serviceConnection.serviceManager.serviceExtensionManager + .setServiceExtensionState( + extensions.brightnessMode.extension, + enabled: value != BrightnessOverride.system, + value: value.value, + ), + ); + } + + void _onTextScaleChanged() { + // TODO(hannah-hyj): Implement VM service extension call for text scale override. + } + + void _onBoldTextChanged() { + // TODO(hannah-hyj): Implement VM service extension call for bold text override. + } + + void _onScreenReaderChanged() { + // TODO(hannah-hyj): Implement VM service extension call for screen reader / semantics debugger. + // e.g. using 'ext.flutter.showSemanticsDebugger'. + } + + void _onHighContrastChanged() { + // TODO(hannah-hyj): Implement VM service extension call for high contrast override. + } + + @override + final screenId = ScreenMetaData.accessibility.id; + + // --- Accessibility Overrides State --- + final brightness = ValueNotifier( + BrightnessOverride.system, + ); + final textScale = ValueNotifier(1.0); + final boldText = ValueNotifier(false); + final screenReader = ValueNotifier(false); + final highContrast = ValueNotifier(false); + + final semanticsRoots = ValueNotifier>([]); + final selectedSemanticsNode = ValueNotifier(null); + final semanticsTreeLoading = ValueNotifier(false); + final semanticsTreeError = ValueNotifier(null); + final treeScrollController = ScrollController(); + + /// Selects [node] in the semantics tree and unselects any previously selected node. + void selectSemanticsNode(SemanticsNodeModel? node) { + if (selectedSemanticsNode.value == node) return; + selectedSemanticsNode.value?.unselect(); + node?.select(); + selectedSemanticsNode.value = node; + } + + Future loadSemanticsTree() async { + if (semanticsTreeLoading.value) return; + + final mainIsolate = + serviceConnection.serviceManager.isolateManager.mainIsolate.value; + if (mainIsolate == null) { + semanticsTreeError.value = + 'Failed to load semantics tree: no connected application.'; + return; + } + + semanticsTreeLoading.value = true; + semanticsTreeError.value = null; + + try { + await serviceConnection.serviceManager.callServiceExtensionOnMainIsolate( + registrations.enableSemantics, + args: {'enabled': 'true'}, + ); + + final response = await serviceConnection.serviceManager + .callServiceExtensionOnMainIsolate( + registrations.getIssuesAndSemanticsTree, + ); + + if (disposed) return; + + final json = response.json; + if (json != null && json.containsKey('error')) { + throw Exception(json['error']); + } + + final rawData = json?['data']; + if (rawData == null) { + throw Exception( + 'Empty semantics tree returned from service extension.', + ); + } + + final issuesMap = >{}; + final rawIssues = json?['issues']; + if (rawIssues is List) { + for (final rawIssue in rawIssues) { + if (rawIssue is! Map) continue; + final nodeId = rawIssue['nodeId']?.toString(); + if (nodeId != null) { + issuesMap + .putIfAbsent(nodeId, () => []) + .add( + AccessibilityIssue( + rule: rawIssue['rule']?.toString() ?? '', + description: rawIssue['description']?.toString() ?? '', + ), + ); + } + } + } + + final roots = []; + if (rawData is Map) { + if (rawData.isNotEmpty) { + final rootId = rawData.containsKey('0') + ? '0' + : rawData.keys.first.toString(); + roots.add( + _buildTreeFromNodesMap(rootId, rawData, {}, issuesMap), + ); + } + } + + if (roots.isEmpty) { + throw Exception('No semantics nodes found in response.'); + } + + for (final root in roots) { + root.expandCascading(); + } + semanticsRoots.value = roots; + semanticsTreeError.value = null; + } catch (e, st) { + _log.warning('Error loading semantics tree: $e', e, st); + if (!disposed) { + semanticsRoots.value = []; + semanticsTreeError.value = 'Failed to load semantics tree: $e'; + } + } finally { + if (!disposed) { + semanticsTreeLoading.value = false; + } + } + } + + SemanticsNodeModel _buildTreeFromNodesMap( + String nodeId, + Map nodesMap, + Set visited, + Map> issuesMap, + ) { + if (!visited.add(nodeId)) { + return SemanticsNodeModel(id: nodeId); + } + + final json = + (nodesMap[nodeId] as Map?) ?? + {'id': nodeId}; + final nodeIssues = issuesMap[nodeId] ?? const []; + final node = _parseSemanticsNode(json, nodeIssues); + + final childIds = + (json['childrenInTraversalOrder'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + (json['childrenInHitTestOrder'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const []; + + for (final childId in childIds) { + if (nodesMap.containsKey(childId)) { + final childNode = _buildTreeFromNodesMap( + childId, + nodesMap, + visited, + issuesMap, + ); + node.addChild(childNode); + } + } + + return node; + } + + SemanticsNodeModel _parseSemanticsNode( + Map json, [ + List issues = const [], + ]) { + final rawFlags = json['flags'] as List?; + final flags = SemanticsNodeModel.parseFlags(rawFlags); + final rect = SemanticsNodeModel.parseRect(json['rect']); + + return SemanticsNodeModel( + id: json['id']?.toString() ?? '', + label: json['label']?.toString() ?? '', + value: json['value']?.toString() ?? '', + hint: json['hint']?.toString() ?? '', + rect: rect, + flags: flags, + issues: issues, + widgetName: json['widgetName']?.toString() ?? '', + ); + } + + @override + void dispose() { + unawaited(_disposeSemanticsOnApp()); + brightness.dispose(); + textScale.dispose(); + boldText.dispose(); + screenReader.dispose(); + highContrast.dispose(); + semanticsRoots.dispose(); + selectedSemanticsNode.dispose(); + semanticsTreeLoading.dispose(); + semanticsTreeError.dispose(); + treeScrollController.dispose(); + super.dispose(); + } + + Future _disposeSemanticsOnApp() async { + try { + if (serviceConnection.serviceManager.connectedState.value.connected) { + await serviceConnection.serviceManager + .callServiceExtensionOnMainIsolate(registrations.disposeSemantics); + } + } catch (_) { + // Ignore errors if the app or isolate connection is already closed. + } + } +} diff --git a/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart b/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart new file mode 100644 index 00000000000..89e47cc6174 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/accessibility_screen.dart @@ -0,0 +1,72 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../shared/framework/screen.dart'; +import '../../shared/globals.dart'; +import 'accessibility_controller.dart'; +import 'overrides_pane.dart'; +import 'semantics_node_details_pane.dart'; +import 'semantics_tree_pane.dart'; + +export 'overrides_pane.dart'; +export 'semantics_tree_pane.dart'; + +/// A screen that displays accessibility information. +class AccessibilityScreen extends Screen { + AccessibilityScreen() : super.fromMetaData(ScreenMetaData.accessibility); + + @override + Widget buildScreenBody(BuildContext context) => + const AccessibilityScreenBody(); +} + +class AccessibilityScreenBody extends StatefulWidget { + const AccessibilityScreenBody({super.key}); + + @override + State createState() => + _AccessibilityScreenBodyState(); +} + +class _AccessibilityScreenBodyState extends State + with AutoDisposeMixin { + // ignore: unused-code, temporarily ignore since this screen is under active development. + late AccessibilityController controller; + + /// The initial split fractions for the three screen panes: + /// - [AccessibilitySemanticsTreePane]: 35% + /// - [SemanticsNodeDetailsPane]: 35% + /// - [AccessibilityOverridesPane]: 30% + static const _initialFractions = [0.35, 0.35, 0.3]; + + @override + void initState() { + super.initState(); + // ignore: unused-code, temporarily ignore since this screen is under active development. + controller = screenControllers.lookup(); + } + + @override + Widget build(BuildContext context) { + final splitAxis = _splitAxisFor(context); + return SplitPane( + axis: splitAxis, + initialFractions: _initialFractions, + children: const [ + AccessibilitySemanticsTreePane(), + SemanticsNodeDetailsPane(), + AccessibilityOverridesPane(), + ], + ); + } + + Axis _splitAxisFor(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + return screenSize.width > 1000 ? Axis.horizontal : Axis.vertical; + } +} diff --git a/packages/devtools_app/lib/src/screens/accessibility/overrides_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/overrides_pane.dart new file mode 100644 index 00000000000..9e2555ac122 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/overrides_pane.dart @@ -0,0 +1,204 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/ui.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../shared/globals.dart'; +import '../../shared/ui/common_widgets.dart'; +import 'accessibility_controller.dart'; + +/// A pane that displays the accessibility overrides controls. +class AccessibilityOverridesPane extends StatelessWidget { + const AccessibilityOverridesPane({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final controller = screenControllers.lookup(); + return DevToolsAreaPane( + header: const AreaPaneHeader( + title: Text('Accessibility Overrides'), + roundedTopBorder: false, + includeTopBorder: false, + ), + child: Scrollbar( + child: SingleChildScrollView( + padding: const EdgeInsets.all(defaultSpacing), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Simulate and test accessibility settings on the connected device in real-time.', + style: theme.subtleTextStyle, + ), + const SizedBox(height: defaultSpacing), + const Divider(), + const SizedBox(height: denseSpacing), + _BrightnessOverride(controller: controller), + const SizedBox(height: defaultSpacing), + _TextScaleOverride(controller: controller), + const SizedBox(height: defaultSpacing), + _SwitchOverride( + label: 'Bold Text', + description: 'Forces all text in the application to be bold.', + notifier: controller.boldText, + ), + const SizedBox(height: defaultSpacing), + _SwitchOverride( + label: 'Screen Reader Debugger', + description: 'Debug and test screen reader layouts.', + notifier: controller.screenReader, + ), + const SizedBox(height: defaultSpacing), + _SwitchOverride( + label: 'High Contrast', + description: 'Increases the contrast of text and icons.', + notifier: controller.highContrast, + ), + ], + ), + ), + ), + ); + } +} + +class _AccessibilityPanelLabel extends StatelessWidget { + const _AccessibilityPanelLabel({ + required this.label, + required this.description, + }); + + final String label; + final String description; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.boldTextStyle), + const SizedBox(height: densePadding), + Text(description, style: theme.subtleTextStyle), + ], + ); + } +} + +class _BrightnessOverride extends StatelessWidget { + const _BrightnessOverride({required this.controller}); + + final AccessibilityController controller; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _AccessibilityPanelLabel( + label: 'Brightness', + description: 'Override the color scheme mode of the app.', + ), + const SizedBox(height: denseSpacing), + ValueListenableBuilder( + valueListenable: controller.brightness, + builder: (context, value, _) { + return RoundedDropDownButton( + isExpanded: true, + value: value, + items: BrightnessOverride.values.map((option) { + return DropdownMenuItem( + value: option, + child: Text(option.display), + ); + }).toList(), + onChanged: (newValue) { + if (newValue != null) { + controller.brightness.value = newValue; + } + }, + ); + }, + ), + ], + ); + } +} + +class _TextScaleOverride extends StatelessWidget { + const _TextScaleOverride({required this.controller}); + + final AccessibilityController controller; + + static const _minTextScale = 0.5; + static const _maxTextScale = 3.0; + static const _textScaleDivisions = 25; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ValueListenableBuilder( + valueListenable: controller.textScale, + builder: (context, value, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const _AccessibilityPanelLabel( + label: 'Text Scale', + description: 'Scale the system font size.', + ), + Text( + '${value.toStringAsFixed(2)}x', + style: theme.boldTextStyle, + ), + ], + ), + const SizedBox(height: densePadding), + Slider( + value: value, + min: _minTextScale, + max: _maxTextScale, + divisions: _textScaleDivisions, + onChanged: (newValue) { + controller.textScale.value = newValue; + }, + ), + ], + ); + }, + ); + } +} + +class _SwitchOverride extends StatelessWidget { + const _SwitchOverride({ + required this.label, + required this.description, + required this.notifier, + }); + + final String label; + final String description; + final ValueNotifier notifier; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _AccessibilityPanelLabel( + label: label, + description: description, + ), + ), + NotifierSwitch(notifier: notifier), + ], + ); + } +} diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart new file mode 100644 index 00000000000..72deee26965 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_details_pane.dart @@ -0,0 +1,344 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:ui' show SemanticsFlag; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../shared/globals.dart'; +import '../../shared/ui/common_widgets.dart'; +import 'accessibility_controller.dart'; +import 'semantics_node_model.dart'; + +/// A pane that displays the details of the currently selected semantics node. +class SemanticsNodeDetailsPane extends StatelessWidget { + const SemanticsNodeDetailsPane({super.key}); + + static const _paneTitle = 'Semantics Node Details'; + static const _emptyMessage = + 'Select a node in the semantics tree to view its details.'; + + @override + Widget build(BuildContext context) { + final controller = screenControllers.lookup(); + return DevToolsAreaPane( + header: const AreaPaneHeader( + title: Text(_paneTitle), + roundedTopBorder: false, + includeTopBorder: false, + ), + child: ValueListenableBuilder( + valueListenable: controller.selectedSemanticsNode, + builder: (context, selectedNode, _) { + if (selectedNode == null) { + return const CenteredMessage(message: _emptyMessage); + } + return _SemanticsNodeDetailsContent(node: selectedNode); + }, + ), + ); + } +} + +/// Displays the properties and flags of a selected [SemanticsNodeModel]. +class _SemanticsNodeDetailsContent extends StatelessWidget { + const _SemanticsNodeDetailsContent({required this.node}); + + final SemanticsNodeModel node; + + static const _labelTitle = 'label'; + static const _labelDescription = 'The screen reader announcement text.'; + + static const _valueTitle = 'value'; + static const _valueDescription = + 'The current value of a progress indicator, slider, etc.'; + + static const _hintTitle = 'hint'; + static const _hintDescription = + 'A brief description of the action that will occur.'; + + static const _rectTitle = 'rect'; + static const _rectDescription = + 'The bounding box of the node in logical pixels.'; + + static const _flagsTitle = 'Flags'; + static const _flagsDescription = + 'Boolean flags that dictate the semantics behavior.'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scrollbar( + child: SingleChildScrollView( + padding: const EdgeInsets.all(defaultSpacing), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('SemanticsNode #${node.id}', style: theme.boldTextStyle), + const SizedBox(height: denseSpacing), + const Divider(), + const SizedBox(height: denseSpacing), + if (node.issues.isNotEmpty) ...[ + _AccessibilityIssuesSection(issues: node.issues), + const SizedBox(height: defaultSpacing), + ], + _NodeDetailSection( + title: _labelTitle, + description: _labelDescription, + child: _NodeDetailValueBox( + text: node.label, + highlightText: true, + wrapInQuotes: true, + ), + ), + const SizedBox(height: defaultSpacing), + _NodeDetailSection( + title: _valueTitle, + description: _valueDescription, + child: _NodeDetailValueBox(text: node.value, wrapInQuotes: true), + ), + const SizedBox(height: defaultSpacing), + _NodeDetailSection( + title: _hintTitle, + description: _hintDescription, + child: _NodeDetailValueBox(text: node.hint, wrapInQuotes: true), + ), + const SizedBox(height: defaultSpacing), + _NodeDetailSection( + title: _rectTitle, + description: _rectDescription, + child: _NodeDetailValueBox(text: node.rectDisplay), + ), + const SizedBox(height: defaultSpacing), + _NodeDetailSection( + title: _flagsTitle, + description: _flagsDescription, + child: node.flags.isEmpty + ? const _NodeDetailValueBox(text: null) + : _SemanticsFlagsWrap(flags: node.flags), + ), + ], + ), + ), + ); + } +} + +/// A labeled section in the node details view with a title, description, and content. +class _NodeDetailSection extends StatelessWidget { + const _NodeDetailSection({ + required this.title, + required this.description, + required this.child, + }); + + final String title; + final String description; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.boldTextStyle), + const SizedBox(height: densePadding), + Text(description, style: theme.subtleTextStyle), + const SizedBox(height: denseSpacing), + child, + ], + ); + } +} + +/// A bordered container that displays a property value or `(empty)` if none is present. +class _NodeDetailValueBox extends StatelessWidget { + const _NodeDetailValueBox({ + required this.text, + this.highlightText = false, + this.wrapInQuotes = false, + }); + + final String? text; + final bool highlightText; + final bool wrapInQuotes; + + static const _emptyPlaceholder = '(empty)'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final hasValue = text != null && text!.isNotEmpty; + final displayText = hasValue + ? (wrapInQuotes ? '"$text"' : text!) + : _emptyPlaceholder; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(defaultSpacing), + decoration: BoxDecoration( + color: colorScheme.onSurface.withValues(alpha: 0.05), + borderRadius: defaultBorderRadius, + border: Border.all( + color: highlightText && hasValue + ? colorScheme.primary.withValues(alpha: 0.5) + : theme.focusColor, + ), + ), + child: SelectableText( + displayText, + style: _textStyle(theme, hasValue: hasValue), + ), + ); + } + + TextStyle _textStyle(ThemeData theme, {required bool hasValue}) { + if (!hasValue) { + return theme.subtleFixedFontStyle; + } + if (highlightText) { + return theme.fixedFontStyle.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ); + } + return theme.fixedFontStyle; + } +} + +/// A wrapping layout of chips representing active [SemanticsFlag]s on a node. +class _SemanticsFlagsWrap extends StatelessWidget { + const _SemanticsFlagsWrap({required this.flags}); + + final Set flags; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: denseSpacing, + runSpacing: denseSpacing, + children: [for (final flag in flags) _SemanticsFlagChip(flag: flag)], + ); + } +} + +/// A chip widget displaying the name of a single [SemanticsFlag]. +class _SemanticsFlagChip extends StatelessWidget { + const _SemanticsFlagChip({required this.flag}); + + final SemanticsFlag flag; + + static const _chipBorderRadius = 4.0; + static const _chipBackgroundAlpha = 0.12; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: denseSpacing, + vertical: densePadding, + ), + decoration: BoxDecoration( + color: colorScheme.onSurface.withValues(alpha: _chipBackgroundAlpha), + borderRadius: BorderRadius.circular(_chipBorderRadius), + ), + child: Text(flag.name, style: theme.fixedFontStyle), + ); + } +} + +/// A section displaying all [AccessibilityIssue]s detected on the selected node. +class _AccessibilityIssuesSection extends StatelessWidget { + const _AccessibilityIssuesSection({required this.issues}); + + final List issues; + + static const _issuesTitle = 'Accessibility Issues'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final errorColor = theme.colorScheme.error; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: defaultIconSize, + color: errorColor, + ), + const SizedBox(width: denseSpacing), + Text( + '$_issuesTitle (${issues.length})', + style: theme.boldTextStyle.copyWith(color: errorColor), + ), + ], + ), + const SizedBox(height: denseSpacing), + for (var i = 0; i < issues.length; i++) ...[ + if (i > 0) const SizedBox(height: denseSpacing), + _AccessibilityIssueBox(issue: issues[i]), + ], + ], + ); + } +} + +/// Displays an accessibility issue detected on a semantics node. +class _AccessibilityIssueBox extends StatelessWidget { + const _AccessibilityIssueBox({required this.issue}); + + final AccessibilityIssue issue; + + static const _boxBackgroundAlpha = 0.05; + static const _boxBorderAlpha = 0.5; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final errorColor = theme.colorScheme.error; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(defaultSpacing), + decoration: BoxDecoration( + color: errorColor.withValues(alpha: _boxBackgroundAlpha), + borderRadius: defaultBorderRadius, + border: Border.all( + color: errorColor.withValues(alpha: _boxBorderAlpha), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.warning_amber_rounded, + size: defaultIconSize, + color: errorColor, + ), + const SizedBox(width: denseSpacing), + Expanded( + child: Text( + issue.rule, + style: theme.boldTextStyle.copyWith(color: errorColor), + ), + ), + ], + ), + const SizedBox(height: denseSpacing), + SelectableText(issue.description, style: theme.fixedFontStyle), + ], + ), + ); + } +} diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart new file mode 100644 index 00000000000..9600d121bfe --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_node_model.dart @@ -0,0 +1,129 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +/// @docImport 'package:flutter/semantics.dart'; +library; + +import 'dart:ui' show Rect, SemanticsFlag; + +import '../../shared/primitives/trees.dart'; + +/// Represents an accessibility issue detected on a semantics node. +class AccessibilityIssue { + const AccessibilityIssue({required this.rule, required this.description}); + + /// The rule name that was violated. + final String rule; + + /// A human-readable description of the issue. + final String description; +} + +/// Represents a node in the accessibility semantics tree. +class SemanticsNodeModel extends TreeNode { + SemanticsNodeModel({ + required this.id, + this.label = '', + this.value = '', + this.hint = '', + this.rect, + this.flags = const {}, + this.issues = const [], + this.widgetName = '', + }); + + /// The semantics node identifier, as provided by the Flutter framework. + final String id; + + /// The user-visible label announced by screen readers (maps to [SemanticsData.label]). + final String label; + + /// The textual description of the value of the node (maps to [SemanticsData.value]). + final String value; + + /// A brief description of the result of performing an action on the node (maps to [SemanticsData.hint]). + final String hint; + + /// The bounding box of the node in logical pixels (maps to [SemanticsNode.rect]). + final Rect? rect; + + /// Semantic flags active on this node. + final Set flags; + + /// The accessibility issues detected for this node. + final List issues; + + /// The name of the Flutter widget that produced this node, if available. + final String widgetName; + + /// Mapping from flag name strings to [SemanticsFlag] instances. + static final _flagByName = { + for (final flag in SemanticsFlag.values) flag.name: flag, + }; + + /// Parses a list of flag name strings into a set of [SemanticsFlag]s. + static Set parseFlags(List? rawFlags) { + if (rawFlags == null) return const {}; + return rawFlags + .map((e) => _flagByName[e?.toString()]) + .whereType() + .toSet(); + } + + /// Parses a JSON representation of a bounding box into a [Rect]. + static Rect? parseRect(Object? rawRect) { + if (rawRect is! Map) return null; + final left = (rawRect['left'] as num?)?.toDouble(); + final top = (rawRect['top'] as num?)?.toDouble(); + if (left == null || top == null) return null; + + if (rawRect.containsKey('width') && rawRect.containsKey('height')) { + final width = (rawRect['width'] as num?)?.toDouble(); + final height = (rawRect['height'] as num?)?.toDouble(); + if (width != null && height != null) { + return Rect.fromLTWH(left, top, width, height); + } + } + if (rawRect.containsKey('right') && rawRect.containsKey('bottom')) { + final right = (rawRect['right'] as num?)?.toDouble(); + final bottom = (rawRect['bottom'] as num?)?.toDouble(); + if (right != null && bottom != null) { + return Rect.fromLTRB(left, top, right, bottom); + } + } + return null; + } + + /// Formatted string representation of [rect] in `Rect.fromLTWH` syntax. + String? get rectDisplay { + final currentRect = rect; + if (currentRect == null) return null; + final left = _formatNumber(currentRect.left); + final top = _formatNumber(currentRect.top); + final width = _formatNumber(currentRect.width); + final height = _formatNumber(currentRect.height); + return 'rect: Rect.fromLTWH($left, $top, $width, $height)'; + } + + static String _formatNumber(double value) { + if (value == value.truncateToDouble()) { + return value.toInt().toString(); + } + return value.toStringAsFixed(1); + } + + @override + SemanticsNodeModel shallowCopy() { + return SemanticsNodeModel( + id: id, + label: label, + value: value, + hint: hint, + rect: rect, + flags: flags, + issues: issues, + widgetName: widgetName, + ); + } +} diff --git a/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart new file mode 100644 index 00000000000..97488c3b83a --- /dev/null +++ b/packages/devtools_app/lib/src/screens/accessibility/semantics_tree_pane.dart @@ -0,0 +1,250 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:ui' show SemanticsFlag; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../shared/analytics/constants.dart' as gac; +import '../../shared/globals.dart'; +import '../../shared/ui/common_widgets.dart'; +import '../../shared/ui/tree_view.dart'; +import 'accessibility_controller.dart'; +import 'semantics_node_model.dart'; + +/// A pane that displays the semantics tree of the connected app. +class AccessibilitySemanticsTreePane extends StatelessWidget { + const AccessibilitySemanticsTreePane({super.key}); + + @override + Widget build(BuildContext context) { + final controller = screenControllers.lookup(); + return ValueListenableBuilder>( + valueListenable: controller.semanticsRoots, + builder: (context, roots, _) { + return DevToolsAreaPane( + header: AreaPaneHeader( + title: const Text('Semantics Tree'), + includeTopBorder: false, + roundedTopBorder: false, + actions: [ + if (roots.isNotEmpty) + RefreshButton( + iconOnly: true, + tooltip: 'Refresh Semantics Tree', + gaScreen: gac.accessibility, + gaSelection: gac.refresh, + onPressed: controller.loadSemanticsTree, + ), + ], + ), + child: ValueListenableBuilder( + valueListenable: controller.semanticsTreeLoading, + builder: (context, loading, _) { + if (loading) { + return const CenteredCircularProgressIndicator(); + } + return ValueListenableBuilder( + valueListenable: controller.semanticsTreeError, + builder: (context, error, _) { + if (error != null) { + return _SemanticsTreeErrorState( + errorMessage: error, + onRetry: controller.loadSemanticsTree, + ); + } + if (roots.isEmpty) { + return _SemanticsTreeEmptyState( + onLoad: controller.loadSemanticsTree, + ); + } + return _SemanticsTreeContent(controller: controller); + }, + ); + }, + ), + ); + }, + ); + } +} + +class _SemanticsTreeEmptyState extends StatelessWidget { + const _SemanticsTreeEmptyState({required this.onLoad}); + + final VoidCallback onLoad; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CenteredMessage( + message: + 'No semantics tree loaded. Inspect the accessibility hierarchy of the connected app.', + ), + const SizedBox(height: defaultSpacing), + DevToolsButton( + onPressed: onLoad, + icon: Icons.account_tree_outlined, + label: 'Load Semantics Tree', + elevated: true, + ), + ], + ), + ); + } +} + +class _SemanticsTreeErrorState extends StatelessWidget { + const _SemanticsTreeErrorState({ + required this.errorMessage, + required this.onRetry, + }); + + final String errorMessage; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(defaultSpacing), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + errorMessage, + style: theme.regularTextStyle.copyWith( + color: theme.colorScheme.error, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: defaultSpacing), + DevToolsButton( + onPressed: onRetry, + icon: Icons.refresh, + label: 'Try Again', + elevated: true, + ), + ], + ), + ), + ); + } +} + +class _SemanticsTreeContent extends StatelessWidget { + const _SemanticsTreeContent({required this.controller}); + + final AccessibilityController controller; + + static IconData _iconForNode(SemanticsNodeModel node) { + if (node.flags.contains(SemanticsFlag.isButton)) { + return Icons.smart_button_rounded; + } + if (node.flags.contains(SemanticsFlag.isTextField)) { + return Icons.text_fields_rounded; + } + if (node.flags.contains(SemanticsFlag.isHeader)) { + return Icons.title_rounded; + } + if (node.flags.contains(SemanticsFlag.isSlider)) { + return Icons.linear_scale_rounded; + } + if (node.flags.contains(SemanticsFlag.hasCheckedState)) { + return Icons.check_box_outlined; + } + return Icons.widgets_outlined; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return ValueListenableBuilder( + valueListenable: controller.selectedSemanticsNode, + builder: (context, _, _) { + return TreeView( + dataRootsListenable: controller.semanticsRoots, + scrollController: controller.treeScrollController, + includeScrollbar: true, + onItemSelected: controller.selectSemanticsNode, + onItemExpanded: (_) {}, + dataDisplayProvider: (node, onPressed) { + return InkWell( + onTap: onPressed, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: denseSpacing), + child: Row( + children: [ + Icon( + _iconForNode(node), + size: defaultIconSize, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + const SizedBox(width: denseSpacing), + Text( + 'SemanticsNode #${node.id}', + maxLines: 1, + style: theme.fixedFontStyle, + ), + if (node.label.isNotEmpty) ...[ + const SizedBox(width: denseSpacing), + Flexible( + child: Text( + '"${node.label}"', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.subtleTextStyle.copyWith( + fontStyle: FontStyle.italic, + color: colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ], + if (node.widgetName.isNotEmpty) ...[ + const SizedBox(width: denseSpacing), + Container( + padding: const EdgeInsets.symmetric( + horizontal: densePadding, + ), + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues( + alpha: 0.2, + ), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + node.widgetName, + maxLines: 1, + style: theme.subtleTextStyle.copyWith( + color: colorScheme.primary, + fontSize: smallFontSize, + ), + ), + ), + ], + if (node.issues.isNotEmpty) ...[ + const SizedBox(width: denseSpacing), + Icon( + Icons.warning_amber_rounded, + size: defaultIconSize, + color: colorScheme.error, + ), + ], + ], + ), + ), + ); + }, + ); + }, + ); + } +} diff --git a/packages/devtools_app/lib/src/screens/app_size/app_size_screen.dart b/packages/devtools_app/lib/src/screens/app_size/app_size_screen.dart index ff9deef67f9..68bce8a3417 100644 --- a/packages/devtools_app/lib/src/screens/app_size/app_size_screen.dart +++ b/packages/devtools_app/lib/src/screens/app_size/app_size_screen.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_snapshot_analysis/precompiler_trace.dart'; import '../../shared/analytics/analytics.dart' as ga; @@ -292,6 +292,7 @@ class DiffTreeTypeDropdown extends StatelessWidget { height: defaultButtonHeight, child: RoundedDropDownButton( key: AppSizeScreen.diffTypeDropdownKey, + value: value, isDense: true, items: [ _buildDiffTreeTypeMenuItem(DiffTreeType.combined), diff --git a/packages/devtools_app/lib/src/screens/app_size/app_size_table.dart b/packages/devtools_app/lib/src/screens/app_size/app_size_table.dart index 9e06c28ffc3..3386b1718bb 100644 --- a/packages/devtools_app/lib/src/screens/app_size/app_size_table.dart +++ b/packages/devtools_app/lib/src/screens/app_size/app_size_table.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/charts/treemap.dart'; import '../../shared/primitives/byte_utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/app_size/code_size_attribution.dart b/packages/devtools_app/lib/src/screens/app_size/code_size_attribution.dart index 739905bb3e7..913e46a4488 100644 --- a/packages/devtools_app/lib/src/screens/app_size/code_size_attribution.dart +++ b/packages/devtools_app/lib/src/screens/app_size/code_size_attribution.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_snapshot_analysis/precompiler_trace.dart'; import 'package:vm_snapshot_analysis/program_info.dart'; diff --git a/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart b/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart index 646fffa5d4b..b8207bc08d4 100644 --- a/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart +++ b/packages/devtools_app/lib/src/screens/debugger/breakpoint_manager.dart @@ -23,7 +23,6 @@ class BreakpointManager with DisposerMixin { final _breakPositionsMap = >{}; - ValueListenable> get breakpoints => _breakpoints; final _breakpoints = ValueNotifier>([]); ValueListenable> diff --git a/packages/devtools_app/lib/src/screens/debugger/breakpoints.dart b/packages/devtools_app/lib/src/screens/debugger/breakpoints.dart index b54f602af8a..7dab8a280d3 100644 --- a/packages/devtools_app/lib/src/screens/debugger/breakpoints.dart +++ b/packages/devtools_app/lib/src/screens/debugger/breakpoints.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart' hide Badge; +import 'package:material_ui/material_ui.dart' hide Badge; import '../../shared/globals.dart'; import '../../shared/primitives/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/debugger/call_stack.dart b/packages/devtools_app/lib/src/screens/debugger/call_stack.dart index eaa2d239982..0aafe12860b 100644 --- a/packages/devtools_app/lib/src/screens/debugger/call_stack.dart +++ b/packages/devtools_app/lib/src/screens/debugger/call_stack.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart' hide Stack; +import 'package:material_ui/material_ui.dart' hide Stack; import 'package:vm_service/vm_service.dart'; import '../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/debugger/codeview.dart b/packages/devtools_app/lib/src/screens/debugger/codeview.dart index d5f1ebd54da..53c83c85293 100644 --- a/packages/devtools_app/lib/src/screens/debugger/codeview.dart +++ b/packages/devtools_app/lib/src/screens/debugger/codeview.dart @@ -8,10 +8,10 @@ import 'dart:math' as math; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart' hide Stack; import '../../shared/console/widgets/expandable_variable.dart'; diff --git a/packages/devtools_app/lib/src/screens/debugger/codeview_controller.dart b/packages/devtools_app/lib/src/screens/debugger/codeview_controller.dart index 53362558278..8f7abe4e6de 100644 --- a/packages/devtools_app/lib/src/screens/debugger/codeview_controller.dart +++ b/packages/devtools_app/lib/src/screens/debugger/codeview_controller.dart @@ -218,6 +218,9 @@ class CodeViewController extends DisposableController ); if (succeeded) { + // The controller may have been disposed while awaiting the asynchronous + // location display setup. + if (disposed) return; // Update the scripts history (and make sure we don't react to the // subsequent event). scriptsHistory.current.removeListener(_scriptHistoryListener); @@ -234,6 +237,8 @@ class CodeViewController extends DisposableController final isolateRef = serviceConnection.serviceManager.isolateManager.selectedIsolate.value!; final processedReport = await _getSourceReport(isolateRef, current.script); + // The controller may have been disposed while awaiting the source report. + if (disposed) return; parsedScript.value = ParsedScript( script: current.script, @@ -264,6 +269,8 @@ class CodeViewController extends DisposableController if (scriptRef.id != parsedScript.value?.script.id) { // Try to parse the script if it isn't the currently parsed script: final script = await _parseScript(scriptRef); + // The controller may have been disposed while awaiting the script parsing. + if (disposed) return false; if (script == null) { // Return early and indicate failure if parsing fails. reportError( @@ -526,6 +533,7 @@ class CodeViewSourceLocationNavigationState extends DevToolsNavigationState { DevToolsNavigationState state, ) : super(kind: type, state: state.state); + @visibleForTesting static CodeViewSourceLocationNavigationState? fromState( DevToolsNavigationState? state, ) { diff --git a/packages/devtools_app/lib/src/screens/debugger/common.dart b/packages/devtools_app/lib/src/screens/debugger/common.dart index 8ef93fb0838..fab3c2b8fe7 100644 --- a/packages/devtools_app/lib/src/screens/debugger/common.dart +++ b/packages/devtools_app/lib/src/screens/debugger/common.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Create a header area for a debugger component. /// diff --git a/packages/devtools_app/lib/src/screens/debugger/controls.dart b/packages/devtools_app/lib/src/screens/debugger/controls.dart index 7db8774ef63..206ce4b2711 100644 --- a/packages/devtools_app/lib/src/screens/debugger/controls.dart +++ b/packages/devtools_app/lib/src/screens/debugger/controls.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart' hide Stack; +import 'package:material_ui/material_ui.dart' hide Stack; import 'package:vm_service/vm_service.dart'; import '../../shared/analytics/constants.dart' as gac; @@ -64,19 +64,37 @@ class _DebuggingControlsState extends State height: defaultButtonHeight, child: Row( children: [ - _pauseAndResumeButtons( - isPaused: serviceConnection.serviceManager.isMainIsolatePaused, - resuming: resuming, + // The debugging controls have no way to shrink further once their + // labels have already been dropped (see + // [DebuggingControls.minWidth]), so below roughly 630px the icon-only + // content still does not fit and the [Row] overflows. Making the + // controls scroll horizontally keeps every control reachable at any + // width instead of clipping them behind an overflow error. The + // libraries button stays pinned on the right, outside the scroll + // view, so it does not scroll out of reach. + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _pauseAndResumeButtons( + isPaused: + serviceConnection.serviceManager.isMainIsolatePaused, + resuming: resuming, + ), + const SizedBox(width: denseSpacing), + _stepButtons(canStep: canStep), + const SizedBox(width: denseSpacing), + BreakOnExceptionsControl(controller: controller), + if (isVmApp) ...[ + const SizedBox(width: denseSpacing), + CodeStatisticsControls(controller: controller), + ], + ], + ), + ), ), const SizedBox(width: denseSpacing), - _stepButtons(canStep: canStep), - const SizedBox(width: denseSpacing), - BreakOnExceptionsControl(controller: controller), - if (isVmApp) ...[ - const SizedBox(width: denseSpacing), - CodeStatisticsControls(controller: controller), - ], - const Expanded(child: SizedBox(width: denseSpacing)), _librariesButton(), ], ), diff --git a/packages/devtools_app/lib/src/screens/debugger/debugger_controller.dart b/packages/devtools_app/lib/src/screens/debugger/debugger_controller.dart index aacc0ec31a1..485fed02bd8 100644 --- a/packages/devtools_app/lib/src/screens/debugger/debugger_controller.dart +++ b/packages/devtools_app/lib/src/screens/debugger/debugger_controller.dart @@ -89,6 +89,8 @@ class DebuggerController extends DevToolsScreenController _selectedBreakpoint.dispose(); _exceptionPauseMode.dispose(); _hasTruncatedFrames.dispose(); + unawaited(_getStackOperation?.cancel()); + _lastService = null; super.dispose(); } diff --git a/packages/devtools_app/lib/src/screens/debugger/debugger_screen.dart b/packages/devtools_app/lib/src/screens/debugger/debugger_screen.dart index 80745175e6f..7972a8eccd8 100644 --- a/packages/devtools_app/lib/src/screens/debugger/debugger_screen.dart +++ b/packages/devtools_app/lib/src/screens/debugger/debugger_screen.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' hide Stack; import 'package:flutter/scheduler.dart'; +import 'package:material_ui/material_ui.dart' hide Stack; import 'package:vm_service/vm_service.dart'; import '../../shared/analytics/analytics.dart' as ga; diff --git a/packages/devtools_app/lib/src/screens/debugger/file_search.dart b/packages/devtools_app/lib/src/screens/debugger/file_search.dart index f32f6e5f769..dd944a1a9ad 100644 --- a/packages/devtools_app/lib/src/screens/debugger/file_search.dart +++ b/packages/devtools_app/lib/src/screens/debugger/file_search.dart @@ -3,9 +3,9 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/debugger/key_sets.dart b/packages/devtools_app/lib/src/screens/debugger/key_sets.dart index 9efc9bd576e..d77a4a7949f 100644 --- a/packages/devtools_app/lib/src/screens/debugger/key_sets.dart +++ b/packages/devtools_app/lib/src/screens/debugger/key_sets.dart @@ -2,8 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/config_specific/host_platform/host_platform.dart'; import '../../shared/primitives/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/debugger/program_explorer.dart b/packages/devtools_app/lib/src/screens/debugger/program_explorer.dart index dc602fa7ac6..71e6d96beee 100644 --- a/packages/devtools_app/lib/src/screens/debugger/program_explorer.dart +++ b/packages/devtools_app/lib/src/screens/debugger/program_explorer.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart' hide Stack; import '../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/debugger/span_parser.dart b/packages/devtools_app/lib/src/screens/debugger/span_parser.dart index 69c96c48ffc..872aef3a219 100644 --- a/packages/devtools_app/lib/src/screens/debugger/span_parser.dart +++ b/packages/devtools_app/lib/src/screens/debugger/span_parser.dart @@ -3,11 +3,12 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:collection'; -import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:string_scanner/string_scanner.dart'; +import '../../shared/primitives/utils.dart'; + //TODO(jacobr): cleanup. /// A namespace for [SpanParser] utilities. extension SpanParser on Never { @@ -68,7 +69,7 @@ class Grammar { @override String toString() { - return const JsonEncoder.withIndent(' ').convert({ + return prettyPrintJson({ 'name': name, 'scopeName': scopeName, 'topLevelMatcher': topLevelMatcher.toJson(), @@ -127,13 +128,13 @@ class ScopeSpan { ); while (!splitScanner.isDone) { - if (splitScanner.matches(cond)) { + if (splitScanner.matchesOnCurrentLine(cond)) { // Update the end position for this span as it's been fully processed. current._endLocation = splitScanner.location; splitSpans.add(current); // Move the scanner position past the matched condition. - splitScanner.scan(cond); + splitScanner.scanOnCurrentLine(cond); // Create a new span based on the current position. current = ScopeSpan( @@ -273,7 +274,7 @@ class _SimpleMatcher extends GrammarMatcher { @override bool scan(Grammar grammar, LineScanner scanner, ScopeStack scopeStack) { final location = scanner.location; - if (scanner.scan(match)) { + if (scanner.scanOnCurrentLine(match)) { scopeStack.push(name, location); _applyCapture(grammar, scanner, scopeStack, captures, location); scopeStack.pop(name, scanner.location); @@ -307,7 +308,7 @@ class _MultilineMatcher extends GrammarMatcher { : RegExp(json['while'] as String, multiLine: true), patterns = (json['patterns'] as List?) ?.cast>() - .map((e) => GrammarMatcher.fromJson(e)) + .map(GrammarMatcher.fromJson) .toList(), super._(); @@ -361,7 +362,7 @@ class _MultilineMatcher extends GrammarMatcher { void _scanBegin(Grammar grammar, LineScanner scanner, ScopeStack scopeStack) { final location = scanner.location; - if (!scanner.scan(begin)) { + if (!scanner.scanOnCurrentLine(begin)) { // This shouldn't happen since we've already checked that `begin` matches // the beginning of the string. throw StateError('Expected ${begin.pattern} to match.'); @@ -402,8 +403,9 @@ class _MultilineMatcher extends GrammarMatcher { Grammar grammar, LineScanner scanner, ScopeStack scopeStack, + RegExp resolvedEnd, ) { - while (!scanner.isDone && end != null && !scanner.matches(end!)) { + while (!scanner.isDone && !scanner.matchesOnCurrentLine(resolvedEnd)) { bool foundMatch = false; for (final pattern in patterns ?? []) { if (pattern.scan(grammar, scanner, scopeStack)) { @@ -418,14 +420,43 @@ class _MultilineMatcher extends GrammarMatcher { } } - void _scanEnd(Grammar grammar, LineScanner scanner, ScopeStack scopeStack) { + void _scanEnd( + Grammar grammar, + LineScanner scanner, + ScopeStack scopeStack, + RegExp resolvedEnd, + ) { final location = scanner.location; - if (end != null && !scanner.scan(end!)) { + if (!scanner.scanOnCurrentLine(resolvedEnd)) { return; } _processCaptureHelper(grammar, scanner, scopeStack, endCaptures, location); } + /// Returns an updated `end` regex that handles references in the form `\\1` + /// to reference matches from the `begin` regex. + /// + /// For example, this allows matching the correct number of backticks to close + /// a code block as were used to open it. + RegExp _resolveEnd(Match beginMatch) { + final resolvedPattern = end!.pattern.replaceAllMapped( + RegExp(r'\\([0-9]+)'), + (match) { + final groupIndex = int.parse(match.group(1)!); + final capturedText = groupIndex <= beginMatch.groupCount + ? beginMatch.group(groupIndex) + : null; + return capturedText == null + // No capture, keep the original text. + ? match.group(0)! + // Otherwise, escape the referenced text in case it contains regex + // chars. + : RegExp.escape(capturedText); + }, + ); + return RegExp(resolvedPattern, multiLine: true); + } + void _processCaptureHelper( Grammar grammar, LineScanner scanner, @@ -446,22 +477,27 @@ class _MultilineMatcher extends GrammarMatcher { @override bool scan(Grammar grammar, LineScanner scanner, ScopeStack scopeStack) { - if (!scanner.matches(begin)) { + if (!scanner.matchesOnCurrentLine(begin)) { return false; } scopeStack.push(name, scanner.location); _scanBegin(grammar, scanner, scopeStack); if (end != null) { + // Resolve any references in the end regex that reference captures from + // the begin match. + final resolvedEnd = _resolveEnd(scanner.lastMatch!); scopeStack.push(contentName, scanner.location); - _scanUpToEndMatch(grammar, scanner, scopeStack); + _scanUpToEndMatch(grammar, scanner, scopeStack, resolvedEnd); scopeStack.pop(contentName, scanner.location); - _scanEnd(grammar, scanner, scopeStack); + _scanEnd(grammar, scanner, scopeStack, resolvedEnd); } else if (whileCond != null) { // Find the range of the string that is matched by the while condition. final start = scanner.position; _skipLine(scanner); - while (!scanner.isDone && whileCond != null && scanner.scan(whileCond!)) { + while (!scanner.isDone && + whileCond != null && + scanner.scanOnCurrentLine(whileCond!)) { _skipLine(scanner); } final end = scanner.position; @@ -482,7 +518,7 @@ class _MultilineMatcher extends GrammarMatcher { // Process each line until the `while` condition fails. while (!contentScanner.isDone && whileCond != null && - contentScanner.scan(whileCond!)) { + contentScanner.scanOnCurrentLine(whileCond!)) { _scanToEndOfLine(grammar, contentScanner, scopeStack); } @@ -520,7 +556,7 @@ class _PatternMatcher extends GrammarMatcher { _PatternMatcher(super.json) : patterns = (json['patterns'] as List?) ?.cast>() - .map((e) => GrammarMatcher.fromJson(e)) + .map(GrammarMatcher.fromJson) .toList(), super._(); @@ -589,19 +625,6 @@ class ScopeStack { /// Location where the next produced span should begin. ScopeStackLocation _nextLocation = ScopeStackLocation.zero; - /// Adds a scope for a given region. - /// - /// This method is the same as calling [push] and then [pop] with the same - /// args. - void add( - String? scope, { - required ScopeStackLocation start, - required ScopeStackLocation end, - }) { - push(scope, start); - pop(scope, end); - } - /// Pushes a new scope onto the stack starting at [location]. void push(String? scope, ScopeStackLocation location) { if (scope == null) return; @@ -721,6 +744,8 @@ class ScopeStackItem { ScopeStackItem(this.scope, this.location); final String scope; + + // ignore: unused-code, foundational to this data class. final ScopeStackLocation location; } @@ -769,4 +794,32 @@ class ScopeStackLocation { extension LineScannerExtension on LineScanner { ScopeStackLocation get location => ScopeStackLocation(position: position, line: line, column: column); + + /// Returns whether [pattern] matches at the scanner's current position + /// without consuming a line break. + /// + /// Use this for all TextMate grammar regular expressions. The TextMate + /// [Language Rules documentation](https://macromates.com/manual/en/language_grammars#language_rules) + /// specifies that regular expressions are matched against one document line + /// at a time, while [LineScanner] searches the remaining source text. Without + /// this check, a pattern containing whitespace such as `\s*` can consume text + /// from the next line and produce scopes that TextMate would not. + bool matchesOnCurrentLine(RegExp pattern) { + if (!matches(pattern)) return false; + + final match = lastMatch![0]!; + return !match.contains('\n') && !match.contains('\r'); + } + + /// Scans [pattern] only when it matches without consuming a line break. + /// + /// Use this instead of [LineScanner.scan] when consuming a TextMate grammar + /// regular expression. See [matchesOnCurrentLine] for the rule requiring + /// line-local matching. Use the scanner directly only for parser operations + /// that intentionally move across lines, such as skipping a physical line. + bool scanOnCurrentLine(RegExp pattern) { + if (!matchesOnCurrentLine(pattern)) return false; + + return scan(pattern); + } } diff --git a/packages/devtools_app/lib/src/screens/debugger/syntax_highlighter.dart b/packages/devtools_app/lib/src/screens/debugger/syntax_highlighter.dart index 4899a83f72f..c76af32971d 100644 --- a/packages/devtools_app/lib/src/screens/debugger/syntax_highlighter.dart +++ b/packages/devtools_app/lib/src/screens/debugger/syntax_highlighter.dart @@ -5,9 +5,9 @@ import 'dart:collection'; import 'dart:convert'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/ui/colors.dart'; @@ -18,6 +18,7 @@ final _log = Logger('syntax_highlighter'); class SyntaxHighlighter { SyntaxHighlighter({String? source}) : source = source ?? ''; + @visibleForTesting SyntaxHighlighter.withGrammar({Grammar? grammar, String? source}) : source = source ?? '' { _grammar = grammar; @@ -246,9 +247,7 @@ class SyntaxHighlighter { 'keyword.control.catch-exception.dart', 'keyword.control.dart', 'keyword.control.return.dart', - // While 'new' is not a control flow keyword, it uses the control flow - // color scheme so we include it here. - 'keyword.control.new.dart', + 'keyword.new.dart', ]; const stringScopes = [ diff --git a/packages/devtools_app/lib/src/screens/debugger/variables.dart b/packages/devtools_app/lib/src/screens/debugger/variables.dart index 6eda6adaaae..4f346c16370 100644 --- a/packages/devtools_app/lib/src/screens/debugger/variables.dart +++ b/packages/devtools_app/lib/src/screens/debugger/variables.dart @@ -7,7 +7,7 @@ import 'dart:async'; -import 'package:flutter/material.dart' hide Stack; +import 'package:material_ui/material_ui.dart' hide Stack; import '../../shared/console/widgets/display_provider.dart'; import '../../shared/diagnostics/dap_object_node.dart'; diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart index 56308e8bc72..2344bac21f7 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_link_list_view.dart @@ -3,8 +3,9 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/globals.dart'; import '../../shared/primitives/utils.dart'; @@ -32,13 +33,12 @@ class DeepLinkListView extends StatefulWidget { } class _DeepLinkListViewState extends State { - late DeepLinksController controller; - @override void initState() { super.initState(); - controller = screenControllers.lookup() - ..firstLoadWithDefaultConfigurations(); + screenControllers + .lookup() + .firstLoadWithDefaultConfigurations(); } @override @@ -93,7 +93,11 @@ class _DeepLinkListViewMainPanel extends StatelessWidget { message: 'Your Flutter project has no Links to verify.', ); case PagePhase.analyzeErrorPage: - assert(controller.currentAppLinkSettings?.error != null); + final error = [ + controller.currentAppLinkSettings?.error, + controller.currentUniversalLinkSettings?.error, + ].nonNulls.join('\n\n'); + assert(error.isNotEmpty); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -104,12 +108,8 @@ class _DeepLinkListViewMainPanel extends StatelessWidget { const SizedBox(height: densePadding), Expanded( child: Scrollbar( - thumbVisibility: true, child: SingleChildScrollView( - child: Text( - controller.currentAppLinkSettings!.error!, - style: theme.errorTextStyle, - ), + child: Text(error, style: theme.errorTextStyle), ), ), ), @@ -439,7 +439,8 @@ class _NotificationCardSection extends StatelessWidget { children: [ if (domainErrorCount > 0) NotificationCard( - title: '$domainErrorCount domain not verified', + title: + '$domainErrorCount ${pluralize('domain', domainErrorCount)} not verified', description: 'This affects all deep links. Fix issues to make users go directly to your app.', actionButton: TextButton( @@ -461,7 +462,8 @@ class _NotificationCardSection extends StatelessWidget { const SizedBox(width: defaultSpacing), if (pathErrorCount > 0) NotificationCard( - title: '$pathErrorCount path not working', + title: + '$pathErrorCount ${pluralize('path', pathErrorCount)} not working', description: 'Fix these path to make sure users are directed to your app', actionButton: TextButton( diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart index 6e51d115fe0..0f6000f51ba 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_controller.dart @@ -8,7 +8,7 @@ import 'dart:math'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_deeplink.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; @@ -444,7 +444,7 @@ class DeepLinksController extends DevToolsScreenController appLink.intentFilterChecks, ), os: {PlatformOS.android}, - scheme: {if (scheme != null) scheme}, + scheme: {?scheme}, ); } else { final linkData = domainPathToLinkData[domainAndPath]!; @@ -663,8 +663,8 @@ class DeepLinksController extends DevToolsScreenController } Future validateLinks() async { - final appLinkSettings = currentAppLinkSettings; - if (appLinkSettings?.error != null) { + if (currentAppLinkSettings?.error != null || + currentUniversalLinkSettings?.error != null) { pagePhase.value = PagePhase.analyzeErrorPage; ga.select( gac.deeplink, diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart index feaed091a20..2eae88e0041 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_model.dart @@ -5,7 +5,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_deeplink.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/table/table.dart'; @@ -219,14 +219,6 @@ class AASAfileFormatSubCheck extends CommonError { ), ); - static final defaultsCaseSensitiveFormat = AASAfileFormatSubCheck( - 'Applinks defaults case sensitive format', - propertyTypeMessage( - property: 'applinks.defaults.caseSensitive', - expectedType: 'boolean', - ), - ); - static const detailsFormat = AASAfileFormatSubCheck( 'Applinks details format', 'This test checks that the `applinks.details` property is formatted properly. Ref - ' @@ -402,6 +394,7 @@ class Path { final String path; // TODO(hangyujin): display queryParams in path table. + // ignore: unused-code, outstanding TODO. final Map queryParams; /// A Boolean value that indicates whether to stop pattern matching and prevent the universal diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart index 07520397476..d8556a8f9cf 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_screen.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; @@ -18,9 +18,8 @@ class DeepLinksScreen extends Screen { static final id = ScreenMetaData.deepLinks.id; - // TODO(https://github.com/flutter/devtools/issues/6013): write documentation. - // @override - // String get docPageId => id; + @override + String get docPageId => id; @override String get docsUrl => 'https://flutter.dev/to/deep-link-tool'; diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_services.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_services.dart index b260c74fe0c..e16accc0afb 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_services.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/deep_links_services.dart @@ -109,6 +109,7 @@ final aasaFileFormatSubCheck = { class ValidateIosDomainResult { ValidateIosDomainResult(this.errorCode, this.domainErrors, this.paths); + // ignore: unused-code, this is addressed in a TODO below. final String errorCode; final Map> domainErrors; final Map> paths; diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/root_selector.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/root_selector.dart index 891ebc15cf0..33085ceed36 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/root_selector.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/root_selector.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/primitives/utils.dart'; import '../../../shared/ui/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/select_project_view.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/select_project_view.dart index d022c479d44..da490861ab4 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/select_project_view.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/project_root_selection/select_project_view.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_shared/devtools_deeplink.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/analytics.dart' as ga; import '../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart b/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart index cd5c3d964d9..1fb5fd1dd53 100644 --- a/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart +++ b/packages/devtools_app/lib/src/screens/deep_link_validation/validation_details_view.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/ui/colors.dart'; import '../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/dtd/dtd_tools_model.dart b/packages/devtools_app/lib/src/screens/dtd/dtd_tools_model.dart index caeb1758ae8..27ddace0d1a 100644 --- a/packages/devtools_app/lib/src/screens/dtd/dtd_tools_model.dart +++ b/packages/devtools_app/lib/src/screens/dtd/dtd_tools_model.dart @@ -18,15 +18,10 @@ abstract class FeatureController extends DisposableController /// [service] may be null if this service method is a first party service /// method registered by DTD or by a DTD-internal service. class DtdServiceMethod implements Comparable { - const DtdServiceMethod({ - required this.service, - required this.method, - this.capabilities, - }); + const DtdServiceMethod({required this.service, required this.method}); final String? service; final String method; - final Map? capabilities; String get displayName => [service, method].nonNulls.join('.'); diff --git a/packages/devtools_app/lib/src/screens/dtd/dtd_tools_screen.dart b/packages/devtools_app/lib/src/screens/dtd/dtd_tools_screen.dart index 774871f46a7..cf74e65e6e3 100644 --- a/packages/devtools_app/lib/src/screens/dtd/dtd_tools_screen.dart +++ b/packages/devtools_app/lib/src/screens/dtd/dtd_tools_screen.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/development_helpers.dart'; import '../../shared/framework/screen.dart'; @@ -27,8 +27,6 @@ import 'shared.dart'; class DTDToolsScreen extends Screen { DTDToolsScreen() : super.fromMetaData(ScreenMetaData.dtdTools); - static final id = ScreenMetaData.dtdTools.id; - @override Widget buildScreenBody(BuildContext _) => const DTDToolsScreenBody(); } diff --git a/packages/devtools_app/lib/src/screens/dtd/events.dart b/packages/devtools_app/lib/src/screens/dtd/events.dart index e00539f25f0..ac34dcb38dc 100644 --- a/packages/devtools_app/lib/src/screens/dtd/events.dart +++ b/packages/devtools_app/lib/src/screens/dtd/events.dart @@ -5,7 +5,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:dtd/dtd.dart' show DartToolingDaemon, DTDEvent; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'dtd_tools_model.dart'; import 'shared.dart'; diff --git a/packages/devtools_app/lib/src/screens/dtd/services.dart b/packages/devtools_app/lib/src/screens/dtd/services.dart index a9dc28da97b..76c98ba7947 100644 --- a/packages/devtools_app/lib/src/screens/dtd/services.dart +++ b/packages/devtools_app/lib/src/screens/dtd/services.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:devtools_app_shared/ui.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/globals.dart'; import '../../shared/primitives/utils.dart'; @@ -57,11 +57,7 @@ class ServicesController extends FeatureController { }), for (final service in response.clientServices) ...[ for (final method in service.methods.values) - DtdServiceMethod( - service: service.name, - method: method.name, - capabilities: method.capabilities, - ), + DtdServiceMethod(service: service.name, method: method.name), ], ]; } diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_breadcrumbs.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_breadcrumbs.dart deleted file mode 100644 index f5db041746e..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector/inspector_breadcrumbs.dart +++ /dev/null @@ -1,201 +0,0 @@ -// Copyright 2021 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -import '../../shared/console/eval/inspector_tree.dart'; -import '../../shared/primitives/diagnostics_text_styles.dart'; -import '../../shared/primitives/utils.dart'; -import '../../shared/ui/common_widgets.dart'; - -class InspectorBreadcrumbNavigator extends StatelessWidget { - const InspectorBreadcrumbNavigator({ - super.key, - required this.items, - required this.onTap, - }); - - /// Max number of visible breadcrumbs including root item but not 'more' item. - /// E.g. value 5 means root and 4 breadcrumbs can be displayed, other - /// breadcrumbs (if any) will be replaced by '...' item. - static const _maxNumberOfBreadcrumbs = 5; - - final List items; - final void Function(InspectorTreeNode?) onTap; - - @override - Widget build(BuildContext context) { - if (items.isEmpty) { - return const SizedBox(); - } - - final breadcrumbs = _generateBreadcrumbs(items); - return SizedBox( - height: Breadcrumb.height, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Row( - children: breadcrumbs.map((item) { - if (item.isChevron) { - return const Icon(Icons.chevron_right, size: defaultIconSize); - } - - return Flexible( - child: _InspectorBreadcrumb( - data: item, - onTap: () => onTap(item.node), - ), - ); - }).toList(), - ), - ), - ); - } - - List<_InspectorBreadcrumbData> _generateBreadcrumbs( - List nodes, - ) { - final lastNode = nodes.safeLast; - final items = nodes.map((node) { - return _InspectorBreadcrumbData.wrap( - node: node, - isSelected: node == lastNode, - ); - }).toList(); - List<_InspectorBreadcrumbData> breadcrumbs; - breadcrumbs = items.length > _maxNumberOfBreadcrumbs - ? [ - items[0], - _InspectorBreadcrumbData.more(), - ...items.sublist( - items.length - _maxNumberOfBreadcrumbs + 1, - items.length, - ), - ] - : items; - - return breadcrumbs.joinWith(_InspectorBreadcrumbData.chevron()); - } -} - -class _InspectorBreadcrumb extends StatelessWidget { - const _InspectorBreadcrumb({required this.data, required this.onTap}); - - static const _iconScale = 0.75; - - final _InspectorBreadcrumbData data; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final text = Text( - data.text, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: DiagnosticsTextStyles.regular( - Theme.of(context).colorScheme, - ).copyWith(fontSize: 11), - ); - - final icon = data.icon == null - ? null - : Transform.scale( - scale: _iconScale, - child: Padding( - padding: const EdgeInsets.only(right: iconPadding), - child: data.icon, - ), - ); - - return InkWell( - onTap: data.isClickable ? onTap : null, - borderRadius: defaultBorderRadius, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: densePadding, - vertical: borderPadding, - ), - decoration: BoxDecoration( - borderRadius: defaultBorderRadius, - color: data.isSelected - ? Theme.of(context).colorScheme.selectedRowBackgroundColor - : Colors.transparent, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) icon, - Flexible(child: text), - ], - ), - ), - ); - } -} - -class _InspectorBreadcrumbData { - const _InspectorBreadcrumbData._({ - required this.node, - required this.isSelected, - required this.alternativeText, - required this.alternativeIcon, - }); - - factory _InspectorBreadcrumbData.wrap({ - required InspectorTreeNode node, - required bool isSelected, - }) { - return _InspectorBreadcrumbData._( - node: node, - isSelected: isSelected, - alternativeText: null, - alternativeIcon: null, - ); - } - - /// Construct a special item for showing '…' symbol between other items - factory _InspectorBreadcrumbData.more() { - return const _InspectorBreadcrumbData._( - node: null, - isSelected: false, - alternativeText: _ellipsisValue, - alternativeIcon: null, - ); - } - - factory _InspectorBreadcrumbData.chevron() { - return const _InspectorBreadcrumbData._( - node: null, - isSelected: false, - alternativeText: null, - alternativeIcon: _breadcrumbSeparatorIcon, - ); - } - - static const _ellipsisValue = '…'; - static const _breadcrumbSeparatorIcon = Icons.chevron_right; - - final InspectorTreeNode? node; - final IconData? alternativeIcon; - final String? alternativeText; - final bool isSelected; - - String get text => alternativeText ?? node?.diagnostic?.description ?? ''; - - Widget? get icon { - if (alternativeIcon != null) { - return const Icon(_breadcrumbSeparatorIcon, size: defaultIconSize); - } - - return node?.diagnostic?.icon; - } - - bool get isChevron => - node == null && alternativeIcon == _breadcrumbSeparatorIcon; - - bool get isEllipsis => node == null && alternativeText == _ellipsisValue; - - bool get isClickable => !isSelected && !isEllipsis; -} diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart index 9ddc8196e90..a5a67830f80 100644 --- a/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart @@ -1,4 +1,4 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. @@ -18,8 +18,8 @@ import 'dart:async'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../service/service_extensions.dart' as extensions; @@ -31,31 +31,48 @@ import '../../shared/console/primitives/simple_items.dart'; import '../../shared/diagnostics/diagnostics_node.dart'; import '../../shared/diagnostics/inspector_service.dart'; import '../../shared/diagnostics/primitives/instance_ref.dart'; +import '../../shared/diagnostics/primitives/source_location.dart'; +import '../../shared/framework/screen_controllers.dart'; import '../../shared/globals.dart'; +import '../../shared/managers/notifications.dart'; import '../../shared/primitives/query_parameters.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/utils/utils.dart'; -import '../inspector_shared/inspector_screen.dart'; +import 'inspector_data_models.dart'; +import 'inspector_screen.dart'; +import 'inspector_screen_controller.dart'; import 'inspector_tree_controller.dart'; final _log = Logger('inspector_controller'); +/// Data pattern containing the properties and render properties for a widget +/// tree node. +typedef WidgetTreeNodeProperties = ({ + /// Properties defined directly on the widget. + List widgetProperties, + + /// Properties defined on the widget's render object. + List renderProperties, + + /// Layout properties for the widget. + LayoutProperties? layoutProperties, + + /// Source location where the selected widget was created. + InspectorSourceLocation? creationLocation, +}); + /// This class is based on the InspectorPanel class from the Flutter IntelliJ /// plugin with some refactors to make it more of a true controller than a view. class InspectorController extends DisposableController with AutoDisposeControllerMixin implements InspectorServiceClient { - InspectorController({ - required this.inspectorTree, - InspectorTreeController? detailsTree, - required this.treeType, - this.parent, - this.isSummaryTree = true, - }) : assert((detailsTree != null) == isSummaryTree) { - unawaited(_init(detailsTree: detailsTree)); + InspectorController({required this.inspectorTree, required this.treeType}) { + unawaited(init()); } - Future _init({InspectorTreeController? detailsTree}) async { + @override + Future init() async { + super.init(); _refreshRateLimiter = RateLimiter(refreshFramesPerSecond, refresh); inspectorTree.config = InspectorTreeConfig( @@ -64,15 +81,6 @@ class InspectorController extends DisposableController onExpand: _onExpand, onClientActiveChange: _onClientChange, ); - details = isSummaryTree - ? InspectorController( - inspectorTree: detailsTree!, - treeType: treeType, - parent: this, - isSummaryTree: false, - ) - : null; - await serviceConnection.serviceManager.onServiceAvailable; if (inspectorService is InspectorService) { @@ -84,38 +92,42 @@ class InspectorController extends DisposableController serviceConnection.inspectorService as InspectorService, 'selection', ); + _layoutGroups = InspectorObjectGroupManager( + serviceConnection.inspectorService as InspectorService, + 'layout', + ); } addAutoDisposeListener( serviceConnection.serviceManager.isolateManager.mainIsolate, () { - final isolate = + final newIsolate = serviceConnection.serviceManager.isolateManager.mainIsolate.value; - if (isolate != _mainIsolate) { - onIsolateStopped(); + if (_mainIsolate == newIsolate) return; + // First deactivate the current widget tree. + setActivate(false); + if (newIsolate != null) { + // Then reactivate it with the new isolate. + setActivate(true); } - _mainIsolate = isolate; + _mainIsolate = newIsolate; }, ); - // This logic only needs to be run once so run it in the outermost - // controller. - if (parent == null) { - // If select mode is available, enable the on device inspector as it - // won't interfere with users. - addAutoDisposeListener(_supportsToggleSelectWidgetMode, () { - if (_supportsToggleSelectWidgetMode.value) { - safeUnawaited( - serviceConnection.serviceManager.serviceExtensionManager - .setServiceExtensionState( - extensions.enableOnDeviceInspector.extension, - enabled: true, - value: true, - ), - ); - } - }); - } + // If select mode is available, enable the on device inspector as it + // won't interfere with users. + addAutoDisposeListener(_supportsToggleSelectWidgetMode, () { + if (_supportsToggleSelectWidgetMode.value) { + safeUnawaited( + serviceConnection.serviceManager.serviceExtensionManager + .setServiceExtensionState( + extensions.enableOnDeviceInspector.extension, + enabled: true, + value: true, + ), + ); + } + }); addAutoDisposeListener(serviceConnection.serviceManager.connectedState, () { if (serviceConnection.serviceManager.connectedState.value.connected) { @@ -130,8 +142,34 @@ class InspectorController extends DisposableController } serviceConnection.consoleService.ensureServiceInitialized(); + + final vmService = serviceConnection.serviceManager.service; + if (vmService != null) { + autoDisposeStreamSubscription( + vmService.onIsolateEvent.listen(_maybeAutoRefreshInspector), + ); + + autoDisposeStreamSubscription( + vmService.onExtensionEvent.listen(_maybeAutoRefreshInspector), + ); + } + } + + /// Returns the [InspectorScreenController] when it is registered. + /// + /// [InspectorController] is sometimes constructed in unit tests without an + /// [InspectorScreenController] registered, so callers that only need to clear + /// errors on connect/reload should use this nullable accessor. + InspectorScreenController? get _inspectorScreenControllerOrNull { + final controllers = globals[ScreenControllers] as ScreenControllers?; + if (controllers == null) return null; + if (!controllers.isRegistered()) return null; + return controllers.lookup(); } + InspectorScreenController get _inspectorScreenController => + screenControllers.lookup(); + void _handleConnectionStart() { // Clear any existing badge/errors for older errors that were collected. // Do this in a post frame callback so that we are not trying to clear the @@ -140,16 +178,13 @@ class InspectorController extends DisposableController // TODO(kenz): When this method is called outside createState(), this post // frame callback can be removed. WidgetsBinding.instance.addPostFrameCallback((_) { - serviceConnection.errorBadgeManager.clearErrorCount(InspectorScreen.id); + _inspectorScreenControllerOrNull?.clearErrors(); }); - filterErrors(); } void _handleConnectionStop() { setActivate(false); - if (isSummaryTree) { - dispose(); - } + dispose(); } IsolateRef? _mainIsolate; @@ -159,7 +194,7 @@ class InspectorController extends DisposableController .serviceExtensionManager .hasServiceExtension(extensions.toggleSelectWidgetMode.extension); - void _onClientChange(bool added) { + Future _onClientChange(bool added) async { if (!added && _clientCount == 0) { // Don't try to remove clients if there are none return; @@ -168,10 +203,10 @@ class InspectorController extends DisposableController _clientCount += added ? 1 : -1; assert(_clientCount >= 0); if (_clientCount == 1) { - setVisibleToUser(true); + await setVisibleToUser(true); setActivate(true); } else if (_clientCount == 0) { - setVisibleToUser(false); + await setVisibleToUser(false); } } @@ -186,14 +221,8 @@ class InspectorController extends DisposableController /// for now mainly to minimize risk. static const refreshFramesPerSecond = 5.0; - final bool isSummaryTree; - - /// Parent InspectorController if this is a details subtree. - InspectorController? parent; - - InspectorController? details; + final InspectorTreeController inspectorTree; - InspectorTreeController inspectorTree; final FlutterTreeType treeType; late RateLimiter _refreshRateLimiter; @@ -209,27 +238,34 @@ class InspectorController extends DisposableController /// selection is. /// /// This group needs to be kept separate from treeGroups as the selection is - /// shared more with the details subtree. + /// shared with the widget details. /// TODO(jacobr): is there a way we can unify the selection and tree groups? InspectorObjectGroupManager? _selectionGroups; - /// Node being highlighted due to the current hover. - InspectorTreeNode? get currentShowNode => inspectorTree.hover; - - set currentShowNode(InspectorTreeNode? node) => inspectorTree.hover = node; + InspectorObjectGroupManager? _layoutGroups; bool flutterAppFrameReady = false; bool treeLoadStarted = false; - RemoteDiagnosticsNode? subtreeRoot; - bool programmaticSelectionChangeInProgress = false; ValueListenable get selectedNode => _selectedNode; final _selectedNode = ValueNotifier(null); - InspectorTreeNode? lastExpanded; + ValueListenable get selectedNodeProperties => + _selectedNodeProperties; + final _selectedNodeProperties = ValueNotifier(( + widgetProperties: [], + renderProperties: [], + layoutProperties: null, + creationLocation: null, + )); + + /// Whether the implementation widgets are hidden in the widget tree. + ValueListenable get implementationWidgetsHidden => + _implementationWidgetsHidden; + final _implementationWidgetsHidden = ValueNotifier(true); bool isActive = false; @@ -239,10 +275,6 @@ class InspectorController extends DisposableController /// not perform any actions. bool visibleToUser = false; - bool highlightNodesShownInBothTrees = false; - - bool get detailsSubtree => parent != null; - RemoteDiagnosticsNode? get selectedDiagnostic => selectedNode.value?.diagnostic; @@ -255,50 +287,25 @@ class InspectorController extends DisposableController /// inspector tree load timing. bool firstInspectorTreeLoadCompleted = false; - FlutterTreeType getTreeType() { - return treeType; - } - - void setVisibleToUser(bool visible) { + Future setVisibleToUser(bool visible) async { if (visibleToUser == visible) { return; } visibleToUser = visible; if (visibleToUser) { - if (parent == null) { - unawaited(maybeLoadUI()); - } + await refreshInspector(); } else { shutdownTree(false); } } - bool hasDiagnosticsValue(InspectorInstanceRef ref) { - return valueToInspectorTreeNode.containsKey(ref); - } - - RemoteDiagnosticsNode? findDiagnosticsValue(InspectorInstanceRef ref) { - return valueToInspectorTreeNode[ref]?.diagnostic; - } - void endShowNode() { highlightShowNode(null); } - bool highlightShowFromNodeInstanceRef(InspectorInstanceRef ref) { - return highlightShowNode(valueToInspectorTreeNode[ref]); - } - bool highlightShowNode(InspectorTreeNode? node) { - if (node == null && parent != null) { - // If nothing is highlighted, highlight the node selected in the parent - // tree so user has context of where the node selected in the parent is - // in the details tree. - node = findMatchingInspectorTreeNode(parent?.selectedDiagnostic); - } - - currentShowNode = node; + inspectorTree.hover = node; return true; } @@ -326,14 +333,7 @@ class InspectorController extends DisposableController return Future.value(); } - // TODO(jacobr): refresh the tree as well as just the properties. - final detailsLocal = details; - if (detailsLocal == null) return _waitForPendingUpdateDone(); - - return [ - _waitForPendingUpdateDone(), - detailsLocal._waitForPendingUpdateDone(), - ].wait; + return _waitForPendingUpdateDone(); } // Note that this may be called after the controller is disposed. We need to handle nulls in the fields. @@ -345,15 +345,15 @@ class InspectorController extends DisposableController _treeGroups?.clear(isolateStopped); _selectionGroups?.clear(isolateStopped); - currentShowNode = null; + inspectorTree.hover = null; _selectedNode.value = null; - lastExpanded = null; - - subtreeRoot = null; inspectorTree.root = inspectorTree.createNode(); programmaticSelectionChangeInProgress = false; valueToInspectorTreeNode.clear(); + // Mark tree as inactive so that it will be re-loaded the next time it is + // opened: + isActive = false; } void onIsolateStopped() { @@ -368,23 +368,30 @@ class InspectorController extends DisposableController if (!visibleToUser || disposed) { return; } - await _recomputeTreeRoot(null, null, false); + await _recomputeTreeRoot(null); if (disposed) { return; } - filterErrors(); - return _waitForPendingUpdateDone(); } - void filterErrors() { - if (isSummaryTree) { - serviceConnection.errorBadgeManager.filterErrors( - InspectorScreen.id, - (id) => hasDiagnosticsValue(InspectorInstanceRef(id)), + Future refreshInspector({bool isManualRefresh = false}) async { + // If the user is manually refreshing the inspector before the first load + // has completed, this could indicate a slow load time or that the inspector + // failed to load the tree once available. + if (isManualRefresh && !firstInspectorTreeLoadCompleted) { + // We do not want to complete this timing operation because the manual + // refresh will skew the results. + ga.cancelTimingOperation(InspectorScreen.id, gac.pageReady); + ga.select( + gac.inspector, + gac.refreshEmptyTree, + screenMetricsProvider: () => InspectorScreenMetrics(), ); + firstInspectorTreeLoadCompleted = true; } + await onForceRefresh(); } void setActivate(bool enabled) { @@ -404,10 +411,6 @@ class InspectorController extends DisposableController } Future maybeLoadUI() async { - if (parent != null) { - // The parent controller will drive loading the UI. - return; - } if (!visibleToUser || !isActive) { return; } @@ -417,10 +420,7 @@ class InspectorController extends DisposableController // We need to start by querying the inspector service to find out the // current state of the UI. final inspectorRef = DevToolsQueryParams.load().inspectorRef; - await updateSelectionFromService( - firstFrame: true, - inspectorRef: inspectorRef, - ); + await updateSelectionFromService(inspectorRef: inspectorRef); } else { if (disposed) return; if (inspectorService is InspectorService) { @@ -434,13 +434,42 @@ class InspectorController extends DisposableController } } + bool _receivedIsolateReloadEvent = false; + bool _receivedFlutterNavigationEvent = false; + + Future _maybeAutoRefreshInspector(Event event) async { + if (!preferences.inspector.autoRefreshEnabled.value) return; + + // It is not sufficent to wait for the navigation and isolate reload events + // only, because Flutter might not have re-painted the app. Instead, we need + // to wait for the first frame AFTER the isolate reload or navigation event + // in order to request the new tree. + if (event.kind == EventKind.kExtension) { + final extensionEventKind = event.extensionKind; + if (extensionEventKind == 'Flutter.Navigation') { + _receivedFlutterNavigationEvent = true; + } + if ((_receivedFlutterNavigationEvent || _receivedIsolateReloadEvent) && + extensionEventKind == 'Flutter.Frame') { + _refreshingAfterNavigationEvent = _receivedFlutterNavigationEvent; + _receivedFlutterNavigationEvent = false; + _receivedIsolateReloadEvent = false; + await refreshInspector(); + } + } + + if (event.kind == EventKind.kIsolateReload) { + _inspectorScreenControllerOrNull?.clearErrors(); + _receivedIsolateReloadEvent = true; + } + } + Future _recomputeTreeRoot( - RemoteDiagnosticsNode? newSelection, - RemoteDiagnosticsNode? detailsSelection, - bool setSubtreeRoot, { - int subtreeDepth = 2, + RemoteDiagnosticsNode? newSelection, { + bool? hideImplementationWidgets, }) async { assert(!disposed); + hideImplementationWidgets ??= _implementationWidgetsHidden.value; final treeGroups = _treeGroups; if (disposed || treeGroups == null) { return; @@ -449,9 +478,11 @@ class InspectorController extends DisposableController treeGroups.cancelNext(); try { final group = treeGroups.next; - final node = await (detailsSubtree - ? group.getDetailsSubtree(subtreeRoot, subtreeDepth: subtreeDepth) - : group.getRoot(treeType, isSummaryTree: true)); + final node = await group.getRoot( + treeType, + isSummaryTree: hideImplementationWidgets, + includeFullDetails: false, + ); if (node == null || group.disposed || disposed) { return; } @@ -465,11 +496,13 @@ class InspectorController extends DisposableController inspectorTree.createNode(), node, expandChildren: true, - expandProperties: false, ); inspectorTree.root = rootNode; - - refreshSelection(newSelection, detailsSelection, setSubtreeRoot); + final selectedNode = _determineNewSelection( + newSelection ?? selectedDiagnostic, + ); + refreshSelection(selectedNode); + _implementationWidgetsHidden.value = hideImplementationWidgets; } catch (error, st) { _log.shout(error, error, st); treeGroups.cancelNext(); @@ -477,108 +510,133 @@ class InspectorController extends DisposableController } } - void _clearValueToInspectorTreeNodeMapping() { - valueToInspectorTreeNode.clear(); - } - - /// Show the details subtree starting with node subtreeRoot highlighting - /// node subtreeSelection. - void _showDetailSubtrees( - RemoteDiagnosticsNode? subtreeRoot, - RemoteDiagnosticsNode? subtreeSelection, - ) { - this.subtreeRoot = subtreeRoot; - details?.setSubtreeRoot(subtreeRoot, subtreeSelection); - } + var _refreshingAfterNavigationEvent = false; - void setSubtreeRoot( - RemoteDiagnosticsNode? node, - RemoteDiagnosticsNode? selection, + RemoteDiagnosticsNode? _determineNewSelection( + RemoteDiagnosticsNode? previousSelection, ) { - assert(detailsSubtree); - selection ??= node; - if (node != null && node == subtreeRoot) { - // Select the new node in the existing subtree. - applyNewSelection(selection, null, false); - return; + if (previousSelection == null) return null; + if (valueToInspectorTreeNode.containsKey(previousSelection.valueRef)) { + return previousSelection; } - subtreeRoot = node; - if (node == null) { - // Passing in a null node indicates we should clear the subtree and free any memory allocated. - shutdownTree(false); - return; + + // TODO(https://github.com/flutter/devtools/issues/8481): Consider using a + // variation of a path-finding algorithm to determine the new selection, + // instead of looking for the first matching descendant. + final (closestUnchangedAncestor, distanceToAncestor) = + _findClosestUnchangedAncestor(previousSelection); + if (closestUnchangedAncestor == null) return inspectorTree.root?.diagnostic; + + // TODO(elliette): This might cause a race event that will set this to false + // for a subsequent navigate event. Consider passing the value of + // _refreshingAfterNavigationEvent through the method chain from where the + // navigation event is detected. This would require updating the interface + // of InspectorServiceClient.onForceRefresh, or refactoring to avoid doing + // so. + if (_refreshingAfterNavigationEvent) { + _refreshingAfterNavigationEvent = false; + return closestUnchangedAncestor; } - // Clear now to eliminate frame of highlighted nodes flicker. - _clearValueToInspectorTreeNodeMapping(); - unawaited(_recomputeTreeRoot(selection, null, false)); + const distanceOffset = 3; + final matchingDescendant = _findMatchingDescendant( + of: closestUnchangedAncestor, + matching: previousSelection, + inRange: Range( + distanceToAncestor - distanceOffset, + distanceToAncestor + distanceOffset, + ), + ); + + return matchingDescendant ?? closestUnchangedAncestor; } - InspectorTreeNode? getSubtreeRootNode() { - if (subtreeRoot == null) { - return null; + (RemoteDiagnosticsNode?, int) _findClosestUnchangedAncestor( + RemoteDiagnosticsNode node, [ + int distanceToAncestor = 1, + ]) { + final inspectorTreeNode = valueToInspectorTreeNode[node.valueRef]; + if (inspectorTreeNode != null) { + return (inspectorTreeNode.diagnostic, distanceToAncestor); } - return valueToInspectorTreeNode[subtreeRoot!.valueRef]; + + final ancestor = node.parent; + if (ancestor == null) return (null, distanceToAncestor); + return _findClosestUnchangedAncestor(ancestor, distanceToAncestor++); } - void refreshSelection( - RemoteDiagnosticsNode? newSelection, - RemoteDiagnosticsNode? detailsSelection, - bool setSubtreeRoot, - ) { - newSelection ??= selectedDiagnostic; - setSelectedNode(findMatchingInspectorTreeNode(newSelection)); - syncSelectionHelper( - maybeRerootDetailsTree: setSubtreeRoot, - selection: newSelection, - detailsSelection: detailsSelection, - ); + RemoteDiagnosticsNode? _findMatchingDescendant({ + required RemoteDiagnosticsNode of, + required RemoteDiagnosticsNode matching, + required Range inRange, + int currentDistance = 1, + }) { + if (currentDistance > inRange.end) return null; - final detailsLocal = details; - if (detailsLocal != null) { - if (subtreeRoot != null && getSubtreeRootNode() == null) { - subtreeRoot = newSelection; - detailsLocal.setSubtreeRoot(newSelection, detailsSelection); + if (inRange.contains(currentDistance)) { + if (of.description == matching.description) { + return of; } } - syncTreeSelection(); - } - - void syncTreeSelection() { - programmaticSelectionChangeInProgress = true; - inspectorTree.selection = selectedNode.value; - inspectorTree.expandPath(selectedNode.value); - programmaticSelectionChangeInProgress = false; - animateTo(selectedNode.value); - } - void selectAndShowNode(RemoteDiagnosticsNode? node) { - if (node == null) { - return; + final children = of.childrenNow; + final distance = currentDistance++; + for (final child in children) { + final matchingDescendant = _findMatchingDescendant( + of: child, + matching: matching, + inRange: inRange, + currentDistance: distance, + ); + if (matchingDescendant != null) return matchingDescendant; } - selectAndShowInspectorInstanceRef(node.valueRef); + + return null; } - void selectAndShowInspectorInstanceRef(InspectorInstanceRef ref) { - final node = valueToInspectorTreeNode[ref]; - if (node == null) { - return; + Future toggleImplementationWidgetsVisibility() async { + final root = inspectorTree.root?.diagnostic; + if (root != null) { + final currentSelectedNode = selectedNode.value; + await _recomputeTreeRoot( + root, + hideImplementationWidgets: !_implementationWidgetsHidden.value, + ); + // Persist the selected node after refreshing the widget tree: + refreshSelection(currentSelectedNode?.diagnostic); + + // If the user is searching the tree, refresh the search matches. + inspectorTree.refreshSearchMatches(); } - setSelectedNode(node); - syncTreeSelection(); } - InspectorTreeNode? getTreeNode(RemoteDiagnosticsNode node) { - return valueToInspectorTreeNode[node.valueRef]; + void _clearValueToInspectorTreeNodeMapping() { + valueToInspectorTreeNode.clear(); } - void maybeUpdateValueUI(InspectorInstanceRef valueRef) { - final node = valueToInspectorTreeNode[valueRef]; - if (node == null) { - // The value isn't shown in the parent tree. Nothing to do. - return; + void refreshSelection(RemoteDiagnosticsNode? newSelection) { + newSelection ??= selectedDiagnostic; + final matchingNode = findMatchingInspectorTreeNode(newSelection); + if (matchingNode != null) { + setSelectedNode(matchingNode); + syncSelectionHelper(selection: matchingNode.diagnostic); + + syncTreeSelection(); } - inspectorTree.nodeChanged(node); + } + + void syncTreeSelection() { + programmaticSelectionChangeInProgress = true; + inspectorTree.refreshTree( + updateTreeAction: () { + inspectorTree + ..setSelectedNode(selectedNode.value) + ..expandPath(selectedNode.value); + return true; + }, + ); + programmaticSelectionChangeInProgress = false; + animateTo(selectedNode.value); } @override @@ -602,22 +660,10 @@ class InspectorController extends DisposableController // Don't do anything. We will update the view once it is visible again. return; } - if (detailsSubtree) { - // Wait for the master to update. - return; - } - unawaited(updateSelectionFromService(firstFrame: false)); + unawaited(updateSelectionFromService()); } - Future updateSelectionFromService({ - required bool firstFrame, - String? inspectorRef, - }) async { - if (parent != null) { - // If we have a parent controller we should wait for the parent to update - // our selection rather than updating it our self. - return; - } + Future updateSelectionFromService({String? inspectorRef}) async { final selectionGroups = _selectionGroups; if (selectionGroups == null) { // Already disposed. Ignore this requested to update selection. @@ -639,41 +685,30 @@ class InspectorController extends DisposableController final pendingSelectionFuture = group.getSelection( selectedDiagnostic, treeType, - restrictToLocalProject: isSummaryTree, + // If implementation widgets are hidden, the only widgets in the tree are + // those that were created by the local project. + restrictToLocalProject: implementationWidgetsHidden.value, ); - final pendingDetailsFuture = isSummaryTree - ? group.getSelection(selectedDiagnostic, treeType) - : null; - try { final newSelection = await pendingSelectionFuture; - if (disposed || group.disposed) return; - RemoteDiagnosticsNode? detailsSelection; - if (pendingDetailsFuture != null) { - detailsSelection = await pendingDetailsFuture; - if (disposed || group.disposed) return; - } + if (disposed || group.disposed) return; - if (!firstFrame && - detailsSelection?.valueRef == details?.selectedDiagnostic?.valueRef && - newSelection?.valueRef == selectedDiagnostic?.valueRef) { - // No need to change the selection as it didn't actually change. - selectionGroups.cancelNext(); - return; - } selectionGroups.promoteNext(); - subtreeRoot = newSelection; + applyNewSelection(newSelection); - applyNewSelection(newSelection, detailsSelection, true); + await _maybeShowNotificationForSelectedNode( + selectedNode: newSelection, + group: group, + ); // Send an event that a widget was selected on the device. ga.select( gac.inspector, gac.onDeviceSelection, - screenMetricsProvider: () => InspectorScreenMetrics.legacy(), + screenMetricsProvider: () => InspectorScreenMetrics(), ); } catch (error, st) { if (selectionGroups.next == group) { @@ -683,22 +718,16 @@ class InspectorController extends DisposableController } } - void applyNewSelection( - RemoteDiagnosticsNode? newSelection, - RemoteDiagnosticsNode? detailsSelection, - bool setSubtreeRoot, - ) { + void applyNewSelection(RemoteDiagnosticsNode? newSelection) { final nodeInTree = findMatchingInspectorTreeNode(newSelection); if (nodeInTree == null) { // The tree has probably changed since we last updated. Do a full refresh // so that the tree includes the new node we care about. - unawaited( - _recomputeTreeRoot(newSelection, detailsSelection, setSubtreeRoot), - ); + unawaited(_recomputeTreeRoot(newSelection)); } - refreshSelection(newSelection, detailsSelection, setSubtreeRoot); + refreshSelection(newSelection); } void animateTo(InspectorTreeNode? node) { @@ -716,28 +745,168 @@ class InspectorController extends DisposableController _selectedNode.value = newSelection; - lastExpanded = null; // New selected node takes precedence. endShowNode(); - final detailsLocal = details; - final parantLocal = parent; - if (detailsLocal != null) { - detailsLocal.endShowNode(); - } else if (parantLocal != null) { - parantLocal.endShowNode(); - } _updateSelectedErrorFromNode(_selectedNode.value); + unawaited(_loadPropertiesForNode(_selectedNode.value)); + + /// If the user selects a hidden implementation widget, first expand that + /// widget's hideable group before scrolling. + final diagnostic = _selectedNode.value?.diagnostic; + if (diagnostic != null && diagnostic.isHidden) { + inspectorTree.refreshTree( + updateTreeAction: () { + diagnostic.hideableGroupLeader?.toggleHiddenGroup(); + return true; + }, + ); + } + animateTo(selectedNode.value); } + static const _implementationWidgetMessage = + 'Selected an implementation widget'; + + static const _notificationDuration = Duration(seconds: 4); + + Future _maybeShowNotificationForSelectedNode({ + required RemoteDiagnosticsNode? selectedNode, + required ObjectGroup group, + }) async { + if (selectedNode == null || + !implementationWidgetsHidden.value || + _selectionIsOutOfDate(selectedNode)) { + return; + } + + final possibleImplementationWidget = await group.getSelection( + selectedDiagnostic, + treeType, + ); + + // Return early if we have a new selected node. + if (_selectionIsOutOfDate(selectedNode)) return; + + final isImplementationWidget = + possibleImplementationWidget != null && + !possibleImplementationWidget.isCreatedByLocalProject; + if (isImplementationWidget) { + final selectedWidgetName = selectedNode.description ?? ''; + final implementationWidgetName = + possibleImplementationWidget.description ?? ''; + + // Return early if we have a new selected node. + if (_selectionIsOutOfDate(selectedNode)) return; + + // Show a notification that the user selected an implementation widget, + // e.g. "Selected an implementation widget of Text: RichText." + final messageDetails = selectedWidgetName.isEmpty + ? '' + : ' of $selectedWidgetName${implementationWidgetName.isEmpty ? '' : ': $implementationWidgetName'}'; + notificationService.pushNotification( + NotificationMessage( + '$_implementationWidgetMessage$messageDetails.', + duration: _notificationDuration, + ), + allowDuplicates: false, + ); + } + } + + bool _selectionIsOutOfDate(RemoteDiagnosticsNode selected) { + return selected.valueRef != selectedNode.value?.diagnostic?.valueRef; + } + + Future _loadPropertiesForNode(InspectorTreeNode? node) async { + final widgetProperties = []; + final renderProperties = []; + LayoutProperties? layoutProperties; + InspectorSourceLocation? creationLocation; + final diagnostic = node?.diagnostic; + final objectGroupApi = diagnostic?.objectGroupApi; + if (diagnostic != null && objectGroupApi != null) { + try { + // Fetch widget properties: + final wProperties = await diagnostic.getProperties(objectGroupApi); + // Check if the selected node has changed, and if so return early: + if (disposed || _selectedNode.value != node) { + return; + } + widgetProperties.addAll( + wProperties.where((p) => p.propertyType != 'RenderObject'), + ); + renderProperties.addAll( + wProperties.where((p) => p.propertyType == 'RenderObject'), + ); + // Fetch layout properties: + layoutProperties = await _loadLayoutPropertiesForNode( + diagnostic, + forFlexLayout: false, + ); + // Fetch creation location from the details subtree. Summary tree nodes + // omit creationLocation when loaded with fullDetails: false. + final detailsNode = await objectGroupApi.getDetailsSubtree( + diagnostic, + subtreeDepth: 0, + ); + // Check if the selected node has changed, and if so return early: + if (disposed || _selectedNode.value != node) { + return; + } + creationLocation = detailsNode?.creationLocation; + // Fetch RenderObject properties: + for (final renderObject in renderProperties) { + final rProperties = await renderObject.getProperties(objectGroupApi); + // Check if the selected node has changed, and if so return early: + if (disposed || _selectedNode.value != node) { + return; + } + renderProperties.addAll(rProperties); + } + } catch (e, st) { + _log.warning(e, st); + } + } + if (disposed) return; + _selectedNodeProperties.value = ( + widgetProperties: widgetProperties, + renderProperties: renderProperties, + layoutProperties: layoutProperties, + creationLocation: creationLocation, + ); + } + + Future _loadLayoutPropertiesForNode( + RemoteDiagnosticsNode diagnostic, { + required bool forFlexLayout, + }) async { + try { + _layoutGroups?.cancelNext(); + final manager = _layoutGroups!; + final nextObjectGroup = manager.next; + final node = await nextObjectGroup.getLayoutExplorerNode( + diagnostic.layoutRootNode(forFlexLayout: forFlexLayout), + ); + if (node == null || node.renderObject == null) return null; + + if (!nextObjectGroup.disposed) { + assert(manager.next == nextObjectGroup); + manager.promoteNext(); + } + return node.computeLayoutProperties(forFlexLayout: forFlexLayout); + } catch (e, st) { + _log.warning(e, st); + return null; + } + } + /// Update the index of the selected error based on a node that has been /// selected in the tree. void _updateSelectedErrorFromNode(InspectorTreeNode? node) { final inspectorRef = node?.diagnostic?.valueRef.id; - final errors = serviceConnection.errorBadgeManager - .erroredItemsForPage(InspectorScreen.id) - .value; + final errors = _inspectorScreenController.inspectorErrors.value; // Check whether the node that was just selected has any errors associated // with it. @@ -751,17 +920,9 @@ class InspectorController extends DisposableController _selectedErrorIndex.value = errorIndex; if (errorIndex != null) { - // Mark the error as "seen" as this will render slightly differently - // so the user can track which errored nodes they've viewed. - serviceConnection.errorBadgeManager.markErrorAsRead( - InspectorScreen.id, - errors[inspectorRef!]!, - ); - // Also clear the error badge since new errors may have arrived while - // the inspector was visible (normally they're cleared when visiting - // the screen) and visiting an errored node seems an appropriate - // acknowledgement of the errors. - serviceConnection.errorBadgeManager.clearErrorCount(InspectorScreen.id); + // Marking an error as read will automatically update the badge count to + // reflect the remaining unread errors. + _inspectorScreenController.markErrorAsRead(errors[inspectorRef!]!); } } @@ -769,15 +930,10 @@ class InspectorController extends DisposableController void selectErrorByIndex(int index) { _selectedErrorIndex.value = index; - final errors = serviceConnection.errorBadgeManager - .erroredItemsForPage(InspectorScreen.id) - .value; + final errors = _inspectorScreenController.inspectorErrors.value; unawaited( - updateSelectionFromService( - firstFrame: false, - inspectorRef: errors.keys.elementAt(index), - ), + updateSelectionFromService(inspectorRef: errors.keys.elementAt(index)), ); } @@ -802,7 +958,13 @@ class InspectorController extends DisposableController } } - void selectionChanged() { + /// Handles updating the widget tree when the selecected widget changes. + /// + /// [notifyFlutterInspector] determines whether a request should be sent to + /// the Widget Inspector in the Flutter framework to update the on-device + /// selection. This should only be true if the the selection was changed due + /// to a user action in DevTools (e.g. clicking on a widget in the tree). + void selectionChanged({bool notifyFlutterInspector = false}) { if (!visibleToUser) { return; } @@ -818,79 +980,30 @@ class InspectorController extends DisposableController setSelectedNode(node); unawaited(_addNodeToConsole(node)); - // Don't reroot if the selected value is already visible in the details tree. - final maybeReroot = - isSummaryTree && - details != null && - selectedDiagnostic != null && - !details!.hasDiagnosticsValue(selectedDiagnostic!.valueRef); syncSelectionHelper( - maybeRerootDetailsTree: maybeReroot, selection: selectedDiagnostic, - detailsSelection: selectedDiagnostic, + notifyFlutterInspector: notifyFlutterInspector, ); - - if (!maybeReroot) { - final parantLocal = parent; - final detailsLocal = details; - - if (isSummaryTree && detailsLocal != null) { - detailsLocal.selectAndShowNode(selectedDiagnostic); - } else if (parantLocal != null) { - parantLocal.selectAndShowNode( - firstAncestorInParentTree(selectedNode.value), - ); - } - } - } - } - - RemoteDiagnosticsNode? firstAncestorInParentTree(InspectorTreeNode? node) { - final parentLocal = parent; - - if (parentLocal == null) { - return node?.diagnostic; - } - while (node != null) { - final diagnostic = node.diagnostic; - if (diagnostic != null && - parentLocal.hasDiagnosticsValue(diagnostic.valueRef)) { - return parentLocal.findDiagnosticsValue(diagnostic.valueRef); - } - node = node.parent; } - return null; } + /// Syncs the selection state after a new widgets was selected. + /// + /// [notifyFlutterInspector] determines whether a request should be sent to + /// the Widget Inspector in the Flutter framework to update the on-device + /// selection. This should only be true if the the selection was changed due + /// to a user action in DevTools (e.g. clicking on a widget in the tree). void syncSelectionHelper({ - required bool maybeRerootDetailsTree, required RemoteDiagnosticsNode? selection, - required RemoteDiagnosticsNode? detailsSelection, + bool notifyFlutterInspector = false, }) { if (selection != null) { if (selection.isCreatedByLocalProject) { _navigateTo(selection); } } - if (detailsSubtree || details == null) { - if (selection != null) { - var toSelect = selectedNode.value; - while (toSelect != null && toSelect.diagnostic!.isProperty) { - toSelect = toSelect.parent; - } - - if (toSelect != null) { - final diagnosticToSelect = toSelect.diagnostic!; - unawaited(diagnosticToSelect.setSelectionInspector(true)); - } - } - } - - if (maybeRerootDetailsTree) { - _showDetailSubtrees(selection, detailsSelection); - } else if (selection != null) { - // We can't rely on the details tree to update the selection on the server in this case. + if (notifyFlutterInspector && selection != null) { unawaited(selection.setSelectionInspector(true)); } } @@ -908,14 +1021,18 @@ class InspectorController extends DisposableController if (serviceConnection.inspectorService != null) { shutdownTree(false); } + _treeGroups?.clear(false); _treeGroups = null; _selectionGroups?.clear(false); _selectionGroups = null; - details?.dispose(); + _layoutGroups?.clear(false); + _layoutGroups = null; _refreshRateLimiter.dispose(); _selectedNode.dispose(); + _selectedNodeProperties.dispose(); + _implementationWidgetsHidden.dispose(); _selectedErrorIndex.dispose(); super.dispose(); } @@ -930,21 +1047,4 @@ class InspectorController extends DisposableController valueToInspectorTreeNode[valueRef] = node; } } - - Future expandAllNodesInDetailsTree() async { - final detailsLocal = details!; - await detailsLocal._recomputeTreeRoot( - inspectorTree.selection?.diagnostic, - detailsLocal.inspectorTree.selection?.diagnostic ?? - detailsLocal.inspectorTree.root?.diagnostic, - false, - subtreeDepth: maxJsInt, - ); - } - - void collapseDetailsToSelected() { - final detailsLocal = details!; - detailsLocal.inspectorTree.collapseToSelected(); - detailsLocal.animateTo(detailsLocal.inspectorTree.selection); - } } diff --git a/packages/devtools_app/lib/src/screens/inspector_shared/inspector_controls.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_controls.dart similarity index 86% rename from packages/devtools_app/lib/src/screens/inspector_shared/inspector_controls.dart rename to packages/devtools_app/lib/src/screens/inspector/inspector_controls.dart index d1f810a2605..951a93f7040 100644 --- a/packages/devtools_app/lib/src/screens/inspector_shared/inspector_controls.dart +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_controls.dart @@ -5,22 +5,21 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../service/service_extension_widgets.dart'; import '../../service/service_extensions.dart' as extensions; import '../../shared/analytics/constants.dart' as gac; -import '../../shared/feature_flags.dart'; import '../../shared/globals.dart'; import '../../shared/ui/common_widgets.dart'; -import '../inspector_shared/inspector_settings_dialog.dart'; -import '../inspector_v2/inspector_controller.dart' as v2; +import 'inspector_controller.dart'; +import 'inspector_settings_dialog.dart'; /// Control buttons for the inspector panel. class InspectorControls extends StatelessWidget { const InspectorControls({super.key, this.controller}); - final v2.InspectorController? controller; + final InspectorController? controller; static const minScreenWidthForTextBeforeTruncating = 800.0; static const minScreenWidthForText = 550.0; @@ -30,8 +29,6 @@ class InspectorControls extends StatelessWidget { final activeButtonColor = Theme.of( context, ).colorScheme.activeToggleButtonColor; - final isInspectorV2 = - controller != null && FeatureFlags.inspectorV2.isEnabled; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -49,16 +46,13 @@ class InspectorControls extends StatelessWidget { : extensions.toggleOnDeviceWidgetInspector, ], minScreenWidthForText: minScreenWidthForText, - minScreenWidthForTextBeforeTruncating: isInspectorV2 - ? minScreenWidthForTextBeforeTruncating - : null, + minScreenWidthForTextBeforeTruncating: + minScreenWidthForTextBeforeTruncating, ); }, ), - if (isInspectorV2) ...[ - const SizedBox(width: defaultSpacing), - ShowImplementationWidgetsButton(controller: controller!), - ], + const SizedBox(width: defaultSpacing), + ShowImplementationWidgetsButton(controller: controller!), const Spacer(), const SizedBox(width: defaultSpacing), const InspectorServiceExtensionButtonGroup(), @@ -113,7 +107,7 @@ class InspectorServiceExtensionButtonGroup extends StatelessWidget { class ShowImplementationWidgetsButton extends StatelessWidget { const ShowImplementationWidgetsButton({super.key, required this.controller}); - final v2.InspectorController controller; + final InspectorController controller; @override Widget build(BuildContext context) { diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_data_models.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_data_models.dart index a12ed91f76e..188dabd14b6 100644 --- a/packages/devtools_app/lib/src/screens/inspector/inspector_data_models.dart +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_data_models.dart @@ -1,8 +1,8 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -/// @docImport 'layout_explorer/ui/overflow_indicator_painter.dart'; +/// @docImport '../inspector/layout_explorer/ui/overflow_indicator_painter.dart'; library; import 'dart:math' as math; @@ -40,6 +40,7 @@ const overflowEpsilon = 0.1; /// * (largestRenderSize - smallestRenderSize) /// / (largestSize - smallestSize) + smallestRenderSize /// ``` +/// /// Explanation: /// - The computation formula for transforming size to renderSize is based on these two things: /// - smallest element will be rendered to [smallestRenderSize] @@ -61,6 +62,7 @@ const overflowEpsilon = 0.1; /// ``` /// sum(renderSize) = maxSizeAvailable /// ``` +/// List computeRenderSizes({ required Iterable sizes, required double smallestSize, @@ -101,6 +103,26 @@ List computeRenderSizes({ return renderSizes; } +/// Data pattern containing a widget's widths or heights. +typedef WidgetSizes = ({ + /// Whether this record represents a widget's widths or heights. + SizeType type, + + /// Either the widget's left (if [type] is [SizeType.widths]) or top (if + /// [type] is [SizeType.heights]) padding. + double paddingA, + + /// Either the widget's width (if [type] is [SizeType.widths]) or height (if + /// [type] is [SizeType.heights]). + double widgetSize, + + /// Either the widget's right (if [type] is [SizeType.widths]) or bottom (if + /// [type] is [SizeType.heights]) padding. + double paddingB, +}); + +enum SizeType { widths, heights } + // TODO(albertusangga): Move this to [RemoteDiagnosticsNode] once dart:html app is removed /// Represents parsed layout information for a specific [RemoteDiagnosticsNode]. class LayoutProperties { @@ -114,6 +136,7 @@ class LayoutProperties { children = copyLevel == 0 ? [] : node.childrenNow + .where((child) => child.size != null) .map( (child) => LayoutProperties(child, copyLevel: copyLevel - 1), ) @@ -227,6 +250,45 @@ class LayoutProperties { return heightUsed > parentHeight + overflowEpsilon; } + LayoutProperties? get parentLayoutProperties { + final parentElement = node.parentRenderElement; + // Fall back to this node's properties if there is no parent. + if (parentElement == null) return this; + final parentProperties = parentElement.computeLayoutProperties( + forFlexLayout: false, + ); + return parentProperties ?? this; + } + + WidgetSizes? get widgetWidths => _widgetSizes(SizeType.widths); + + WidgetSizes? get widgetHeights => _widgetSizes(SizeType.heights); + + WidgetSizes? _widgetSizes(SizeType type) { + if (parentLayoutProperties == null) return null; + final parentProperties = parentLayoutProperties!; + + final parentData = node.parentData; + final parentSize = parentProperties.size; + + switch (type) { + case SizeType.heights: + return ( + type: type, + paddingA: parentData.offset.dy, + widgetSize: size.height, + paddingB: parentSize.height - (size.height + parentData.offset.dy), + ); + case SizeType.widths: + return ( + type: type, + paddingA: parentData.offset.dx, + widgetSize: size.width, + paddingB: parentSize.width - (size.width + parentData.offset.dx), + ); + } + } + static String describeAxis(double min, double max, String axis) { if (min == max) return '$axis=${min.toStringAsFixed(1)}'; return '${min.toStringAsFixed(1)}<=$axis<=${max.toStringAsFixed(1)}'; @@ -287,6 +349,37 @@ extension MainAxisAlignmentExtension on MainAxisAlignment { } } +/// Encapsulation of [widths] and [heights] for the layout. +class LayoutWidthsAndHeights { + LayoutWidthsAndHeights({required this.widths, required this.heights}); + + final WidgetSizes widths; + final WidgetSizes heights; + + double get widgetWidth => widths.widgetSize; + + double get widgetHeight => heights.widgetSize; + + double get leftPadding => widths.paddingA; + + double get rightPadding => widths.paddingB; + + double get topPadding => heights.paddingA; + + double get bottomPadding => heights.paddingB; + + bool get hasLeftPadding => leftPadding > 0; + + bool get hasRightPadding => rightPadding > 0; + + bool get hasTopPadding => topPadding > 0; + + bool get hasBottomPadding => bottomPadding > 0; + + bool get hasAnyPadding => + hasLeftPadding || hasRightPadding || hasTopPadding || hasBottomPadding; +} + /// TODO(albertusangga): Move this to [RemoteDiagnosticsNode] once dart:html app is removed. class FlexLayoutProperties extends LayoutProperties { FlexLayoutProperties({ diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_errors.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_errors.dart new file mode 100644 index 00000000000..eb12e8e4475 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_errors.dart @@ -0,0 +1,15 @@ +// Copyright 2024 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import '../../shared/managers/error_badge_manager.dart'; + +/// An error associated with a specific widget that can be inspected in the +/// Inspector screen. +class InspectableWidgetError extends DevToolsError { + InspectableWidgetError(super.errorMessage, super.id, {super.read}); + + @override + InspectableWidgetError asRead() => + InspectableWidgetError(errorMessage, id, read: true); +} diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_screen.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_screen.dart new file mode 100644 index 00000000000..799abb0e871 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_screen.dart @@ -0,0 +1,31 @@ +// Copyright 2024 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/shared.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../shared/framework/screen.dart'; +import '../../shared/globals.dart'; +import 'inspector_screen_body.dart'; +import 'inspector_screen_controller.dart'; + +class InspectorScreen extends Screen { + InspectorScreen() : super.fromMetaData(ScreenMetaData.inspector); + + static final id = ScreenMetaData.inspector.id; + + // There is not enough room to safely show the console in the embed view of + // the DevTools and IDEs have their own consoles. + @override + bool showConsole(EmbedMode embedMode) => !embedMode.embedded; + + @override + String get docPageId => screenId; + + @override + Widget buildScreenBody(BuildContext context) { + final controller = screenControllers.lookup(); + return InspectorScreenBody(controller: controller.inspectorController); + } +} diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_body.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_body.dart index 5ab577d564d..855e57a35a5 100644 --- a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_body.dart +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_body.dart @@ -1,4 +1,4 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. @@ -7,26 +7,25 @@ import 'dart:collection'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; -import '../../service/service_extension_widgets.dart'; -import '../../service/service_extensions.dart' as extensions; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; import '../../shared/analytics/metrics.dart'; import '../../shared/console/eval/inspector_tree.dart'; import '../../shared/globals.dart'; -import '../../shared/managers/banner_messages.dart'; import '../../shared/managers/error_badge_manager.dart'; import '../../shared/primitives/blocking_action_mixin.dart'; import '../../shared/ui/common_widgets.dart'; import '../../shared/ui/search.dart'; -import '../inspector_shared/inspector_controls.dart'; -import '../inspector_shared/inspector_screen.dart'; -import '../inspector_shared/inspector_settings_dialog.dart'; +import '../../shared/utils/utils.dart'; import 'inspector_controller.dart'; -import 'inspector_screen_details_tab.dart'; +import 'inspector_controls.dart'; +import 'inspector_errors.dart'; +import 'inspector_screen.dart'; +import 'inspector_screen_controller.dart'; import 'inspector_tree_controller.dart'; +import 'widget_details.dart'; class InspectorScreenBody extends StatefulWidget { const InspectorScreenBody({super.key, required this.controller}); @@ -41,15 +40,12 @@ class InspectorScreenBodyState extends State with BlockingActionMixin, AutoDisposeMixin { InspectorController get controller => widget.controller; - InspectorTreeController get _summaryTreeController => + InspectorTreeController get _inspectorTreeController => controller.inspectorTree; - InspectorTreeController get _detailsTreeController => - controller.details!.inspectorTree; - bool searchVisible = false; - SearchControllerMixin get searchController => _summaryTreeController; + SearchControllerMixin get searchController => _inspectorTreeController; /// Indicates whether search can be closed. The value is set to true when /// search target type dropdown is displayed @@ -58,10 +54,7 @@ class InspectorScreenBodyState extends State SearchTargetType searchTarget = SearchTargetType.widget; - static const summaryTreeKey = Key('Summary Tree'); - static const detailsTreeKey = Key('Details Tree'); - static const minScreenWidthForText = 900.0; - static const serviceExtensionButtonsIncludeTextWidth = 1200.0; + static const inspectorTreeKey = Key('Inspector Tree'); @override void initState() { @@ -103,7 +96,7 @@ class InspectorScreenBodyState extends State addAutoDisposeListener(preferences.inspector.pubRootDirectories, () { if (serviceConnection.serviceManager.connectedState.value.connected && controller.firstInspectorTreeLoadCompleted) { - _refreshInspector(); + safeUnawaited(controller.refreshInspector()); } }); @@ -111,55 +104,45 @@ class InspectorScreenBodyState extends State ga.timeStart(InspectorScreen.id, gac.pageReady); } - _summaryTreeController.setSearchTarget(searchTarget); - - _showLegacyInspectorWarning(context); + _inspectorTreeController.setSearchTarget(searchTarget); } @override Widget build(BuildContext context) { - final summaryTree = _buildSummaryTreeColumn(); - - final detailsTree = InspectorTree( - key: detailsTreeKey, - controller: controller, - treeController: _detailsTreeController, - summaryTreeController: _summaryTreeController, - screenId: InspectorScreen.id, - ); + final inspectorTree = _buildInspectorTreeColumn(); final splitAxis = SplitPane.axisFor(context, 0.85); final widgetTrees = SplitPane( axis: splitAxis, initialFractions: const [0.33, 0.67], children: [ - summaryTree, - InspectorDetails(detailsTree: detailsTree, controller: controller), + inspectorTree, + WidgetDetails(controller: controller), ], ); return Column( children: [ - const InspectorControls(), + InspectorControls(controller: controller), const SizedBox(height: intermediateSpacing), Expanded(child: widgetTrees), ], ); } - Widget _buildSummaryTreeColumn() { + Widget _buildInspectorTreeColumn() { return LayoutBuilder( builder: (context, constraints) { return RoundedOutlinedBorder( child: Column( children: [ - InspectorSummaryTreeControls( + InspectorTreeControls( isSearchVisible: searchVisible, constraints: constraints, - onRefreshInspectorPressed: _refreshInspector, + onRefreshInspectorPressed: _manualInspectorRefresh, onSearchVisibleToggle: _onSearchVisibleToggle, searchFieldBuilder: () => StatelessSearchField( - controller: _summaryTreeController, + controller: _inspectorTreeController, searchFieldEnabled: true, shouldRequestFocus: searchVisible, supportsNavigation: true, @@ -168,8 +151,9 @@ class InspectorScreenBodyState extends State ), Expanded( child: ValueListenableBuilder( - valueListenable: serviceConnection.errorBadgeManager - .erroredItemsForPage(InspectorScreen.id), + valueListenable: screenControllers + .lookup() + .inspectorErrors, builder: (_, LinkedHashMap errors, _) { final inspectableErrors = errors.map( @@ -182,10 +166,9 @@ class InspectorScreenBodyState extends State return Stack( children: [ InspectorTree( - key: summaryTreeKey, + key: inspectorTreeKey, controller: controller, - treeController: _summaryTreeController, - isSummaryTree: true, + treeController: _inspectorTreeController, widgetErrors: inspectableErrors, screenId: InspectorScreen.id, ), @@ -218,76 +201,25 @@ class InspectorScreenBodyState extends State setState(() { searchVisible = !searchVisible; }); - _summaryTreeController.resetSearch(); - } - - void _showLegacyInspectorWarning(BuildContext context) { - if (context.mounted) { - pushLegacyInspectorWarning(InspectorScreen.id); - } - } - - List getServiceExtensionWidgets() { - return [ - ServiceExtensionButtonGroup( - minScreenWidthForText: serviceExtensionButtonsIncludeTextWidth, - extensions: [ - extensions.slowAnimations, - extensions.debugPaint, - extensions.debugPaintBaselines, - extensions.repaintRainbow, - extensions.invertOversizedImages, - ], - ), - const SizedBox(width: defaultSpacing), - SettingsOutlinedButton( - gaScreen: gac.inspector, - gaSelection: gac.inspectorSettings, - tooltip: 'Flutter Inspector Settings', - onPressed: () { - unawaited( - showDialog( - context: context, - builder: (context) => const FlutterInspectorSettingsDialog(), - ), - ); - }, - ), - // TODO(jacobr): implement TogglePlatformSelector. - // TogglePlatformSelector().selector - ]; + _inspectorTreeController.resetSearch(); } - void _refreshInspector() { + void _manualInspectorRefresh() { ga.select( gac.inspector, gac.refresh, - screenMetricsProvider: () => InspectorScreenMetrics.legacy(), + screenMetricsProvider: () => InspectorScreenMetrics(), ); unawaited( blockWhileInProgress(() async { - // If the user is force refreshing the inspector before the first load has - // completed, this could indicate a slow load time or that the inspector - // failed to load the tree once available. - if (!controller.firstInspectorTreeLoadCompleted) { - // We do not want to complete this timing operation because the force - // refresh will skew the results. - ga.cancelTimingOperation(InspectorScreen.id, gac.pageReady); - ga.select( - gac.inspector, - gac.refreshEmptyTree, - screenMetricsProvider: () => InspectorScreenMetrics.legacy(), - ); - controller.firstInspectorTreeLoadCompleted = true; - } - await controller.onForceRefresh(); + await controller.refreshInspector(isManualRefresh: true); }), ); } } -class InspectorSummaryTreeControls extends StatelessWidget { - const InspectorSummaryTreeControls({ +class InspectorTreeControls extends StatelessWidget { + const InspectorTreeControls({ super.key, required this.constraints, required this.isSearchVisible, diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_controller.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_controller.dart new file mode 100644 index 00000000000..47c314e8742 --- /dev/null +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_controller.dart @@ -0,0 +1,194 @@ +// Copyright 2024 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:collection'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:vm_service/vm_service.dart'; + +import '../../service/vm_service_wrapper.dart'; +import '../../shared/analytics/metrics.dart'; +import '../../shared/console/primitives/simple_items.dart'; +import '../../shared/diagnostics/diagnostics_node.dart'; +import '../../shared/framework/screen.dart'; +import '../../shared/framework/screen_controllers.dart'; +import '../../shared/globals.dart'; +import '../../shared/managers/error_badge_manager.dart'; +import '../../shared/primitives/query_parameters.dart'; +import 'inspector_controller.dart'; +import 'inspector_errors.dart'; +import 'inspector_screen.dart'; +import 'inspector_tree_controller.dart'; + +/// Screen controller for the Inspector screen. +/// +/// This controller can be accessed from anywhere in DevTools, as long as it was +/// first registered, by +/// calling `screenControllers.lookup()`. +/// +/// The controller lifecycle is managed by the [ScreenControllers] class. The +/// `init` method is called lazily upon the first controller access from +/// `screenControllers`. The `dispose` method is called by `screenControllers` +/// when DevTools is destroying a set of DevTools screen controllers. +class InspectorScreenController extends DevToolsScreenController + with AutoDisposeControllerMixin { + @override + final screenId = ScreenMetaData.inspector.id; + + late InspectorController inspectorController; + late InspectorTreeController inspectorTreeController; + + /// Stores the inspector-specific errors keyed by inspector reference ID. + final _activeInspectorErrors = + ValueNotifier>( + LinkedHashMap(), + ); + + /// The errors currently tracked for the inspector screen. + ValueListenable> get inspectorErrors => + _activeInspectorErrors; + + @override + void init() { + super.init(); + // Inspector owns unread state for its badge; scaffold tab switches must not + // clear it. See https://github.com/flutter/devtools/pull/9805. + serviceConnection.errorBadgeManager.manageErrorCount(InspectorScreen.id); + + inspectorTreeController = InspectorTreeController( + gaId: InspectorScreenMetrics.summaryTreeGaId, + ); + inspectorController = InspectorController( + inspectorTree: inspectorTreeController, + treeType: FlutterTreeType.widget, + ); + + // Listen for Flutter extension events to extract inspector-specific errors. + // Match other screen controllers: attach now if connected, and on connect. + addAutoDisposeListener(serviceConnection.serviceManager.connectedState, () { + if (serviceConnection.serviceManager.connectedState.value.connected) { + _handleConnectionStart(serviceConnection.serviceManager.service!); + } + }); + if (serviceConnection.serviceManager.connectedAppInitialized) { + _handleConnectionStart(serviceConnection.serviceManager.service!); + } + } + + void _handleConnectionStart(VmServiceWrapper service) { + autoDisposeStreamSubscription( + service.onExtensionEventWithHistorySafe.listen(_handleExtensionEvent), + ); + } + + void _handleExtensionEvent(Event e) { + if (e.extensionKind == FlutterEvent.error) { + final inspectableError = _extractInspectableError(e); + if (inspectableError != null) { + appendError(inspectableError); + } + } + } + + InspectableWidgetError? _extractInspectableError(Event error) { + final extensionData = error.extensionData; + if (extensionData == null) return null; + + final node = RemoteDiagnosticsNode(extensionData.data, null, false, null); + + final errorSummaryNode = node.inlineProperties.firstWhereOrNull( + (p) => p.type == 'ErrorSummary', + ); + final errorMessage = errorSummaryNode?.description; + if (errorMessage == null) { + return null; + } + + final devToolsUrlNode = node.inlineProperties.firstWhereOrNull( + (p) => + p.type == 'DevToolsDeepLinkProperty' && + p.getStringMember('value') != null, + ); + if (devToolsUrlNode == null) { + return null; + } + + final queryParams = DevToolsQueryParams.fromUrl( + devToolsUrlNode.getStringMember('value')!, + ); + final inspectorRef = queryParams.inspectorRef ?? ''; + + return InspectableWidgetError(errorMessage, inspectorRef); + } + + /// Appends an error to the inspector's active errors and updates the badge + /// count. + void appendError(DevToolsError error) { + final errors = _activeInspectorErrors; + final previousError = errors.value[error.id]; + + // Build a new map with the new error. Adding to the existing map + // won't cause the ValueNotifier to fire (and it's not permitted to call + // notifyListeners() directly). + final newValue = LinkedHashMap.of(errors.value); + newValue[error.id] = error; + errors.value = newValue; + + if (previousError == null) { + if (!error.read) { + _incrementUnreadCount(); + } + return; + } + + if (previousError.read && !error.read) { + _incrementUnreadCount(); + } else if (!previousError.read && error.read) { + _decrementUnreadCount(); + } + } + + /// Clears all inspector errors and resets the badge count. + void clearErrors() { + _activeInspectorErrors.value = LinkedHashMap(); + serviceConnection.errorBadgeManager.resetErrorCount(InspectorScreen.id); + } + + /// Marks an error as read and decrements the unread count. + void markErrorAsRead(DevToolsError error) { + final errors = _activeInspectorErrors; + + // If this error doesn't exist anymore or is already read, nothing to do. + final currentError = errors.value[error.id]; + if (currentError == null || currentError.read) { + return; + } + + // Otherwise, replace the map with a new one that has the error marked + // as read. + final newValue = LinkedHashMap.of(errors.value); + newValue[error.id] = currentError.asRead(); + errors.value = newValue; + _decrementUnreadCount(); + } + + void _incrementUnreadCount() { + serviceConnection.errorBadgeManager.incrementBadgeCount(InspectorScreen.id); + } + + void _decrementUnreadCount() { + serviceConnection.errorBadgeManager.decrementBadgeCount(InspectorScreen.id); + } + + @override + void dispose() { + _activeInspectorErrors.dispose(); + inspectorTreeController.dispose(); + inspectorController.dispose(); + super.dispose(); + } +} diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_details_tab.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_screen_details_tab.dart deleted file mode 100644 index ae8cc53e551..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector/inspector_screen_details_tab.dart +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:async'; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -import '../../shared/analytics/analytics.dart' as ga; -import '../../shared/analytics/constants.dart' as gac; -import '../../shared/globals.dart'; -import '../../shared/preferences/preferences.dart'; -import '../../shared/primitives/blocking_action_mixin.dart'; -import '../../shared/ui/common_widgets.dart'; -import '../../shared/ui/tab.dart'; -import 'inspector_controller.dart'; -import 'inspector_screen_body.dart'; -import 'layout_explorer/layout_explorer.dart'; - -class InspectorDetails extends StatelessWidget { - const InspectorDetails({ - required this.detailsTree, - required this.controller, - super.key, - }); - - final Widget detailsTree; - final InspectorController controller; - - @override - Widget build(BuildContext context) { - final tabs = [ - ( - tab: _buildTab(tabName: InspectorDetailsViewType.layoutExplorer.key), - tabView: LayoutExplorerTab(controller: controller), - ), - ( - tab: _buildTab( - tabName: InspectorDetailsViewType.widgetDetailsTree.key, - trailing: InspectorExpandCollapseButtons(controller: controller), - ), - tabView: detailsTree, - ), - ]; - return ValueListenableBuilder( - valueListenable: preferences.inspector.defaultDetailsView, - builder: (BuildContext context, value, Widget? child) { - int defaultInspectorViewIndex = 0; - - if (preferences.inspector.defaultDetailsView.value == - InspectorDetailsViewType.widgetDetailsTree) { - defaultInspectorViewIndex = 1; - } - - return AnalyticsTabbedView( - tabs: tabs, - gaScreen: gac.inspector, - initialSelectedIndex: defaultInspectorViewIndex, - ); - }, - ); - } - - DevToolsTab _buildTab({required String tabName, Widget? trailing}) { - return DevToolsTab.create( - tabName: tabName, - gaPrefix: 'inspectorDetailsTab', - trailing: trailing, - ); - } -} - -class InspectorExpandCollapseButtons extends StatefulWidget { - const InspectorExpandCollapseButtons({super.key, required this.controller}); - - final InspectorController controller; - - @override - State createState() => - _InspectorExpandCollapseButtonsState(); -} - -class _InspectorExpandCollapseButtonsState - extends State - with BlockingActionMixin { - bool get enableButtons => !actionInProgress; - - @override - Widget build(BuildContext context) { - return Container( - alignment: Alignment.centerRight, - decoration: BoxDecoration( - border: Border(left: defaultBorderSide(Theme.of(context))), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - child: GaDevToolsButton( - icon: Icons.unfold_more, - onPressed: enableButtons ? _onExpandClick : null, - label: 'Expand all', - minScreenWidthForText: - InspectorScreenBodyState.minScreenWidthForText, - gaScreen: gac.inspector, - gaSelection: gac.expandAll, - outlined: false, - ), - ), - const SizedBox(width: denseSpacing), - SizedBox( - child: GaDevToolsButton( - icon: Icons.unfold_less, - onPressed: enableButtons ? _onCollapseClick : null, - label: 'Collapse to selected', - minScreenWidthForText: - InspectorScreenBodyState.minScreenWidthForText, - gaScreen: gac.inspector, - gaSelection: gac.collapseAll, - outlined: false, - ), - ), - ], - ), - ); - } - - void _onExpandClick() { - unawaited( - blockWhileInProgress(() async { - ga.select(gac.inspector, gac.expandAll); - await widget.controller.expandAllNodesInDetailsTree(); - }), - ); - } - - void _onCollapseClick() { - ga.select(gac.inspector, gac.collapseAll); - widget.controller.collapseDetailsToSelected(); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_settings_dialog.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_settings_dialog.dart new file mode 100644 index 00000000000..8401ec5104a --- /dev/null +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_settings_dialog.dart @@ -0,0 +1,127 @@ +// Copyright 2024 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; + +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:vm_service/vm_service.dart' hide Stack; + +import '../../shared/analytics/constants.dart' as gac; +import '../../shared/globals.dart'; +import '../../shared/primitives/simple_items.dart'; +import '../../shared/ui/common_widgets.dart'; +import '../../shared/ui/editable_list.dart'; + +class FlutterInspectorSettingsDialog extends StatefulWidget { + const FlutterInspectorSettingsDialog({super.key}); + + @override + State createState() => + _FlutterInspectorSettingsDialogState(); +} + +class _FlutterInspectorSettingsDialogState + extends State + with AutoDisposeMixin { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + const dialogHeight = 500.0; + + return DevToolsDialog( + title: const DialogTitleText('Flutter Inspector Settings'), + content: SizedBox( + width: defaultDialogWidth, + height: dialogHeight, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...dialogSubHeader(theme, 'General'), + CheckboxSetting( + notifier: + preferences.inspector.hoverEvalModeEnabled + as ValueNotifier, + title: 'Enable hover inspection', + description: + 'Hovering over any widget displays its properties and values.', + gaItem: gac.inspectorHoverEvalMode, + ), + const SizedBox(height: largeSpacing), + CheckboxSetting( + notifier: + preferences.inspector.autoRefreshEnabled + as ValueNotifier, + title: 'Enable widget tree auto-refreshing', + description: + 'The widget tree will automatically refresh after a hot-reload or navigation event.', + gaItem: gac.inspectorAutoRefreshEnabled, + ), + const SizedBox(height: largeSpacing), + ...dialogSubHeader(theme, 'Package Directories'), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + 'Widgets in these directories will show up in your summary tree.', + style: theme.subtleTextStyle, + ), + ), + MoreInfoLink( + url: DocLinks.inspectorPackageDirectories.value, + gaScreenName: gac.inspector, + gaSelectedItemDescription: + gac.InspectorDocs.packageDirectoriesDocs.name, + ), + ], + ), + Text( + '(e.g. /absolute/path/to/myPackage/)', + style: theme.subtleTextStyle, + ), + const SizedBox(height: denseSpacing), + const Expanded(child: PubRootDirectorySection()), + ], + ), + ), + actions: const [DialogCloseButton()], + ); + } +} + +class PubRootDirectorySection extends StatelessWidget { + const PubRootDirectorySection({super.key}); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: + serviceConnection.serviceManager.isolateManager.mainIsolate, + builder: (_, _, _) { + return SizedBox( + height: 200.0, + child: EditableList( + gaScreen: gac.inspector, + gaRefreshSelection: gac.refreshPubRoots, + entries: preferences.inspector.pubRootDirectories, + textFieldLabel: 'Enter a new package directory', + isRefreshing: preferences.inspector.isRefreshingPubRootDirectories, + onEntryAdded: (p0) => unawaited( + preferences.inspector.addPubRootDirectories([ + p0, + ], shouldCache: true), + ), + onEntryRemoved: (p0) => + unawaited(preferences.inspector.removePubRootDirectories([p0])), + onRefreshTriggered: () => + unawaited(preferences.inspector.loadPubRootDirectories()), + ), + ); + }, + ); + } +} diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart index 35b5d9b66cd..f8d785a6148 100644 --- a/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart +++ b/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart @@ -1,4 +1,4 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. @@ -9,9 +9,9 @@ import 'dart:math'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; @@ -29,8 +29,8 @@ import '../../shared/ui/common_widgets.dart'; import '../../shared/ui/search.dart'; import '../../shared/ui/utils.dart'; import '../../shared/utils/utils.dart'; -import 'inspector_breadcrumbs.dart'; import 'inspector_controller.dart'; +import 'inspector_errors.dart'; final _log = Logger('inspector_tree_controller'); @@ -90,11 +90,17 @@ class _InspectorTreeRowState extends State<_InspectorTreeRowWidget> void onExpandChanged(bool expanded) { setState(() { final row = widget.row; - if (expanded) { - widget.inspectorTreeState.treeController!.onExpandRow(row); - } else { - widget.inspectorTreeState.treeController!.onCollapseRow(row); - } + final treeController = widget.inspectorTreeState.treeController!; + treeController.refreshTree( + updateTreeAction: () { + if (expanded) { + treeController.onExpandRow(row); + } else { + treeController.onCollapseRow(row); + } + return true; + }, + ); }); } @@ -127,14 +133,20 @@ class InspectorTreeController extends DisposableController gac.inspector, gac.inspectorTreeControllerInitialized, nonInteraction: true, - screenMetricsProvider: () => InspectorScreenMetrics.legacy( + screenMetricsProvider: () => InspectorScreenMetrics( inspectorTreeControllerId: gaId, rootSetCount: _rootSetCount, - rowCount: _root?.subtreeSize, + rowCount: _rowsInTree.value.length, ), ); } + @override + void dispose() { + _rowsInTree.dispose(); + super.dispose(); + } + void addClient(InspectorControllerClient value) { final firstClient = _clients.isEmpty; _clients.add(value); @@ -150,14 +162,6 @@ class InspectorTreeController extends DisposableController } } - // Method defined to avoid a direct Flutter dependency. - void setState(VoidCallback fn) { - fn(); - for (final client in _clients) { - client.onChanged(); - } - } - void requestFocus() { for (final client in _clients) { client.requestFocus(); @@ -168,23 +172,21 @@ class InspectorTreeController extends DisposableController InspectorTreeNode? _root; set root(InspectorTreeNode? node) { - if (disposed) return; + if (node != null) { + _updateRows(node: node, updateSearchableRows: true); + } + _root = node; - setState(() { - _root = node; - _populateSearchableCachedRows(); - - ga.select( - gac.inspector, - gac.inspectorTreeControllerRootChange, - nonInteraction: true, - screenMetricsProvider: () => InspectorScreenMetrics.legacy( - inspectorTreeControllerId: gaId, - rootSetCount: ++_rootSetCount, - rowCount: _root?.subtreeSize, - ), - ); - }); + ga.select( + gac.inspector, + gac.inspectorTreeControllerRootChange, + nonInteraction: true, + screenMetricsProvider: () => InspectorScreenMetrics( + inspectorTreeControllerId: gaId, + rootSetCount: ++_rootSetCount, + rowCount: _rowsInTree.value.length, + ), + ); } InspectorTreeNode? get selection => _selection; @@ -192,104 +194,111 @@ class InspectorTreeController extends DisposableController late final InspectorTreeConfig config; - set selection(InspectorTreeNode? node) { - if (node == _selection) return; + /// Refreshes the tree's rows if the return value of the [updateTreeAction] + /// callback is true. + void refreshTree({required bool Function() updateTreeAction}) { + final requiresRefresh = updateTreeAction(); + if (requiresRefresh) { + _updateRows(); + } + } - setState(() { - _selection?.selected = false; - _selection = node; - _selection?.selected = true; - final configLocal = config; - if (configLocal.onSelectionChange != null) { - configLocal.onSelectionChange!(); - } - }); + bool setSelectedNode( + InspectorTreeNode? node, { + bool notifyFlutterInspector = false, + }) { + if (node == _selection) return false; + + _selection?.selected = false; + _selection = node; + _selection?.selected = true; + final configLocal = config; + if (configLocal.onSelectionChange != null) { + configLocal.onSelectionChange!( + notifyFlutterInspector: notifyFlutterInspector, + ); + } + return true; } - InspectorTreeNode? get hover => _hover; InspectorTreeNode? _hover; double? lastContentWidth; - final cachedRows = []; - InspectorTreeRow? _cachedSelectedRow; - /// All cached rows of the tree. /// - /// Similar to [cachedRows] but: + /// Similar to [rowsInTree] but: /// * contains every row in the tree (including collapsed rows) /// * items don't change when nodes are expanded or collapsed /// * items are populated only when root is changed final _searchableCachedRows = []; - void setSearchTarget(SearchTargetType searchTarget) { - _searchTarget = searchTarget; - refreshSearchMatches(); - } + /// All the rows that should be displayed in the tree. + /// + /// The rows can be updated with a call to [_updateRows]. + ValueListenable> get rowsInTree => _rowsInTree; + final _rowsInTree = ValueNotifier>([]); - // TODO: we should add a listener instead that clears the cache when the - // root is marked as dirty. - void _maybeClearCache() { - final rootLocal = root; - if (rootLocal != null && rootLocal.isDirty) { - cachedRows.clear(); - _cachedSelectedRow = null; - rootLocal.isDirty = false; - lastContentWidth = null; - } - } + /// Map from node to the index for that node's row in [rowsInTree]. + final _nodeToRowIndex = {}; - void _populateSearchableCachedRows() { - _searchableCachedRows.clear(); - for (int i = 0; i < numRows; i++) { - _searchableCachedRows.add(getCachedRow(i)); - } - } + /// Rebuilds the tree and updates [rowsInTree] with the new values. + /// + /// If [updateSearchableRows] is true, also updates [_searchableCachedRows] + /// with the new values. + void _updateRows({ + InspectorTreeNode? node, + bool updateSearchableRows = false, + }) { + if (disposed) return; + + // TODO(elliette): Consider only updating an [InspectorTreeNode]'s branch + // when it is marked as dirty, instead of the entire tree. See: + // https://github.com/flutter/devtools/issues/7980 + node ??= root; + if (node == null) return; - InspectorTreeRow? getCachedRow(int index) { - if (index < 0) return null; + final rows = _buildRows(node); + _rowsInTree.value = rows; - _maybeClearCache(); - while (cachedRows.length <= index) { - cachedRows.add(null); + // Build the reverse node-to-index map for faster lookups: + for (int i = 0; i < _rowsInTree.value.length; i++) { + final row = _rowsInTree.value[i]; + final node = row.node; + _nodeToRowIndex[node] = i; } - cachedRows[index] ??= root?.getRow(index); - final cachedRow = cachedRows[index]; - cachedRow?.isSearchMatch = - _searchableCachedRows.safeGet(index)?.isSearchMatch ?? false; + if (updateSearchableRows) { + final searchableRows = _buildRows( + node, + includeHiddenRows: true, + includeCollapsedRows: true, + ); - if (cachedRow?.isSelected == true) { - _cachedSelectedRow = cachedRow; + _searchableCachedRows + ..clear() + ..addAll(searchableRows); } - return cachedRow; } - double getRowOffset(int index) { - return (getCachedRow(index)?.depth ?? 0) * inspectorColumnWidth; + void setSearchTarget(SearchTargetType searchTarget) { + _searchTarget = searchTarget; + refreshSearchMatches(); } - List getPathFromSelectedRowToRoot() { - final selectedItem = _cachedSelectedRow?.node; - if (selectedItem == null) return []; + InspectorTreeRow? rowAtIndex(int index) => _rowsInTree.value.safeGet(index); - final pathToRoot = [selectedItem]; - InspectorTreeNode? nextParentNode = selectedItem.parent; - while (nextParentNode != null) { - pathToRoot.add(nextParentNode); - nextParentNode = nextParentNode.parent; - } - return pathToRoot.reversed.toList(); + @visibleForTesting + double rowOffset(int index) { + return (rowAtIndex(index)?.depth ?? 0) * inspectorColumnIndent; } set hover(InspectorTreeNode? node) { if (node == _hover) { return; } - setState(() { - _hover = node; - // TODO(jacobr): we could choose to repaint only a portion of the UI - }); + + _hover = node; } void navigateUp() { @@ -302,6 +311,13 @@ class InspectorTreeController extends DisposableController void navigateLeft() { final selectionLocal = selection; + final diagnostic = selectionLocal?.diagnostic; + + final toggledHideableGroup = _maybeToggleHideableGroup( + diagnostic, + showGroup: false, + ); + if (toggledHideableGroup) return; // This logic is consistent with how IntelliJ handles tree navigation on // on left arrow key press. @@ -310,85 +326,110 @@ class InspectorTreeController extends DisposableController return; } - if (selectionLocal.isExpanded) { - setState(() { - selectionLocal.isExpanded = false; - }); - return; - } - if (selectionLocal.parent != null) { - selection = selectionLocal.parent; - } + refreshTree( + updateTreeAction: () { + if (selectionLocal.isExpanded) { + selectionLocal.isExpanded = false; + return true; + } + if (selectionLocal.parent != null) { + return setSelectedNode( + selectionLocal.parent, + notifyFlutterInspector: true, + ); + } + return false; + }, + ); } void navigateRight() { + final selectionLocal = selection; + final diagnostic = selectionLocal?.diagnostic; + + final toggledHideableGroup = _maybeToggleHideableGroup( + diagnostic, + showGroup: true, + ); + if (toggledHideableGroup) return; + // This logic is consistent with how IntelliJ handles tree navigation on // on right arrow key press. - final selectionLocal = selection; - if (selectionLocal == null || selectionLocal.isExpanded) { _navigateHelper(1); return; } - setState(() { - selectionLocal.isExpanded = true; - }); + selectionLocal.isExpanded = true; + _updateRows(); } void _navigateHelper(int indexOffset) { - if (numRows == 0) return; + if (_numRows == 0) return; + + refreshTree( + updateTreeAction: () { + final nodeToSelect = selection == null + ? root + : rowAtIndex( + (_rowIndexFromNode(selection!) + indexOffset).clamp( + 0, + _numRows - 1, + ), + )?.node; + return setSelectedNode(nodeToSelect, notifyFlutterInspector: true); + }, + ); + } - if (selection == null) { - selection = root; - return; + /// Given [showGroup], toggles the visibility of a hideable group. + /// + /// Returns a [bool] representing whether or not the group was toggled. + bool _maybeToggleHideableGroup( + RemoteDiagnosticsNode? diagnostic, { + required bool showGroup, + }) { + final isHideableGroupLeader = + diagnostic != null && diagnostic.isHideableGroupLeader; + final shouldToggle = + isHideableGroupLeader && + (showGroup ? diagnostic.groupIsHidden : !diagnostic.groupIsHidden); + + if (shouldToggle) { + refreshTree( + updateTreeAction: () { + diagnostic.toggleHiddenGroup(); + return true; + }, + ); + return true; } - final rootLocal = root!; - - selection = rootLocal - .getRow( - (rootLocal.getRowIndex(selection!) + indexOffset).clamp( - 0, - numRows - 1, - ), - ) - ?.node; + return false; } static const horizontalPadding = 10.0; + /// Returns the indentation of a row at the given [depth] in the inspector. + /// + /// This indentation roughly corresponds to the center of the icon next to the + /// widget name. double getDepthIndent(int depth) { - return (depth + 1) * inspectorColumnWidth + horizontalPadding; + // Note: depth is 0-based, therefore add 1. + return (depth + 1) * inspectorColumnIndent + horizontalPadding; } double rowYTop(int index) { return inspectorRowHeight * index; } - void nodeChanged(InspectorTreeNode node) { - setState(() { - node.isDirty = true; - }); - } - void removeNodeFromParent(InspectorTreeNode node) { - setState(() { - node.parent?.removeChild(node); - }); - } - - void appendChild(InspectorTreeNode node, InspectorTreeNode child) { - setState(() { - node.appendChild(child); - }); + node.parent?.removeChild(node); } void expandPath(InspectorTreeNode? node) { - setState(() { - _expandPath(node); - }); + _expandPath(node); } void _expandPath(InspectorTreeNode? node) { @@ -400,50 +441,96 @@ class InspectorTreeController extends DisposableController } } - void collapseToSelected() { - setState(() { - _collapseAllNodes(root!); - if (selection == null) return; - _expandPath(selection); - }); + void toggleHiddenGroup(InspectorTreeNode? node) { + final diagnostic = node?.diagnostic; + if (diagnostic != null) { + diagnostic.toggleHiddenGroup(); + } } - void _collapseAllNodes(InspectorTreeNode root) { - root.isExpanded = false; - root.children.forEach(_collapseAllNodes); - } + int get _numRows => _rowsInTree.value.length; - int get numRows => root?.subtreeSize ?? 0; + int _rowIndexFromNode(InspectorTreeNode node) => _nodeToRowIndex[node] ?? -1; - int getRowIndex(double y) => max(0, y ~/ inspectorRowHeight); + int _rowIndexFromOffset(double y) => max(0, y ~/ inspectorRowHeight); + + List _buildRows( + InspectorTreeNode node, { + bool includeHiddenRows = false, + bool includeCollapsedRows = false, + }) { + final rows = []; + + void buildRowsHelper( + InspectorTreeNode node, { + required int depth, + required List ticks, + }) { + final currentIdx = rows.length; + final isHidden = node.diagnostic?.isHidden ?? false; + if (!isHidden || includeHiddenRows) { + rows.add( + InspectorTreeRow( + node: node, + index: currentIdx, + ticks: ticks, + depth: depth, + lineToParent: + !node.isProperty && + currentIdx != 0 && + node.parent!.showLinesToChildren, + hasSingleChild: node.children.length == 1, + ), + ); + } + + if (!node.isExpanded && !includeCollapsedRows) return; + final children = node.children; + final parentDepth = depth; + final childrenDepth = children.length > 1 ? parentDepth + 1 : parentDepth; + for (final child in children) { + final shouldAddTick = + children.length > 1 && + children.last != child && + !children.last.isProperty && + node.diagnostic?.shouldIndent == true; + + buildRowsHelper( + child, + depth: childrenDepth, + ticks: [...ticks, if (shouldAddTick) parentDepth], + ); + } + } + + buildRowsHelper(node, depth: 0, ticks: []); + return rows; + } InspectorTreeRow? getRowForNode(InspectorTreeNode node) { final rootLocal = root; if (rootLocal == null) return null; - return getCachedRow(rootLocal.getRowIndex(node)); + return rowAtIndex(_rowIndexFromNode(node)); } - InspectorTreeRow? getRow(Offset offset) { + @visibleForTesting + InspectorTreeRow? rowForOffset(Offset offset) { final rootLocal = root; if (rootLocal == null) return null; - final row = getRowIndex(offset.dy); - return row < rootLocal.subtreeSize ? getCachedRow(row) : null; + final row = _rowIndexFromOffset(offset.dy); + return row < _rowsInTree.value.length ? rowAtIndex(row) : null; } void onExpandRow(InspectorTreeRow row) { - setState(() { - final onExpand = config.onExpand; - row.node.isExpanded = true; - if (onExpand != null) { - onExpand(row.node); - } - }); + final onExpand = config.onExpand; + row.node.isExpanded = true; + if (onExpand != null) { + onExpand(row.node); + } } void onCollapseRow(InspectorTreeRow row) { - setState(() { - row.node.isExpanded = false; - }); + row.node.isExpanded = false; } void onSelectRow(InspectorTreeRow row) { @@ -451,13 +538,22 @@ class InspectorTreeController extends DisposableController } void onSelectNode(InspectorTreeNode? node) { - selection = node; + setSelectedNode(node, notifyFlutterInspector: true); ga.select( gac.inspector, gac.treeNodeSelection, - screenMetricsProvider: () => InspectorScreenMetrics.legacy(), + screenMetricsProvider: () => InspectorScreenMetrics(), ); - expandPath(node); + final diagnostic = node?.diagnostic; + if (diagnostic != null && diagnostic.groupIsHidden) { + diagnostic.hideableGroupLeader?.toggleHiddenGroup(); + } + // Intentionally do NOT call expandPath(node) here. User clicks happen on + // already-visible rows, so ancestors are already expanded; calling + // expandPath would also re-expand the clicked node itself, undoing any + // collapse the user just performed via the left arrow key. Programmatic + // selection paths (search, on-device pick) call expandPath themselves via + // [InspectorController.syncTreeSelection]. } Rect getBoundingBox(InspectorTreeRow row) { @@ -480,7 +576,7 @@ class InspectorTreeController extends DisposableController void scrollToRect(Rect targetRect) { for (final client in _clients) { - client.scrollToRect(targetRect); + client.waitForClientsThenScrollToRect(targetRect); } } @@ -495,8 +591,8 @@ class InspectorTreeController extends DisposableController double get maxRowIndent { if (lastContentWidth == null) { double maxIndent = 0; - for (int i = 0; i < numRows; i++) { - final row = getCachedRow(i); + for (int i = 0; i < _numRows; i++) { + final row = rowAtIndex(i); if (row != null) { maxIndent = max(maxIndent, getDepthIndent(row.depth)); } @@ -551,13 +647,17 @@ class InspectorTreeController extends DisposableController InspectorTreeNode node, RemoteDiagnosticsNode diagnosticsNode, { required bool expandChildren, - required bool expandProperties, + RemoteDiagnosticsNode? hideableGroupLeader, }) { node.diagnostic = diagnosticsNode; final configLocal = config; if (configLocal.onNodeAdded != null) { configLocal.onNodeAdded!(node, diagnosticsNode); } + final inHideableGroup = diagnosticsNode.inHideableGroup; + if (inHideableGroup && hideableGroupLeader != null) { + hideableGroupLeader.addHideableGroupSubordinate(diagnosticsNode); + } if (diagnosticsNode.hasChildren || diagnosticsNode.inlineProperties.isNotEmpty) { @@ -570,7 +670,9 @@ class InspectorTreeController extends DisposableController node, node.diagnostic!.childrenNow, expandChildren: expandChildren && styleIsMultiline, - expandProperties: expandProperties && styleIsMultiline, + hideableGroupLeader: inHideableGroup + ? (hideableGroupLeader ?? diagnosticsNode) + : null, ); } else { node.clearChildren(); @@ -585,38 +687,42 @@ class InspectorTreeController extends DisposableController InspectorTreeNode treeNode, List? children, { required bool expandChildren, - required bool expandProperties, + RemoteDiagnosticsNode? hideableGroupLeader, }) { treeNode.isExpanded = expandChildren; if (treeNode.children.isNotEmpty) { // Only case supported is this is the loading node. assert(treeNode.children.length == 1); - removeNodeFromParent(treeNode.children.first); + refreshTree( + updateTreeAction: () { + removeNodeFromParent(treeNode.children.first); + return true; + }, + ); } final inlineProperties = parent.inlineProperties; for (final property in inlineProperties) { - appendChild( - treeNode, + treeNode.appendChild( setupInspectorTreeNode( createNode(), property, // We are inside a property so only expand children if // expandProperties is true. - expandChildren: expandProperties, - expandProperties: expandProperties, + expandChildren: false, ), ); } if (children != null) { for (final child in children) { - appendChild( - treeNode, + treeNode.appendChild( setupInspectorTreeNode( createNode(), child, expandChildren: expandChildren, - expandProperties: expandProperties, + hideableGroupLeader: child.inHideableGroup + ? hideableGroupLeader + : null, ), ); } @@ -631,17 +737,15 @@ class InspectorTreeController extends DisposableController try { final children = await diagnostic.children; if (treeNode.hasPlaceholderChildren || treeNode.children.isEmpty) { - setupChildren( - diagnostic, - treeNode, - children, - expandChildren: true, - expandProperties: false, + setupChildren(diagnostic, treeNode, children, expandChildren: true); + refreshTree( + updateTreeAction: () { + if (treeNode == selection) { + expandPath(treeNode); + } + return true; + }, ); - nodeChanged(treeNode); - if (treeNode == selection) { - expandPath(treeNode); - } } } catch (e, st) { _log.shout(e, e, st); @@ -652,7 +756,12 @@ class InspectorTreeController extends DisposableController /* Search support */ @override void onMatchChanged(int index) { - onSelectRow(searchMatches.value[index]); + refreshTree( + updateTreeAction: () { + onSelectRow(searchMatches.value[index]); + return true; + }, + ); } @override @@ -734,10 +843,10 @@ extension RemoteDiagnosticsNodeExtension on RemoteDiagnosticsNode { } abstract class InspectorControllerClient { - void onChanged(); - void scrollToRect(Rect rect); + void waitForClientsThenScrollToRect(Rect rect, {int retries}); + void requestFocus(); } @@ -746,24 +855,14 @@ class InspectorTree extends StatefulWidget { super.key, required this.controller, required this.treeController, - this.summaryTreeController, - this.isSummaryTree = false, this.widgetErrors, this.screenId, - }) : assert(isSummaryTree == (summaryTreeController == null)); + }); final InspectorController controller; final InspectorTreeController? treeController; - /// Stores the summary tree controller when this instance of [InspectorTree] - /// is for the details tree (i.e. when [isSummaryTree] is false). - /// - /// This value should be null when this instance of [InspectorTree] is for the - /// summary tree itself. - final InspectorTreeController? summaryTreeController; - - final bool isSummaryTree; final LinkedHashMap? widgetErrors; final String? screenId; @@ -787,6 +886,8 @@ class _InspectorTreeState extends State Rect? _currentAnimateTarget; AnimationController? _constraintDisplayController; + + // ignore: dispose-fields, false positive. Disposed via autoDisposeFocusNode. late FocusNode _focusNode; /// When autoscrolling, the number of rows to pad the target location with. @@ -799,9 +900,7 @@ class _InspectorTreeState extends State _scrollControllerY = ScrollController(); // TODO(devoncarew): Commented out as per flutter/devtools/pull/2001. //_scrollControllerY.addListener(_onScrollYChange); - if (widget.isSummaryTree) { - _constraintDisplayController = longAnimationController(this); - } + _constraintDisplayController = longAnimationController(this); _focusNode = FocusNode(debugLabel: 'inspector-tree'); autoDisposeFocusNode(_focusNode); final mainIsolateState = @@ -813,6 +912,10 @@ class _InspectorTreeState extends State readyWhen: (triggerValue) => !triggerValue, ); } + + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.animateTo(controller.selectedNode.value); + }); } @override @@ -861,6 +964,19 @@ class _InspectorTreeState extends State // ); // } + @override + Future waitForClientsThenScrollToRect( + Rect rect, { + int retries = 5, + }) async { + if (_scrollControllerY.hasClients || _scrollControllerX.hasClients) { + return scrollToRect(rect); + } + if (retries == 0) return; + await Future.delayed(const Duration(milliseconds: 20)); + return waitForClientsThenScrollToRect(rect, retries: retries - 1); + } + @override Future scrollToRect(Rect rect) async { if (rect == _currentAnimateTarget) { @@ -884,9 +1000,15 @@ class _InspectorTreeState extends State safeViewportHeight, ); - final isRectInViewPort = - viewPortInScrollControllerSpace.contains(rect.topLeft) && - viewPortInScrollControllerSpace.contains(rect.bottomRight); + // Decide to scroll based on whether the middle of the center-left half of + // the row is visible. See https://github.com/flutter/devtools/pull/8367. + final centerLeftHalf = Offset( + (rect.centerLeft.dx + rect.center.dx) / 2, + rect.center.dy, + ); + final isRectInViewPort = viewPortInScrollControllerSpace.contains( + centerLeftHalf, + ); if (isRectInViewPort) { // The rect is already in view, don't scroll return; @@ -951,7 +1073,7 @@ class _InspectorTreeState extends State required double initialX, int padCount = _scrollPadCount, }) { - return initialX - inspectorColumnWidth * padCount; + return initialX - inspectorColumnIndent * padCount; } /// Pad [initialY] so that a row would be placed in the vertical center of @@ -988,11 +1110,6 @@ class _InspectorTreeState extends State treeController?.addClient(this); } - @override - void onChanged() { - setState(() {}); - } - @override Widget build(BuildContext context) { super.build(context); @@ -1001,113 +1118,109 @@ class _InspectorTreeState extends State // Indicate the tree is loading. return const CenteredCircularProgressIndicator(); } - if (treeControllerLocal.numRows == 0) { - // This works around a bug when Scrollbars are present on a short lived - // widget. - return const SizedBox(); - } - if (!controller.firstInspectorTreeLoadCompleted && widget.isSummaryTree) { - final screenId = widget.screenId; - if (screenId != null) { - ga.timeEnd( - screenId, - gac.pageReady, - screenMetricsProvider: () => InspectorScreenMetrics.legacy( - rowCount: treeControllerLocal.numRows, - ), - ); - unawaited( - serviceConnection.sendDwdsEvent( - screen: screenId, - action: gac.pageReady, - ), - ); - } - controller.firstInspectorTreeLoadCompleted = true; - } - return LayoutBuilder( - builder: (context, constraints) { - final viewportWidth = constraints.maxWidth; - final tree = Scrollbar( - thumbVisibility: true, - controller: _scrollControllerX, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _scrollControllerX, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: - treeControllerLocal.rowWidth + - treeControllerLocal.maxRowIndent, + return ValueListenableBuilder>( + valueListenable: treeControllerLocal.rowsInTree, + builder: (context, rows, _) { + // Note: The inspector rows contain only the fake root node when the + // inspector tree is shutdown. Only show the loading indicator on the + // initial tree load (before [firstInspectorTreeLoadCompleted] is set); + // after that, a one-row tree is the legitimate result of the user + // collapsing all the way to the root via the keyboard, and we should + // render the tree (with its single row) rather than a spinner. + if (rows.length <= 1 && !controller.firstInspectorTreeLoadCompleted) { + // This works around a bug when Scrollbars are present on a short lived + // widget. + return const SizedBox(child: CenteredCircularProgressIndicator()); + } + + if (!controller.firstInspectorTreeLoadCompleted) { + final screenId = widget.screenId; + if (screenId != null) { + ga.timeEnd( + screenId, + gac.pageReady, + screenMetricsProvider: () => + InspectorScreenMetrics(rowCount: rows.length), + ); + unawaited( + serviceConnection.sendDwdsEvent( + screen: screenId, + action: gac.pageReady, ), - // TODO(kenz): this scrollbar needs to be sticky to the right side of - // the visible container - right now it is lined up to the right of - // the widest row (which is likely not visible). This may require some - // refactoring. - child: GestureDetector( - onTap: _focusNode.requestFocus, - child: Focus( - onKeyEvent: _handleKeyEvent, - autofocus: widget.isSummaryTree, - focusNode: _focusNode, - child: OffsetScrollbar( - isAlwaysShown: true, - axis: Axis.vertical, - controller: _scrollControllerY, - offsetController: _scrollControllerX, - offsetControllerViewportDimension: viewportWidth, - child: ListView.custom( - itemExtent: inspectorRowHeight, - childrenDelegate: SliverChildBuilderDelegate(( - context, - index, - ) { - if (index == treeControllerLocal.numRows) { - return const SizedBox(height: inspectorRowHeight); - } - final row = treeControllerLocal.getCachedRow(index)!; - final inspectorRef = row.node.diagnostic?.valueRef.id; - return _InspectorTreeRowWidget( - key: PageStorageKey(row.node), - inspectorTreeState: this, - row: row, - scrollControllerX: _scrollControllerX, - viewportWidth: viewportWidth, - error: - widget.widgetErrors != null && - inspectorRef != null - ? widget.widgetErrors![inspectorRef] - : null, - ); - }, childCount: treeControllerLocal.numRows + 1), - controller: _scrollControllerY, + ); + } + controller.firstInspectorTreeLoadCompleted = true; + } + + return LayoutBuilder( + builder: (context, constraints) { + final viewportWidth = constraints.maxWidth; + final tree = Scrollbar( + thumbVisibility: true, + controller: _scrollControllerX, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _scrollControllerX, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: + treeControllerLocal.rowWidth + + treeControllerLocal.maxRowIndent, + ), + // TODO(kenz): this scrollbar needs to be sticky to the right side of + // the visible container - right now it is lined up to the right of + // the widest row (which is likely not visible). This may require some + // refactoring. + child: GestureDetector( + onTap: _focusNode.requestFocus, + child: Focus( + onKeyEvent: _handleKeyEvent, + autofocus: true, + focusNode: _focusNode, + child: OffsetScrollbar( + isAlwaysShown: true, + axis: Axis.vertical, + controller: _scrollControllerY, + offsetController: _scrollControllerX, + offsetControllerViewportDimension: viewportWidth, + child: ListView.custom( + itemExtent: inspectorRowHeight, + childrenDelegate: SliverChildBuilderDelegate(( + context, + index, + ) { + if (index == rows.length) { + return const SizedBox(height: inspectorRowHeight); + } + final row = treeControllerLocal.rowAtIndex(index)!; + final inspectorRef = + row.node.diagnostic?.valueRef.id; + return _InspectorTreeRowWidget( + key: PageStorageKey(row.node), + inspectorTreeState: this, + row: row, + scrollControllerX: _scrollControllerX, + viewportWidth: viewportWidth, + error: + widget.widgetErrors != null && + inspectorRef != null + ? widget.widgetErrors![inspectorRef] + : null, + ); + }, childCount: rows.length + 1), + controller: _scrollControllerY, + ), + ), ), ), ), ), - ), - ), - ); - - final shouldShowBreadcrumbs = !widget.isSummaryTree; - if (shouldShowBreadcrumbs) { - final inspectorTreeController = widget.summaryTreeController!; - - final parents = inspectorTreeController - .getPathFromSelectedRowToRoot(); - return Column( - children: [ - InspectorBreadcrumbNavigator( - items: parents, - onTap: (node) => inspectorTreeController.onSelectNode(node), - ), - Expanded(child: tree), - ], - ); - } + ); - return tree; + return tree; + }, + ); }, ); } @@ -1120,6 +1233,20 @@ Paint _defaultPaint(ColorScheme colorScheme) => Paint() ..color = colorScheme.treeGuidelineColor ..strokeWidth = chartLineStrokeWidth; +/// The distance (on the x-axis) between the center of the widget icon and the +/// start of the row, as determined by a percentage of the +/// [inspectorColumnIndent]. +const _iconCenterToRowStartXDistancePercentage = 0.41; + +/// The distance (on the y-axis) between the bottom of the widget icon and the +/// top of the row, as determined by a percentage of the [inspectorRowHeight]. +const _iconBottomToRowTopYDistancePercentage = 0.75; + +/// The distance (on the y-axis) between the top of the child widget's icon and +/// the top of the current row, as determined by a percentage of the +/// [inspectorRowHeight]. +const _childIconTopToRowTopYDistancePercentage = 1.25; + /// Custom painter that draws lines indicating how parent and child rows are /// connected to each other. /// @@ -1138,38 +1265,76 @@ class _RowPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - double currentX = 0; final paint = _defaultPaint(colorScheme); final node = row.node; final showExpandCollapse = node.showExpandCollapse; + const distanceFromIconCenterToRowStart = + inspectorColumnIndent * _iconCenterToRowStartXDistancePercentage; for (final tick in row.ticks) { - currentX = _controller.getDepthIndent(tick) - inspectorColumnWidth * 0.5; + final expandCollapseX = + _controller.getDepthIndent(tick) - distanceFromIconCenterToRowStart; // Draw a vertical line for each tick identifying a connection between // an ancestor of this node and some other node in the tree. canvas.drawLine( - Offset(currentX, 0.0), - Offset(currentX, inspectorRowHeight), + Offset(expandCollapseX, 0.0), + Offset(expandCollapseX, inspectorRowHeight), paint, ); } // If this row is itself connected to a parent then draw the L shaped line // to make that connection. if (row.lineToParent) { - currentX = + final parentExpandCollapseX = _controller.getDepthIndent(row.depth - 1) - - inspectorColumnWidth * 0.5; + distanceFromIconCenterToRowStart; final width = showExpandCollapse - ? inspectorColumnWidth * 0.5 - : inspectorColumnWidth; + ? inspectorColumnIndent * 0.45 + : inspectorColumnIndent * 0.6; + canvas.drawLine( + Offset(parentExpandCollapseX, 0.0), + Offset(parentExpandCollapseX, inspectorRowHeight * 0.5), + paint, + ); canvas.drawLine( - Offset(currentX, 0.0), - Offset(currentX, inspectorRowHeight * 0.5), + Offset(parentExpandCollapseX, inspectorRowHeight * 0.5), + Offset(parentExpandCollapseX + width, inspectorRowHeight * 0.5), paint, ); + } + + // Draw a straight vertical line from current node's icon to the icon below + // it if either the current node: + // 1. is expanded (meaning its child is visible) and it only has one child + // (because multiple children get indented). + // 2. is NOT the first node in a hidden group of which the last hidden node + // in that group is childless (meaning that last node is at the end of a + // branch and therefore has nothing below it). + final expandedWithSingleChild = row.hasSingleChild && node.isExpanded; + final subordinates = + node.diagnostic?.hideableGroupSubordinates ?? []; + final groupIsHidden = node.diagnostic?.groupIsHidden ?? false; + final lastHiddenSubordinateHasNoChildren = + groupIsHidden && + subordinates.isNotEmpty && + subordinates.last.childrenNow.isEmpty; + if (expandedWithSingleChild && !lastHiddenSubordinateHasNoChildren) { + const distanceFromIconCenterToRowStart = + inspectorColumnIndent * _iconCenterToRowStartXDistancePercentage; + final iconCenterX = + _controller.getDepthIndent(row.depth) - + distanceFromIconCenterToRowStart; + // Draw a line from the bottom of the current row's icon to the top of the + // child row's icon: canvas.drawLine( - Offset(currentX, inspectorRowHeight * 0.5), - Offset(currentX + width, inspectorRowHeight * 0.5), + Offset( + iconCenterX, + inspectorRowHeight * _iconBottomToRowTopYDistancePercentage, + ), + Offset( + iconCenterX, + inspectorRowHeight * _childIconTopToRowTopYDistancePercentage, + ), paint, ); } @@ -1224,7 +1389,7 @@ class InspectorRowContent extends StatelessWidget { @override Widget build(BuildContext context) { final currentX = - controller.getDepthIndent(row.depth) - inspectorColumnWidth; + controller.getDepthIndent(row.depth) - inspectorColumnIndent; final theme = Theme.of(context); final colorScheme = theme.colorScheme; @@ -1236,14 +1401,16 @@ class InspectorRowContent extends StatelessWidget { } final node = row.node; - + final diagnostic = node.diagnostic; + final isHideableGroupLeader = diagnostic?.isHideableGroupLeader ?? false; + const expandCollapseWidth = 14.0; Widget rowWidget = Padding( padding: EdgeInsets.only(left: currentX), child: ValueListenableBuilder( valueListenable: controller.searchNotifier, builder: (context, searchValue, _) { return Opacity( - opacity: searchValue.isEmpty || row.isSearchMatch ? 1 : 0.2, + opacity: searchValue.isEmpty || row.isSearchMatch ? 1 : 0.6, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1259,7 +1426,7 @@ class InspectorRowContent extends StatelessWidget { ), ) : const SizedBox( - width: defaultSpacing, + width: expandCollapseWidth, height: defaultSpacing, ), Expanded( @@ -1267,11 +1434,16 @@ class InspectorRowContent extends StatelessWidget { color: backgroundColor, child: InkWell( onTap: () { - controller.onSelectRow(row); - // TODO(gmoothart): It may be possible to capture the tap - // and request focus directly from the InspectorTree. Then - // we wouldn't need this. - controller.requestFocus(); + controller.refreshTree( + updateTreeAction: () { + controller.onSelectRow(row); + // TODO(gmoothart): It may be possible to capture the tap + // and request focus directly from the InspectorTree. Then + // we wouldn't need this. + controller.requestFocus(); + return true; + }, + ); }, child: SizedBox( height: inspectorRowHeight, @@ -1288,6 +1460,29 @@ class InspectorRowContent extends StatelessWidget { : row.isSelected ? theme.searchMatchHighlightStyleFocused : theme.searchMatchHighlightStyle, + actionLabel: isHideableGroupLeader + ? diagnostic!.groupIsHidden + ? '(expand)' + : '(collapse)' + : null, + actionCallback: isHideableGroupLeader + ? () { + controller.refreshTree( + updateTreeAction: () { + controller.toggleHiddenGroup(node); + return true; + }, + ); + } + : null, + customDescription: + isHideableGroupLeader && diagnostic!.groupIsHidden + ? '${diagnostic.hideableGroupSubordinates!.length + 1} more widgets...' + : null, + customIconName: + isHideableGroupLeader && diagnostic!.groupIsHidden + ? 'HiddenGroup' + : null, ), ), ), diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/box/box.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/box/box.dart index ff579921994..fbd2d023b3e 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/box/box.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/box/box.dart @@ -1,91 +1,31 @@ -// Copyright 2021 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'dart:async'; -import 'dart:math' as math; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/diagnostics/diagnostics_node.dart'; -import '../../../../shared/primitives/math_utils.dart'; import '../../../../shared/primitives/utils.dart'; import '../../inspector_data_models.dart'; import '../ui/free_space.dart'; -import '../ui/layout_explorer_widget.dart'; import '../ui/theme.dart'; import '../ui/utils.dart'; import '../ui/widget_constraints.dart'; import '../ui/widgets_theme.dart'; -/// Layout visualizer for a widget with a box-layout. -class BoxLayoutExplorerWidget extends LayoutExplorerWidget { - const BoxLayoutExplorerWidget(super.inspectorController, {super.key}); - - static bool shouldDisplay(RemoteDiagnosticsNode _) { - // Pretend this layout explorer is always available. This layout explorer - // will gracefully fall back to an error message if the required properties - // are not needed. - // TODO(jacobr) pass a RemoteDiagnosticsNode to this method that contains - // the layout explorer related supplemental properties so that we can - // accurately determine whether the widget uses box layout. - return true; - } - - @override - State createState() => - BoxLayoutExplorerWidgetState(); -} - -class BoxLayoutExplorerWidgetState - extends - LayoutExplorerWidgetState { - @override - RemoteDiagnosticsNode? getRoot(RemoteDiagnosticsNode? node) { - final nodeLocal = node; - if (nodeLocal == null) return null; - if (!shouldDisplay(nodeLocal)) return null; - return node; - } - - @override - bool shouldDisplay(RemoteDiagnosticsNode node) { - final selectedNodeLocal = selectedNode; - if (selectedNodeLocal == null) return false; - return BoxLayoutExplorerWidget.shouldDisplay(selectedNodeLocal); - } - - @override - AnimatedLayoutProperties computeAnimatedProperties( - LayoutProperties nextProperties, - ) { - return AnimatedLayoutProperties( - // If an animation is in progress, freeze it and start animating from there, else start a fresh animation from widget.properties. - animatedProperties?.copyWith() ?? properties!, - nextProperties, - changeAnimation, - ); - } +class BoxLayoutExplorerWidget extends StatelessWidget { + const BoxLayoutExplorerWidget({ + super.key, + required this.layoutProperties, + required this.selectedNode, + }); - @override - LayoutProperties computeLayoutProperties(RemoteDiagnosticsNode node) => - LayoutProperties(node); - - @override - void updateHighlighted(LayoutProperties? newProperties) { - setState(() { - // This implementation will need to change if we support showing more than - // a single widget in the box visualization for the layout explorer. - highlighted = newProperties != null && selectedNode == newProperties.node - ? newProperties - : null; - }); - } + final LayoutProperties? layoutProperties; + final RemoteDiagnosticsNode? selectedNode; @override Widget build(BuildContext context) { - if (properties == null) { + if (layoutProperties == null) { final selectedNodeLocal = selectedNode; return Center( child: Text( @@ -95,292 +35,207 @@ class BoxLayoutExplorerWidgetState ), ); } - return Container( - margin: const EdgeInsets.all(denseSpacing), - child: AnimatedBuilder( - animation: changeController, - builder: (context, _) { - return LayoutBuilder(builder: _buildLayout); - }, - ), - ); + return LayoutBuilder(builder: _buildLayout); } - /// Simplistic layout algorithm to roughly match minFraction restrictions for - /// each sizes attempting to render a stylized version of the original layout. - /// TODO(jacobr): see if we can unify with the stylized version of the overall - /// layout used for Flex. Our constraints are quite different as we can - /// guarantee that the entire layout fits without scrolling while in the Flex - /// case that would be difficult. - /// - /// The overall layout will expand to use the full availableSize treating null - /// values in [sizes] as an indication that the items should have zero size. - /// On the other hand, a non-null size indicates that the minFractions - /// constraints should be obeyed. This is needed to ensure that negative sizes - /// are visualized reasonably. - /// The minFractions aren't exactly obeyed but they are approximated in a way - /// that keeps this algorithm simple and has the nice property that an initial - /// value much smaller than the minSize results in a slightly smaller value - /// than a value that is almost minSize. - /// In the most extreme case an item will get not minFraction but will instead - /// get the slightly smaller value of minFraction / (1 + minFraction) - /// which is close enough for the simple values we need this for. - static List minFractionLayout({ - required double availableSize, - required List sizes, - required List minFractions, + List _paddingWidgets({ + required LayoutProperties childProperties, + required LayoutProperties parentProperties, + required LayoutWidthsAndHeights widthsAndHeights, + required LayoutWidthsAndHeights displayWidthsAndHeights, + required ColorScheme colorScheme, + required Color widgetColor, }) { - assert(sizes.length == minFractions.length); - final length = sizes.length; - double total = 1.0; // This isn't set to zero to avoid divide by zero bugs. - final fractions = minFractions.toList(); - for (final size in sizes) { - if (size != null) { - total += math.max(0, size); - } - } - - double totalFraction = 0.0; - for (int i = 0; i < length; i++) { - final size = sizes[i]; - if (size != null) { - fractions[i] = math.max(size / total, minFractions[i]); - totalFraction += fractions[i]; - } else { - fractions[i] = 0.0; - } - } - if (totalFraction != 1.0) { - for (int i = 0; i < length; i++) { - fractions[i] = fractions[i] / totalFraction; - } - } - final output = []; - for (final fraction in fractions) { - output.add(fraction * availableSize); - } - return output; + if (!widthsAndHeights.hasAnyPadding) return []; + + final LayoutWidthsAndHeights( + :topPadding, + :bottomPadding, + :leftPadding, + :rightPadding, + :hasTopPadding, + :hasBottomPadding, + :hasLeftPadding, + :hasRightPadding, + ) = widthsAndHeights; + + final displayWidgetHeight = displayWidthsAndHeights.widgetHeight; + final displayWidgetWidth = displayWidthsAndHeights.widgetWidth; + final displayTopPadding = displayWidthsAndHeights.topPadding; + final displayBottomPadding = displayWidthsAndHeights.bottomPadding; + final displayLeftPadding = displayWidthsAndHeights.leftPadding; + final displayRightPadding = displayWidthsAndHeights.rightPadding; + + final parentHeight = parentProperties.size.height; + final parentWidth = parentProperties.size.width; + + return [ + LayoutExplorerBackground(colorScheme: colorScheme), + PositionedBackgroundLabel( + labelText: _describeBoxName(parentProperties), + labelColor: widgetColor, + hasTopPadding: hasTopPadding, + hasBottomPadding: hasBottomPadding, + hasLeftPadding: hasLeftPadding, + hasRightPadding: hasRightPadding, + ), + if (hasLeftPadding) + PaddingVisualizerWidget( + RenderProperties( + axis: Axis.horizontal, + size: Size(displayLeftPadding, displayWidgetHeight), + offset: Offset(0, displayTopPadding), + realSize: Size(leftPadding, parentHeight), + layoutProperties: childProperties, + isFreeSpace: true, + ), + horizontal: true, + ), + if (hasTopPadding) + PaddingVisualizerWidget( + RenderProperties( + axis: Axis.horizontal, + size: Size(displayWidgetWidth, displayTopPadding), + offset: Offset(displayLeftPadding, 0), + realSize: Size(parentWidth, topPadding), + layoutProperties: childProperties, + isFreeSpace: true, + ), + horizontal: false, + ), + if (hasRightPadding) + PaddingVisualizerWidget( + RenderProperties( + axis: Axis.horizontal, + size: Size(displayRightPadding, displayWidgetHeight), + offset: Offset( + displayLeftPadding + displayWidgetWidth, + displayTopPadding, + ), + realSize: Size(rightPadding, parentHeight), + layoutProperties: childProperties, + isFreeSpace: true, + ), + horizontal: true, + ), + if (hasBottomPadding) + PaddingVisualizerWidget( + RenderProperties( + axis: Axis.horizontal, + size: Size(displayWidgetWidth, displayBottomPadding), + offset: Offset( + displayLeftPadding, + displayTopPadding + displayWidgetHeight, + ), + realSize: Size(parentWidth, bottomPadding), + layoutProperties: childProperties, + isFreeSpace: true, + ), + horizontal: false, + ), + ]; } - Widget _buildChild(BuildContext context) { - final propertiesLocal = properties!; + Widget _buildLayout(BuildContext context, BoxConstraints constraints) { + final propertiesLocal = layoutProperties!; final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final parentProperties = - this.parentProperties ?? - propertiesLocal; // Fall back to this node's properties if there is no parent. - - final parentSize = parentProperties.size; - final offset = propertiesLocal.node.parentData; + final parentProperties = propertiesLocal.parentLayoutProperties!; - return LayoutBuilder( + final child = LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { // Subtract out one pixel border on each side. final availableHeight = constraints.maxHeight - 2; final availableWidth = constraints.maxWidth - 2; - final minFractions = [0.2, 0.5, 0.2]; - // TODO(polinach, jacobr): consider using zeros for zero values, - // without replacing them with nulls. - // See https://github.com/flutter/devtools/issues/3931. - double? nullOutZero(double value) => value != 0.0 ? value : null; - final widths = [ - nullOutZero(offset.offset.dx), - propertiesLocal.size.width, - nullOutZero( - parentSize.width - (propertiesLocal.size.width + offset.offset.dx), - ), - ]; - final heights = [ - nullOutZero(offset.offset.dy), - propertiesLocal.size.height, - nullOutZero( - parentSize.height - - (propertiesLocal.size.height + offset.offset.dy), - ), - ]; - // 3 element array with [left padding, widget width, right padding]. - final displayWidths = minFractionLayout( + final widgetWidths = propertiesLocal.widgetWidths; + final widgetHeights = propertiesLocal.widgetHeights; + + final widthsAndHeights = LayoutWidthsAndHeights( + widths: widgetWidths!, + heights: widgetHeights!, + ); + + final displayWidths = _simpleFractionalLayout( availableSize: availableWidth, - sizes: widths, - minFractions: minFractions, + sizes: widgetWidths, ); - // 3 element array with [top padding, widget height, bottom padding]. - final displayHeights = minFractionLayout( + final displayHeights = _simpleFractionalLayout( availableSize: availableHeight, - sizes: heights, - minFractions: minFractions, + sizes: widgetHeights, ); - final widgetWidth = displayWidths[1]; - final widgetHeight = displayHeights[1]; - final safeParentSize = parentSize; - final width0 = widths[0]; - final width2 = widths[2]; - final height0 = heights[0]; - final height2 = heights[2]; - return Container( - width: constraints.maxWidth, - height: constraints.maxHeight, - decoration: BoxDecoration( - border: Border.all( - color: WidgetTheme.fromName( - propertiesLocal.node.description, - ).color, - ), - ), - child: Stack( - children: [ - LayoutExplorerBackground(colorScheme: colorScheme), - // Left padding. - if (width0 != null) - PaddingVisualizerWidget( - RenderProperties( - axis: Axis.horizontal, - size: Size(displayWidths[0], widgetHeight), - offset: Offset(0, displayHeights[0]), - realSize: Size(width0, safeParentSize.height), - layoutProperties: propertiesLocal, - isFreeSpace: true, - ), - horizontal: true, - ), - // Top padding. - if (height0 != null) - PaddingVisualizerWidget( - RenderProperties( - axis: Axis.horizontal, - size: Size(widgetWidth, displayHeights[0]), - offset: Offset(displayWidths[0], 0), - realSize: Size(safeParentSize.width, height0), - layoutProperties: propertiesLocal, - isFreeSpace: true, + final displayWidthsAndHeights = LayoutWidthsAndHeights( + widths: displayWidths, + heights: displayHeights, + ); + + final widgetColor = WidgetTheme.fromName( + propertiesLocal.node.description, + ).color; + return Column( + children: [ + Container( + width: constraints.maxWidth, + height: constraints.maxHeight, + decoration: BoxDecoration(border: Border.all(color: widgetColor)), + child: Stack( + children: [ + ..._paddingWidgets( + childProperties: propertiesLocal, + parentProperties: parentProperties, + widthsAndHeights: widthsAndHeights, + displayWidthsAndHeights: displayWidthsAndHeights, + colorScheme: colorScheme, + widgetColor: widgetColor, ), - horizontal: false, - ), - // Right padding. - if (width2 != null) - PaddingVisualizerWidget( - RenderProperties( - axis: Axis.horizontal, - size: Size(displayWidths[2], widgetHeight), - offset: Offset( - displayWidths[0] + displayWidths[1], - displayHeights[0], - ), - realSize: Size(width2, safeParentSize.height), + BoxChildVisualizer( + isSelected: true, layoutProperties: propertiesLocal, - isFreeSpace: true, - ), - horizontal: true, - ), - // Bottom padding. - if (height2 != null) - PaddingVisualizerWidget( - RenderProperties( - axis: Axis.horizontal, - size: Size(widgetWidth, displayHeights[2]), - offset: Offset( - displayWidths[0], - displayHeights[0] + displayHeights[1], + renderProperties: RenderProperties( + axis: Axis.horizontal, + size: Size( + displayWidthsAndHeights.widgetWidth, + displayWidthsAndHeights.widgetHeight, + ), + offset: Offset( + displayWidthsAndHeights.leftPadding, + displayWidthsAndHeights.topPadding, + ), + realSize: propertiesLocal.size, + layoutProperties: propertiesLocal, ), - realSize: Size(safeParentSize.width, height2), - layoutProperties: propertiesLocal, - isFreeSpace: true, ), - horizontal: false, - ), - BoxChildVisualizer( - isSelected: true, - state: this, - layoutProperties: propertiesLocal, - renderProperties: RenderProperties( - axis: Axis.horizontal, - size: Size(widgetWidth, widgetHeight), - offset: Offset(displayWidths[0], displayHeights[0]), - realSize: propertiesLocal.size, - layoutProperties: propertiesLocal, - ), + ], ), - ], - ), + ), + ], ); }, ); - } - LayoutProperties? get parentProperties { - final parentElement = properties?.node.parentRenderElement; - if (parentElement == null) return null; - return computeLayoutProperties(parentElement); - } - - Widget _buildLayout(BuildContext context, BoxConstraints constraints) { - final maxHeight = constraints.maxHeight; - final maxWidth = constraints.maxWidth; - - Widget widget = _buildChild(context); - final parentProperties = this.parentProperties; - if (parentProperties != null) { - // Wrap with a widget visualizer for the parent if there is a valid parent. - widget = WidgetVisualizer( - // TODO(jacobr): this node's name can be misleading more often than - // in the flex case the widget doesn't have its own RenderObject. - // Consider showing the true ancestor for the summary tree that first - // has a different render object. - title: describeBoxName(parentProperties), - largeTitle: true, - layoutProperties: parentProperties, - isSelected: false, - child: VisualizeWidthAndHeightWithConstraints( - properties: parentProperties, - warnIfUnconstrained: false, - child: Padding( - padding: const EdgeInsets.all(denseSpacing), - child: widget, - ), - ), - ); - } return Container( - constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight), - child: widget, + constraints: BoxConstraints( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + ), + child: child, ); } } -String describeBoxName(LayoutProperties properties) { - // Displaying a high quality name is more ambiguous for the Box case than the - // Flex case because the RenderObject for each widget is often quite - // different than the user expected as not all widgets have RenderObjects. - // As a compromise we currently show 'WidgetName - RenderObjectName'. - // This is clearer but risks more confusion - - // Widget name. - var title = properties.node.description ?? ''; - final renderDescription = properties.node.renderObject?.description; - // TODO(jacobr): consider de-emphasizing the render object name by putting it - // in more transparent text or just calling the widget Parent instead of - // surfacing a widget name. - if (renderDescription != null) { - // Name of the associated RenderObject if one is available. - title += ' - $renderDescription'; - } - return title; -} +String _describeBoxName(LayoutProperties properties) => + properties.node.description ?? ''; /// Widget that represents and visualize a direct child of Flex widget. class BoxChildVisualizer extends StatelessWidget { const BoxChildVisualizer({ super.key, - required this.state, required this.layoutProperties, required this.renderProperties, required this.isSelected, }); - final BoxLayoutExplorerWidgetState state; - final bool isSelected; final LayoutProperties layoutProperties; final RenderProperties renderProperties; @@ -392,55 +247,69 @@ class BoxChildVisualizer extends StatelessWidget { final renderSize = renderProperties.size; final renderOffset = renderProperties.offset; - Widget buildEntranceAnimation(BuildContext _, Widget? child) { - final size = renderSize; - // TODO(jacobr): does this entrance animation really add value. - return Opacity( - opacity: min([state.entranceCurve.value * 5, 1.0]), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: math.max(0.0, (renderSize.width - size.width) / 2), - vertical: math.max(0.0, (renderSize.height - size.height) / 2), - ), - child: child, - ), - ); - } - final propertiesLocal = properties!; return Positioned( top: renderOffset.dy, left: renderOffset.dx, - child: InkWell( - onTap: () => unawaited(state.onTap(propertiesLocal)), - onDoubleTap: () => state.onDoubleTap(propertiesLocal), - child: SizedBox( - width: safePositiveDouble(renderSize.width), - height: safePositiveDouble(renderSize.height), - child: AnimatedBuilder( - animation: state.entranceController, - builder: buildEntranceAnimation, - child: WidgetVisualizer( - isSelected: isSelected, - layoutProperties: layoutProperties, - title: describeBoxName(propertiesLocal), - // TODO(jacobr): consider surfacing the overflow size information - // if we determine - // overflowSide: properties.overflowSide, - - // We only show one child at a time so a large title is safe. - largeTitle: true, - child: VisualizeWidthAndHeightWithConstraints( - arrowHeadSize: arrowHeadSize, - properties: propertiesLocal, - warnIfUnconstrained: false, - child: const SizedBox.shrink(), - ), - ), + child: SizedBox( + width: safePositiveDouble(renderSize.width), + height: safePositiveDouble(renderSize.height), + child: WidgetVisualizer( + isSelected: isSelected, + layoutProperties: layoutProperties, + title: _describeBoxName(propertiesLocal), + // TODO(jacobr): consider surfacing the overflow size information + // if we determine + // overflowSide: properties.overflowSide, + + // We only show one child at a time so a large title is safe. + largeTitle: true, + child: VisualizeWidthAndHeightWithConstraints( + arrowHeadSize: arrowHeadSize, + properties: propertiesLocal, + warnIfUnconstrained: false, + child: const SizedBox.shrink(), ), ), ), ); } } + +/// The percent of the visualizer dedicating to a single padding block when +/// the box child has multiple padding blocks. +const _narrowPaddingVisualizerPercent = 0.3; + +/// The percent of the visualizer dedicating to a single padding block when +/// the box child has only one padding block. +const _widePaddingVisualizerPercent = 0.35; + +/// Simplistic layout algorithm that will return [WidgetSizes] for the widget +/// display based on the display's [availableSize] and the real widget's +/// [WidgetSizes]. +/// +/// Uses a constant `paddingFraction` for the display padding, regardless of +/// the actual size. +WidgetSizes _simpleFractionalLayout({ + required double availableSize, + required WidgetSizes sizes, +}) { + final paddingASize = sizes.paddingA; + final paddingBSize = sizes.paddingB; + + final paddingFraction = paddingASize > 0 && paddingBSize > 0 + ? _narrowPaddingVisualizerPercent + : _widePaddingVisualizerPercent; + + final paddingAFraction = paddingASize > 0 ? paddingFraction : 0.0; + final paddingBFraction = paddingBSize > 0 ? paddingFraction : 0.0; + final widgetFraction = 1 - paddingAFraction - paddingBFraction; + + return ( + type: sizes.type, + paddingA: paddingAFraction * availableSize, + widgetSize: widgetFraction * availableSize, + paddingB: paddingBFraction * availableSize, + ); +} diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/flex/flex.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/flex/flex.dart index c00d352397b..897ef1809c7 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/flex/flex.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/flex/flex.dart @@ -1,4 +1,4 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. @@ -6,7 +6,7 @@ import 'dart:async'; import 'dart:math' as math; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/diagnostics/diagnostics_node.dart'; import '../../../../shared/diagnostics/inspector_service.dart'; @@ -131,7 +131,7 @@ class FlexLayoutExplorerWidgetState alignmentEnumEntries = MainAxisAlignment.values; selected = propertiesLocal.mainAxisAlignment; } else { - alignmentEnumEntries = CrossAxisAlignment.values.toList(growable: true); + alignmentEnumEntries = CrossAxisAlignment.values.toList(); if (propertiesLocal.textBaseline == null) { // TODO(albertusangga): Look for ways to visualize baseline when it is null alignmentEnumEntries.remove(CrossAxisAlignment.baseline); @@ -286,6 +286,7 @@ class FlexLayoutExplorerWidgetState child: InkWell( onTap: () => unawaited(onTap(propertiesLocal)), child: WidgetVisualizer( + isFlex: true, title: flexType, layoutProperties: propertiesLocal, isSelected: highlighted == properties, @@ -393,6 +394,12 @@ class FlexLayoutExplorerWidgetState ), ); } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } } class VisualizeFlexChildren extends StatefulWidget { @@ -752,6 +759,7 @@ class FlexChildVisualizer extends StatelessWidget { animation: state.entranceController, builder: buildEntranceAnimation, child: WidgetVisualizer( + isFlex: true, isSelected: isSelected, layoutProperties: layoutProperties, title: propertiesLocal.description ?? '', diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/flex/utils.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/flex/utils.dart index cc3b2019ca3..a6dcd6e0d07 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/flex/utils.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/flex/utils.dart @@ -1,11 +1,11 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:ui'; -import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:material_ui/material_ui.dart'; import '../../inspector_data_models.dart'; import '../ui/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/layout_explorer.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/layout_explorer.dart deleted file mode 100644 index 116a3c6c849..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/layout_explorer.dart +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/widgets.dart'; - -import '../../../shared/diagnostics/diagnostics_node.dart'; -import '../inspector_controller.dart'; -import '../layout_explorer/box/box.dart'; -import '../layout_explorer/flex/flex.dart'; - -/// Tab that acts as a proxy to decide which widget to be displayed -class LayoutExplorerTab extends StatefulWidget { - const LayoutExplorerTab({super.key, required this.controller}); - - final InspectorController controller; - - @override - State createState() => _LayoutExplorerTabState(); -} - -class _LayoutExplorerTabState extends State - with AutomaticKeepAliveClientMixin, AutoDisposeMixin { - InspectorController get controller => widget.controller; - - RemoteDiagnosticsNode? get selected => - controller.selectedNode.value?.diagnostic; - - RemoteDiagnosticsNode? previousSelection; - - Widget rootWidget(RemoteDiagnosticsNode? node) { - if (node != null && FlexLayoutExplorerWidget.shouldDisplay(node)) { - return FlexLayoutExplorerWidget(controller); - } - if (node != null && BoxLayoutExplorerWidget.shouldDisplay(node)) { - return BoxLayoutExplorerWidget(controller); - } - return Center( - child: Text( - node != null - ? 'Currently, Layout Explorer only supports Box and Flex-based widgets.' - : 'Select a widget to view its layout.', - textAlign: TextAlign.center, - overflow: TextOverflow.clip, - ), - ); - } - - void onSelectionChanged() { - if (rootWidget(previousSelection).runtimeType != - rootWidget(selected).runtimeType) { - setState(() => previousSelection = selected); - } - } - - @override - void initState() { - super.initState(); - addAutoDisposeListener(controller.selectedNode, onSelectionChanged); - } - - @override - Widget build(BuildContext context) { - super.build(context); - return rootWidget(selected); - } - - @override - bool get wantKeepAlive => true; -} diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/arrow.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/arrow.dart index 7fcf002900a..786a96ed4b1 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/arrow.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/arrow.dart @@ -1,11 +1,11 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:math'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; const defaultArrowColor = Colors.white; const defaultArrowStrokeWidth = 2.0; @@ -100,7 +100,7 @@ class ArrowWrapper extends StatelessWidget { ), ), ), - if (child != null) child!, + ?child, Expanded( child: Container( margin: EdgeInsets.only( diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/dimension.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/dimension.dart index cc5e40cc5df..fec202b9ccb 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/dimension.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/dimension.dart @@ -1,8 +1,8 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'theme.dart'; diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/free_space.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/free_space.dart index d515ed554fc..7801196d358 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/free_space.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/free_space.dart @@ -1,9 +1,9 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/primitives/utils.dart'; import '../../inspector_data_models.dart'; @@ -96,9 +96,8 @@ class PaddingVisualizerWidget extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final heightDescription = - 'h=${toStringAsFixed(renderProperties.realHeight)}'; - final widthDescription = 'w=${toStringAsFixed(renderProperties.realWidth)}'; + final heightDescription = toStringAsFixed(renderProperties.realHeight); + final widthDescription = toStringAsFixed(renderProperties.realWidth); final widthWidget = Column( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/layout_explorer_widget.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/layout_explorer_widget.dart index ac4229c49b7..d1392f2c7d8 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/layout_explorer_widget.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/layout_explorer_widget.dart @@ -1,12 +1,12 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/diagnostics/diagnostics_node.dart'; import '../../../../shared/diagnostics/inspector_service.dart'; @@ -162,6 +162,9 @@ abstract class LayoutExplorerWidgetState< entranceController.dispose(); changeController.dispose(); _unregisterInspectorControllerService(); + entranceCurve.dispose(); + changeAnimation.dispose(); + rateLimiter.dispose(); super.dispose(); } @@ -188,7 +191,7 @@ abstract class LayoutExplorerWidgetState< // update selected widget and trigger selection listener event to change focus. void refreshSelection(RemoteDiagnosticsNode node) { - inspectorController.refreshSelection(node, node, true); + inspectorController.refreshSelection(node); } Future onTap(LayoutProperties properties) async { diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/overflow_indicator_painter.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/overflow_indicator_painter.dart index 298b7782ef8..2295666ceb5 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/overflow_indicator_painter.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/overflow_indicator_painter.dart @@ -1,4 +1,4 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/theme.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/theme.dart index 974a0a5be75..80626ffc8b6 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/theme.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/theme.dart @@ -1,9 +1,9 @@ -// Copyright 2021 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; const margin = 6.0; @@ -68,11 +68,6 @@ const overflowBackgroundColorLight = Color(0xFFB00020); const overflowTextColorDark = Color(0xfff5846b); const overflowTextColorLight = Color(0xffdea089); -const backgroundColorSelectedDark = Color( - 0x4d474747, -); // TODO(jacobr): we would like Color(0x4dedeeef) but that makes the background show through. -const backgroundColorSelectedLight = Color(0x4dedeeef); - extension LayoutExplorerColorScheme on ColorScheme { Color get mainAxisColor => isLight ? mainAxisLightColor : mainAxisDarkColor; @@ -93,16 +88,10 @@ extension LayoutExplorerColorScheme on ColorScheme { Color get overflowTextColor => isLight ? overflowTextColorLight : overflowTextColorDark; - Color get backgroundColorSelected => - isLight ? backgroundColorSelectedLight : backgroundColorSelectedDark; - Color get unconstrainedColor => isLight ? unconstrainedLightColor : unconstrainedDarkColor; } -const backgroundColorDark = Color(0xff30302f); -const backgroundColorLight = Color(0xffffffff); - const unconstrainedDarkColor = Color(0xffdea089); const unconstrainedLightColor = Color(0xfff5846b); diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/utils.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/utils.dart index 798ea7a45f7..40ca5f48e8a 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/utils.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/utils.dart @@ -1,15 +1,16 @@ -// Copyright 2021 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:ui'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/diagnostics/diagnostics_node.dart'; import '../../../../shared/primitives/utils.dart'; import '../../inspector_data_models.dart'; +import 'dimension.dart'; import 'overflow_indicator_painter.dart'; import 'theme.dart'; import 'widgets_theme.dart'; @@ -118,6 +119,7 @@ class WidgetVisualizer extends StatelessWidget { required this.child, this.overflowSide, this.largeTitle = false, + this.isFlex = false, }); final LayoutProperties layoutProperties; @@ -126,6 +128,7 @@ class WidgetVisualizer extends StatelessWidget { final Widget? hint; final bool isSelected; final bool largeTitle; + final bool isFlex; final OverflowSide? overflowSide; @@ -139,14 +142,12 @@ class WidgetVisualizer extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final colorScheme = theme.colorScheme; final properties = layoutProperties; final borderColor = WidgetTheme.fromName(properties.node.description).color; final boxAdjust = isSelected ? _selectedPadding : 0.0; return LayoutBuilder( builder: (context, constraints) { - final hintLocal = hint; return OverflowBox( minWidth: constraints.minWidth + boxAdjust, maxWidth: constraints.maxWidth + boxAdjust, @@ -167,7 +168,7 @@ class WidgetVisualizer extends StatelessWidget { ? [ BoxShadow( color: Colors.black.withAlpha(255 ~/ 2), - blurRadius: 20, + blurRadius: 10, ), ] : null, @@ -192,43 +193,19 @@ class WidgetVisualizer extends StatelessWidget { ? _overflowIndicatorSize : 0.0, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Container( - constraints: BoxConstraints( - maxWidth: largeTitle - ? defaultMaxRenderWidth - : minRenderWidth * - widgetTitleMaxWidthPercentage, - ), - decoration: BoxDecoration(color: borderColor), - padding: const EdgeInsets.all(4.0), - child: Center( - child: Text( - title, - style: theme.regularTextStyleWithColor( - colorScheme.widgetNameColor, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - if (hintLocal != null) Flexible(child: hintLocal), - ], + child: isFlex + ? FlexWidgetVisualizer( + title: title, + largeTitle: largeTitle, + borderColor: borderColor, + hint: hint, + child: child, + ) + : BoxWidgetVisualizer( + borderColor: borderColor, + title: title, + properties: properties, ), - ), - Expanded(child: child), - ], - ), ), ], ), @@ -239,6 +216,148 @@ class WidgetVisualizer extends StatelessWidget { } } +/// Visualizer display for a widget in a flex layout. +class FlexWidgetVisualizer extends StatelessWidget { + const FlexWidgetVisualizer({ + super.key, + required this.largeTitle, + required this.borderColor, + required this.title, + required this.hint, + required this.child, + }); + + final bool largeTitle; + final Color borderColor; + final String title; + final Widget? hint; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final hintLocal = hint; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Container( + constraints: BoxConstraints( + maxWidth: largeTitle + ? defaultMaxRenderWidth + : minRenderWidth * widgetTitleMaxWidthPercentage, + ), + decoration: BoxDecoration(color: borderColor), + padding: const EdgeInsets.all(densePadding), + child: Center( + child: Text( + title, + style: theme.regularTextStyleWithColor( + colorScheme.widgetNameColor, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + if (hintLocal != null) Flexible(child: hintLocal), + ], + ), + ), + Expanded(child: child), + ], + ); + } +} + +/// Visualizer display for a widget in a box layout. +class BoxWidgetVisualizer extends StatelessWidget { + const BoxWidgetVisualizer({ + super.key, + required this.borderColor, + required this.title, + required this.properties, + }); + + final Color borderColor; + final String title; + final LayoutProperties properties; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: WidgetLabel(labelColor: borderColor, labelText: title), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + dimensionDescription( + TextSpan(text: properties.describeHeight()), + false, + theme.colorScheme, + ), + dimensionDescription( + TextSpan(text: properties.describeWidth()), + false, + theme.colorScheme, + ), + ], + ), + ), + ], + ); + } +} + +/// A label for the widget in the layout explorer. +class WidgetLabel extends StatelessWidget { + const WidgetLabel({ + super.key, + required this.labelColor, + required this.labelText, + this.positionedAtBottom = false, + }); + + final Color labelColor; + final String labelText; + final bool positionedAtBottom; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + decoration: BoxDecoration(color: labelColor), + padding: EdgeInsets.fromLTRB( + densePadding, + positionedAtBottom ? borderPadding : 0.0, + densePadding, + positionedAtBottom ? 0.0 : borderPadding, + ), + child: Text( + labelText, + style: theme.regularTextStyleWithColor(colorScheme.widgetNameColor), + overflow: TextOverflow.ellipsis, + ), + ); + } +} + class AnimatedLayoutProperties implements LayoutProperties { AnimatedLayoutProperties(this.begin, this.end, this.animation) @@ -265,6 +384,15 @@ class AnimatedLayoutProperties end.parent = parent; } + @override + LayoutProperties? get parentLayoutProperties => null; + + @override + WidgetSizes? get widgetWidths => null; + + @override + WidgetSizes? get widgetHeights => null; + @override List get children { return _children; @@ -437,3 +565,51 @@ class LayoutExplorerBackground extends StatelessWidget { ); } } + +/// Builds and positions a label for the [LayoutExplorerBackground] as +/// determined by the widget's padding. +class PositionedBackgroundLabel extends StatelessWidget { + const PositionedBackgroundLabel({ + super.key, + required this.labelText, + required this.labelColor, + required this.hasTopPadding, + required this.hasBottomPadding, + required this.hasLeftPadding, + required this.hasRightPadding, + }); + + final String labelText; + final Color labelColor; + final bool hasTopPadding; + final bool hasBottomPadding; + final bool hasLeftPadding; + final bool hasRightPadding; + + @override + Widget build(BuildContext context) { + return Column( + // Push to the bottom if there is no padding on the top. + mainAxisAlignment: !hasTopPadding && hasBottomPadding + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Row( + // Push to the right if there is no padding on the left. + mainAxisAlignment: (!hasLeftPadding && hasRightPadding) + ? MainAxisAlignment.end + : MainAxisAlignment.start, + children: [ + Flexible( + child: WidgetLabel( + labelColor: labelColor, + labelText: labelText, + positionedAtBottom: !hasTopPadding && hasBottomPadding, + ), + ), + ], + ), + ], + ); + } +} diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/widget_constraints.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/widget_constraints.dart index 45de4048974..87e8e5ca6bf 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/widget_constraints.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/widget_constraints.dart @@ -1,9 +1,9 @@ -// Copyright 2019 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/primitives/math_utils.dart'; import '../../../../shared/primitives/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/widgets_theme.dart b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/widgets_theme.dart index 4abdb55e500..c78d2051ce2 100644 --- a/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/widgets_theme.dart +++ b/packages/devtools_app/lib/src/screens/inspector/layout_explorer/ui/widgets_theme.dart @@ -1,8 +1,8 @@ -// Copyright 2021 The Flutter Authors +// Copyright 2024 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; class WidgetTheme { const WidgetTheme({this.iconAsset, this.color = otherWidgetColor}); @@ -192,14 +192,12 @@ class WidgetTheme { 'PageView': WidgetTheme(iconAsset: WidgetIcons.pageView), 'Material': WidgetTheme(iconAsset: WidgetIcons.material), 'AppBar': WidgetTheme(iconAsset: WidgetIcons.appBar), - 'HiddenGroup': WidgetTheme(iconAsset: WidgetIcons.hidden), }; } class WidgetIcons { static const root = 'icons/inspector/widget_icons/root.png'; static const text = 'icons/inspector/widget_icons/text.png'; - static const icon = 'icons/inspector/widget_icons/icon.png'; static const image = 'icons/inspector/widget_icons/image.png'; static const floatingActionButton = 'icons/inspector/widget_icons/floatingab.png'; @@ -246,5 +244,4 @@ class WidgetIcons { static const materialApp = 'icons/inspector/widget_icons/materialapp.png'; static const bottomNavigationBar = 'icons/inspector/widget_icons/bottomnavigationbar.png'; - static const hidden = 'icons/inspector/widget_icons/onedot.png'; } diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/widget_details.dart b/packages/devtools_app/lib/src/screens/inspector/widget_details.dart similarity index 93% rename from packages/devtools_app/lib/src/screens/inspector_v2/widget_details.dart rename to packages/devtools_app/lib/src/screens/inspector/widget_details.dart index cd8ed215c0f..369c657ce73 100644 --- a/packages/devtools_app/lib/src/screens/inspector_v2/widget_details.dart +++ b/packages/devtools_app/lib/src/screens/inspector/widget_details.dart @@ -6,7 +6,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/widgets.dart'; -import '../../shared/console/eval/inspector_tree_v2.dart'; +import '../../shared/console/eval/inspector_tree.dart'; import '../../shared/diagnostics/diagnostics_node.dart'; import 'inspector_controller.dart'; import 'layout_explorer/box/box.dart'; @@ -50,7 +50,7 @@ class _WidgetDetailsState extends State with AutoDisposeMixin { ); } - return DetailsTable(controller: controller, node: node); + return DetailsTable(controller: controller); }, ); } diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/widget_properties/properties_view.dart b/packages/devtools_app/lib/src/screens/inspector/widget_properties/properties_view.dart similarity index 84% rename from packages/devtools_app/lib/src/screens/inspector_v2/widget_properties/properties_view.dart rename to packages/devtools_app/lib/src/screens/inspector/widget_properties/properties_view.dart index 1822d718819..49b0bce21c2 100644 --- a/packages/devtools_app/lib/src/screens/inspector_v2/widget_properties/properties_view.dart +++ b/packages/devtools_app/lib/src/screens/inspector/widget_properties/properties_view.dart @@ -2,14 +2,18 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:async'; import 'dart:math'; +import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/constants.dart' as gac; import '../../../shared/console/widgets/description.dart'; import '../../../shared/diagnostics/diagnostics_node.dart'; +import '../../../shared/globals.dart'; import '../../../shared/primitives/utils.dart'; import '../../../shared/ui/tab.dart'; import '../inspector_controller.dart'; @@ -20,18 +24,11 @@ import '../layout_explorer/flex/flex.dart'; /// Table for the widget's properties, along with its render object and a /// flex layout explorer if the widget is part of a flex layout. class DetailsTable extends StatefulWidget { - const DetailsTable({ - super.key, - required this.controller, - required this.node, - this.extraTabs, - }); + const DetailsTable({super.key, required this.controller}); static const gaPrefix = 'inspectorDetailsTable'; final InspectorController controller; - final RemoteDiagnosticsNode node; - final List? extraTabs; @override State createState() => _DetailsTableState(); @@ -44,9 +41,6 @@ class _DetailsTableState extends State { RemoteDiagnosticsNode? get selectedNode => widget.controller.selectedDiagnostic; - LayoutProperties? get layoutProperties => - widget.controller.selectedNodeProperties.value.layoutProperties; - final _widgetPropertiesTab = DevToolsTab.create( tabName: 'Widget properties', gaPrefix: DetailsTable.gaPrefix, @@ -86,12 +80,14 @@ class _DetailsTableState extends State { final widgetProperties = properties.widgetProperties; final renderProperties = properties.renderProperties; final layoutProperties = properties.layoutProperties; - final renderTabExists = renderProperties.isNotEmpty; final flexExplorerTabExists = selectedNode?.isFlexLayout ?? false; return AnalyticsTabbedView( gaScreen: gac.inspector, + trailingWidgets: [ + WidgetCreationLocationTrailing(controller: widget.controller), + ], onTabChanged: (int tabIndex) { _lastSelectedTab = _getTabForIndex( tabIndex, @@ -170,6 +166,90 @@ class _DetailsTableState extends State { ]; } +/// Displays the source file path for the selected widget in the tab bar. +/// +/// Matches the legacy inspector format: `filename.dart:line:column`. +/// Tapping the link navigates the connected IDE to the source location. +class WidgetCreationLocationTrailing extends StatefulWidget { + const WidgetCreationLocationTrailing({super.key, required this.controller}); + + final InspectorController controller; + + @override + State createState() => + _WidgetCreationLocationTrailingState(); +} + +class _WidgetCreationLocationTrailingState + extends State { + late final TapGestureRecognizer _tapRecognizer; + + @override + void initState() { + super.initState(); + _tapRecognizer = TapGestureRecognizer() + ..onTap = () { + unawaited(_navigateToLocation()); + }; + } + + @override + void dispose() { + _tapRecognizer.dispose(); + super.dispose(); + } + + Future _navigateToLocation() async { + final location = + widget.controller.selectedNodeProperties.value.creationLocation; + final file = location?.getFile(); + if (file == null) { + return; + } + + await serviceConnection.serviceManager.service?.navigateToCode( + fileUriString: file, + line: location!.getLine(), + column: location.getColumn(), + source: 'devtools.inspector', + ); + } + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: widget.controller.selectedNodeProperties, + builder: (context, properties, _) { + final location = properties.creationLocation; + final file = location?.getFile(); + if (file == null) { + return const SizedBox.shrink(); + } + + final line = location!.getLine(); + final column = location.getColumn(); + final shortLocation = '${fileNameFromUri(file)}:$line:$column'; + final fullLocation = '$file:$line:$column'; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: denseSpacing), + child: DevToolsTooltip( + message: fullLocation, + child: RichText( + overflow: TextOverflow.ellipsis, + text: TextSpan( + text: shortLocation, + style: Theme.of(context).linkTextStyle, + recognizer: _tapRecognizer, + ), + ), + ), + ); + }, + ); + } +} + /// Displays a widget's properties, including the layout properties and a /// layout visualizer. class PropertiesView extends StatefulWidget { @@ -269,7 +349,6 @@ class _PropertiesViewState extends State { height: PropertiesView.layoutExplorerHeight, width: PropertiesView.layoutExplorerWidth, child: BoxLayoutExplorerWidget( - widget.controller, selectedNode: selectedNode, layoutProperties: widget.layoutProperties, ), diff --git a/packages/devtools_app/lib/src/screens/inspector_shared/inspector_screen.dart b/packages/devtools_app/lib/src/screens/inspector_shared/inspector_screen.dart deleted file mode 100644 index 710743dee72..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_shared/inspector_screen.dart +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app_shared/shared.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; - -import '../../shared/feature_flags.dart'; -import '../../shared/framework/screen.dart'; -import '../../shared/globals.dart'; -import '../inspector/inspector_screen_body.dart' as legacy; -import '../inspector_v2/inspector_screen_body.dart' as v2; -import 'inspector_screen_controller.dart'; - -class InspectorScreen extends Screen { - InspectorScreen() : super.fromMetaData(ScreenMetaData.inspector); - - static const minScreenWidthForText = 900.0; - - static final id = ScreenMetaData.inspector.id; - - // There is not enough room to safely show the console in the embed view of - // the DevTools and IDEs have their own consoles. - @override - bool showConsole(EmbedMode embedMode) => !embedMode.embedded; - - @override - bool showAiAssistant() => true; - - @override - String get docPageId => screenId; - - @override - Widget buildScreenBody(BuildContext context) => - const InspectorScreenSwitcher(); -} - -class InspectorScreenSwitcher extends StatefulWidget { - const InspectorScreenSwitcher({super.key}); - - @override - State createState() => - _InspectorScreenSwitcherState(); -} - -class _InspectorScreenSwitcherState extends State - with AutoDisposeMixin { - late InspectorScreenController controller; - - bool get shouldShowInspectorV2 => - FeatureFlags.inspectorV2.isEnabled && - !preferences.inspector.legacyInspectorEnabled.value; - - @override - void initState() { - super.initState(); - controller = screenControllers.lookup(); - addAutoDisposeListener( - preferences.inspector.legacyInspectorEnabled, - () async { - controller.legacyInspectorController.setVisibleToUser( - !shouldShowInspectorV2, - ); - await controller.v2InspectorController.setVisibleToUser( - shouldShowInspectorV2, - ); - }, - ); - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: preferences.inspector.legacyInspectorEnabled, - builder: (context, _, _) { - if (shouldShowInspectorV2) { - return v2.InspectorScreenBody( - controller: controller.v2InspectorController, - ); - } - - return legacy.InspectorScreenBody( - controller: controller.legacyInspectorController, - ); - }, - ); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_shared/inspector_screen_controller.dart b/packages/devtools_app/lib/src/screens/inspector_shared/inspector_screen_controller.dart deleted file mode 100644 index 5ca8073fa6f..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_shared/inspector_screen_controller.dart +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import '../../shared/analytics/metrics.dart'; -import '../../shared/console/primitives/simple_items.dart'; -import '../../shared/framework/screen.dart'; -import '../../shared/framework/screen_controllers.dart'; -import '../inspector/inspector_controller.dart' as legacy; -import '../inspector/inspector_tree_controller.dart' as legacy; -import '../inspector_v2/inspector_controller.dart' as v2; -import '../inspector_v2/inspector_tree_controller.dart' as v2; - -/// Screen controller for the Inspector screen. -/// -/// This controller can be accessed from anywhere in DevTools, as long as it was -/// first registered, by -/// calling `screenControllers.lookup()`. -/// -/// The controller lifecycle is managed by the [ScreenControllers] class. The -/// `init` method is called lazily upon the first controller access from -/// `screenControllers`. The `dispose` method is called by `screenControllers` -/// when DevTools is destroying a set of DevTools screen controllers. -class InspectorScreenController extends DevToolsScreenController { - @override - final screenId = ScreenMetaData.inspector.id; - - late v2.InspectorController v2InspectorController; - late v2.InspectorTreeController v2InspectorTreeController; - - late legacy.InspectorController legacyInspectorController; - late legacy.InspectorTreeController legacyInspectorTreeController; - late legacy.InspectorTreeController legacyDetailsTreeController; - - @override - void init() { - super.init(); - v2InspectorTreeController = v2.InspectorTreeController( - gaId: InspectorScreenMetrics.summaryTreeGaId, - ); - v2InspectorController = v2.InspectorController( - inspectorTree: v2InspectorTreeController, - treeType: FlutterTreeType.widget, - ); - - // TODO(elliette): Remove legacy inspector. - legacyInspectorTreeController = legacy.InspectorTreeController( - gaId: InspectorScreenMetrics.summaryTreeGaId, - ); - legacyDetailsTreeController = legacy.InspectorTreeController( - gaId: InspectorScreenMetrics.detailsTreeGaId, - ); - legacyInspectorController = legacy.InspectorController( - inspectorTree: legacyInspectorTreeController, - detailsTree: legacyDetailsTreeController, - treeType: FlutterTreeType.widget, - ); - } - - @override - void dispose() { - v2InspectorTreeController.dispose(); - v2InspectorController.dispose(); - - legacyInspectorTreeController.dispose(); - legacyDetailsTreeController.dispose(); - legacyInspectorController.dispose(); - super.dispose(); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_shared/inspector_settings_dialog.dart b/packages/devtools_app/lib/src/screens/inspector_shared/inspector_settings_dialog.dart deleted file mode 100644 index 823a3dd3c6a..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_shared/inspector_settings_dialog.dart +++ /dev/null @@ -1,225 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:async'; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; -import 'package:vm_service/vm_service.dart' hide Stack; - -import '../../shared/analytics/analytics.dart' as ga; -import '../../shared/analytics/constants.dart' as gac; -import '../../shared/feature_flags.dart'; -import '../../shared/globals.dart'; -import '../../shared/managers/banner_messages.dart'; -import '../../shared/preferences/preferences.dart'; -import '../../shared/primitives/simple_items.dart'; -import '../../shared/ui/common_widgets.dart'; -import '../../shared/ui/editable_list.dart'; -import 'inspector_screen.dart'; - -class FlutterInspectorSettingsDialog extends StatefulWidget { - const FlutterInspectorSettingsDialog({super.key}); - - @override - State createState() => - _FlutterInspectorSettingsDialogState(); -} - -class _FlutterInspectorSettingsDialogState - extends State - with AutoDisposeMixin { - @override - void initState() { - super.initState(); - addAutoDisposeListener(preferences.inspector.legacyInspectorEnabled, () { - if (!preferences.inspector.legacyInspectorEnabled.value) { - bannerMessages.removeMessageByKey( - LegacyInspectorWarningMessage.buildKey(InspectorScreen.id), - InspectorScreen.id, - ); - } - }); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - const dialogHeight = 500.0; - - return ValueListenableBuilder( - valueListenable: preferences.inspector.legacyInspectorEnabled, - builder: (context, legacyInspectorEnabled, _) { - final inspectorV2Enabled = !legacyInspectorEnabled; - return DevToolsDialog( - title: const DialogTitleText('Flutter Inspector Settings'), - content: SizedBox( - width: defaultDialogWidth, - height: dialogHeight, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...dialogSubHeader(theme, 'General'), - CheckboxSetting( - notifier: - preferences.inspector.hoverEvalModeEnabled - as ValueNotifier, - title: 'Enable hover inspection', - description: - 'Hovering over any widget displays its properties and values.', - gaItem: gac.inspectorHoverEvalMode, - ), - const SizedBox(height: largeSpacing), - if (inspectorV2Enabled) ...[ - CheckboxSetting( - notifier: - preferences.inspector.autoRefreshEnabled - as ValueNotifier, - title: 'Enable widget tree auto-refreshing', - description: - 'The widget tree will automatically refresh after a hot-reload or navigation event.', - gaItem: gac.inspectorAutoRefreshEnabled, - ), - ] else ...[ - const InspectorDefaultDetailsViewOption(), - ], - const SizedBox(height: largeSpacing), - // TODO(https://github.com/flutter/devtools/issues/7860): Clean-up - // after Inspector V2 has been released. - if (FeatureFlags.inspectorV2.isEnabled) - Flexible( - child: CheckboxSetting( - notifier: - preferences.inspector.legacyInspectorEnabled - as ValueNotifier, - title: 'Use legacy inspector', - description: - 'Disable the redesigned Flutter inspector. Please know that ' - 'the legacy inspector will be removed in a future release.', - gaItem: gac.inspectorV2Disabled, - ), - ), - const SizedBox(height: largeSpacing), - ...dialogSubHeader(theme, 'Package Directories'), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - 'Widgets in these directories will show up in your summary tree.', - style: theme.subtleTextStyle, - ), - ), - MoreInfoLink( - url: DocLinks.inspectorPackageDirectories.value, - gaScreenName: gac.inspector, - gaSelectedItemDescription: - gac.InspectorDocs.packageDirectoriesDocs.name, - ), - ], - ), - Text( - '(e.g. /absolute/path/to/myPackage/)', - style: theme.subtleTextStyle, - ), - const SizedBox(height: denseSpacing), - const Expanded(child: PubRootDirectorySection()), - ], - ), - ), - actions: const [DialogCloseButton()], - ); - }, - ); - } -} - -class InspectorDefaultDetailsViewOption extends StatelessWidget { - const InspectorDefaultDetailsViewOption({super.key}); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: preferences.inspector.defaultDetailsView, - builder: (context, selection, _) { - final theme = Theme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Select the default tab for the inspector.', - style: theme.subtleTextStyle, - ), - const SizedBox(height: denseSpacing), - RadioGroup( - groupValue: selection, - onChanged: _onChanged, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Radio( - value: InspectorDetailsViewType.layoutExplorer, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - Text(InspectorDetailsViewType.layoutExplorer.key), - const SizedBox(width: denseSpacing), - const Radio( - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - value: InspectorDetailsViewType.widgetDetailsTree, - ), - Text(InspectorDetailsViewType.widgetDetailsTree.key), - ], - ), - ), - ], - ); - }, - ); - } - - void _onChanged(InspectorDetailsViewType? value) { - if (value != null) { - preferences.inspector.setDefaultInspectorDetailsView(value); - final item = value.name == InspectorDetailsViewType.layoutExplorer.name - ? gac.defaultDetailsViewToLayoutExplorer - : gac.defaultDetailsViewToWidgetDetails; - ga.select(gac.inspector, item); - } - } -} - -class PubRootDirectorySection extends StatelessWidget { - const PubRootDirectorySection({super.key}); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: - serviceConnection.serviceManager.isolateManager.mainIsolate, - builder: (_, _, _) { - return SizedBox( - height: 200.0, - child: EditableList( - gaScreen: gac.inspector, - gaRefreshSelection: gac.refreshPubRoots, - entries: preferences.inspector.pubRootDirectories, - textFieldLabel: 'Enter a new package directory', - isRefreshing: preferences.inspector.isRefreshingPubRootDirectories, - onEntryAdded: (p0) => unawaited( - preferences.inspector.addPubRootDirectories([ - p0, - ], shouldCache: true), - ), - onEntryRemoved: (p0) => - unawaited(preferences.inspector.removePubRootDirectories([p0])), - onRefreshTriggered: () => - unawaited(preferences.inspector.loadPubRootDirectories()), - ), - ); - }, - ); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/inspector_controller.dart b/packages/devtools_app/lib/src/screens/inspector_v2/inspector_controller.dart deleted file mode 100644 index 32de022bcdf..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/inspector_controller.dart +++ /dev/null @@ -1,1117 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -/// This library must not have direct dependencies on dart:html. -/// -/// This allows tests of the complicated logic in this class to run on the VM -/// and will help simplify porting this code to work with Hummingbird. -/// -/// This code is directly based on -/// src/io/flutter/view/InspectorPanel.java -/// with some refactors to make the code more of a controller than a combination -/// of view and controller. View specific portions of InspectorPanel.java have -/// been moved to inspector.dart. -library; - -import 'dart:async'; - -import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:vm_service/vm_service.dart'; - -import '../../service/service_extensions.dart' as extensions; -import '../../shared/analytics/analytics.dart' as ga; -import '../../shared/analytics/constants.dart' as gac; -import '../../shared/analytics/metrics.dart'; -import '../../shared/console/eval/inspector_tree_v2.dart'; -import '../../shared/console/primitives/simple_items.dart'; -import '../../shared/diagnostics/diagnostics_node.dart'; -import '../../shared/diagnostics/inspector_service.dart'; -import '../../shared/diagnostics/primitives/instance_ref.dart'; -import '../../shared/globals.dart'; -import '../../shared/managers/notifications.dart'; -import '../../shared/primitives/query_parameters.dart'; -import '../../shared/primitives/utils.dart'; -import '../../shared/utils/utils.dart'; -import '../inspector_shared/inspector_screen.dart'; -import 'inspector_data_models.dart'; -import 'inspector_tree_controller.dart'; - -final _log = Logger('inspector_controller'); - -/// Data pattern containing the properties and render properties for a widget -/// tree node. -typedef WidgetTreeNodeProperties = ({ - /// Properties defined directly on the widget. - List widgetProperties, - - /// Properties defined on the widget's render object. - List renderProperties, - - /// Layout properties for the widget. - LayoutProperties? layoutProperties, -}); - -/// This class is based on the InspectorPanel class from the Flutter IntelliJ -/// plugin with some refactors to make it more of a true controller than a view. -class InspectorController extends DisposableController - with AutoDisposeControllerMixin - implements InspectorServiceClient { - InspectorController({required this.inspectorTree, required this.treeType}) { - unawaited(init()); - } - - @override - Future init() async { - super.init(); - _refreshRateLimiter = RateLimiter(refreshFramesPerSecond, refresh); - - inspectorTree.config = InspectorTreeConfig( - onNodeAdded: _onNodeAdded, - onSelectionChange: selectionChanged, - onExpand: _onExpand, - onClientActiveChange: _onClientChange, - ); - await serviceConnection.serviceManager.onServiceAvailable; - - if (inspectorService is InspectorService) { - _treeGroups = InspectorObjectGroupManager( - serviceConnection.inspectorService as InspectorService, - 'tree', - ); - _selectionGroups = InspectorObjectGroupManager( - serviceConnection.inspectorService as InspectorService, - 'selection', - ); - _layoutGroups = InspectorObjectGroupManager( - serviceConnection.inspectorService as InspectorService, - 'layout', - ); - } - - addAutoDisposeListener( - serviceConnection.serviceManager.isolateManager.mainIsolate, - () { - final newIsolate = - serviceConnection.serviceManager.isolateManager.mainIsolate.value; - if (_mainIsolate == newIsolate) return; - // First deactivate the current widget tree. - setActivate(false); - if (newIsolate != null) { - // Then reactivate it with the new isolate. - setActivate(true); - } - _mainIsolate = newIsolate; - }, - ); - - // If select mode is available, enable the on device inspector as it - // won't interfere with users. - addAutoDisposeListener(_supportsToggleSelectWidgetMode, () { - if (_supportsToggleSelectWidgetMode.value) { - safeUnawaited( - serviceConnection.serviceManager.serviceExtensionManager - .setServiceExtensionState( - extensions.enableOnDeviceInspector.extension, - enabled: true, - value: true, - ), - ); - } - }); - - addAutoDisposeListener(serviceConnection.serviceManager.connectedState, () { - if (serviceConnection.serviceManager.connectedState.value.connected) { - _handleConnectionStart(); - } else { - _handleConnectionStop(); - } - }); - - if (serviceConnection.serviceManager.connectedAppInitialized) { - _handleConnectionStart(); - } - - serviceConnection.consoleService.ensureServiceInitialized(); - - final vmService = serviceConnection.serviceManager.service; - if (vmService != null) { - autoDisposeStreamSubscription( - vmService.onIsolateEvent.listen(_maybeAutoRefreshInspector), - ); - - autoDisposeStreamSubscription( - vmService.onExtensionEvent.listen(_maybeAutoRefreshInspector), - ); - } - } - - void _handleConnectionStart() { - // Clear any existing badge/errors for older errors that were collected. - // Do this in a post frame callback so that we are not trying to clear the - // error notifiers for this screen while the framework is already in the - // process of building widgets. - // TODO(kenz): When this method is called outside createState(), this post - // frame callback can be removed. - WidgetsBinding.instance.addPostFrameCallback((_) { - serviceConnection.errorBadgeManager.clearErrorCount(InspectorScreen.id); - }); - filterErrors(); - } - - void _handleConnectionStop() { - setActivate(false); - dispose(); - } - - IsolateRef? _mainIsolate; - - ValueListenable get _supportsToggleSelectWidgetMode => serviceConnection - .serviceManager - .serviceExtensionManager - .hasServiceExtension(extensions.toggleSelectWidgetMode.extension); - - Future _onClientChange(bool added) async { - if (!added && _clientCount == 0) { - // Don't try to remove clients if there are none - return; - } - - _clientCount += added ? 1 : -1; - assert(_clientCount >= 0); - if (_clientCount == 1) { - await setVisibleToUser(true); - setActivate(true); - } else if (_clientCount == 0) { - await setVisibleToUser(false); - } - } - - int _clientCount = 0; - - /// Maximum frame rate to refresh the inspector at to avoid taxing the - /// physical device with too many requests to recompute properties and trees. - /// - /// A value up to around 30 frames per second could be reasonable for - /// debugging highly interactive cases particularly when the user is on a - /// simulator or high powered native device. The frame rate is set low - /// for now mainly to minimize risk. - static const refreshFramesPerSecond = 5.0; - - InspectorTreeController inspectorTree; - final FlutterTreeType treeType; - - late RateLimiter _refreshRateLimiter; - - InspectorServiceBase get inspectorService => - serviceConnection.inspectorService as InspectorServiceBase; - - /// Groups used to manage and cancel requests to load data to display directly - /// in the tree. - InspectorObjectGroupManager? _treeGroups; - - /// Groups used to manage and cancel requests to determine what the current - /// selection is. - /// - /// This group needs to be kept separate from treeGroups as the selection is - /// shared with the widget details. - /// TODO(jacobr): is there a way we can unify the selection and tree groups? - InspectorObjectGroupManager? _selectionGroups; - - InspectorObjectGroupManager? _layoutGroups; - - /// Node being highlighted due to the current hover. - InspectorTreeNode? get currentShowNode => inspectorTree.hover; - - set currentShowNode(InspectorTreeNode? node) => inspectorTree.hover = node; - - bool flutterAppFrameReady = false; - - bool treeLoadStarted = false; - - RemoteDiagnosticsNode? subtreeRoot; - - bool programmaticSelectionChangeInProgress = false; - - ValueListenable get selectedNode => _selectedNode; - final _selectedNode = ValueNotifier(null); - - ValueListenable get selectedNodeProperties => - _selectedNodeProperties; - final _selectedNodeProperties = ValueNotifier(( - widgetProperties: [], - renderProperties: [], - layoutProperties: null, - )); - - /// Whether the implementation widgets are hidden in the widget tree. - ValueListenable get implementationWidgetsHidden => - _implementationWidgetsHidden; - final _implementationWidgetsHidden = ValueNotifier(true); - - InspectorTreeNode? lastExpanded; - - bool isActive = false; - - final valueToInspectorTreeNode = {}; - - /// When visibleToUser is false we should dispose all allocated objects and - /// not perform any actions. - bool visibleToUser = false; - - bool highlightNodesShownInBothTrees = false; - - RemoteDiagnosticsNode? get selectedDiagnostic => - selectedNode.value?.diagnostic; - - ValueListenable get selectedErrorIndex => _selectedErrorIndex; - final _selectedErrorIndex = ValueNotifier(null); - - /// Tracks whether the first load of the inspector tree has been completed. - /// - /// This field is used to prevent sending multiple analytics events for - /// inspector tree load timing. - bool firstInspectorTreeLoadCompleted = false; - - FlutterTreeType getTreeType() { - return treeType; - } - - Future setVisibleToUser(bool visible) async { - if (visibleToUser == visible) { - return; - } - visibleToUser = visible; - - if (visibleToUser) { - await refreshInspector(); - } else { - shutdownTree(false); - } - } - - bool hasDiagnosticsValue(InspectorInstanceRef ref) { - return valueToInspectorTreeNode.containsKey(ref); - } - - RemoteDiagnosticsNode? findDiagnosticsValue(InspectorInstanceRef ref) { - return valueToInspectorTreeNode[ref]?.diagnostic; - } - - void endShowNode() { - highlightShowNode(null); - } - - bool highlightShowFromNodeInstanceRef(InspectorInstanceRef ref) { - return highlightShowNode(valueToInspectorTreeNode[ref]); - } - - bool highlightShowNode(InspectorTreeNode? node) { - currentShowNode = node; - return true; - } - - InspectorTreeNode? findMatchingInspectorTreeNode( - RemoteDiagnosticsNode? node, - ) { - final valueRef = node?.valueRef; - if (valueRef == null) { - return null; - } - return valueToInspectorTreeNode[valueRef]; - } - - Future _waitForPendingUpdateDone() async { - // Wait for the selection to be resolved followed by waiting for the tree to be computed. - await _selectionGroups?.pendingUpdateDone; - await _treeGroups?.pendingUpdateDone; - // TODO(jacobr): are there race conditions we need to think more carefully about here? - } - - Future refresh() { - if (!visibleToUser) { - // We will refresh again once we are visible. - // There is a risk a refresh got triggered before the view was visble. - return Future.value(); - } - - return _waitForPendingUpdateDone(); - } - - // Note that this may be called after the controller is disposed. We need to handle nulls in the fields. - void shutdownTree(bool isolateStopped) { - // It is critical we clear all data that is kept alive by inspector object - // references in this method as that stale data will trigger inspector - // exceptions. - programmaticSelectionChangeInProgress = true; - _treeGroups?.clear(isolateStopped); - _selectionGroups?.clear(isolateStopped); - - currentShowNode = null; - _selectedNode.value = null; - lastExpanded = null; - - subtreeRoot = null; - - inspectorTree.root = inspectorTree.createNode(); - programmaticSelectionChangeInProgress = false; - valueToInspectorTreeNode.clear(); - // Mark tree as inactive so that it will be re-loaded the next time it is - // opened: - isActive = false; - } - - void onIsolateStopped() { - flutterAppFrameReady = false; - treeLoadStarted = false; - shutdownTree(true); - } - - @override - Future onForceRefresh() async { - assert(!disposed); - if (!visibleToUser || disposed) { - return; - } - await _recomputeTreeRoot(null); - if (disposed) { - return; - } - - filterErrors(); - - return _waitForPendingUpdateDone(); - } - - Future refreshInspector({bool isManualRefresh = false}) async { - // If the user is manually refreshing the inspector before the first load - // has completed, this could indicate a slow load time or that the inspector - // failed to load the tree once available. - if (isManualRefresh && !firstInspectorTreeLoadCompleted) { - // We do not want to complete this timing operation because the manual - // refresh will skew the results. - ga.cancelTimingOperation(InspectorScreen.id, gac.pageReady); - ga.select( - gac.inspector, - gac.refreshEmptyTree, - screenMetricsProvider: () => InspectorScreenMetrics.v2(), - ); - firstInspectorTreeLoadCompleted = true; - } - await onForceRefresh(); - } - - void filterErrors() { - serviceConnection.errorBadgeManager.filterErrors( - InspectorScreen.id, - (id) => hasDiagnosticsValue(InspectorInstanceRef(id)), - ); - } - - void setActivate(bool enabled) { - if (!enabled) { - onIsolateStopped(); - isActive = false; - return; - } - if (isActive) { - // Already activated. - return; - } - - isActive = true; - inspectorService.addClient(this); - unawaited(maybeLoadUI()); - } - - Future maybeLoadUI() async { - if (!visibleToUser || !isActive) { - return; - } - - if (flutterAppFrameReady) { - if (disposed) return; - // We need to start by querying the inspector service to find out the - // current state of the UI. - final inspectorRef = DevToolsQueryParams.load().inspectorRef; - await updateSelectionFromService(inspectorRef: inspectorRef); - } else { - if (disposed) return; - if (inspectorService is InspectorService) { - final widgetTreeReady = await (inspectorService as InspectorService) - .isWidgetTreeReady(); - flutterAppFrameReady = widgetTreeReady; - } - if (isActive && flutterAppFrameReady) { - await maybeLoadUI(); - } - } - } - - bool _receivedIsolateReloadEvent = false; - bool _receivedFlutterNavigationEvent = false; - - Future _maybeAutoRefreshInspector(Event event) async { - if (!preferences.inspector.autoRefreshEnabled.value) return; - - // It is not sufficent to wait for the navigation and isolate reload events - // only, because Flutter might not have re-painted the app. Instead, we need - // to wait for the first frame AFTER the isolate reload or navigation event - // in order to request the new tree. - if (event.kind == EventKind.kExtension) { - final extensionEventKind = event.extensionKind; - if (extensionEventKind == 'Flutter.Navigation') { - _receivedFlutterNavigationEvent = true; - } - if ((_receivedFlutterNavigationEvent || _receivedIsolateReloadEvent) && - extensionEventKind == 'Flutter.Frame') { - _refreshingAfterNavigationEvent = _receivedFlutterNavigationEvent; - _receivedFlutterNavigationEvent = false; - _receivedIsolateReloadEvent = false; - await refreshInspector(); - } - } - - if (event.kind == EventKind.kIsolateReload) { - serviceConnection.errorBadgeManager.clearErrors(InspectorScreen.id); - _receivedIsolateReloadEvent = true; - } - } - - Future _recomputeTreeRoot( - RemoteDiagnosticsNode? newSelection, { - bool? hideImplementationWidgets, - }) async { - assert(!disposed); - hideImplementationWidgets ??= _implementationWidgetsHidden.value; - final treeGroups = _treeGroups; - if (disposed || treeGroups == null) { - return; - } - - treeGroups.cancelNext(); - try { - final group = treeGroups.next; - final node = await group.getRoot( - treeType, - isSummaryTree: hideImplementationWidgets, - includeFullDetails: false, - ); - if (node == null || group.disposed || disposed) { - return; - } - // TODO(jacobr): as a performance optimization we should check if the - // new tree is identical to the existing tree in which case we should - // dispose the new tree and keep the old tree. - treeGroups.promoteNext(); - _clearValueToInspectorTreeNodeMapping(); - - final rootNode = inspectorTree.setupInspectorTreeNode( - inspectorTree.createNode(), - node, - expandChildren: true, - ); - inspectorTree.root = rootNode; - final selectedNode = _determineNewSelection( - newSelection ?? selectedDiagnostic, - ); - refreshSelection(selectedNode); - _implementationWidgetsHidden.value = hideImplementationWidgets; - } catch (error, st) { - _log.shout(error, error, st); - treeGroups.cancelNext(); - return; - } - } - - var _refreshingAfterNavigationEvent = false; - - RemoteDiagnosticsNode? _determineNewSelection( - RemoteDiagnosticsNode? previousSelection, - ) { - if (previousSelection == null) return null; - if (valueToInspectorTreeNode.containsKey(previousSelection.valueRef)) { - return previousSelection; - } - - // TODO(https://github.com/flutter/devtools/issues/8481): Consider using a - // variation of a path-finding algorithm to determine the new selection, - // instead of looking for the first matching descendant. - final (closestUnchangedAncestor, distanceToAncestor) = - _findClosestUnchangedAncestor(previousSelection); - if (closestUnchangedAncestor == null) return inspectorTree.root?.diagnostic; - - // TODO(elliette): This might cause a race event that will set this to false - // for a subsequent navigate event. Consider passing the value of - // _refreshingAfterNavigationEvent through the method chain from where the - // navigation event is detected. This would require updating the interface - // of InspectorServiceClient.onForceRefresh, or refactoring to avoid doing - // so. - if (_refreshingAfterNavigationEvent) { - _refreshingAfterNavigationEvent = false; - return closestUnchangedAncestor; - } - - const distanceOffset = 3; - final matchingDescendant = _findMatchingDescendant( - of: closestUnchangedAncestor, - matching: previousSelection, - inRange: Range( - distanceToAncestor - distanceOffset, - distanceToAncestor + distanceOffset, - ), - ); - - return matchingDescendant ?? closestUnchangedAncestor; - } - - (RemoteDiagnosticsNode?, int) _findClosestUnchangedAncestor( - RemoteDiagnosticsNode node, [ - int distanceToAncestor = 1, - ]) { - final inspectorTreeNode = valueToInspectorTreeNode[node.valueRef]; - if (inspectorTreeNode != null) { - return (inspectorTreeNode.diagnostic, distanceToAncestor); - } - - final ancestor = node.parent; - if (ancestor == null) return (null, distanceToAncestor); - return _findClosestUnchangedAncestor(ancestor, distanceToAncestor++); - } - - RemoteDiagnosticsNode? _findMatchingDescendant({ - required RemoteDiagnosticsNode of, - required RemoteDiagnosticsNode matching, - required Range inRange, - int currentDistance = 1, - }) { - if (currentDistance > inRange.end) return null; - - if (inRange.contains(currentDistance)) { - if (of.description == matching.description) { - return of; - } - } - - final children = of.childrenNow; - final distance = currentDistance++; - for (final child in children) { - final matchingDescendant = _findMatchingDescendant( - of: child, - matching: matching, - inRange: inRange, - currentDistance: distance, - ); - if (matchingDescendant != null) return matchingDescendant; - } - - return null; - } - - Future toggleImplementationWidgetsVisibility() async { - final root = inspectorTree.root?.diagnostic; - if (root != null) { - final currentSelectedNode = selectedNode.value; - await _recomputeTreeRoot( - root, - hideImplementationWidgets: !_implementationWidgetsHidden.value, - ); - // Persist the selected node after refreshing the widget tree: - refreshSelection(currentSelectedNode?.diagnostic); - - // If the user is searching the tree, refresh the search matches. - inspectorTree.refreshSearchMatches(); - } - } - - void _clearValueToInspectorTreeNodeMapping() { - valueToInspectorTreeNode.clear(); - } - - void setSubtreeRoot( - RemoteDiagnosticsNode? node, - RemoteDiagnosticsNode? selection, - ) { - selection ??= node; - if (node != null && node == subtreeRoot) { - // Select the new node in the existing subtree. - applyNewSelection(selection); - return; - } - subtreeRoot = node; - if (node == null) { - // Passing in a null node indicates we should clear the subtree and free any memory allocated. - shutdownTree(false); - return; - } - - // Clear now to eliminate frame of highlighted nodes flicker. - _clearValueToInspectorTreeNodeMapping(); - unawaited(_recomputeTreeRoot(selection)); - } - - InspectorTreeNode? getSubtreeRootNode() { - if (subtreeRoot == null) { - return null; - } - return valueToInspectorTreeNode[subtreeRoot!.valueRef]; - } - - void refreshSelection(RemoteDiagnosticsNode? newSelection) { - newSelection ??= selectedDiagnostic; - final matchingNode = findMatchingInspectorTreeNode(newSelection); - if (matchingNode != null) { - setSelectedNode(matchingNode); - syncSelectionHelper(selection: matchingNode.diagnostic); - - syncTreeSelection(); - } - } - - void syncTreeSelection() { - programmaticSelectionChangeInProgress = true; - inspectorTree.refreshTree( - updateTreeAction: () { - inspectorTree - ..setSelectedNode(selectedNode.value) - ..expandPath(selectedNode.value); - return true; - }, - ); - programmaticSelectionChangeInProgress = false; - animateTo(selectedNode.value); - } - - void selectAndShowNode(RemoteDiagnosticsNode? node) { - if (node == null) { - return; - } - selectAndShowInspectorInstanceRef(node.valueRef); - } - - void selectAndShowInspectorInstanceRef(InspectorInstanceRef ref) { - final node = valueToInspectorTreeNode[ref]; - if (node == null) { - return; - } - setSelectedNode(node); - syncTreeSelection(); - } - - InspectorTreeNode? getTreeNode(RemoteDiagnosticsNode node) { - return valueToInspectorTreeNode[node.valueRef]; - } - - @override - void onFlutterFrame() { - flutterAppFrameReady = true; - if (!visibleToUser) { - return; - } - - if (!treeLoadStarted) { - treeLoadStarted = true; - // This was the first frame. - unawaited(maybeLoadUI()); - } - _refreshRateLimiter.scheduleRequest(); - } - - @override - void onInspectorSelectionChanged() { - if (!visibleToUser) { - // Don't do anything. We will update the view once it is visible again. - return; - } - unawaited(updateSelectionFromService()); - } - - Future updateSelectionFromService({String? inspectorRef}) async { - final selectionGroups = _selectionGroups; - if (selectionGroups == null) { - // Already disposed. Ignore this requested to update selection. - return; - } - treeLoadStarted = true; - - selectionGroups.cancelNext(); - - final group = selectionGroups.next; - - if (inspectorRef != null) { - await group.setSelectionInspector( - InspectorInstanceRef(inspectorRef), - false, - ); - if (disposed) return; - } - final pendingSelectionFuture = group.getSelection( - selectedDiagnostic, - treeType, - // If implementation widgets are hidden, the only widgets in the tree are - // those that were created by the local project. - restrictToLocalProject: implementationWidgetsHidden.value, - ); - - try { - final newSelection = await pendingSelectionFuture; - - if (disposed || group.disposed) return; - - selectionGroups.promoteNext(); - - subtreeRoot = newSelection; - - applyNewSelection(newSelection); - - await _maybeShowNotificationForSelectedNode( - selectedNode: newSelection, - group: group, - ); - - // Send an event that a widget was selected on the device. - ga.select( - gac.inspector, - gac.onDeviceSelection, - screenMetricsProvider: () => InspectorScreenMetrics.v2(), - ); - } catch (error, st) { - if (selectionGroups.next == group) { - _log.shout(error, error, st); - selectionGroups.cancelNext(); - } - } - } - - void applyNewSelection(RemoteDiagnosticsNode? newSelection) { - final nodeInTree = findMatchingInspectorTreeNode(newSelection); - - if (nodeInTree == null) { - // The tree has probably changed since we last updated. Do a full refresh - // so that the tree includes the new node we care about. - unawaited(_recomputeTreeRoot(newSelection)); - } - - refreshSelection(newSelection); - } - - void animateTo(InspectorTreeNode? node) { - if (node == null) { - return; - } - - inspectorTree.animateToTargets([node]); - } - - void setSelectedNode(InspectorTreeNode? newSelection) { - if (newSelection == selectedNode.value) { - return; - } - - _selectedNode.value = newSelection; - - lastExpanded = null; // New selected node takes precedence. - endShowNode(); - - _updateSelectedErrorFromNode(_selectedNode.value); - unawaited(_loadPropertiesForNode(_selectedNode.value)); - - /// If the user selects a hidden implementation widget, first expand that - /// widget's hideable group before scrolling. - final diagnostic = _selectedNode.value?.diagnostic; - if (diagnostic != null && diagnostic.isHidden) { - inspectorTree.refreshTree( - updateTreeAction: () { - diagnostic.hideableGroupLeader?.toggleHiddenGroup(); - return true; - }, - ); - } - - animateTo(selectedNode.value); - } - - static const _implementationWidgetMessage = - 'Selected an implementation widget'; - - static const _notificationDuration = Duration(seconds: 4); - - Future _maybeShowNotificationForSelectedNode({ - required RemoteDiagnosticsNode? selectedNode, - required ObjectGroup group, - }) async { - if (selectedNode == null || - !implementationWidgetsHidden.value || - _selectionIsOutOfDate(selectedNode)) { - return; - } - - final possibleImplementationWidget = await group.getSelection( - selectedDiagnostic, - treeType, - ); - - // Return early if we have a new selected node. - if (_selectionIsOutOfDate(selectedNode)) return; - - final isImplementationWidget = - possibleImplementationWidget != null && - !possibleImplementationWidget.isCreatedByLocalProject; - if (isImplementationWidget) { - final selectedWidgetName = selectedNode.description ?? ''; - final implementationWidgetName = - possibleImplementationWidget.description ?? ''; - - // Return early if we have a new selected node. - if (_selectionIsOutOfDate(selectedNode)) return; - - // Show a notification that the user selected an implementation widget, - // e.g. "Selected an implementation widget of Text: RichText." - final messageDetails = selectedWidgetName.isEmpty - ? '' - : ' of $selectedWidgetName${implementationWidgetName.isEmpty ? '' : ': $implementationWidgetName'}'; - notificationService.pushNotification( - NotificationMessage( - '$_implementationWidgetMessage$messageDetails.', - duration: _notificationDuration, - ), - allowDuplicates: false, - ); - } - } - - bool _selectionIsOutOfDate(RemoteDiagnosticsNode selected) { - return selected.valueRef != selectedNode.value?.diagnostic?.valueRef; - } - - Future _loadPropertiesForNode(InspectorTreeNode? node) async { - final widgetProperties = []; - final renderProperties = []; - LayoutProperties? layoutProperties; - final diagnostic = node?.diagnostic; - final objectGroupApi = diagnostic?.objectGroupApi; - if (diagnostic != null && objectGroupApi != null) { - try { - // Fetch widget properties: - final wProperties = await diagnostic.getProperties(objectGroupApi); - // Check if the selected node has changed, and if so return early: - if (_selectedNode.value != node) { - return; - } - widgetProperties.addAll( - wProperties.where((p) => p.propertyType != 'RenderObject'), - ); - renderProperties.addAll( - wProperties.where((p) => p.propertyType == 'RenderObject'), - ); - // Fetch layout properties: - layoutProperties = await _loadLayoutPropertiesForNode( - diagnostic, - forFlexLayout: false, - ); - // Fetch RenderObject properties: - for (final renderObject in renderProperties) { - final rProperties = await renderObject.getProperties(objectGroupApi); - // Check if the selected node has changed, and if so return early: - if (_selectedNode.value != node) { - return; - } - renderProperties.addAll(rProperties); - } - } catch (e, st) { - _log.warning(e, st); - } - } - _selectedNodeProperties.value = ( - widgetProperties: widgetProperties, - renderProperties: renderProperties, - layoutProperties: layoutProperties, - ); - } - - Future _loadLayoutPropertiesForNode( - RemoteDiagnosticsNode diagnostic, { - required bool forFlexLayout, - }) async { - try { - _layoutGroups?.cancelNext(); - final manager = _layoutGroups!; - final nextObjectGroup = manager.next; - final node = await nextObjectGroup.getLayoutExplorerNode( - diagnostic.layoutRootNode(forFlexLayout: forFlexLayout), - ); - if (node == null || node.renderObject == null) return null; - - if (!nextObjectGroup.disposed) { - assert(manager.next == nextObjectGroup); - manager.promoteNext(); - } - return node.computeLayoutProperties(forFlexLayout: forFlexLayout); - } catch (e, st) { - _log.warning(e, st); - return null; - } - } - - /// Update the index of the selected error based on a node that has been - /// selected in the tree. - void _updateSelectedErrorFromNode(InspectorTreeNode? node) { - final inspectorRef = node?.diagnostic?.valueRef.id; - - final errors = serviceConnection.errorBadgeManager - .erroredItemsForPage(InspectorScreen.id) - .value; - - // Check whether the node that was just selected has any errors associated - // with it. - var errorIndex = inspectorRef != null - ? errors.keys.toList().indexOf(inspectorRef) - : null; - if (errorIndex == -1) { - errorIndex = null; - } - - _selectedErrorIndex.value = errorIndex; - - if (errorIndex != null) { - // Mark the error as "seen" as this will render slightly differently - // so the user can track which errored nodes they've viewed. - serviceConnection.errorBadgeManager.markErrorAsRead( - InspectorScreen.id, - errors[inspectorRef!]!, - ); - // Also clear the error badge since new errors may have arrived while - // the inspector was visible (normally they're cleared when visiting - // the screen) and visiting an errored node seems an appropriate - // acknowledgement of the errors. - serviceConnection.errorBadgeManager.clearErrorCount(InspectorScreen.id); - } - } - - /// Updates the index of the selected error and selects its node in the tree. - void selectErrorByIndex(int index) { - _selectedErrorIndex.value = index; - - final errors = serviceConnection.errorBadgeManager - .erroredItemsForPage(InspectorScreen.id) - .value; - - unawaited( - updateSelectionFromService(inspectorRef: errors.keys.elementAt(index)), - ); - } - - void _onExpand(InspectorTreeNode node) { - unawaited(inspectorTree.maybePopulateChildren(node)); - } - - Future _addNodeToConsole(InspectorTreeNode node) async { - final valueRef = node.diagnostic!.valueRef; - final isolateRef = inspectorService.isolateRef; - final instanceRef = await node.diagnostic!.objectGroupApi - ?.toObservatoryInstanceRef(valueRef); - if (disposed) return; - - if (instanceRef != null) { - await serviceConnection.consoleService.appendInstanceRef( - value: instanceRef, - diagnostic: node.diagnostic, - isolateRef: isolateRef, - forceScrollIntoView: true, - ); - } - } - - /// Handles updating the widget tree when the selecected widget changes. - /// - /// [notifyFlutterInspector] determines whether a request should be sent to - /// the Widget Inspector in the Flutter framework to update the on-device - /// selection. This should only be true if the the selection was changed due - /// to a user action in DevTools (e.g. clicking on a widget in the tree). - void selectionChanged({bool notifyFlutterInspector = false}) { - if (!visibleToUser) { - return; - } - - final node = inspectorTree.selection; - if (node != null) { - unawaited(inspectorTree.maybePopulateChildren(node)); - } - if (programmaticSelectionChangeInProgress) { - return; - } - if (node != null) { - setSelectedNode(node); - unawaited(_addNodeToConsole(node)); - - syncSelectionHelper( - selection: selectedDiagnostic, - notifyFlutterInspector: notifyFlutterInspector, - ); - } - } - - /// Syncs the selection state after a new widgets was selected. - /// - /// [notifyFlutterInspector] determines whether a request should be sent to - /// the Widget Inspector in the Flutter framework to update the on-device - /// selection. This should only be true if the the selection was changed due - /// to a user action in DevTools (e.g. clicking on a widget in the tree). - void syncSelectionHelper({ - required RemoteDiagnosticsNode? selection, - bool notifyFlutterInspector = false, - }) { - if (selection != null) { - if (selection.isCreatedByLocalProject) { - _navigateTo(selection); - } - } - - if (notifyFlutterInspector && selection != null) { - unawaited(selection.setSelectionInspector(true)); - } - } - - // TODO(jacobr): implement this method and use the parameter. - // ignore: avoid-unused-parameters - void _navigateTo(RemoteDiagnosticsNode diagnostic) { - // TODO(jacobr): dispatch an event over the inspectorService requesting a - // navigate operation. - } - - @override - void dispose() { - assert(!disposed); - if (serviceConnection.inspectorService != null) { - shutdownTree(false); - } - - _treeGroups?.clear(false); - _treeGroups = null; - _selectionGroups?.clear(false); - _selectionGroups = null; - _layoutGroups?.clear(false); - _layoutGroups = null; - - _refreshRateLimiter.dispose(); - _selectedNode.dispose(); - _selectedNodeProperties.dispose(); - _implementationWidgetsHidden.dispose(); - _selectedErrorIndex.dispose(); - super.dispose(); - } - - void _onNodeAdded( - InspectorTreeNode node, - RemoteDiagnosticsNode diagnosticsNode, - ) { - final valueRef = diagnosticsNode.valueRef; - // Properties do not have unique values so should not go in the valueToInspectorTreeNode map. - if (valueRef.id != null && !diagnosticsNode.isProperty) { - valueToInspectorTreeNode[valueRef] = node; - } - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/inspector_data_models.dart b/packages/devtools_app/lib/src/screens/inspector_v2/inspector_data_models.dart deleted file mode 100644 index 188dabd14b6..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/inspector_data_models.dart +++ /dev/null @@ -1,980 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -/// @docImport '../inspector/layout_explorer/ui/overflow_indicator_painter.dart'; -library; - -import 'dart:math' as math; - -import 'package:flutter/rendering.dart'; - -import '../../shared/diagnostics/diagnostics_node.dart'; -import '../../shared/primitives/math_utils.dart'; -import '../../shared/primitives/utils.dart'; -import 'layout_explorer/flex/utils.dart'; - -const overflowEpsilon = 0.1; - -/// Compute real widget sizes into rendered sizes to be displayed on the details tab. -/// The sum of the resulting render sizes may or may not be greater than the [maxSizeAvailable] -/// In the case where it is greater, we should render it with scrolling capability. -/// -/// Variables: -/// - [sizes] : real size for widgets that want to be rendered / scaled -/// - [smallestSize] : the smallest element in the array [sizes] -/// - [largestSize] : the largest element in the array [sizes] -/// - [smallestRenderSize] : render size for smallest element -/// - [largestRenderSize] : render size for largest element -/// - [maxSizeAvailable] : maximum size available for rendering the widget -/// - [useMaxSizeAvailable] : flag for forcing the widget dimension to be at least [maxSizeAvailable] -/// -/// if [useMaxSizeAvailable] is set to true, -/// this method will ignore the largestRenderSize -/// and compute its own largestRenderSize to force -/// the sum of the render size to be equals to [maxSizeAvailable] -/// -/// Formula for computing render size: -/// ``` -/// renderSize[i] = (size[i] - smallestSize) -/// * (largestRenderSize - smallestRenderSize) -/// / (largestSize - smallestSize) + smallestRenderSize -/// ``` -/// -/// Explanation: -/// - The computation formula for transforming size to renderSize is based on these two things: -/// - smallest element will be rendered to [smallestRenderSize] -/// - largest element will be rendered to [largestRenderSize] -/// - any other size will be scaled accordingly -/// - The formula above is derived from: -/// ``` -/// (renderSize[i] - smallestRenderSize) / (largestRenderSize - smallestRenderSize) -/// = (size[i] - smallestSize) / (size[i] - smallestSize) -/// ``` -/// -/// Formula for computing forced [largestRenderSize]: -/// ``` -/// largestRenderSize = (maxSizeAvailable - sizes.length * smallestRenderSize) -/// * (largestSize - smallestSize) / sum(s[i] - ss) + smallestRenderSize -/// ``` -/// Explanation: -/// - This formula is derived from the equation: -/// ``` -/// sum(renderSize) = maxSizeAvailable -/// ``` -/// -List computeRenderSizes({ - required Iterable sizes, - required double smallestSize, - required double largestSize, - required double smallestRenderSize, - required double largestRenderSize, - required double maxSizeAvailable, - bool useMaxSizeAvailable = true, -}) { - final n = sizes.length; - - if (smallestSize == largestSize) { - // It means that all widget have the same size - // and we can just divide the size evenly - // but it should be at least as big as [smallestRenderSize] - final renderSize = math.max(smallestRenderSize, maxSizeAvailable / n); - return [for (final _ in sizes) renderSize]; - } - - List transformToRenderSize(double largestRenderSize) => [ - for (final s in sizes) - (s - smallestSize) * - (largestRenderSize - smallestRenderSize) / - (largestSize - smallestSize) + - smallestRenderSize, - ]; - - var renderSizes = transformToRenderSize(largestRenderSize); - - if (useMaxSizeAvailable && sum(renderSizes) < maxSizeAvailable) { - largestRenderSize = - (maxSizeAvailable - n * smallestRenderSize) * - (largestSize - smallestSize) / - sum([for (final s in sizes) s - smallestSize]) + - smallestRenderSize; - renderSizes = transformToRenderSize(largestRenderSize); - } - return renderSizes; -} - -/// Data pattern containing a widget's widths or heights. -typedef WidgetSizes = ({ - /// Whether this record represents a widget's widths or heights. - SizeType type, - - /// Either the widget's left (if [type] is [SizeType.widths]) or top (if - /// [type] is [SizeType.heights]) padding. - double paddingA, - - /// Either the widget's width (if [type] is [SizeType.widths]) or height (if - /// [type] is [SizeType.heights]). - double widgetSize, - - /// Either the widget's right (if [type] is [SizeType.widths]) or bottom (if - /// [type] is [SizeType.heights]) padding. - double paddingB, -}); - -enum SizeType { widths, heights } - -// TODO(albertusangga): Move this to [RemoteDiagnosticsNode] once dart:html app is removed -/// Represents parsed layout information for a specific [RemoteDiagnosticsNode]. -class LayoutProperties { - LayoutProperties(this.node, {int copyLevel = 1}) - : description = node.description, - size = node.size!, - constraints = node.constraints, - isFlex = node.isFlex, - flexFactor = node.flexFactor, - flexFit = node.flexFit, - children = copyLevel == 0 - ? [] - : node.childrenNow - .where((child) => child.size != null) - .map( - (child) => LayoutProperties(child, copyLevel: copyLevel - 1), - ) - .toList(growable: false) { - for (final child in children) { - child.parent = this; - } - } - - LayoutProperties.values({ - required this.node, - required this.children, - required this.constraints, - required this.description, - required this.flexFactor, - required this.isFlex, - required this.size, - required this.flexFit, - }) { - for (final child in children) { - child.parent = this; - } - } - - LayoutProperties? parent; - final RemoteDiagnosticsNode node; - final List children; - final BoxConstraints? constraints; - final String? description; - final num? flexFactor; - final FlexFit? flexFit; - final bool isFlex; - final Size size; - - /// Represents the order of [children] to be displayed. - List get displayChildren => children; - - bool get hasFlexFactor { - final flexFactorLocal = flexFactor; - if (flexFactorLocal == null) return false; - return flexFactorLocal > 0; - } - - int get totalChildren => children.length; - - bool get hasChildren => children.isNotEmpty; - - double get width => size.width; - - double get height => size.height; - - double dimension(Axis axis) => axis == Axis.horizontal ? width : height; - - List childrenDimensions(Axis axis) { - return displayChildren.map((child) => child.dimension(axis)).toList(); - } - - List get childrenWidths => childrenDimensions(Axis.horizontal); - - List get childrenHeights => childrenDimensions(Axis.vertical); - - String describeWidthConstraints() { - final constraintsLocal = constraints; - if (constraintsLocal == null) return ''; - return constraintsLocal.hasBoundedWidth - ? describeAxis( - constraintsLocal.minWidth, - constraintsLocal.maxWidth, - 'w', - ) - : 'width is unconstrained'; - } - - String describeHeightConstraints() { - final constraintsLocal = constraints; - if (constraintsLocal == null) return ''; - return constraintsLocal.hasBoundedHeight - ? describeAxis( - constraintsLocal.minHeight, - constraintsLocal.maxHeight, - 'h', - ) - : 'height is unconstrained'; - } - - String describeWidth() => 'w=${toStringAsFixed(size.width)}'; - - String describeHeight() => 'h=${toStringAsFixed(size.height)}'; - - bool get isOverflowWidth { - final parentWidth = parent?.width; - if (parentWidth == null) return false; - final parentData = node.parentData; - double widthUsed = width; - - widthUsed += parentData.offset.dx; - - // TODO(jacobr): certain widgets may allow overflow so this may false - // positive a bit for cases like Stack. - return widthUsed > parentWidth + overflowEpsilon; - } - - bool get isOverflowHeight { - final parentHeight = parent?.height; - if (parentHeight == null) return false; - final parentData = node.parentData; - double heightUsed = height; - - heightUsed += parentData.offset.dy; - - return heightUsed > parentHeight + overflowEpsilon; - } - - LayoutProperties? get parentLayoutProperties { - final parentElement = node.parentRenderElement; - // Fall back to this node's properties if there is no parent. - if (parentElement == null) return this; - final parentProperties = parentElement.computeLayoutProperties( - forFlexLayout: false, - ); - return parentProperties ?? this; - } - - WidgetSizes? get widgetWidths => _widgetSizes(SizeType.widths); - - WidgetSizes? get widgetHeights => _widgetSizes(SizeType.heights); - - WidgetSizes? _widgetSizes(SizeType type) { - if (parentLayoutProperties == null) return null; - final parentProperties = parentLayoutProperties!; - - final parentData = node.parentData; - final parentSize = parentProperties.size; - - switch (type) { - case SizeType.heights: - return ( - type: type, - paddingA: parentData.offset.dy, - widgetSize: size.height, - paddingB: parentSize.height - (size.height + parentData.offset.dy), - ); - case SizeType.widths: - return ( - type: type, - paddingA: parentData.offset.dx, - widgetSize: size.width, - paddingB: parentSize.width - (size.width + parentData.offset.dx), - ); - } - } - - static String describeAxis(double min, double max, String axis) { - if (min == max) return '$axis=${min.toStringAsFixed(1)}'; - return '${min.toStringAsFixed(1)}<=$axis<=${max.toStringAsFixed(1)}'; - } - - LayoutProperties copyWith({ - List? children, - BoxConstraints? constraints, - String? description, - int? flexFactor, - FlexFit? flexFit, - bool? isFlex, - Size? size, - }) { - return LayoutProperties.values( - node: node, - children: children ?? this.children, - constraints: constraints ?? this.constraints, - description: description ?? this.description, - flexFactor: flexFactor ?? this.flexFactor, - isFlex: isFlex ?? this.isFlex, - size: size ?? this.size, - flexFit: flexFit ?? this.flexFit, - ); - } -} - -/// Enum object to represent which side of the widget is overflowing. -/// -/// See also: -/// * [OverflowIndicatorPainter] -enum OverflowSide { right, bottom } - -// TODO(jacobr): is it possible to overflow on multiple sides? -// TODO(jacobr): do we need to worry about overflowing on the left side in RTL -// layouts? We need to audit the Flutter semantics for determining overflow to -// make sure we are consistent. -extension LayoutPropertiesExtension on LayoutProperties { - OverflowSide? get overflowSide { - if (isOverflowWidth) return OverflowSide.right; - if (isOverflowHeight) return OverflowSide.bottom; - return null; - } -} - -final _flexLayoutExpando = Expando(); - -extension MainAxisAlignmentExtension on MainAxisAlignment { - MainAxisAlignment get reversed { - switch (this) { - case MainAxisAlignment.start: - return MainAxisAlignment.end; - case MainAxisAlignment.end: - return MainAxisAlignment.start; - default: - return this; - } - } -} - -/// Encapsulation of [widths] and [heights] for the layout. -class LayoutWidthsAndHeights { - LayoutWidthsAndHeights({required this.widths, required this.heights}); - - final WidgetSizes widths; - final WidgetSizes heights; - - double get widgetWidth => widths.widgetSize; - - double get widgetHeight => heights.widgetSize; - - double get leftPadding => widths.paddingA; - - double get rightPadding => widths.paddingB; - - double get topPadding => heights.paddingA; - - double get bottomPadding => heights.paddingB; - - bool get hasLeftPadding => leftPadding > 0; - - bool get hasRightPadding => rightPadding > 0; - - bool get hasTopPadding => topPadding > 0; - - bool get hasBottomPadding => bottomPadding > 0; - - bool get hasAnyPadding => - hasLeftPadding || hasRightPadding || hasTopPadding || hasBottomPadding; -} - -/// TODO(albertusangga): Move this to [RemoteDiagnosticsNode] once dart:html app is removed. -class FlexLayoutProperties extends LayoutProperties { - FlexLayoutProperties({ - required super.size, - required super.children, - required super.node, - super.constraints, - super.isFlex = false, - super.description, - super.flexFactor, - super.flexFit, - this.direction = Axis.vertical, - this.mainAxisAlignment, - this.crossAxisAlignment, - this.mainAxisSize, - required this.textDirection, - required this.verticalDirection, - this.textBaseline, - }) : super.values(); - - FlexLayoutProperties._fromNode( - super.node, { - this.direction = Axis.vertical, - this.mainAxisAlignment, - this.mainAxisSize, - this.crossAxisAlignment, - required this.textDirection, - required this.verticalDirection, - this.textBaseline, - }); - - factory FlexLayoutProperties.fromDiagnostics(RemoteDiagnosticsNode node) { - // Cache the properties on an expando so that local tweaks to - // FlexLayoutProperties persist across multiple lookups from an - // RemoteDiagnosticsNode. - return _flexLayoutExpando[node] ??= _buildNode(node); - } - - @override - FlexLayoutProperties copyWith({ - Size? size, - List? children, - BoxConstraints? constraints, - bool? isFlex, - String? description, - num? flexFactor, - FlexFit? flexFit, - Axis? direction, - MainAxisAlignment? mainAxisAlignment, - MainAxisSize? mainAxisSize, - CrossAxisAlignment? crossAxisAlignment, - TextDirection? textDirection, - VerticalDirection? verticalDirection, - TextBaseline? textBaseline, - }) { - return FlexLayoutProperties( - size: size ?? this.size, - children: children ?? this.children, - node: node, - constraints: constraints ?? this.constraints, - isFlex: isFlex ?? this.isFlex, - description: description ?? this.description, - flexFactor: flexFactor ?? this.flexFactor, - flexFit: flexFit ?? this.flexFit, - direction: direction ?? this.direction, - mainAxisAlignment: mainAxisAlignment ?? this.mainAxisAlignment, - mainAxisSize: mainAxisSize ?? this.mainAxisSize, - crossAxisAlignment: crossAxisAlignment ?? this.crossAxisAlignment, - textDirection: textDirection ?? this.textDirection, - verticalDirection: verticalDirection ?? this.verticalDirection, - textBaseline: textBaseline ?? this.textBaseline, - ); - } - - static FlexLayoutProperties _buildNode(RemoteDiagnosticsNode node) { - final renderObjectJson = node.renderObject!.json; - final properties = (renderObjectJson['properties'] as List) - .cast>(); - - final data = { - for (final property in properties) - property['name']: property['description'] as String?, - }; - - return FlexLayoutProperties._fromNode( - node, - direction: _directionNamesToValues[data['direction']] ?? Axis.vertical, - mainAxisAlignment: - _mainAxisAlignmentNamesToValues[data['mainAxisAlignment']], - mainAxisSize: _mainAxisSizeNamesToValues[data['mainAxisSize']], - crossAxisAlignment: - _crossAxisAlignmentNamesToValues[data['crossAxisAlignment']], - textDirection: - _textDirectionNamesToValues[data['textDirection']] ?? - TextDirection.ltr, - verticalDirection: - _verticalDirectionNamesToValues[data['verticalDirection']] ?? - VerticalDirection.down, - textBaseline: _textBaselineNamesToValues[data['textBaseline']], - ); - } - - final Axis direction; - final MainAxisAlignment? mainAxisAlignment; - final CrossAxisAlignment? crossAxisAlignment; - final MainAxisSize? mainAxisSize; - final TextDirection textDirection; - final VerticalDirection verticalDirection; - final TextBaseline? textBaseline; - - List? _displayChildren; - - @override - List get displayChildren { - final displayChildren = _displayChildren; - if (displayChildren != null) return displayChildren; - return _displayChildren = startIsTopLeft - ? children - : children.reversed.toList(); - } - - int? _totalFlex; - - bool get isMainAxisHorizontal => direction == Axis.horizontal; - - bool get isMainAxisVertical => direction == Axis.vertical; - - String get horizontalDirectionDescription { - return direction == Axis.horizontal ? 'Main Axis' : 'Cross Axis'; - } - - String get verticalDirectionDescription { - return direction == Axis.vertical ? 'Main Axis' : 'Cross Axis'; - } - - String get type => direction.flexType; - - num get totalFlex { - if (children.isEmpty) return 0; - _totalFlex ??= children - .map((child) => child.flexFactor ?? 0) - .reduce((value, element) => value + element) - .toInt(); - return _totalFlex!; - } - - Axis get crossAxisDirection { - return direction == Axis.horizontal ? Axis.vertical : Axis.horizontal; - } - - double get mainAxisDimension => dimension(direction); - - double get crossAxisDimension => dimension(crossAxisDirection); - - @override - bool get isOverflowWidth { - if (direction == Axis.horizontal) { - return width + overflowEpsilon < sum(childrenWidths); - } - return width + overflowEpsilon < max(childrenWidths); - } - - @override - bool get isOverflowHeight { - if (direction == Axis.vertical) { - return height + overflowEpsilon < sum(childrenHeights); - } - return height + overflowEpsilon < max(childrenHeights); - } - - bool get startIsTopLeft { - switch (direction) { - case Axis.horizontal: - switch (textDirection) { - case TextDirection.ltr: - return true; - case TextDirection.rtl: - return false; - } - case Axis.vertical: - switch (verticalDirection) { - case VerticalDirection.down: - return true; - case VerticalDirection.up: - return false; - } - } - } - - /// render properties for laying out rendered Flex & Flex children widgets - /// the computation is similar to [RenderFlex].performLayout() method - List childrenRenderProperties({ - required double smallestRenderWidth, - required double largestRenderWidth, - required double smallestRenderHeight, - required double largestRenderHeight, - required double Function(Axis) maxSizeAvailable, - }) { - /// calculate the render empty spaces - final freeSpace = dimension(direction) - sum(childrenDimensions(direction)); - final displayMainAxisAlignment = startIsTopLeft - ? mainAxisAlignment - : mainAxisAlignment?.reversed; - - double leadingSpace(double freeSpace) { - if (children.isEmpty) return 0.0; - switch (displayMainAxisAlignment) { - case MainAxisAlignment.start: - case MainAxisAlignment.end: - return freeSpace; - case MainAxisAlignment.center: - return freeSpace * 0.5; - case MainAxisAlignment.spaceBetween: - return 0.0; - case MainAxisAlignment.spaceAround: - final spaceBetweenChildren = freeSpace / children.length; - return spaceBetweenChildren * 0.5; - case MainAxisAlignment.spaceEvenly: - return freeSpace / (children.length + 1); - default: - return 0.0; - } - } - - double betweenSpace(double freeSpace) { - if (children.isEmpty) return 0.0; - switch (displayMainAxisAlignment) { - case MainAxisAlignment.start: - case MainAxisAlignment.end: - case MainAxisAlignment.center: - return 0.0; - case MainAxisAlignment.spaceBetween: - if (children.length == 1) return freeSpace; - return freeSpace / (children.length - 1); - case MainAxisAlignment.spaceAround: - return freeSpace / children.length; - case MainAxisAlignment.spaceEvenly: - return freeSpace / (children.length + 1); - default: - return 0.0; - } - } - - double smallestRenderSize(Axis axis) { - return axis == Axis.horizontal - ? smallestRenderWidth - : smallestRenderHeight; - } - - double largestRenderSize(Axis axis) { - final lrs = axis == Axis.horizontal - ? largestRenderWidth - : largestRenderHeight; - // use all the space when visualizing cross axis - return (axis == direction) ? lrs : maxSizeAvailable(axis); - } - - List renderSizes(Axis axis) { - final sizes = childrenDimensions(axis); - if (freeSpace > 0.0 && axis == direction) { - /// include free space in the computation - sizes.add(freeSpace); - } - final smallestSize = min(sizes); - final largestSize = max(sizes); - if (axis == direction || - (crossAxisAlignment != CrossAxisAlignment.stretch && - smallestSize != largestSize)) { - return computeRenderSizes( - sizes: sizes, - smallestSize: smallestSize, - largestSize: largestSize, - smallestRenderSize: smallestRenderSize(axis), - largestRenderSize: largestRenderSize(axis), - maxSizeAvailable: maxSizeAvailable(axis), - ); - } else { - // uniform cross axis sizes. - double size = crossAxisAlignment == CrossAxisAlignment.stretch - ? maxSizeAvailable(axis) - : largestSize / - math.max(dimension(axis), 1.0) * - maxSizeAvailable(axis); - size = math.max(size, smallestRenderSize(axis)); - return sizes.map((_) => size).toList(); - } - } - - final widths = renderSizes(Axis.horizontal); - final heights = renderSizes(Axis.vertical); - - final renderFreeSpace = freeSpace > 0.0 - ? (isMainAxisHorizontal ? widths.last : heights.last) - : 0.0; - - final renderLeadingSpace = leadingSpace(renderFreeSpace); - final renderBetweenSpace = betweenSpace(renderFreeSpace); - - final childrenRenderProps = []; - - double lastMainAxisOffset() { - if (childrenRenderProps.isEmpty) return 0.0; - return childrenRenderProps.last.mainAxisOffset; - } - - double lastMainAxisDimension() { - if (childrenRenderProps.isEmpty) return 0.0; - return childrenRenderProps.last.mainAxisDimension; - } - - double space(int index) { - if (index == 0) { - if (displayMainAxisAlignment == MainAxisAlignment.start) return 0.0; - return renderLeadingSpace; - } - return renderBetweenSpace; - } - - double calculateMainAxisOffset(int i) { - return lastMainAxisOffset() + lastMainAxisDimension() + space(i); - } - - double calculateCrossAxisOffset(int i) { - final maxDimension = maxSizeAvailable(crossAxisDirection); - final usedDimension = crossAxisDirection == Axis.horizontal - ? widths[i] - : heights[i]; - - if (crossAxisAlignment == CrossAxisAlignment.start || - crossAxisAlignment == CrossAxisAlignment.stretch || - maxDimension == usedDimension) { - return 0.0; - } - final emptySpace = math.max(0.0, maxDimension - usedDimension); - if (crossAxisAlignment == CrossAxisAlignment.end) return emptySpace; - return emptySpace * 0.5; - } - - for (var i = 0; i < children.length; ++i) { - childrenRenderProps.add( - RenderProperties( - axis: direction, - size: Size(widths[i], heights[i]), - offset: Offset.zero, - realSize: displayChildren[i].size, - layoutProperties: displayChildren[i], - ) - ..mainAxisOffset = calculateMainAxisOffset(i) - ..crossAxisOffset = calculateCrossAxisOffset(i), - ); - } - - final spaces = []; - final actualLeadingSpace = leadingSpace(freeSpace); - final actualBetweenSpace = betweenSpace(freeSpace); - final renderPropsWithFullCrossAxisDimension = - RenderProperties( - axis: direction, - isFreeSpace: true, - layoutProperties: this, - ) - ..crossAxisDimension = maxSizeAvailable(crossAxisDirection) - ..crossAxisRealDimension = dimension(crossAxisDirection) - ..crossAxisOffset = 0.0; - if (actualLeadingSpace > 0.0 && - displayMainAxisAlignment != MainAxisAlignment.start) { - spaces.add( - renderPropsWithFullCrossAxisDimension.copyWith() - ..mainAxisOffset = 0.0 - ..mainAxisDimension = renderLeadingSpace - ..mainAxisRealDimension = actualLeadingSpace, - ); - } - if (actualBetweenSpace > 0.0) { - for (var i = 0; i < childrenRenderProps.length - 1; ++i) { - final child = childrenRenderProps[i]; - spaces.add( - renderPropsWithFullCrossAxisDimension.copyWith() - ..mainAxisDimension = renderBetweenSpace - ..mainAxisRealDimension = actualBetweenSpace - ..mainAxisOffset = child.mainAxisOffset + child.mainAxisDimension, - ); - } - } - if (actualLeadingSpace > 0.0 && - displayMainAxisAlignment != MainAxisAlignment.end) { - spaces.add( - renderPropsWithFullCrossAxisDimension.copyWith() - ..mainAxisOffset = - childrenRenderProps.last.mainAxisDimension + - childrenRenderProps.last.mainAxisOffset - ..mainAxisDimension = renderLeadingSpace - ..mainAxisRealDimension = actualLeadingSpace, - ); - } - return [...childrenRenderProps, ...spaces]; - } - - List crossAxisSpaces({ - required List childrenRenderProperties, - required double Function(Axis) maxSizeAvailable, - }) { - if (crossAxisAlignment == CrossAxisAlignment.stretch) return []; - final spaces = []; - for (var i = 0; i < children.length; ++i) { - if (dimension(crossAxisDirection) == - displayChildren[i].dimension(crossAxisDirection) || - childrenRenderProperties[i].crossAxisDimension == - maxSizeAvailable(crossAxisDirection)) { - continue; - } - - final renderProperties = childrenRenderProperties[i]; - final space = renderProperties.copyWith(isFreeSpace: true); - - space.crossAxisRealDimension = - crossAxisDimension - space.crossAxisRealDimension; - space.crossAxisDimension = - maxSizeAvailable(crossAxisDirection) - space.crossAxisDimension; - if (space.crossAxisDimension <= 0.0) continue; - if (crossAxisAlignment == CrossAxisAlignment.center) { - space.crossAxisDimension *= 0.5; - final crossAxisRealDimension = space.crossAxisRealDimension; - space.crossAxisRealDimension = crossAxisRealDimension * 0.5; - spaces.add(space.copyWith()..crossAxisOffset = 0.0); - spaces.add( - space.copyWith() - ..crossAxisOffset = - renderProperties.crossAxisDimension + - renderProperties.crossAxisOffset, - ); - } else { - space.crossAxisOffset = crossAxisAlignment == CrossAxisAlignment.end - ? 0 - : renderProperties.crossAxisDimension; - spaces.add(space); - } - } - return spaces; - } - - static final _directionNamesToValues = Axis.values.asNameMap(); - static final _mainAxisAlignmentNamesToValues = MainAxisAlignment.values - .asNameMap(); - static final _mainAxisSizeNamesToValues = MainAxisSize.values.asNameMap(); - static final _crossAxisAlignmentNamesToValues = CrossAxisAlignment.values - .asNameMap(); - static final _textDirectionNamesToValues = TextDirection.values.asNameMap(); - static final _verticalDirectionNamesToValues = VerticalDirection.values - .asNameMap(); - static final _textBaselineNamesToValues = TextBaseline.values.asNameMap(); -} - -/// Information for rendering a [LayoutProperties] node. -class RenderProperties { - RenderProperties({ - required this.axis, - required this.layoutProperties, - this.isFreeSpace = false, - Size? size, - Offset? offset, - Size? realSize, - }) : width = size?.width ?? 0.0, - height = size?.height ?? 0.0, - realWidth = realSize?.width ?? 0.0, - realHeight = realSize?.height ?? 0.0, - dx = offset?.dx ?? 0.0, - dy = offset?.dy ?? 0.0; - - final Axis axis; - - /// Represents which node is rendered for this object. - final LayoutProperties layoutProperties; - - final bool isFreeSpace; - - double dx, dy; - double width, height; - double realWidth, realHeight; - - Size get size => Size(width, height); - - Size get realSize => Size(realWidth, realHeight); - - Offset get offset => Offset(dx, dy); - - double get mainAxisDimension => axis == Axis.horizontal ? width : height; - - set mainAxisDimension(double dim) { - if (axis == Axis.horizontal) { - width = dim; - } else { - height = dim; - } - } - - double get crossAxisDimension => axis == Axis.horizontal ? height : width; - - set crossAxisDimension(double dim) { - if (axis == Axis.horizontal) { - height = dim; - } else { - width = dim; - } - } - - double get mainAxisOffset => axis == Axis.horizontal ? dx : dy; - - set mainAxisOffset(double offset) { - if (axis == Axis.horizontal) { - dx = offset; - } else { - dy = offset; - } - } - - double get crossAxisOffset => axis == Axis.horizontal ? dy : dx; - - set crossAxisOffset(double offset) { - if (axis == Axis.horizontal) { - dy = offset; - } else { - dx = offset; - } - } - - double get mainAxisRealDimension => - axis == Axis.horizontal ? realWidth : realHeight; - - set mainAxisRealDimension(double newVal) { - if (axis == Axis.horizontal) { - realWidth = newVal; - } else { - realHeight = newVal; - } - } - - double get crossAxisRealDimension => - axis == Axis.horizontal ? realHeight : realWidth; - - set crossAxisRealDimension(double newVal) { - if (axis == Axis.horizontal) { - realHeight = newVal; - } else { - realWidth = newVal; - } - } - - RenderProperties copyWith({bool? isFreeSpace}) { - return RenderProperties( - axis: axis, - size: size, - offset: offset, - realSize: realSize, - layoutProperties: layoutProperties, - isFreeSpace: isFreeSpace ?? this.isFreeSpace, - ); - } - - @override - int get hashCode => - axis.hashCode ^ - size.hashCode ^ - offset.hashCode ^ - realSize.hashCode ^ - isFreeSpace.hashCode; - - @override - bool operator ==(Object other) { - return other is RenderProperties && - axis == other.axis && - size.closeTo(other.size) && - offset.closeTo(other.offset) && - realSize.closeTo(other.realSize) && - isFreeSpace == other.isFreeSpace; - } - - @override - String toString() { - return '{ axis: $axis, size: $size, offset: $offset, realSize: $realSize, isFreeSpace: $isFreeSpace }'; - } -} - -bool _closeTo(double a, double b, {int precision = 1}) { - return a.toStringAsPrecision(precision) == b.toStringAsPrecision(precision); -} - -extension on Size { - bool closeTo(Size other) { - return _closeTo(width, other.width) && _closeTo(height, other.height); - } -} - -extension on Offset { - bool closeTo(Offset other) { - return _closeTo(dx, other.dx) && _closeTo(dy, other.dy); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/inspector_screen_body.dart b/packages/devtools_app/lib/src/screens/inspector_v2/inspector_screen_body.dart deleted file mode 100644 index bcb6070927b..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/inspector_screen_body.dart +++ /dev/null @@ -1,388 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:async'; -import 'dart:collection'; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; - -import '../../shared/analytics/analytics.dart' as ga; -import '../../shared/analytics/constants.dart' as gac; -import '../../shared/analytics/metrics.dart'; -import '../../shared/console/eval/inspector_tree_v2.dart'; -import '../../shared/globals.dart'; -import '../../shared/managers/error_badge_manager.dart'; -import '../../shared/primitives/blocking_action_mixin.dart'; -import '../../shared/ui/common_widgets.dart'; -import '../../shared/ui/search.dart'; -import '../../shared/utils/utils.dart'; -import '../inspector_shared/inspector_controls.dart'; -import '../inspector_shared/inspector_screen.dart'; -import 'inspector_controller.dart'; -import 'inspector_tree_controller.dart'; -import 'widget_details.dart'; - -class InspectorScreenBody extends StatefulWidget { - const InspectorScreenBody({super.key, required this.controller}); - - final InspectorController controller; - - @override - InspectorScreenBodyState createState() => InspectorScreenBodyState(); -} - -class InspectorScreenBodyState extends State - with BlockingActionMixin, AutoDisposeMixin { - InspectorController get controller => widget.controller; - - InspectorTreeController get _inspectorTreeController => - controller.inspectorTree; - - bool searchVisible = false; - - SearchControllerMixin get searchController => _inspectorTreeController; - - /// Indicates whether search can be closed. The value is set to true when - /// search target type dropdown is displayed - /// TODO(https://github.com/flutter/devtools/issues/3489) use this variable when adding the scope dropdown - bool searchPreventClose = false; - - SearchTargetType searchTarget = SearchTargetType.widget; - - static const inspectorTreeKey = Key('Inspector Tree'); - static const minScreenWidthForText = 900.0; - - @override - void initState() { - super.initState(); - ga.screen(InspectorScreen.id); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - - if (serviceConnection.inspectorService == null) { - // The app must not be a Flutter app. - return; - } - - cancelListeners(); - searchVisible = searchController.search.isNotEmpty; - addAutoDisposeListener(searchController.searchFieldFocusNode, () { - final searchFieldFocusNode = searchController.searchFieldFocusNode; - if (searchFieldFocusNode == null) return; - // Close the search once focus is lost and following conditions are met: - // 1. Search string is empty. - // 2. [searchPreventClose] == false (this is set true when searchTargetType Dropdown is opened). - if (!searchFieldFocusNode.hasFocus && - searchController.search.isEmpty && - !searchPreventClose) { - setState(() { - searchVisible = false; - }); - } - - // Reset [searchPreventClose] state to false after the search field gains focus. - // Focus is returned automatically once the Dropdown menu is closed. - if (searchFieldFocusNode.hasFocus) { - searchPreventClose = false; - } - }); - addAutoDisposeListener(preferences.inspector.pubRootDirectories, () { - if (serviceConnection.serviceManager.connectedState.value.connected && - controller.firstInspectorTreeLoadCompleted) { - safeUnawaited(controller.refreshInspector()); - } - }); - - if (!controller.firstInspectorTreeLoadCompleted) { - ga.timeStart(InspectorScreen.id, gac.pageReady); - } - - _inspectorTreeController.setSearchTarget(searchTarget); - } - - @override - Widget build(BuildContext context) { - final inspectorTree = _buildInspectorTreeColumn(); - - final splitAxis = SplitPane.axisFor(context, 0.85); - final widgetTrees = SplitPane( - axis: splitAxis, - initialFractions: const [0.33, 0.67], - children: [ - inspectorTree, - WidgetDetails(controller: controller), - ], - ); - return Column( - children: [ - InspectorControls(controller: controller), - const SizedBox(height: intermediateSpacing), - Expanded(child: widgetTrees), - ], - ); - } - - Widget _buildInspectorTreeColumn() { - return LayoutBuilder( - builder: (context, constraints) { - return RoundedOutlinedBorder( - child: Column( - children: [ - InspectorTreeControls( - isSearchVisible: searchVisible, - constraints: constraints, - onRefreshInspectorPressed: _manualInspectorRefresh, - onSearchVisibleToggle: _onSearchVisibleToggle, - searchFieldBuilder: () => - StatelessSearchField( - controller: _inspectorTreeController, - searchFieldEnabled: true, - shouldRequestFocus: searchVisible, - supportsNavigation: true, - onClose: _onSearchVisibleToggle, - ), - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: serviceConnection.errorBadgeManager - .erroredItemsForPage(InspectorScreen.id), - builder: (_, LinkedHashMap errors, _) { - final inspectableErrors = - errors.map( - (key, value) => MapEntry( - key, - value as InspectableWidgetError, - ), - ) - as LinkedHashMap; - return Stack( - children: [ - InspectorTree( - key: inspectorTreeKey, - controller: controller, - treeController: _inspectorTreeController, - widgetErrors: inspectableErrors, - screenId: InspectorScreen.id, - ), - if (errors.isNotEmpty) - ValueListenableBuilder( - valueListenable: controller.selectedErrorIndex, - builder: (_, selectedErrorIndex, _) => Positioned( - top: 0, - right: 0, - child: ErrorNavigator( - errors: inspectableErrors, - errorIndex: selectedErrorIndex, - onSelectError: controller.selectErrorByIndex, - ), - ), - ), - ], - ); - }, - ), - ), - ], - ), - ); - }, - ); - } - - void _onSearchVisibleToggle() { - setState(() { - searchVisible = !searchVisible; - }); - _inspectorTreeController.resetSearch(); - } - - void _manualInspectorRefresh() { - ga.select( - gac.inspector, - gac.refresh, - screenMetricsProvider: () => InspectorScreenMetrics.v2(), - ); - unawaited( - blockWhileInProgress(() async { - await controller.refreshInspector(isManualRefresh: true); - }), - ); - } -} - -class InspectorTreeControls extends StatelessWidget { - const InspectorTreeControls({ - super.key, - required this.constraints, - required this.isSearchVisible, - required this.onRefreshInspectorPressed, - required this.onSearchVisibleToggle, - required this.searchFieldBuilder, - }); - - static const _searchBreakpoint = 375.0; - - final bool isSearchVisible; - final BoxConstraints constraints; - final VoidCallback onRefreshInspectorPressed; - final VoidCallback onSearchVisibleToggle; - final Widget Function() searchFieldBuilder; - - @override - Widget build(BuildContext context) { - return Column( - children: [ - _controlsContainer( - context, - Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: denseSpacing), - child: Text( - 'Widget Tree', - style: Theme.of(context).textTheme.titleMedium, - ), - ), - ...!isSearchVisible - ? [ - const Spacer(), - ToolbarAction( - icon: Icons.search, - onPressed: onSearchVisibleToggle, - tooltip: 'Search Tree', - ), - ] - : [ - constraints.maxWidth >= _searchBreakpoint - ? _buildSearchControls() - : const Spacer(), - ], - ToolbarAction( - icon: Icons.refresh, - onPressed: onRefreshInspectorPressed, - tooltip: 'Refresh Tree', - ), - ], - ), - ), - if (isSearchVisible && constraints.maxWidth < _searchBreakpoint) - _controlsContainer(context, Row(children: [_buildSearchControls()])), - ], - ); - } - - Container _controlsContainer(BuildContext context, Widget child) { - return Container( - height: defaultHeaderHeight, - decoration: BoxDecoration( - border: Border(bottom: defaultBorderSide(Theme.of(context))), - ), - child: child, - ); - } - - Widget _buildSearchControls() { - return Expanded( - child: SizedBox( - height: defaultTextFieldHeight, - child: searchFieldBuilder(), - ), - ); - } -} - -class ErrorNavigator extends StatelessWidget { - const ErrorNavigator({ - super.key, - required this.errors, - required this.errorIndex, - required this.onSelectError, - }); - - final LinkedHashMap errors; - - final int? errorIndex; - - final void Function(int) onSelectError; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final label = errorIndex != null - ? 'Error ${errorIndex! + 1}/${errors.length}' - : 'Errors: ${errors.length}'; - return Container( - color: colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: defaultSpacing, - vertical: denseSpacing, - ), - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: denseSpacing), - child: Text( - label, - style: TextStyle(color: colorScheme.onErrorContainer), - ), - ), - _ErrorNavigatorButton( - icon: Icons.keyboard_arrow_up, - onPressed: _previousError, - ), - _ErrorNavigatorButton( - icon: Icons.keyboard_arrow_down, - onPressed: _nextError, - ), - ], - ), - ), - ); - } - - void _previousError() { - var newIndex = errorIndex == null ? errors.length - 1 : errorIndex! - 1; - while (newIndex < 0) { - newIndex += errors.length; - } - - onSelectError(newIndex); - } - - void _nextError() { - final newIndex = errorIndex == null ? 0 : (errorIndex! + 1) % errors.length; - - onSelectError(newIndex); - } -} - -class _ErrorNavigatorButton extends StatelessWidget { - const _ErrorNavigatorButton({required this.icon, required this.onPressed}); - - final IconData icon; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - // This is required to force the button size. - height: defaultButtonHeight, - width: defaultButtonHeight, - child: IconButton( - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - splashRadius: defaultIconSize, - icon: Icon(icon), - color: Theme.of(context).colorScheme.onErrorContainer, - onPressed: onPressed, - ), - ); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/inspector_tree_controller.dart b/packages/devtools_app/lib/src/screens/inspector_v2/inspector_tree_controller.dart deleted file mode 100644 index a29c12f22e2..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/inspector_tree_controller.dart +++ /dev/null @@ -1,1542 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:async'; -import 'dart:collection'; -import 'dart:math'; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:logging/logging.dart'; - -import '../../shared/analytics/analytics.dart' as ga; -import '../../shared/analytics/constants.dart' as gac; -import '../../shared/analytics/metrics.dart'; -import '../../shared/console/eval/inspector_tree_v2.dart'; -import '../../shared/console/widgets/description.dart'; -import '../../shared/diagnostics/diagnostics_node.dart'; -import '../../shared/globals.dart'; -import '../../shared/managers/error_badge_manager.dart'; -import '../../shared/primitives/collapsible_mixin.dart'; -import '../../shared/primitives/diagnostics_text_styles.dart'; -import '../../shared/primitives/utils.dart'; -import '../../shared/ui/colors.dart'; -import '../../shared/ui/common_widgets.dart'; -import '../../shared/ui/search.dart'; -import '../../shared/ui/utils.dart'; -import '../../shared/utils/utils.dart'; -import 'inspector_controller.dart'; - -final _log = Logger('inspector_tree_controller'); - -/// Presents a [InspectorTreeNode]. -class _InspectorTreeRowWidget extends StatefulWidget { - /// Constructs a [_InspectorTreeRowWidget] that presents a line in the - /// Inspector tree. - const _InspectorTreeRowWidget({ - required super.key, - required this.row, - required this.inspectorTreeState, - this.error, - required this.scrollControllerX, - required this.viewportWidth, - }); - - final _InspectorTreeState inspectorTreeState; - - InspectorTreeNode get node => row.node; - final InspectorTreeRow row; - final ScrollController scrollControllerX; - final double viewportWidth; - - /// A [DevToolsError] that applies to the widget in this row. - /// - /// This will be null if there is no error for this row. - final DevToolsError? error; - - @override - _InspectorTreeRowState createState() => _InspectorTreeRowState(); -} - -class _InspectorTreeRowState extends State<_InspectorTreeRowWidget> - with TickerProviderStateMixin, CollapsibleAnimationMixin { - @override - Widget build(BuildContext context) { - return SizedBox( - height: inspectorRowHeight, - child: InspectorRowContent( - row: widget.row, - error: widget.error, - expandArrowAnimation: expandArrowAnimation, - controller: widget.inspectorTreeState.treeController!, - scrollControllerX: widget.scrollControllerX, - viewportWidth: widget.viewportWidth, - onToggle: () { - setExpanded(!isExpanded); - }, - ), - ); - } - - @override - bool get isExpanded => widget.node.isExpanded; - - @override - void onExpandChanged(bool expanded) { - setState(() { - final row = widget.row; - final treeController = widget.inspectorTreeState.treeController!; - treeController.refreshTree( - updateTreeAction: () { - if (expanded) { - treeController.onExpandRow(row); - } else { - treeController.onCollapseRow(row); - } - return true; - }, - ); - }); - } - - @override - bool shouldShow() => widget.node.shouldShow; -} - -class InspectorTreeController extends DisposableController - with SearchControllerMixin { - InspectorTreeController({this.gaId}) { - init(); - } - - /// Clients the controller notifies to trigger changes to the UI. - final _clients = {}; - - /// Identifier used when sending Google Analytics about events in this - /// [InspectorTreeController]. - final int? gaId; - - InspectorTreeNode createNode() => - InspectorTreeNode(whenDirty: _handleDirtyNode); - - SearchTargetType _searchTarget = SearchTargetType.widget; - int _rootSetCount = 0; - - @override - void init() { - super.init(); - ga.select( - gac.inspector, - gac.inspectorTreeControllerInitialized, - nonInteraction: true, - screenMetricsProvider: () => InspectorScreenMetrics.v2( - inspectorTreeControllerId: gaId, - rootSetCount: _rootSetCount, - rowCount: _rowsInTree.value.length, - ), - ); - } - - @override - void dispose() { - _rowsInTree.dispose(); - super.dispose(); - } - - void addClient(InspectorControllerClient value) { - final firstClient = _clients.isEmpty; - _clients.add(value); - if (firstClient) { - config.onClientActiveChange?.call(true); - } - } - - void removeClient(InspectorControllerClient value) { - _clients.remove(value); - if (_clients.isEmpty) { - config.onClientActiveChange?.call(false); - } - } - - void requestFocus() { - for (final client in _clients) { - client.requestFocus(); - } - } - - InspectorTreeNode? get root => _root; - InspectorTreeNode? _root; - - set root(InspectorTreeNode? node) { - if (node != null) { - _updateRows(node: node, updateSearchableRows: true); - } - _root = node; - - ga.select( - gac.inspector, - gac.inspectorTreeControllerRootChange, - nonInteraction: true, - screenMetricsProvider: () => InspectorScreenMetrics.v2( - inspectorTreeControllerId: gaId, - rootSetCount: ++_rootSetCount, - rowCount: _rowsInTree.value.length, - ), - ); - } - - InspectorTreeNode? get selection => _selection; - InspectorTreeNode? _selection; - - late final InspectorTreeConfig config; - - /// Refreshes the tree's rows if the return value of the [updateTreeAction] - /// callback is true. - void refreshTree({required bool Function() updateTreeAction}) { - final requiresRefresh = updateTreeAction(); - if (requiresRefresh) { - _updateRows(); - } - } - - bool setSelectedNode( - InspectorTreeNode? node, { - bool notifyFlutterInspector = false, - }) { - if (node == _selection) return false; - - _selection?.selected = false; - _selection = node; - _selection?.selected = true; - final configLocal = config; - if (configLocal.onSelectionChange != null) { - configLocal.onSelectionChange!( - notifyFlutterInspector: notifyFlutterInspector, - ); - } - return true; - } - - InspectorTreeNode? get hover => _hover; - InspectorTreeNode? _hover; - - double? lastContentWidth; - - InspectorTreeRow? _cachedSelectedRow; - - /// All cached rows of the tree. - /// - /// Similar to [rowsInTree] but: - /// * contains every row in the tree (including collapsed rows) - /// * items don't change when nodes are expanded or collapsed - /// * items are populated only when root is changed - final _searchableCachedRows = []; - - /// All the rows that should be displayed in the tree. - /// - /// The rows can be updated with a call to [_updateRows]. - ValueListenable> get rowsInTree => _rowsInTree; - final _rowsInTree = ValueNotifier>([]); - - /// Map from node to the index for that node's row in [rowsInTree]. - final _nodeToRowIndex = {}; - - /// Rebuilds the tree and updates [rowsInTree] with the new values. - /// - /// If [updateSearchableRows] is true, also updates [_searchableCachedRows] - /// with the new values. - void _updateRows({ - InspectorTreeNode? node, - bool updateSearchableRows = false, - }) { - if (disposed) return; - - // TODO(elliette): Consider only updating an [InspectorTreeNode]'s branch - // when it is marked as dirty, instead of the entire tree. See: - // https://github.com/flutter/devtools/issues/7980 - node ??= root; - if (node == null) return; - - final rows = _buildRows(node); - _rowsInTree.value = rows; - - // Build the reverse node-to-index map for faster lookups: - for (int i = 0; i < _rowsInTree.value.length; i++) { - final row = _rowsInTree.value[i]; - final node = row.node; - _nodeToRowIndex[node] = i; - } - - if (updateSearchableRows) { - final searchableRows = _buildRows( - node, - includeHiddenRows: true, - includeCollapsedRows: true, - ); - - _searchableCachedRows - ..clear() - ..addAll(searchableRows); - } - } - - /// Resets the state if the root has been marked as dirty. - void _handleDirtyNode(InspectorTreeNode node) { - if (node == root) { - _cachedSelectedRow = null; - lastContentWidth = null; - _updateRows(); - } - } - - void setSearchTarget(SearchTargetType searchTarget) { - _searchTarget = searchTarget; - refreshSearchMatches(); - } - - InspectorTreeRow? rowAtIndex(int index) => _rowsInTree.value.safeGet(index); - - double rowOffset(int index) { - return (rowAtIndex(index)?.depth ?? 0) * inspectorColumnIndent; - } - - List getPathFromSelectedRowToRoot() { - final selectedItem = _cachedSelectedRow?.node; - if (selectedItem == null) return []; - - final pathToRoot = [selectedItem]; - InspectorTreeNode? nextParentNode = selectedItem.parent; - while (nextParentNode != null) { - pathToRoot.add(nextParentNode); - nextParentNode = nextParentNode.parent; - } - return pathToRoot.reversed.toList(); - } - - set hover(InspectorTreeNode? node) { - if (node == _hover) { - return; - } - - _hover = node; - } - - void navigateUp() { - _navigateHelper(-1); - } - - void navigateDown() { - _navigateHelper(1); - } - - void navigateLeft() { - final selectionLocal = selection; - final diagnostic = selectionLocal?.diagnostic; - - final toggledHideableGroup = _maybeToggleHideableGroup( - diagnostic, - showGroup: false, - ); - if (toggledHideableGroup) return; - - // This logic is consistent with how IntelliJ handles tree navigation on - // on left arrow key press. - if (selectionLocal == null) { - _navigateHelper(-1); - return; - } - - refreshTree( - updateTreeAction: () { - if (selectionLocal.isExpanded) { - selectionLocal.isExpanded = false; - return true; - } - if (selectionLocal.parent != null) { - return setSelectedNode(selectionLocal.parent); - } - return false; - }, - ); - } - - void navigateRight() { - final selectionLocal = selection; - final diagnostic = selectionLocal?.diagnostic; - - final toggledHideableGroup = _maybeToggleHideableGroup( - diagnostic, - showGroup: true, - ); - if (toggledHideableGroup) return; - - // This logic is consistent with how IntelliJ handles tree navigation on - // on right arrow key press. - - if (selectionLocal == null || selectionLocal.isExpanded) { - _navigateHelper(1); - return; - } - - selectionLocal.isExpanded = true; - _updateRows(); - } - - void _navigateHelper(int indexOffset) { - if (_numRows == 0) return; - - refreshTree( - updateTreeAction: () { - final nodeToSelect = selection == null - ? root - : rowAtIndex( - (_rowIndexFromNode(selection!) + indexOffset).clamp( - 0, - _numRows - 1, - ), - )?.node; - setSelectedNode(nodeToSelect); - return true; - }, - ); - } - - /// Given [showGroup], toggles the visibility of a hideable group. - /// - /// Returns a [bool] representing whether or not the group was toggled. - bool _maybeToggleHideableGroup( - RemoteDiagnosticsNode? diagnostic, { - required bool showGroup, - }) { - final isHideableGroupLeader = - diagnostic != null && diagnostic.isHideableGroupLeader; - final shouldToggle = - isHideableGroupLeader && - (showGroup ? diagnostic.groupIsHidden : !diagnostic.groupIsHidden); - - if (shouldToggle) { - refreshTree( - updateTreeAction: () { - diagnostic.toggleHiddenGroup(); - return true; - }, - ); - return true; - } - - return false; - } - - static const horizontalPadding = 10.0; - - /// Returns the indentation of a row at the given [depth] in the inspector. - /// - /// This indentation roughly corresponds to the center of the icon next to the - /// widget name. - double getDepthIndent(int depth) { - // Note: depth is 0-based, therefore add 1. - return (depth + 1) * inspectorColumnIndent + horizontalPadding; - } - - double rowYTop(int index) { - return inspectorRowHeight * index; - } - - void nodeChanged(InspectorTreeNode node) { - node.isDirty = true; - } - - void removeNodeFromParent(InspectorTreeNode node) { - node.parent?.removeChild(node); - } - - void expandPath(InspectorTreeNode? node) { - _expandPath(node); - } - - void _expandPath(InspectorTreeNode? node) { - while (node != null) { - if (!node.isExpanded) { - node.isExpanded = true; - } - node = node.parent; - } - } - - void toggleHiddenGroup(InspectorTreeNode? node) { - final diagnostic = node?.diagnostic; - if (diagnostic != null) { - diagnostic.toggleHiddenGroup(); - } - } - - int get _numRows => _rowsInTree.value.length; - - int _rowIndexFromNode(InspectorTreeNode node) => _nodeToRowIndex[node] ?? -1; - - int _rowIndexFromOffset(double y) => max(0, y ~/ inspectorRowHeight); - - List _buildRows( - InspectorTreeNode node, { - bool includeHiddenRows = false, - bool includeCollapsedRows = false, - }) { - final rows = []; - - void buildRowsHelper( - InspectorTreeNode node, { - required int depth, - required List ticks, - }) { - final currentIdx = rows.length; - final isHidden = node.diagnostic?.isHidden ?? false; - if (!isHidden || includeHiddenRows) { - rows.add( - InspectorTreeRow( - node: node, - index: currentIdx, - ticks: ticks, - depth: depth, - lineToParent: - !node.isProperty && - currentIdx != 0 && - node.parent!.showLinesToChildren, - hasSingleChild: node.children.length == 1, - ), - ); - } - - if (!node.isExpanded && !includeCollapsedRows) return; - final children = node.children; - final parentDepth = depth; - final childrenDepth = children.length > 1 ? parentDepth + 1 : parentDepth; - for (final child in children) { - final shouldAddTick = - children.length > 1 && - children.last != child && - !children.last.isProperty && - node.diagnostic?.shouldIndent == true; - - buildRowsHelper( - child, - depth: childrenDepth, - ticks: [...ticks, if (shouldAddTick) parentDepth], - ); - } - } - - buildRowsHelper(node, depth: 0, ticks: []); - return rows; - } - - InspectorTreeRow? getRowForNode(InspectorTreeNode node) { - final rootLocal = root; - if (rootLocal == null) return null; - return rowAtIndex(_rowIndexFromNode(node)); - } - - InspectorTreeRow? rowForOffset(Offset offset) { - final rootLocal = root; - if (rootLocal == null) return null; - final row = _rowIndexFromOffset(offset.dy); - return row < _rowsInTree.value.length ? rowAtIndex(row) : null; - } - - void onExpandRow(InspectorTreeRow row) { - final onExpand = config.onExpand; - row.node.isExpanded = true; - if (onExpand != null) { - onExpand(row.node); - } - } - - void onCollapseRow(InspectorTreeRow row) { - row.node.isExpanded = false; - } - - void onSelectRow(InspectorTreeRow row) { - onSelectNode(row.node); - } - - void onSelectNode(InspectorTreeNode? node) { - setSelectedNode(node, notifyFlutterInspector: true); - ga.select( - gac.inspector, - gac.treeNodeSelection, - screenMetricsProvider: () => InspectorScreenMetrics.v2(), - ); - final diagnostic = node?.diagnostic; - if (diagnostic != null && diagnostic.groupIsHidden) { - diagnostic.hideableGroupLeader?.toggleHiddenGroup(); - } - expandPath(node); - } - - Rect getBoundingBox(InspectorTreeRow row) { - // For future reference: the bounding box likely needs to be in terms of - // positions after the current animations are complete so that computations - // to start animations to show specific widget scroll to where the target - // nodes will be displayed rather than where they are currently displayed. - final diagnostic = row.node.diagnostic; - // The node width is approximated since the widgets are not available at the - // time of calculating the bounding box. - final approximateNodeWidth = - DiagnosticsNodeDescription.approximateNodeWidth(diagnostic); - return Rect.fromLTWH( - getDepthIndent(row.depth), - rowYTop(row.index), - approximateNodeWidth, - inspectorRowHeight, - ); - } - - void scrollToRect(Rect targetRect) { - for (final client in _clients) { - client.waitForClientsThenScrollToRect(targetRect); - } - } - - /// Width each row in the tree should have ignoring its indent. - /// - /// Content in rows should wrap if it exceeds this width. - final rowWidth = 1200; - - /// Maximum indent of the tree in pixels. - double? _maxIndent; - - double get maxRowIndent { - if (lastContentWidth == null) { - double maxIndent = 0; - for (int i = 0; i < _numRows; i++) { - final row = rowAtIndex(i); - if (row != null) { - maxIndent = max(maxIndent, getDepthIndent(row.depth)); - } - } - lastContentWidth = maxIndent + maxIndent; - _maxIndent = maxIndent; - } - return _maxIndent!; - } - - void animateToTargets(List targets) { - Rect? targetRect; - - for (final target in targets) { - final row = getRowForNode(target); - if (row != null) { - final rowRect = getBoundingBox(row); - targetRect = targetRect == null - ? rowRect - : targetRect.expandToInclude(rowRect); - } - } - - if (targetRect == null || targetRect.isEmpty) return; - - scrollToRect(targetRect); - } - - bool expandPropertiesByDefault(DiagnosticsTreeStyle style) { - // This code matches the text style defaults for which styles are - // by default and which aren't. - switch (style) { - case DiagnosticsTreeStyle.none: - case DiagnosticsTreeStyle.singleLine: - case DiagnosticsTreeStyle.errorProperty: - return false; - - case DiagnosticsTreeStyle.sparse: - case DiagnosticsTreeStyle.offstage: - case DiagnosticsTreeStyle.dense: - case DiagnosticsTreeStyle.transition: - case DiagnosticsTreeStyle.error: - case DiagnosticsTreeStyle.whitespace: - case DiagnosticsTreeStyle.flat: - case DiagnosticsTreeStyle.shallow: - case DiagnosticsTreeStyle.truncateChildren: - return true; - } - } - - InspectorTreeNode setupInspectorTreeNode( - InspectorTreeNode node, - RemoteDiagnosticsNode diagnosticsNode, { - required bool expandChildren, - RemoteDiagnosticsNode? hideableGroupLeader, - }) { - node.diagnostic = diagnosticsNode; - final configLocal = config; - if (configLocal.onNodeAdded != null) { - configLocal.onNodeAdded!(node, diagnosticsNode); - } - final inHideableGroup = diagnosticsNode.inHideableGroup; - if (inHideableGroup && hideableGroupLeader != null) { - hideableGroupLeader.addHideableGroupSubordinate(diagnosticsNode); - } - - if (diagnosticsNode.hasChildren || - diagnosticsNode.inlineProperties.isNotEmpty) { - if (diagnosticsNode.childrenReady || !diagnosticsNode.hasChildren) { - final styleIsMultiline = expandPropertiesByDefault( - diagnosticsNode.style, - ); - setupChildren( - diagnosticsNode, - node, - node.diagnostic!.childrenNow, - expandChildren: expandChildren && styleIsMultiline, - hideableGroupLeader: inHideableGroup - ? (hideableGroupLeader ?? diagnosticsNode) - : null, - ); - } else { - node.clearChildren(); - node.appendChild(createNode()); - } - } - return node; - } - - void setupChildren( - RemoteDiagnosticsNode parent, - InspectorTreeNode treeNode, - List? children, { - required bool expandChildren, - RemoteDiagnosticsNode? hideableGroupLeader, - }) { - treeNode.isExpanded = expandChildren; - if (treeNode.children.isNotEmpty) { - // Only case supported is this is the loading node. - assert(treeNode.children.length == 1); - refreshTree( - updateTreeAction: () { - removeNodeFromParent(treeNode.children.first); - return true; - }, - ); - } - final inlineProperties = parent.inlineProperties; - - for (final property in inlineProperties) { - treeNode.appendChild( - setupInspectorTreeNode( - createNode(), - property, - // We are inside a property so only expand children if - // expandProperties is true. - expandChildren: false, - ), - ); - } - if (children != null) { - for (final child in children) { - treeNode.appendChild( - setupInspectorTreeNode( - createNode(), - child, - expandChildren: expandChildren, - hideableGroupLeader: child.inHideableGroup - ? hideableGroupLeader - : null, - ), - ); - } - } - } - - Future maybePopulateChildren(InspectorTreeNode treeNode) async { - final diagnostic = treeNode.diagnostic; - if (diagnostic != null && - diagnostic.hasChildren && - (treeNode.hasPlaceholderChildren || treeNode.children.isEmpty)) { - try { - final children = await diagnostic.children; - if (treeNode.hasPlaceholderChildren || treeNode.children.isEmpty) { - setupChildren(diagnostic, treeNode, children, expandChildren: true); - refreshTree( - updateTreeAction: () { - nodeChanged(treeNode); - if (treeNode == selection) { - expandPath(treeNode); - } - return true; - }, - ); - } - } catch (e, st) { - _log.shout(e, e, st); - } - } - } - - /* Search support */ - @override - void onMatchChanged(int index) { - refreshTree( - updateTreeAction: () { - onSelectRow(searchMatches.value[index]); - return true; - }, - ); - } - - @override - Duration get debounceDelay => const Duration(milliseconds: 300); - - @override - List matchesForSearch( - String search, { - bool searchPreviousMatches = false, - }) { - final matches = []; - - if (searchPreviousMatches) { - final previousMatches = searchMatches.value; - for (final previousMatch in previousMatches) { - if (previousMatch.node.diagnostic!.searchValue.caseInsensitiveContains( - search, - )) { - matches.add(previousMatch); - } - } - - if (matches.isNotEmpty) return matches; - } - - int debugStatsSearchOps = 0; - final debugStatsWidgets = _searchableCachedRows.length; - - final inspectorService = serviceConnection.inspectorService; - if (search.isEmpty || - inspectorService == null || - inspectorService.isDisposed) { - assert(() { - debugPrint('Search completed, no search'); - return true; - }()); - return matches; - } - - assert(() { - debugPrint('Search started: $_searchTarget'); - return true; - }()); - - for (final row in _searchableCachedRows) { - final diagnostic = row!.node.diagnostic; - if (diagnostic == null) continue; - - // Widget search begin - if (_searchTarget == SearchTargetType.widget) { - debugStatsSearchOps++; - if (diagnostic.searchValue.caseInsensitiveContains(search)) { - matches.add(row); - continue; - } - } - // Widget search end - } - - assert(() { - debugPrint( - 'Search completed with $debugStatsWidgets widgets, $debugStatsSearchOps ops', - ); - return true; - }()); - - return matches; - } -} - -extension RemoteDiagnosticsNodeExtension on RemoteDiagnosticsNode { - String get searchValue { - final description = toStringShort(); - final textPreview = json['textPreview']; - return textPreview is String - ? '$description ${textPreview.replaceAll('\n', ' ')}' - : description; - } -} - -abstract class InspectorControllerClient { - void scrollToRect(Rect rect); - - void waitForClientsThenScrollToRect(Rect rect, {int retries}); - - void requestFocus(); -} - -class InspectorTree extends StatefulWidget { - const InspectorTree({ - super.key, - required this.controller, - required this.treeController, - this.widgetErrors, - this.screenId, - }); - - final InspectorController controller; - - final InspectorTreeController? treeController; - - final LinkedHashMap? widgetErrors; - final String? screenId; - - @override - State createState() => _InspectorTreeState(); -} - -// AutomaticKeepAlive is necessary so that the tree does not get recreated when we switch tabs. -class _InspectorTreeState extends State - with - SingleTickerProviderStateMixin, - AutomaticKeepAliveClientMixin, - AutoDisposeMixin - implements InspectorControllerClient { - InspectorController get controller => widget.controller; - InspectorTreeController? get treeController => widget.treeController; - - late ScrollController _scrollControllerY; - late ScrollController _scrollControllerX; - Future? _currentAnimateY; - Rect? _currentAnimateTarget; - - AnimationController? _constraintDisplayController; - late FocusNode _focusNode; - - /// When autoscrolling, the number of rows to pad the target location with. - static const _scrollPadCount = 3; - - @override - void initState() { - super.initState(); - _scrollControllerX = ScrollController(); - _scrollControllerY = ScrollController(); - // TODO(devoncarew): Commented out as per flutter/devtools/pull/2001. - //_scrollControllerY.addListener(_onScrollYChange); - _constraintDisplayController = longAnimationController(this); - _focusNode = FocusNode(debugLabel: 'inspector-tree'); - autoDisposeFocusNode(_focusNode); - final mainIsolateState = - serviceConnection.serviceManager.isolateManager.mainIsolateState; - if (mainIsolateState != null) { - callOnceWhenReady( - trigger: mainIsolateState.isPaused, - callback: _bindToController, - readyWhen: (triggerValue) => !triggerValue, - ); - } - - WidgetsBinding.instance.addPostFrameCallback((_) { - controller.animateTo(controller.selectedNode.value); - }); - } - - @override - void didUpdateWidget(InspectorTree oldWidget) { - final oldTreeController = oldWidget.treeController; - if (oldTreeController != widget.treeController) { - oldTreeController?.removeClient(this); - - // TODO(elliette): Figure out if we can remove this. See explanation: - // https://github.com/flutter/devtools/pull/1290/files#r342399899. - cancelListeners(); - - _bindToController(); - } - super.didUpdateWidget(oldWidget); - } - - @override - void dispose() { - treeController?.removeClient(this); - _scrollControllerX.dispose(); - _scrollControllerY.dispose(); - _constraintDisplayController?.dispose(); - super.dispose(); - } - - @override - void requestFocus() { - _focusNode.requestFocus(); - } - - // TODO(devoncarew): Commented out as per flutter/devtools/pull/2001. - // void _onScrollYChange() { - // if (controller == null) return; - // - // // If the vertical position is already being animated we should not trigger - // // a new animation of the horizontal position as a more direct animation of - // // the horizontal position has already been triggered. - // if (currentAnimateY != null) return; - // - // final x = _computeTargetX(_scrollControllerY.offset); - // _scrollControllerX.animateTo( - // x, - // duration: defaultDuration, - // curve: defaultCurve, - // ); - // } - - @override - Future waitForClientsThenScrollToRect( - Rect rect, { - int retries = 5, - }) async { - if (_scrollControllerY.hasClients || _scrollControllerX.hasClients) { - return scrollToRect(rect); - } - if (retries == 0) return; - await Future.delayed(const Duration(milliseconds: 20)); - return waitForClientsThenScrollToRect(rect, retries: retries - 1); - } - - @override - Future scrollToRect(Rect rect) async { - if (rect == _currentAnimateTarget) { - // We are in the middle of an animation to this exact rectangle. - return; - } - - final initialX = rect.left; - final initialY = rect.top; - final yOffsetAtViewportTop = _scrollControllerY.hasClients - ? _scrollControllerY.offset - : _scrollControllerY.initialScrollOffset; - final xOffsetAtViewportLeft = _scrollControllerX.hasClients - ? _scrollControllerX.offset - : _scrollControllerX.initialScrollOffset; - - final viewPortInScrollControllerSpace = Rect.fromLTWH( - xOffsetAtViewportLeft, - yOffsetAtViewportTop, - safeViewportWidth, - safeViewportHeight, - ); - - // Decide to scroll based on whether the middle of the center-left half of - // the row is visible. See https://github.com/flutter/devtools/pull/8367. - final centerLeftHalf = Offset( - (rect.centerLeft.dx + rect.center.dx) / 2, - rect.center.dy, - ); - final isRectInViewPort = viewPortInScrollControllerSpace.contains( - centerLeftHalf, - ); - if (isRectInViewPort) { - // The rect is already in view, don't scroll - return; - } - - _currentAnimateTarget = rect; - - final targetY = _padTargetY(initialY: initialY); - if (_scrollControllerY.hasClients) { - _currentAnimateY = _scrollControllerY.animateTo( - targetY, - duration: longDuration, - curve: defaultCurve, - ); - } else { - _currentAnimateY = null; - _scrollControllerY = ScrollController(initialScrollOffset: targetY); - } - - final targetX = _padTargetX(initialX: initialX); - if (_scrollControllerX.hasClients) { - unawaited( - _scrollControllerX.animateTo( - targetX, - duration: longDuration, - curve: defaultCurve, - ), - ); - } else { - _scrollControllerX = ScrollController(initialScrollOffset: targetX); - } - - try { - await _currentAnimateY; - } catch (e) { - // Doesn't matter if the animation was cancelled. - } - _currentAnimateY = null; - _currentAnimateTarget = null; - } - - // TODO(jacobr): resolve cases where we need to know the viewport height - // before it is available so we don't need this approximation. - /// Placeholder viewport height to use if we don't yet know the real - /// viewport height. - static const _placeholderViewportSize = Size(1000.0, 1000.0); - - double get safeViewportHeight { - return _scrollControllerY.hasClients - ? _scrollControllerY.position.viewportDimension - : _placeholderViewportSize.height; - } - - double get safeViewportWidth { - return _scrollControllerX.hasClients - ? _scrollControllerX.position.viewportDimension - : _placeholderViewportSize.width; - } - - /// Pad [initialX] with the horizontal indentation of [padCount] rows. - double _padTargetX({ - required double initialX, - int padCount = _scrollPadCount, - }) { - return initialX - inspectorColumnIndent * padCount; - } - - /// Pad [initialY] so that a row would be placed in the vertical center of - /// the screen. - double _padTargetY({required double initialY}) { - return initialY - (safeViewportHeight / 2) + inspectorRowHeight / 2; - } - - /// Handle arrow keys for the InspectorTree. Ignore other key events so that - /// other widgets have a chance to respond to them. - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - if (!event.isKeyDownOrRepeat) return KeyEventResult.ignored; - - final treeControllerLocal = treeController!; - - if (event.logicalKey == LogicalKeyboardKey.arrowDown) { - treeControllerLocal.navigateDown(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { - treeControllerLocal.navigateUp(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { - treeControllerLocal.navigateLeft(); - return KeyEventResult.handled; - } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { - treeControllerLocal.navigateRight(); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - - void _bindToController() { - treeController?.addClient(this); - } - - @override - Widget build(BuildContext context) { - super.build(context); - final treeControllerLocal = treeController; - if (treeControllerLocal == null) { - // Indicate the tree is loading. - return const CenteredCircularProgressIndicator(); - } - - return ValueListenableBuilder>( - valueListenable: treeControllerLocal.rowsInTree, - builder: (context, rows, _) { - // Note: The inspector rows contain only the fake root node when the - // inspector tree is shutdown. - if (rows.length <= 1) { - // This works around a bug when Scrollbars are present on a short lived - // widget. - return const SizedBox(child: CenteredCircularProgressIndicator()); - } - - if (!controller.firstInspectorTreeLoadCompleted) { - final screenId = widget.screenId; - if (screenId != null) { - ga.timeEnd( - screenId, - gac.pageReady, - screenMetricsProvider: () => - InspectorScreenMetrics.v2(rowCount: rows.length), - ); - unawaited( - serviceConnection.sendDwdsEvent( - screen: screenId, - action: gac.pageReady, - ), - ); - } - controller.firstInspectorTreeLoadCompleted = true; - } - - return LayoutBuilder( - builder: (context, constraints) { - final viewportWidth = constraints.maxWidth; - final tree = Scrollbar( - thumbVisibility: true, - controller: _scrollControllerX, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _scrollControllerX, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: - treeControllerLocal.rowWidth + - treeControllerLocal.maxRowIndent, - ), - // TODO(kenz): this scrollbar needs to be sticky to the right side of - // the visible container - right now it is lined up to the right of - // the widest row (which is likely not visible). This may require some - // refactoring. - child: GestureDetector( - onTap: _focusNode.requestFocus, - child: Focus( - onKeyEvent: _handleKeyEvent, - autofocus: true, - focusNode: _focusNode, - child: OffsetScrollbar( - isAlwaysShown: true, - axis: Axis.vertical, - controller: _scrollControllerY, - offsetController: _scrollControllerX, - offsetControllerViewportDimension: viewportWidth, - child: ListView.custom( - itemExtent: inspectorRowHeight, - childrenDelegate: SliverChildBuilderDelegate(( - context, - index, - ) { - if (index == rows.length) { - return const SizedBox(height: inspectorRowHeight); - } - final row = treeControllerLocal.rowAtIndex(index)!; - final inspectorRef = - row.node.diagnostic?.valueRef.id; - return _InspectorTreeRowWidget( - key: PageStorageKey(row.node), - inspectorTreeState: this, - row: row, - scrollControllerX: _scrollControllerX, - viewportWidth: viewportWidth, - error: - widget.widgetErrors != null && - inspectorRef != null - ? widget.widgetErrors![inspectorRef] - : null, - ); - }, childCount: rows.length + 1), - controller: _scrollControllerY, - ), - ), - ), - ), - ), - ), - ); - - return tree; - }, - ); - }, - ); - } - - @override - bool get wantKeepAlive => true; -} - -Paint _defaultPaint(ColorScheme colorScheme) => Paint() - ..color = colorScheme.treeGuidelineColor - ..strokeWidth = chartLineStrokeWidth; - -/// The distance (on the x-axis) between the center of the widget icon and the -/// start of the row, as determined by a percentage of the -/// [inspectorColumnIndent]. -const _iconCenterToRowStartXDistancePercentage = 0.41; - -/// The distance (on the y-axis) between the bottom of the widget icon and the -/// top of the row, as determined by a percentage of the [inspectorRowHeight]. -const _iconBottomToRowTopYDistancePercentage = 0.75; - -/// The distance (on the y-axis) between the top of the child widget's icon and -/// the top of the current row, as determined by a percentage of the -/// [inspectorRowHeight]. -const _childIconTopToRowTopYDistancePercentage = 1.25; - -/// Custom painter that draws lines indicating how parent and child rows are -/// connected to each other. -/// -/// Each rows object contains a list of ticks that indicate the x coordinates of -/// vertical lines connecting other rows need to be drawn within the vertical -/// area of the current row. This approach has the advantage that a row contains -/// all information required to render all content within it but has the -/// disadvantage that the x coordinates of each line connecting rows must be -/// computed in advance. -class _RowPainter extends CustomPainter { - _RowPainter(this.row, this._controller, this.colorScheme); - - final InspectorTreeController _controller; - final InspectorTreeRow row; - final ColorScheme colorScheme; - - @override - void paint(Canvas canvas, Size size) { - final paint = _defaultPaint(colorScheme); - - final node = row.node; - final showExpandCollapse = node.showExpandCollapse; - const distanceFromIconCenterToRowStart = - inspectorColumnIndent * _iconCenterToRowStartXDistancePercentage; - for (final tick in row.ticks) { - final expandCollapseX = - _controller.getDepthIndent(tick) - distanceFromIconCenterToRowStart; - // Draw a vertical line for each tick identifying a connection between - // an ancestor of this node and some other node in the tree. - canvas.drawLine( - Offset(expandCollapseX, 0.0), - Offset(expandCollapseX, inspectorRowHeight), - paint, - ); - } - // If this row is itself connected to a parent then draw the L shaped line - // to make that connection. - if (row.lineToParent) { - final parentExpandCollapseX = - _controller.getDepthIndent(row.depth - 1) - - distanceFromIconCenterToRowStart; - final width = showExpandCollapse - ? inspectorColumnIndent * 0.45 - : inspectorColumnIndent * 0.6; - canvas.drawLine( - Offset(parentExpandCollapseX, 0.0), - Offset(parentExpandCollapseX, inspectorRowHeight * 0.5), - paint, - ); - canvas.drawLine( - Offset(parentExpandCollapseX, inspectorRowHeight * 0.5), - Offset(parentExpandCollapseX + width, inspectorRowHeight * 0.5), - paint, - ); - } - - // Draw a straight vertical line from current node's icon to the icon below - // it if either the current node: - // 1. is expanded (meaning its child is visible) and it only has one child - // (because multiple children get indented). - // 2. is NOT the first node in a hidden group of which the last hidden node - // in that group is childless (meaning that last node is at the end of a - // branch and therefore has nothing below it). - final expandedWithSingleChild = row.hasSingleChild && node.isExpanded; - final subordinates = - node.diagnostic?.hideableGroupSubordinates ?? []; - final groupIsHidden = node.diagnostic?.groupIsHidden ?? false; - final lastHiddenSubordinateHasNoChildren = - groupIsHidden && - subordinates.isNotEmpty && - subordinates.last.childrenNow.isEmpty; - if (expandedWithSingleChild && !lastHiddenSubordinateHasNoChildren) { - const distanceFromIconCenterToRowStart = - inspectorColumnIndent * _iconCenterToRowStartXDistancePercentage; - final iconCenterX = - _controller.getDepthIndent(row.depth) - - distanceFromIconCenterToRowStart; - // Draw a line from the bottom of the current row's icon to the top of the - // child row's icon: - canvas.drawLine( - Offset( - iconCenterX, - inspectorRowHeight * _iconBottomToRowTopYDistancePercentage, - ), - Offset( - iconCenterX, - inspectorRowHeight * _childIconTopToRowTopYDistancePercentage, - ), - paint, - ); - } - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) { - if (oldDelegate is _RowPainter) { - // TODO(jacobr): check whether the row has different ticks. - return oldDelegate.colorScheme.isLight != colorScheme.isLight; - } - return true; - } -} - -/// Widget defining the contents of a single row in the InspectorTree. -/// -/// This class defines the scaffolding around the rendering of the actual -/// content of a [RemoteDiagnosticsNode] provided by -/// [DiagnosticsNodeDescription] to provide a tree implementation with lines -/// drawn between parent and child nodes when nodes have multiple children. -/// -/// Changes to how the actual content of the node within the row should -/// be implemented by changing [DiagnosticsNodeDescription] instead. -class InspectorRowContent extends StatelessWidget { - const InspectorRowContent({ - super.key, - required this.row, - required this.controller, - required this.onToggle, - required this.expandArrowAnimation, - this.error, - required this.scrollControllerX, - required this.viewportWidth, - }); - - final InspectorTreeRow row; - final InspectorTreeController controller; - final VoidCallback onToggle; - final Animation expandArrowAnimation; - final ScrollController scrollControllerX; - final double viewportWidth; - - /// A [DevToolsError] that applies to the widget in this row. - /// - /// This will be null if there is no error for this row. - final DevToolsError? error; - - /// Whether this row has any error. - bool get hasError => error != null; - - @override - Widget build(BuildContext context) { - final currentX = - controller.getDepthIndent(row.depth) - inspectorColumnIndent; - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - Color? backgroundColor; - if (row.isSelected) { - backgroundColor = hasError - ? colorScheme.errorContainer - : colorScheme.selectedRowBackgroundColor; - } - - final node = row.node; - final diagnostic = node.diagnostic; - final isHideableGroupLeader = diagnostic?.isHideableGroupLeader ?? false; - const expandCollapseWidth = 14.0; - Widget rowWidget = Padding( - padding: EdgeInsets.only(left: currentX), - child: ValueListenableBuilder( - valueListenable: controller.searchNotifier, - builder: (context, searchValue, _) { - return Opacity( - opacity: searchValue.isEmpty || row.isSearchMatch ? 1 : 0.6, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - node.showExpandCollapse - ? InkWell( - onTap: onToggle, - child: RotationTransition( - turns: expandArrowAnimation, - child: const Icon( - Icons.expand_more, - size: defaultIconSize, - ), - ), - ) - : const SizedBox( - width: expandCollapseWidth, - height: defaultSpacing, - ), - Expanded( - child: Container( - color: backgroundColor, - child: InkWell( - onTap: () { - controller.refreshTree( - updateTreeAction: () { - controller.onSelectRow(row); - // TODO(gmoothart): It may be possible to capture the tap - // and request focus directly from the InspectorTree. Then - // we wouldn't need this. - controller.requestFocus(); - return true; - }, - ); - }, - child: SizedBox( - height: inspectorRowHeight, - child: DiagnosticsNodeDescription( - node.diagnostic, - isSelected: row.isSelected, - searchValue: searchValue, - errorText: error?.errorMessage, - emphasizeNodesFromLocalProject: true, - nodeDescriptionHighlightStyle: - searchValue.isEmpty || !row.isSearchMatch - ? DiagnosticsTextStyles.regular( - Theme.of(context).colorScheme, - ) - : row.isSelected - ? theme.searchMatchHighlightStyleFocused - : theme.searchMatchHighlightStyle, - actionLabel: isHideableGroupLeader - ? diagnostic!.groupIsHidden - ? '(expand)' - : '(collapse)' - : null, - actionCallback: isHideableGroupLeader - ? () { - controller.refreshTree( - updateTreeAction: () { - controller.toggleHiddenGroup(node); - return true; - }, - ); - } - : null, - customDescription: - isHideableGroupLeader && diagnostic!.groupIsHidden - ? '${diagnostic.hideableGroupSubordinates!.length + 1} more widgets...' - : null, - customIconName: - isHideableGroupLeader && diagnostic!.groupIsHidden - ? 'HiddenGroup' - : null, - ), - ), - ), - ), - ), - ], - ), - ); - }, - ), - ); - - // Wrap with tooltip if there is an error for this node's widget. - if (hasError) { - rowWidget = DevToolsTooltip( - message: error!.errorMessage, - child: rowWidget, - ); - } - - return CustomPaint( - painter: _RowPainter(row, controller, colorScheme), - size: Size(currentX, inspectorRowHeight), - child: Align( - alignment: Alignment.topLeft, - child: AnimatedBuilder( - animation: scrollControllerX, - builder: (context, child) { - final rowWidth = - scrollControllerX.offset + viewportWidth - defaultSpacing; - return SizedBox( - width: max(rowWidth, currentX + 100), - child: rowWidth > currentX ? child : const SizedBox(), - ); - }, - child: rowWidget, - ), - ), - ); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/box/box.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/box/box.dart deleted file mode 100644 index bfd22ddd984..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/box/box.dart +++ /dev/null @@ -1,368 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:flutter/material.dart'; - -import '../../../../shared/diagnostics/diagnostics_node.dart'; -import '../../../../shared/primitives/utils.dart'; -import '../../inspector_controller.dart'; -import '../../inspector_data_models.dart'; -import '../ui/free_space.dart'; -import '../ui/theme.dart'; -import '../ui/utils.dart'; -import '../ui/widget_constraints.dart'; -import '../ui/widgets_theme.dart'; - -class BoxLayoutExplorerWidget extends StatelessWidget { - const BoxLayoutExplorerWidget( - this.inspectorController, { - super.key, - required this.layoutProperties, - required this.selectedNode, - }); - - final InspectorController inspectorController; - final LayoutProperties? layoutProperties; - final RemoteDiagnosticsNode? selectedNode; - - @override - Widget build(BuildContext context) { - if (layoutProperties == null) { - final selectedNodeLocal = selectedNode; - return Center( - child: Text( - '${selectedNodeLocal?.description ?? 'Widget'} has no layout properties to display.', - textAlign: TextAlign.center, - overflow: TextOverflow.clip, - ), - ); - } - return LayoutBuilder(builder: _buildLayout); - } - - List _paddingWidgets({ - required LayoutProperties childProperties, - required LayoutProperties parentProperties, - required LayoutWidthsAndHeights widthsAndHeights, - required LayoutWidthsAndHeights displayWidthsAndHeights, - required ColorScheme colorScheme, - required Color widgetColor, - }) { - if (!widthsAndHeights.hasAnyPadding) return []; - - final LayoutWidthsAndHeights( - :topPadding, - :bottomPadding, - :leftPadding, - :rightPadding, - :hasTopPadding, - :hasBottomPadding, - :hasLeftPadding, - :hasRightPadding, - ) = widthsAndHeights; - - final displayWidgetHeight = displayWidthsAndHeights.widgetHeight; - final displayWidgetWidth = displayWidthsAndHeights.widgetWidth; - final displayTopPadding = displayWidthsAndHeights.topPadding; - final displayBottomPadding = displayWidthsAndHeights.bottomPadding; - final displayLeftPadding = displayWidthsAndHeights.leftPadding; - final displayRightPadding = displayWidthsAndHeights.rightPadding; - - final parentHeight = parentProperties.size.height; - final parentWidth = parentProperties.size.width; - - return [ - LayoutExplorerBackground(colorScheme: colorScheme), - PositionedBackgroundLabel( - labelText: _describeBoxName(parentProperties), - labelColor: widgetColor, - hasTopPadding: hasTopPadding, - hasBottomPadding: hasBottomPadding, - hasLeftPadding: hasLeftPadding, - hasRightPadding: hasRightPadding, - ), - if (hasLeftPadding) - PaddingVisualizerWidget( - RenderProperties( - axis: Axis.horizontal, - size: Size(displayLeftPadding, displayWidgetHeight), - offset: Offset(0, displayTopPadding), - realSize: Size(leftPadding, parentHeight), - layoutProperties: childProperties, - isFreeSpace: true, - ), - horizontal: true, - ), - if (hasTopPadding) - PaddingVisualizerWidget( - RenderProperties( - axis: Axis.horizontal, - size: Size(displayWidgetWidth, displayTopPadding), - offset: Offset(displayLeftPadding, 0), - realSize: Size(parentWidth, topPadding), - layoutProperties: childProperties, - isFreeSpace: true, - ), - horizontal: false, - ), - if (hasRightPadding) - PaddingVisualizerWidget( - RenderProperties( - axis: Axis.horizontal, - size: Size(displayRightPadding, displayWidgetHeight), - offset: Offset( - displayLeftPadding + displayWidgetWidth, - displayTopPadding, - ), - realSize: Size(rightPadding, parentHeight), - layoutProperties: childProperties, - isFreeSpace: true, - ), - horizontal: true, - ), - if (hasBottomPadding) - PaddingVisualizerWidget( - RenderProperties( - axis: Axis.horizontal, - size: Size(displayWidgetWidth, displayBottomPadding), - offset: Offset( - displayLeftPadding, - displayTopPadding + displayWidgetHeight, - ), - realSize: Size(parentWidth, bottomPadding), - layoutProperties: childProperties, - isFreeSpace: true, - ), - horizontal: false, - ), - ]; - } - - Widget _buildLayout(BuildContext context, BoxConstraints constraints) { - final propertiesLocal = layoutProperties!; - - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final parentProperties = propertiesLocal.parentLayoutProperties!; - - final child = LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - // Subtract out one pixel border on each side. - final availableHeight = constraints.maxHeight - 2; - final availableWidth = constraints.maxWidth - 2; - - final widgetWidths = propertiesLocal.widgetWidths; - final widgetHeights = propertiesLocal.widgetHeights; - - final widthsAndHeights = LayoutWidthsAndHeights( - widths: widgetWidths!, - heights: widgetHeights!, - ); - - final displayWidths = _simpleFractionalLayout( - availableSize: availableWidth, - sizes: widgetWidths, - ); - final displayHeights = _simpleFractionalLayout( - availableSize: availableHeight, - sizes: widgetHeights, - ); - final displayWidthsAndHeights = LayoutWidthsAndHeights( - widths: displayWidths, - heights: displayHeights, - ); - - final widgetColor = WidgetTheme.fromName( - propertiesLocal.node.description, - ).color; - return Column( - children: [ - Container( - width: constraints.maxWidth, - height: constraints.maxHeight, - decoration: BoxDecoration(border: Border.all(color: widgetColor)), - child: Stack( - children: [ - ..._paddingWidgets( - childProperties: propertiesLocal, - parentProperties: parentProperties, - widthsAndHeights: widthsAndHeights, - displayWidthsAndHeights: displayWidthsAndHeights, - colorScheme: colorScheme, - widgetColor: widgetColor, - ), - BoxChildVisualizer( - isSelected: true, - layoutProperties: propertiesLocal, - renderProperties: RenderProperties( - axis: Axis.horizontal, - size: Size( - displayWidthsAndHeights.widgetWidth, - displayWidthsAndHeights.widgetHeight, - ), - offset: Offset( - displayWidthsAndHeights.leftPadding, - displayWidthsAndHeights.topPadding, - ), - realSize: propertiesLocal.size, - layoutProperties: propertiesLocal, - ), - ), - ], - ), - ), - ], - ); - }, - ); - - return Container( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth, - maxHeight: constraints.maxHeight, - ), - child: child, - ); - } -} - -String _describeBoxName(LayoutProperties properties) => - properties.node.description ?? ''; - -/// Represents a box widget and its surrounding padding. -class BoxChildAndPaddingVisualizer extends StatelessWidget { - const BoxChildAndPaddingVisualizer({ - super.key, - required this.layoutProperties, - required this.renderProperties, - required this.isSelected, - }); - - final bool isSelected; - final LayoutProperties layoutProperties; - final RenderProperties renderProperties; - - LayoutProperties? get properties => renderProperties.layoutProperties; - - @override - Widget build(BuildContext context) { - final renderSize = renderProperties.size; - final renderOffset = renderProperties.offset; - - final propertiesLocal = properties!; - - return Positioned( - top: renderOffset.dy, - left: renderOffset.dx, - child: SizedBox( - width: safePositiveDouble(renderSize.width), - height: safePositiveDouble(renderSize.height), - child: WidgetVisualizer( - isSelected: isSelected, - layoutProperties: layoutProperties, - title: _describeBoxName(propertiesLocal), - // TODO(jacobr): consider surfacing the overflow size information - // if we determine - // overflowSide: properties.overflowSide, - - // We only show one child at a time so a large title is safe. - largeTitle: true, - child: VisualizeWidthAndHeightWithConstraints( - arrowHeadSize: arrowHeadSize, - properties: propertiesLocal, - warnIfUnconstrained: false, - child: const SizedBox.shrink(), - ), - ), - ), - ); - } -} - -/// Widget that represents and visualize a direct child of Flex widget. -class BoxChildVisualizer extends StatelessWidget { - const BoxChildVisualizer({ - super.key, - required this.layoutProperties, - required this.renderProperties, - required this.isSelected, - }); - - final bool isSelected; - final LayoutProperties layoutProperties; - final RenderProperties renderProperties; - - LayoutProperties? get properties => renderProperties.layoutProperties; - - @override - Widget build(BuildContext context) { - final renderSize = renderProperties.size; - final renderOffset = renderProperties.offset; - - final propertiesLocal = properties!; - - return Positioned( - top: renderOffset.dy, - left: renderOffset.dx, - child: SizedBox( - width: safePositiveDouble(renderSize.width), - height: safePositiveDouble(renderSize.height), - child: WidgetVisualizer( - isSelected: isSelected, - layoutProperties: layoutProperties, - title: _describeBoxName(propertiesLocal), - // TODO(jacobr): consider surfacing the overflow size information - // if we determine - // overflowSide: properties.overflowSide, - - // We only show one child at a time so a large title is safe. - largeTitle: true, - child: VisualizeWidthAndHeightWithConstraints( - arrowHeadSize: arrowHeadSize, - properties: propertiesLocal, - warnIfUnconstrained: false, - child: const SizedBox.shrink(), - ), - ), - ), - ); - } -} - -/// The percent of the visualizer dedicating to a single padding block when -/// the box child has multiple padding blocks. -const _narrowPaddingVisualizerPercent = 0.3; - -/// The percent of the visualizer dedicating to a single padding block when -/// the box child has only one padding block. -const _widePaddingVisualizerPercent = 0.35; - -/// Simplistic layout algorithm that will return [WidgetSizes] for the widget -/// display based on the display's [availableSize] and the real widget's -/// [WidgetSizes]. -/// -/// Uses a constant `paddingFraction` for the display padding, regardless of -/// the actual size. -WidgetSizes _simpleFractionalLayout({ - required double availableSize, - required WidgetSizes sizes, -}) { - final paddingASize = sizes.paddingA; - final paddingBSize = sizes.paddingB; - - final paddingFraction = paddingASize > 0 && paddingBSize > 0 - ? _narrowPaddingVisualizerPercent - : _widePaddingVisualizerPercent; - - final paddingAFraction = paddingASize > 0 ? paddingFraction : 0.0; - final paddingBFraction = paddingBSize > 0 ? paddingFraction : 0.0; - final widgetFraction = 1 - paddingAFraction - paddingBFraction; - - return ( - type: sizes.type, - paddingA: paddingAFraction * availableSize, - widgetSize: widgetFraction * availableSize, - paddingB: paddingBFraction * availableSize, - ); -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/flex/flex.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/flex/flex.dart deleted file mode 100644 index 3c3cb55e2ec..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/flex/flex.dart +++ /dev/null @@ -1,779 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:async'; -import 'dart:math' as math; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -import '../../../../shared/diagnostics/diagnostics_node.dart'; -import '../../../../shared/diagnostics/inspector_service.dart'; -import '../../../../shared/primitives/math_utils.dart'; -import '../../../../shared/ui/common_widgets.dart'; -import '../../inspector_data_models.dart'; -import '../ui/arrow.dart'; -import '../ui/free_space.dart'; -import '../ui/layout_explorer_widget.dart'; -import '../ui/theme.dart'; -import '../ui/utils.dart'; -import '../ui/widget_constraints.dart'; -import 'utils.dart'; - -// TODO(kenz): clean up this file so that we use helper widgets instead of -// methods that pass around build context. - -// TODO(kenz): densify the layout explorer visualization for flex widgets. - -const alignmentDropdownMaxSize = 140.0; - -class FlexLayoutExplorerWidget extends LayoutExplorerWidget { - const FlexLayoutExplorerWidget(super.inspectorController, {super.key}); - - static bool shouldDisplay(RemoteDiagnosticsNode node) { - return (node.isFlex) || (node.parent?.isFlex ?? false); - } - - @override - State createState() => - FlexLayoutExplorerWidgetState(); -} - -class FlexLayoutExplorerWidgetState - extends - LayoutExplorerWidgetState< - FlexLayoutExplorerWidget, - FlexLayoutProperties - > { - final scrollController = ScrollController(); - - Axis get direction => properties!.direction; - - ObjectGroup? get objectGroup => - properties!.node.objectGroupApi as ObjectGroup?; - - Color horizontalColor(ColorScheme colorScheme) => - properties!.isMainAxisHorizontal - ? colorScheme.mainAxisColor - : colorScheme.crossAxisColor; - - Color verticalColor(ColorScheme colorScheme) => properties!.isMainAxisVertical - ? colorScheme.mainAxisColor - : colorScheme.crossAxisColor; - - Color horizontalTextColor(ColorScheme colorScheme) => - properties!.isMainAxisHorizontal - ? colorScheme.mainAxisTextColor - : colorScheme.crossAxisTextColor; - - Color verticalTextColor(ColorScheme colorScheme) => - properties!.isMainAxisVertical - ? colorScheme.mainAxisTextColor - : colorScheme.crossAxisTextColor; - - String get flexType => properties!.type; - - @override - RemoteDiagnosticsNode? getRoot(RemoteDiagnosticsNode? node) { - if (node == null) return null; - if (!shouldDisplay(node)) return null; - if (node.isFlex) return node; - return node.parent; - } - - @override - bool shouldDisplay(RemoteDiagnosticsNode node) { - final selectedNodeLocal = selectedNode; - if (selectedNodeLocal == null) return false; - return FlexLayoutExplorerWidget.shouldDisplay(selectedNodeLocal); - } - - @override - AnimatedFlexLayoutProperties computeAnimatedProperties( - FlexLayoutProperties nextProperties, - ) { - return AnimatedFlexLayoutProperties( - // If an animation is in progress, freeze it and start animating from there, else start a fresh animation from widget.properties. - animatedProperties?.copyWith() as FlexLayoutProperties? ?? properties!, - nextProperties, - changeAnimation, - ); - } - - @override - FlexLayoutProperties computeLayoutProperties(RemoteDiagnosticsNode node) => - FlexLayoutProperties.fromDiagnostics(node); - - @override - void updateHighlighted(FlexLayoutProperties? newProperties) { - setState(() { - if (selectedNode!.isFlex) { - highlighted = newProperties; - } else { - final idx = - selectedNode?.parent?.childrenNow.indexOf(selectedNode!) ?? -1; - if (newProperties == null) return; - if (idx != -1) highlighted = newProperties.children[idx]; - } - }); - } - - Widget _buildAxisAlignmentDropdown(Axis axis, ThemeData theme) { - final colorScheme = theme.colorScheme; - final color = axis == direction - ? colorScheme.mainAxisTextColor - : colorScheme.crossAxisTextColor; - List alignmentEnumEntries; - Object? selected; - final propertiesLocal = properties!; - if (axis == direction) { - alignmentEnumEntries = MainAxisAlignment.values; - selected = propertiesLocal.mainAxisAlignment; - } else { - alignmentEnumEntries = CrossAxisAlignment.values.toList(growable: true); - if (propertiesLocal.textBaseline == null) { - // TODO(albertusangga): Look for ways to visualize baseline when it is null - alignmentEnumEntries.remove(CrossAxisAlignment.baseline); - } - selected = propertiesLocal.crossAxisAlignment; - } - return RotatedBox( - quarterTurns: axis == Axis.vertical ? 3 : 0, - child: Container( - constraints: const BoxConstraints( - maxWidth: alignmentDropdownMaxSize, - maxHeight: defaultButtonHeight, - ), - child: DropdownButton( - value: selected, - isDense: true, - isExpanded: true, - // Avoid showing an underline for the main axis and cross-axis drop downs. - underline: const SizedBox(), - iconEnabledColor: axis == propertiesLocal.direction - ? colorScheme.mainAxisColor - : colorScheme.crossAxisColor, - selectedItemBuilder: (context) { - return [ - for (final alignment in alignmentEnumEntries) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: Text( - alignment.name, - style: theme.regularTextStyleWithColor(color), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - ), - ), - SizedBox( - height: actionsIconSize, - width: actionsIconSize, - child: Image.asset( - (axis == direction) - ? mainAxisAssetImageUrl( - direction, - alignment as MainAxisAlignment, - ) - : crossAxisAssetImageUrl( - direction, - alignment as CrossAxisAlignment, - ), - height: axisAlignmentAssetImageHeight, - fit: BoxFit.fitHeight, - color: color, - ), - ), - ], - ), - ]; - }, - items: [ - for (final alignment in alignmentEnumEntries) - DropdownMenuItem( - value: alignment, - child: Container( - padding: const EdgeInsets.symmetric(vertical: margin), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Expanded( - child: Text( - alignment.name, - style: theme.regularTextStyleWithColor(color), - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - ), - ), - SizedBox( - height: actionsIconSize, - width: actionsIconSize, - child: Image.asset( - (axis == direction) - ? mainAxisAssetImageUrl( - direction, - alignment as MainAxisAlignment, - ) - : crossAxisAssetImageUrl( - direction, - alignment as CrossAxisAlignment, - ), - fit: BoxFit.fitHeight, - color: color, - ), - ), - ], - ), - ), - ), - ], - onChanged: (Object? newSelection) async { - // newSelection is an object instead of type here because - // the type is dependent on the `axis` parameter - // if the axis is the main axis the type should be [MainAxisAlignment] - // if the axis is the cross axis the type should be [CrossAxisAlignment] - FlexLayoutProperties changedProperties; - changedProperties = axis == direction - ? propertiesLocal.copyWith( - mainAxisAlignment: newSelection as MainAxisAlignment?, - ) - : propertiesLocal.copyWith( - crossAxisAlignment: newSelection as CrossAxisAlignment?, - ); - final valueRef = propertiesLocal.node.valueRef; - markAsDirty(); - await objectGroup!.invokeSetFlexProperties( - valueRef, - changedProperties.mainAxisAlignment, - changedProperties.crossAxisAlignment, - ); - }, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - if (properties == null) return const SizedBox(); - return Container( - margin: const EdgeInsets.all(margin), - padding: const EdgeInsets.only(bottom: margin, right: margin), - child: AnimatedBuilder( - animation: changeController, - builder: (context, _) { - return LayoutBuilder(builder: _buildLayout); - }, - ), - ); - } - - Widget _buildLayout(BuildContext context, BoxConstraints constraints) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final maxHeight = constraints.maxHeight; - final maxWidth = constraints.maxWidth; - final propertiesLocal = properties!; - final flexDescription = Align( - alignment: Alignment.centerLeft, - child: Container( - margin: const EdgeInsets.only( - top: mainAxisArrowIndicatorSize, - left: crossAxisArrowIndicatorSize + margin, - ), - child: InkWell( - onTap: () => unawaited(onTap(propertiesLocal)), - child: WidgetVisualizer( - isFlex: true, - title: flexType, - layoutProperties: propertiesLocal, - isSelected: highlighted == properties, - overflowSide: propertiesLocal.overflowSide, - hint: Container( - padding: const EdgeInsets.all(4.0), - child: Text( - 'Total Flex Factor: ${propertiesLocal.totalFlex.toInt()}', - style: theme.regularTextStyleWithColor(emphasizedTextColor), - overflow: TextOverflow.ellipsis, - ), - ), - child: VisualizeFlexChildren( - state: this, - properties: propertiesLocal, - children: children, - highlighted: highlighted, - scrollController: scrollController, - direction: direction, - ), - ), - ), - ), - ); - - final verticalAxisDescription = Align( - alignment: Alignment.bottomLeft, - child: Container( - margin: const EdgeInsets.only(top: mainAxisArrowIndicatorSize + margin), - width: crossAxisArrowIndicatorSize, - child: Column( - children: [ - Expanded( - child: ArrowWrapper.unidirectional( - arrowColor: verticalColor(colorScheme), - type: ArrowType.down, - child: Truncateable( - truncate: maxHeight <= minHeightToAllowTruncating, - child: RotatedBox( - quarterTurns: 3, - child: Text( - propertiesLocal.verticalDirectionDescription, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: theme.regularTextStyleWithColor( - verticalTextColor(colorScheme), - ), - ), - ), - ), - ), - ), - Truncateable( - truncate: maxHeight <= minHeightToAllowTruncating, - child: _buildAxisAlignmentDropdown(Axis.vertical, theme), - ), - ], - ), - ), - ); - - final horizontalAxisDescription = Align( - alignment: Alignment.topRight, - child: Container( - margin: const EdgeInsets.only( - left: crossAxisArrowIndicatorSize + margin, - ), - height: mainAxisArrowIndicatorSize, - child: Row( - children: [ - Expanded( - child: ArrowWrapper.unidirectional( - arrowColor: horizontalColor(colorScheme), - type: ArrowType.right, - child: Truncateable( - truncate: maxWidth <= minWidthToAllowTruncating, - child: Text( - propertiesLocal.horizontalDirectionDescription, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: theme.regularTextStyleWithColor( - horizontalTextColor(colorScheme), - ), - ), - ), - ), - ), - Truncateable( - truncate: maxWidth <= minWidthToAllowTruncating, - child: _buildAxisAlignmentDropdown(Axis.horizontal, theme), - ), - ], - ), - ), - ); - - return Container( - constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight), - child: Stack( - children: [ - flexDescription, - verticalAxisDescription, - horizontalAxisDescription, - ], - ), - ); - } -} - -class VisualizeFlexChildren extends StatefulWidget { - const VisualizeFlexChildren({ - super.key, - required this.state, - required this.properties, - required this.children, - required this.highlighted, - required this.scrollController, - required this.direction, - }); - - final FlexLayoutProperties properties; - final List children; - final LayoutProperties? highlighted; - final ScrollController scrollController; - final Axis direction; - final FlexLayoutExplorerWidgetState state; - - @override - State createState() => _VisualizeFlexChildrenState(); -} - -class _VisualizeFlexChildrenState extends State { - LayoutProperties? lastHighlighted; - static final selectedChildKey = GlobalKey(debugLabel: 'selectedChild'); - - @override - Widget build(BuildContext context) { - if (lastHighlighted != widget.highlighted) { - lastHighlighted = widget.highlighted; - if (widget.highlighted != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final selectedRenderObject = selectedChildKey.currentContext - ?.findRenderObject(); - if (selectedRenderObject != null && - widget.scrollController.hasClients) { - unawaited( - widget.scrollController.position.ensureVisible( - selectedRenderObject, - alignment: 0.5, - duration: defaultDuration, - ), - ); - } - }); - } - } - - if (!widget.properties.hasChildren) { - return const CenteredMessage(message: 'No Children'); - } - - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - final contents = Container( - decoration: BoxDecoration( - border: Border.all(color: theme.primaryColorLight), - ), - margin: const EdgeInsets.only(top: margin, left: margin), - child: LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final maxHeight = constraints.maxHeight; - - double maxSizeAvailable(Axis axis) { - return axis == Axis.horizontal ? maxWidth : maxHeight; - } - - final childrenAndMainAxisSpacesRenderProps = widget.properties - .childrenRenderProperties( - smallestRenderWidth: minRenderWidth, - largestRenderWidth: defaultMaxRenderWidth, - smallestRenderHeight: minRenderHeight, - largestRenderHeight: defaultMaxRenderHeight, - maxSizeAvailable: maxSizeAvailable, - ); - - final renderProperties = childrenAndMainAxisSpacesRenderProps - .where((renderProps) => !renderProps.isFreeSpace) - .toList(); - final mainAxisSpaces = childrenAndMainAxisSpacesRenderProps - .where((renderProps) => renderProps.isFreeSpace) - .toList(); - final crossAxisSpaces = widget.properties.crossAxisSpaces( - childrenRenderProperties: renderProperties, - maxSizeAvailable: maxSizeAvailable, - ); - - final childrenRenderWidgets = []; - Widget? selectedWidget; - for (var i = 0; i < widget.children.length; i++) { - final child = widget.children[i]; - final isSelected = widget.highlighted == child; - - final visualizer = FlexChildVisualizer( - key: isSelected ? selectedChildKey : null, - state: widget.state, - layoutProperties: child, - isSelected: isSelected, - renderProperties: renderProperties[i], - ); - - if (isSelected) { - selectedWidget = visualizer; - } else { - childrenRenderWidgets.add(visualizer); - } - } - - // Selected widget needs to be last to draw its border over other children - if (selectedWidget != null) { - childrenRenderWidgets.add(selectedWidget); - } - - final freeSpacesWidgets = [ - for (final renderProperties in [ - ...mainAxisSpaces, - ...crossAxisSpaces, - ]) - FreeSpaceVisualizerWidget(renderProperties), - ]; - return Scrollbar( - thumbVisibility: true, - controller: widget.scrollController, - child: SingleChildScrollView( - scrollDirection: widget.properties.direction, - controller: widget.scrollController, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: maxWidth, - minHeight: maxHeight, - maxWidth: widget.direction == Axis.horizontal - ? sum( - childrenAndMainAxisSpacesRenderProps.map( - (renderSize) => renderSize.width, - ), - ) - : maxWidth, - maxHeight: widget.direction == Axis.vertical - ? sum( - childrenAndMainAxisSpacesRenderProps.map( - (renderSize) => renderSize.height, - ), - ) - : maxHeight, - ).normalize(), - child: Stack( - children: [ - LayoutExplorerBackground(colorScheme: colorScheme), - ...freeSpacesWidgets, - ...childrenRenderWidgets, - ], - ), - ), - ), - ); - }, - ), - ); - return VisualizeWidthAndHeightWithConstraints( - properties: widget.properties, - child: contents, - ); - } -} - -/// Widget that represents and visualize a direct child of Flex widget. -class FlexChildVisualizer extends StatelessWidget { - const FlexChildVisualizer({ - super.key, - required this.state, - required this.layoutProperties, - required this.renderProperties, - required this.isSelected, - }); - - final FlexLayoutExplorerWidgetState state; - - final bool isSelected; - - final LayoutProperties layoutProperties; - - final RenderProperties renderProperties; - - // TODO(polina-c, jacob314): consider refactoring to remove `!`. - FlexLayoutProperties get root => state.properties!; - - LayoutProperties get properties => renderProperties.layoutProperties; - - ObjectGroup? get objectGroup => - properties.node.objectGroupApi as ObjectGroup?; - - void onChangeFlexFactor(int? newFlexFactor) async { - state.markAsDirty(); - await objectGroup!.invokeSetFlexFactor( - properties.node.valueRef, - newFlexFactor, - ); - } - - void onChangeFlexFit(FlexFit? newFlexFit) async { - state.markAsDirty(); - await objectGroup!.invokeSetFlexFit(properties.node.valueRef, newFlexFit!); - } - - Widget _buildFlexFactorChangerDropdown( - int maximumFlexFactor, - ThemeData theme, - ) { - final propertiesLocal = properties; - - Widget buildMenuitemChild(int? flexFactor) { - return Text( - 'flex: $flexFactor', - style: flexFactor == propertiesLocal.flexFactor - ? theme.boldTextStyle.copyWith(color: emphasizedTextColor) - : theme.regularTextStyleWithColor(emphasizedTextColor), - ); - } - - DropdownMenuItem buildMenuItem(int? flexFactor) { - return DropdownMenuItem( - value: flexFactor, - child: buildMenuitemChild(flexFactor), - ); - } - - return DropdownButton( - value: propertiesLocal.flexFactor?.toInt().clamp(0, maximumFlexFactor), - onChanged: onChangeFlexFactor, - iconEnabledColor: textColor, - underline: buildUnderline(), - items: >[ - buildMenuItem(null), - for (var i = 0; i <= maximumFlexFactor; ++i) buildMenuItem(i), - ], - ); - } - - Widget _buildFlexFitChangerDropdown(ThemeData theme) { - Widget flexFitDescription(FlexFit flexFit) => Text( - 'fit: ${flexFit.name}', - style: theme.regularTextStyleWithColor(emphasizedTextColor), - ); - - final propertiesLocal = properties; - - // Disable FlexFit changer if widget is Expanded. - if (propertiesLocal.description == 'Expanded') { - return flexFitDescription(FlexFit.tight); - } - - DropdownMenuItem buildMenuItem(FlexFit flexFit) { - return DropdownMenuItem( - value: flexFit, - child: flexFitDescription(flexFit), - ); - } - - return DropdownButton( - value: propertiesLocal.flexFit, - onChanged: onChangeFlexFit, - underline: buildUnderline(), - iconEnabledColor: emphasizedTextColor, - items: >[ - buildMenuItem(FlexFit.loose), - if (propertiesLocal.description != 'Expanded') - buildMenuItem(FlexFit.tight), - ], - ); - } - - Widget _buildContent(ThemeData theme) { - // TODO(https://github.com/flutter/devtools/issues/4058) allow more dynamic - // flex factor input - final currentFlexFactor = properties.flexFactor?.toInt() ?? 0; - final currentMaxFlexFactor = math.max( - currentFlexFactor, - maximumFlexFactorOptions, - ); - - return Container( - margin: const EdgeInsets.only(top: margin, left: margin), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Flexible( - child: _buildFlexFactorChangerDropdown(currentMaxFlexFactor, theme), - ), - if (!properties.hasFlexFactor) - Text( - 'unconstrained ${root.isMainAxisHorizontal ? 'horizontal' : 'vertical'}', - style: theme.regularTextStyle.copyWith( - color: theme.colorScheme.unconstrainedColor, - fontStyle: FontStyle.italic, - ), - maxLines: 2, - softWrap: true, - overflow: TextOverflow.ellipsis, - textScaler: const TextScaler.linear(smallTextScaleFactor), - textAlign: TextAlign.center, - ), - _buildFlexFitChangerDropdown(theme), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - final renderSize = renderProperties.size; - final renderOffset = renderProperties.offset; - final propertiesLocal = properties; - final rootLocal = root; - - Widget buildEntranceAnimation(BuildContext _, Widget? child) { - final vertical = rootLocal.isMainAxisVertical; - final horizontal = rootLocal.isMainAxisHorizontal; - - late Size size; - size = propertiesLocal.hasFlexFactor - ? SizeTween( - begin: Size( - horizontal ? minRenderWidth - entranceMargin : renderSize.width, - vertical ? minRenderHeight - entranceMargin : renderSize.height, - ), - end: renderSize, - ).evaluate(state.entranceCurve)! - : renderSize; - // Not-expanded widgets enter much faster. - return Opacity( - opacity: min([state.entranceCurve.value * 5, 1.0]), - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: math.max(0.0, (renderSize.width - size.width) / 2), - vertical: math.max(0.0, (renderSize.height - size.height) / 2), - ), - child: child, - ), - ); - } - - return Positioned( - top: renderOffset.dy, - left: renderOffset.dx, - child: GestureDetector( - onTap: () => unawaited(state.onTap(propertiesLocal)), - onDoubleTap: () => state.onDoubleTap(propertiesLocal), - onLongPress: () => state.onDoubleTap(propertiesLocal), - child: SizedBox( - width: renderSize.width, - height: renderSize.height, - child: AnimatedBuilder( - animation: state.entranceController, - builder: buildEntranceAnimation, - child: WidgetVisualizer( - isFlex: true, - isSelected: isSelected, - layoutProperties: layoutProperties, - title: propertiesLocal.description ?? '', - overflowSide: propertiesLocal.overflowSide, - child: VisualizeWidthAndHeightWithConstraints( - arrowHeadSize: arrowHeadSize, - properties: propertiesLocal, - child: Align( - alignment: Alignment.topRight, - child: _buildContent(Theme.of(context)), - ), - ), - ), - ), - ), - ), - ); - } - - /// define the number of flex factor to be shown in the flex dropdown button - /// for example if it's set to 5 the dropdown will consist of 6 items (null and 0..5) - static const maximumFlexFactorOptions = 5; -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/flex/utils.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/flex/utils.dart deleted file mode 100644 index 5e67d1e3117..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/flex/utils.dart +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; - -import '../../inspector_data_models.dart'; -import '../ui/utils.dart'; - -String crossAxisAssetImageUrl(Axis direction, CrossAxisAlignment alignment) { - return 'assets/img/layout_explorer/cross_axis_alignment/' - '${direction.flexType.toLowerCase()}_${alignment.name}.png'; -} - -String mainAxisAssetImageUrl(Axis direction, MainAxisAlignment alignment) { - return 'assets/img/layout_explorer/main_axis_alignment/' - '${direction.flexType.toLowerCase()}_${alignment.name}.png'; -} - -class AnimatedFlexLayoutProperties - extends AnimatedLayoutProperties - implements FlexLayoutProperties { - AnimatedFlexLayoutProperties(super.begin, super.end, super.animation); - - @override - CrossAxisAlignment? get crossAxisAlignment => end.crossAxisAlignment; - - @override - MainAxisAlignment? get mainAxisAlignment => end.mainAxisAlignment; - - @override - List childrenRenderProperties({ - required double smallestRenderWidth, - required double largestRenderWidth, - required double smallestRenderHeight, - required double largestRenderHeight, - required double Function(Axis) maxSizeAvailable, - }) { - final beginRenderProperties = begin.childrenRenderProperties( - smallestRenderHeight: smallestRenderHeight, - smallestRenderWidth: smallestRenderWidth, - largestRenderHeight: largestRenderHeight, - largestRenderWidth: largestRenderWidth, - maxSizeAvailable: maxSizeAvailable, - ); - final endRenderProperties = end.childrenRenderProperties( - smallestRenderHeight: smallestRenderHeight, - smallestRenderWidth: smallestRenderWidth, - largestRenderHeight: largestRenderHeight, - largestRenderWidth: largestRenderWidth, - maxSizeAvailable: maxSizeAvailable, - ); - final result = []; - for (var i = 0; i < children.length; i++) { - final beginProps = beginRenderProperties[i]; - final endProps = endRenderProperties[i]; - final t = animation.value; - result.add( - RenderProperties( - axis: endProps.axis, - offset: Offset.lerp(beginProps.offset, endProps.offset, t), - size: Size.lerp(beginProps.size, endProps.size, t), - realSize: Size.lerp(beginProps.realSize, endProps.realSize, t), - layoutProperties: AnimatedLayoutProperties( - beginProps.layoutProperties, - endProps.layoutProperties, - animation, - ), - ), - ); - } - // Add in the free space from the end. - // TODO(djshuckerow): We should make free space a part of - // RenderProperties so that we can animate between those. - result.addAll(endRenderProperties.where((prop) => prop.isFreeSpace)); - return result; - } - - @override - double get crossAxisDimension => lerpDouble( - begin.crossAxisDimension, - end.crossAxisDimension, - animation.value, - )!; - - @override - Axis get crossAxisDirection => end.crossAxisDirection; - - @override - List crossAxisSpaces({ - required List childrenRenderProperties, - required double Function(Axis) maxSizeAvailable, - }) { - return end.crossAxisSpaces( - childrenRenderProperties: childrenRenderProperties, - maxSizeAvailable: maxSizeAvailable, - ); - } - - @override - Axis get direction => end.direction; - - @override - String get horizontalDirectionDescription => - end.horizontalDirectionDescription; - - @override - bool get isMainAxisHorizontal => end.isMainAxisHorizontal; - - @override - bool get isMainAxisVertical => end.isMainAxisVertical; - - @override - double get mainAxisDimension => lerpDouble( - begin.mainAxisDimension, - end.mainAxisDimension, - animation.value, - )!; - - @override - MainAxisSize? get mainAxisSize => end.mainAxisSize; - - @override - TextBaseline? get textBaseline => end.textBaseline; - - @override - TextDirection get textDirection => end.textDirection; - - @override - double get totalFlex => - lerpDouble(begin.totalFlex, end.totalFlex, animation.value)!; - - @override - String get type => end.type; - - @override - VerticalDirection get verticalDirection => end.verticalDirection; - - @override - String get verticalDirectionDescription => end.verticalDirectionDescription; - - /// Returns a frozen copy of these FlexLayoutProperties that does not animate. - /// - /// Useful for interrupting an animation with a transition to another [FlexLayoutProperties]. - @override - FlexLayoutProperties copyWith({ - Size? size, - List? children, - BoxConstraints? constraints, - bool? isFlex, - String? description, - num? flexFactor, - FlexFit? flexFit, - Axis? direction, - MainAxisAlignment? mainAxisAlignment, - MainAxisSize? mainAxisSize, - CrossAxisAlignment? crossAxisAlignment, - TextDirection? textDirection, - VerticalDirection? verticalDirection, - TextBaseline? textBaseline, - }) { - return FlexLayoutProperties( - size: size ?? this.size, - children: children ?? this.children, - node: node, - constraints: constraints ?? this.constraints, - isFlex: isFlex ?? this.isFlex, - description: description ?? this.description, - flexFactor: flexFactor ?? this.flexFactor, - direction: direction ?? this.direction, - mainAxisAlignment: mainAxisAlignment ?? this.mainAxisAlignment, - mainAxisSize: mainAxisSize ?? this.mainAxisSize, - crossAxisAlignment: crossAxisAlignment ?? this.crossAxisAlignment, - textDirection: textDirection ?? this.textDirection, - verticalDirection: verticalDirection ?? this.verticalDirection, - textBaseline: textBaseline ?? this.textBaseline, - ); - } - - @override - bool get startIsTopLeft => end.startIsTopLeft; -} - -/// LayoutProperties extension to be reused on LayoutProperties and AnimatedLayoutProperties - -extension AxisExtension on Axis { - String get flexType { - switch (this) { - case Axis.horizontal: - return 'Row'; - case Axis.vertical: - return 'Column'; - } - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/arrow.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/arrow.dart deleted file mode 100644 index 3adb4e615cb..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/arrow.dart +++ /dev/null @@ -1,280 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:math'; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -const defaultArrowColor = Colors.white; -const defaultArrowStrokeWidth = 2.0; -const defaultDistanceToArrow = 4.0; - -enum ArrowType { up, left, right, down } - -Axis axis(ArrowType type) => (type == ArrowType.up || type == ArrowType.down) - ? Axis.vertical - : Axis.horizontal; - -/// Widget that draws a bidirectional arrow around another widget. -/// -/// This widget is typically used to help draw diagrams. -@immutable -class ArrowWrapper extends StatelessWidget { - ArrowWrapper.unidirectional({ - super.key, - this.child, - required ArrowType type, - this.arrowColor = defaultArrowColor, - double? arrowHeadSize, - this.arrowStrokeWidth = defaultArrowStrokeWidth, - this.childMarginFromArrow = defaultDistanceToArrow, - }) : assert(childMarginFromArrow > 0.0), - direction = axis(type), - isBidirectional = false, - startArrowType = type, - endArrowType = type, - arrowHeadSize = arrowHeadSize ?? defaultIconSize; - - const ArrowWrapper.bidirectional({ - super.key, - this.child, - required this.direction, - this.arrowColor = defaultArrowColor, - required this.arrowHeadSize, - this.arrowStrokeWidth = defaultArrowStrokeWidth, - this.childMarginFromArrow = defaultDistanceToArrow, - }) : assert(arrowHeadSize >= 0.0), - assert(childMarginFromArrow >= 0.0), - isBidirectional = true, - startArrowType = direction == Axis.horizontal - ? ArrowType.left - : ArrowType.up, - endArrowType = direction == Axis.horizontal - ? ArrowType.right - : ArrowType.down; - - final Color arrowColor; - final double arrowHeadSize; - final double arrowStrokeWidth; - final Widget? child; - - final Axis direction; - final double childMarginFromArrow; - - final bool isBidirectional; - final ArrowType startArrowType; - final ArrowType endArrowType; - - double get verticalMarginFromArrow { - if (child == null || direction == Axis.horizontal) return 0.0; - return childMarginFromArrow; - } - - double get horizontalMarginFromArrow { - if (child == null || direction == Axis.vertical) return 0.0; - return childMarginFromArrow; - } - - @override - Widget build(BuildContext context) { - return Flex( - direction: direction, - children: [ - Expanded( - child: Container( - margin: EdgeInsets.only( - bottom: verticalMarginFromArrow, - right: horizontalMarginFromArrow, - ), - child: ArrowWidget( - color: arrowColor, - headSize: arrowHeadSize, - strokeWidth: arrowStrokeWidth, - type: startArrowType, - shouldDrawHead: isBidirectional - ? true - : (startArrowType == ArrowType.left || - startArrowType == ArrowType.up), - ), - ), - ), - if (child != null) child!, - Expanded( - child: Container( - margin: EdgeInsets.only( - top: verticalMarginFromArrow, - left: horizontalMarginFromArrow, - ), - child: ArrowWidget( - color: arrowColor, - headSize: arrowHeadSize, - strokeWidth: arrowStrokeWidth, - type: endArrowType, - shouldDrawHead: isBidirectional - ? true - : (endArrowType == ArrowType.right || - endArrowType == ArrowType.down), - ), - ), - ), - ], - ); - } -} - -/// Widget that draws a fully sized, centered, unidirectional arrow according to its constraints -@immutable -class ArrowWidget extends StatelessWidget { - ArrowWidget({ - this.color = defaultArrowColor, - required this.headSize, - super.key, - this.shouldDrawHead = true, - this.strokeWidth = defaultArrowStrokeWidth, - required this.type, - }) : assert(headSize > 0.0), - assert(strokeWidth > 0.0), - _painter = _ArrowPainter( - headSize: headSize, - color: color, - strokeWidth: strokeWidth, - type: type, - shouldDrawHead: shouldDrawHead, - ); - - final Color color; - - /// The arrow head is a Equilateral triangle - final double headSize; - - final double strokeWidth; - - final ArrowType type; - - final CustomPainter _painter; - - final bool shouldDrawHead; - - @override - Widget build(BuildContext context) { - return CustomPaint(painter: _painter, child: Container()); - } -} - -class _ArrowPainter extends CustomPainter { - _ArrowPainter({ - required this.headSize, - this.strokeWidth = defaultArrowStrokeWidth, - this.color = defaultArrowColor, - this.shouldDrawHead = true, - required this.type, - }) : // the height of an equilateral triangle - headHeight = 0.5 * sqrt(3) * headSize; - - final Color color; - final double headSize; - final bool shouldDrawHead; - final double strokeWidth; - final ArrowType type; - - final double headHeight; - - bool headIsGreaterThanConstraint(Size size) { - if (type == ArrowType.left || type == ArrowType.right) { - return headHeight >= (size.width); - } - return headHeight >= (size.height); - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) => - !(oldDelegate is _ArrowPainter && - headSize == oldDelegate.headSize && - strokeWidth == oldDelegate.strokeWidth && - color == oldDelegate.color && - type == oldDelegate.type); - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..strokeWidth = strokeWidth; - - final originX = size.width / 2, originY = size.height / 2; - Offset lineStartingPoint = Offset.zero; - Offset lineEndingPoint = Offset.zero; - - if (!headIsGreaterThanConstraint(size) && shouldDrawHead) { - Offset p1, p2, p3; - final headSizeDividedBy2 = headSize / 2; - switch (type) { - case ArrowType.up: - p1 = Offset(originX, 0); - p2 = Offset(originX - headSizeDividedBy2, headHeight); - p3 = Offset(originX + headSizeDividedBy2, headHeight); - break; - case ArrowType.left: - p1 = Offset(0, originY); - p2 = Offset(headHeight, originY - headSizeDividedBy2); - p3 = Offset(headHeight, originY + headSizeDividedBy2); - break; - case ArrowType.right: - final startingX = size.width - headHeight; - p1 = Offset(size.width, originY); - p2 = Offset(startingX, originY - headSizeDividedBy2); - p3 = Offset(startingX, originY + headSizeDividedBy2); - break; - case ArrowType.down: - final startingY = size.height - headHeight; - p1 = Offset(originX, size.height); - p2 = Offset(originX - headSizeDividedBy2, startingY); - p3 = Offset(originX + headSizeDividedBy2, startingY); - break; - } - final path = Path() - ..moveTo(p1.dx, p1.dy) - ..lineTo(p2.dx, p2.dy) - ..lineTo(p3.dx, p3.dy) - ..close(); - canvas.drawPath(path, paint); - - switch (type) { - case ArrowType.up: - lineStartingPoint = Offset(originX, headHeight); - lineEndingPoint = Offset(originX, size.height); - break; - case ArrowType.left: - lineStartingPoint = Offset(headHeight, originY); - lineEndingPoint = Offset(size.width, originY); - break; - case ArrowType.right: - final arrowHeadStartingX = size.width - headHeight; - lineStartingPoint = Offset(0, originY); - lineEndingPoint = Offset(arrowHeadStartingX, originY); - break; - case ArrowType.down: - final headStartingY = size.height - headHeight; - lineStartingPoint = Offset(originX, 0); - lineEndingPoint = Offset(originX, headStartingY); - break; - } - } else { - // draw full line - switch (type) { - case ArrowType.up: - case ArrowType.down: - lineStartingPoint = Offset(originX, 0); - lineEndingPoint = Offset(originX, size.height); - break; - case ArrowType.left: - case ArrowType.right: - lineStartingPoint = Offset(0, originY); - lineEndingPoint = Offset(size.width, originY); - break; - } - } - canvas.drawLine(lineStartingPoint, lineEndingPoint, paint); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/dimension.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/dimension.dart deleted file mode 100644 index 7dfb1b8e2c1..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/dimension.dart +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:flutter/material.dart'; - -import 'theme.dart'; - -/// Text widget for displaying width / height. -Widget dimensionDescription( - TextSpan description, - bool overflow, - ColorScheme colorScheme, -) { - final text = Text.rich( - description, - textAlign: TextAlign.center, - style: overflow - ? overflowingDimensionIndicatorTextStyle(colorScheme) - : dimensionIndicatorTextStyle, - overflow: TextOverflow.ellipsis, - ); - if (overflow) { - return Container( - padding: const EdgeInsets.symmetric( - vertical: minPadding, - horizontal: overflowTextHorizontalPadding, - ), - decoration: BoxDecoration( - color: colorScheme.overflowBackgroundColor, - borderRadius: BorderRadius.circular(4.0), - ), - child: Center(child: text), - ); - } - return text; -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/free_space.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/free_space.dart deleted file mode 100644 index aeb3766ae9e..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/free_space.dart +++ /dev/null @@ -1,155 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -import '../../../../shared/primitives/utils.dart'; -import '../../inspector_data_models.dart'; -import 'arrow.dart'; -import 'dimension.dart'; -import 'theme.dart'; - -class FreeSpaceVisualizerWidget extends StatelessWidget { - const FreeSpaceVisualizerWidget(this.renderProperties, {super.key}); - - final RenderProperties renderProperties; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final heightDescription = - 'h=${toStringAsFixed(renderProperties.realHeight)}'; - final widthDescription = 'w=${toStringAsFixed(renderProperties.realWidth)}'; - final showWidth = - renderProperties.realWidth != renderProperties.layoutProperties.width; - final widthWidget = Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: dimensionDescription( - TextSpan(text: widthDescription), - false, - colorScheme, - ), - ), - Container( - margin: const EdgeInsets.symmetric(vertical: arrowMargin), - child: const ArrowWrapper.bidirectional( - arrowColor: widthIndicatorColor, - direction: Axis.horizontal, - arrowHeadSize: arrowHeadSize, - ), - ), - ], - ); - final heightWidget = SizedBox( - width: heightOnlyIndicatorSize, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: dimensionDescription( - TextSpan(text: heightDescription), - false, - colorScheme, - ), - ), - Container( - margin: const EdgeInsets.symmetric(horizontal: arrowMargin), - child: const ArrowWrapper.bidirectional( - arrowColor: heightIndicatorColor, - direction: Axis.vertical, - arrowHeadSize: arrowHeadSize, - childMarginFromArrow: 0.0, - ), - ), - ], - ), - ); - return Positioned( - top: renderProperties.offset.dy, - left: renderProperties.offset.dx, - child: SizedBox( - width: renderProperties.width, - height: renderProperties.height, - child: DevToolsTooltip( - message: '$widthDescription\n$heightDescription', - child: showWidth ? widthWidget : heightWidget, - ), - ), - ); - } -} - -class PaddingVisualizerWidget extends StatelessWidget { - const PaddingVisualizerWidget( - this.renderProperties, { - required this.horizontal, - super.key, - }); - - final RenderProperties renderProperties; - final bool horizontal; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final heightDescription = toStringAsFixed(renderProperties.realHeight); - final widthDescription = toStringAsFixed(renderProperties.realWidth); - final widthWidget = Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: dimensionDescription( - TextSpan(text: widthDescription), - false, - colorScheme, - ), - ), - Container( - margin: const EdgeInsets.symmetric(vertical: arrowMargin), - child: const ArrowWrapper.bidirectional( - arrowColor: widthIndicatorColor, - direction: Axis.horizontal, - arrowHeadSize: arrowHeadSize, - ), - ), - ], - ); - final heightWidget = SizedBox( - width: heightOnlyIndicatorSize, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Flexible( - child: dimensionDescription( - TextSpan(text: heightDescription), - false, - colorScheme, - ), - ), - Container( - margin: const EdgeInsets.symmetric(horizontal: arrowMargin), - child: const ArrowWrapper.bidirectional( - arrowColor: heightIndicatorColor, - direction: Axis.vertical, - arrowHeadSize: arrowHeadSize, - childMarginFromArrow: 0.0, - ), - ), - ], - ), - ); - return Positioned( - top: renderProperties.offset.dy, - left: renderProperties.offset.dx, - child: SizedBox( - width: safePositiveDouble(renderProperties.width), - height: safePositiveDouble(renderProperties.height), - child: horizontal ? widthWidget : heightWidget, - ), - ); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/layout_explorer_widget.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/layout_explorer_widget.dart deleted file mode 100644 index 1a9840f9ead..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/layout_explorer_widget.dart +++ /dev/null @@ -1,296 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:async'; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; - -import '../../../../shared/diagnostics/diagnostics_node.dart'; -import '../../../../shared/diagnostics/inspector_service.dart'; -import '../../../../shared/globals.dart'; -import '../../../../shared/primitives/utils.dart'; -import '../../inspector_controller.dart'; -import '../../inspector_data_models.dart'; -import 'utils.dart'; - -const maxRequestsPerSecond = 3.0; - -/// Base class for layout widgets for all widget types. -abstract class LayoutExplorerWidget extends StatefulWidget { - const LayoutExplorerWidget(this.inspectorController, {super.key}); - - final InspectorController inspectorController; -} - -/// Base class for state objects for layout widgets for all widget types. -abstract class LayoutExplorerWidgetState< - W extends LayoutExplorerWidget, - L extends LayoutProperties -> - extends State - with TickerProviderStateMixin - implements InspectorServiceClient { - LayoutExplorerWidgetState() { - _onSelectionChangedCallback = onSelectionChanged; - } - - late AnimationController entranceController; - late CurvedAnimation entranceCurve; - late AnimationController changeController; - - late CurvedAnimation changeAnimation; - - L? _previousProperties; - - L? _properties; - - InspectorObjectGroupManager? objectGroupManager; - - AnimatedLayoutProperties? get animatedProperties => _animatedProperties; - AnimatedLayoutProperties? _animatedProperties; - - L? get properties => - _previousProperties ?? _animatedProperties as L? ?? _properties; - - RemoteDiagnosticsNode? get selectedNode => - inspectorController.selectedNode.value?.diagnostic; - - InspectorController get inspectorController => widget.inspectorController; - - InspectorService? get inspectorService => - serviceConnection.inspectorService as InspectorService?; - - late RateLimiter rateLimiter; - - late Future Function() _onSelectionChangedCallback; - - Future onSelectionChanged() async { - if (!mounted) return; - final selectedNodeLocal = selectedNode; - if (selectedNodeLocal == null) return; - if (!shouldDisplay(selectedNodeLocal)) return; - final prevRootId = id(_properties?.node); - final newRootId = id(getRoot(selectedNodeLocal)); - final shouldFetch = prevRootId != newRootId; - if (shouldFetch) { - _dirty = false; - final newSelection = await fetchLayoutProperties(); - _setProperties(newSelection); - } else { - updateHighlighted(_properties); - } - } - - /// Whether this layout explorer can work with this kind of node. - bool shouldDisplay(RemoteDiagnosticsNode node); - - List get children => properties!.displayChildren; - - LayoutProperties? highlighted; - - /// Returns the root widget to show. - /// - /// For cases such as Flex widgets or in the future ListView widgets we may - /// want to show the layout for all widgets under a root that is the parent - /// of the current widget. - RemoteDiagnosticsNode? getRoot(RemoteDiagnosticsNode? node); - - Future fetchLayoutProperties() async { - objectGroupManager?.cancelNext(); - final manager = objectGroupManager!; - final nextObjectGroup = manager.next; - final node = await nextObjectGroup.getLayoutExplorerNode( - getRoot(selectedNode), - ); - if (node == null || node.renderObject == null) return null; - - if (!nextObjectGroup.disposed) { - assert(manager.next == nextObjectGroup); - manager.promoteNext(); - } - return computeLayoutProperties(node); - } - - L computeLayoutProperties(RemoteDiagnosticsNode node); - - AnimatedLayoutProperties computeAnimatedProperties(L nextProperties); - - void updateHighlighted(L? newProperties); - - String? id(RemoteDiagnosticsNode? node) => node?.valueRef.id; - - void _registerInspectorControllerService() { - inspectorController.selectedNode.addListener(_onSelectionChangedCallback); - inspectorService?.addClient(this); - } - - void _unregisterInspectorControllerService() { - inspectorController.selectedNode.removeListener( - _onSelectionChangedCallback, - ); - inspectorService?.removeClient(this); - } - - @override - void initState() { - super.initState(); - rateLimiter = RateLimiter(maxRequestsPerSecond, refresh); - _registerInspectorControllerService(); - _initAnimationStates(); - _updateObjectGroupManager(); - // TODO(jacobr): put inspector controller in Controllers and - // update on didChangeDependencies. - _animateProperties(); - } - - @override - void didUpdateWidget(W oldWidget) { - super.didUpdateWidget(oldWidget); - _updateObjectGroupManager(); - _animateProperties(); - if (oldWidget.inspectorController != inspectorController) { - _unregisterInspectorControllerService(); - _registerInspectorControllerService(); - } - } - - @override - void dispose() { - entranceController.dispose(); - changeController.dispose(); - _unregisterInspectorControllerService(); - super.dispose(); - } - - void _animateProperties() { - if (_animatedProperties != null) { - unawaited(changeController.forward()); - } - if (_previousProperties != null) { - unawaited(entranceController.reverse()); - } else { - unawaited(entranceController.forward()); - } - } - - // update selected widget in the device without triggering selection listener event. - // this is required so that we don't change focus - // when tapping on a child is also Flex-based widget. - Future setSelectionInspector(RemoteDiagnosticsNode node) async { - final service = node.objectGroupApi; - if (service != null && service is ObjectGroup) { - await service.setSelectionInspector(node.valueRef, false); - } - } - - // update selected widget and trigger selection listener event to change focus. - void refreshSelection(RemoteDiagnosticsNode node) { - inspectorController.refreshSelection(node); - } - - Future onTap(LayoutProperties properties) async { - setState(() => highlighted = properties); - await setSelectionInspector(properties.node); - } - - void onDoubleTap(LayoutProperties properties) { - refreshSelection(properties.node); - } - - Future refresh() async { - if (!_dirty) return; - _dirty = false; - final updatedProperties = await fetchLayoutProperties(); - if (updatedProperties != null) { - _changeProperties(updatedProperties); - } - } - - void _changeProperties(L nextProperties) { - if (!mounted) return; - updateHighlighted(nextProperties); - setState(() { - _animatedProperties = computeAnimatedProperties(nextProperties); - unawaited(changeController.forward(from: 0.0)); - }); - } - - void _setProperties(L? newProperties) { - if (!mounted) return; - updateHighlighted(newProperties); - if (_properties == newProperties) { - return; - } - setState(() { - _previousProperties ??= _properties; - _properties = newProperties; - }); - _animateProperties(); - } - - void _initAnimationStates() { - entranceController = longAnimationController(this) - ..addStatusListener((status) { - if (status == AnimationStatus.dismissed) { - setState(() { - _previousProperties = null; - unawaited(entranceController.forward()); - }); - } - }); - entranceCurve = defaultCurvedAnimation(entranceController); - changeController = longAnimationController(this) - ..addStatusListener((status) { - if (status == AnimationStatus.completed) { - setState(() { - _properties = _animatedProperties!.end; - _animatedProperties = null; - changeController.value = 0.0; - }); - } - }); - changeAnimation = defaultCurvedAnimation(changeController); - } - - void _updateObjectGroupManager() { - final service = serviceConnection.inspectorService; - if (service != objectGroupManager?.inspectorService) { - objectGroupManager = InspectorObjectGroupManager( - service as InspectorService, - 'flex-layout', - ); - } - unawaited(onSelectionChanged()); - } - - bool _dirty = false; - - @override - void onFlutterFrame() { - if (!mounted) return; - if (_dirty) { - rateLimiter.scheduleRequest(); - } - } - - // TODO(albertusangga): Investigate why onForceRefresh is not getting called. - @override - Future onForceRefresh() async { - final properties = await fetchLayoutProperties(); - if (properties != null) { - _setProperties(properties); - } - } - - /// Currently this is not working so we should listen to controller selection event instead. - @override - Future onInspectorSelectionChanged() async {} - - /// Register callback to be executed once Flutter frame is ready. - void markAsDirty() { - _dirty = true; - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/overflow_indicator_painter.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/overflow_indicator_painter.dart deleted file mode 100644 index 2295666ceb5..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/overflow_indicator_painter.dart +++ /dev/null @@ -1,63 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:ui' as ui; - -import 'package:flutter/rendering.dart'; - -import '../../inspector_data_models.dart'; - -/// CustomPainter for drawing [DebugOverflowIndicatorMixin]'s patterned background. -/// Draws overflow pattern on the [OverflowSide] of the widget. -/// -/// [DebugOverflowIndicatorMixin] can not be reused here -/// because it is a mixin on RenderObject and requires real overflows on the widget. -/// -/// If [side] is set to [OverflowSide.right], -/// the pattern will occupy the whole height -/// and the width will be the given [size]. -/// -/// If [side] is set to [OverflowSide.bottom], -/// the pattern will occupy the whole width -/// and the height will be the given [size]. -/// -/// See also: -/// * [DebugOverflowIndicatorMixin] -class OverflowIndicatorPainter extends CustomPainter { - const OverflowIndicatorPainter(this.side, this.size); - - final OverflowSide side; - final double size; - - /// These static variables are taken from [DebugOverflowIndicatorMixin] - /// since all of them are private. - static const black = Color(0xBF000000); - static const yellow = Color(0xBFFFFF00); - static final indicatorPaint = Paint() - ..shader = ui.Gradient.linear( - const Offset(0.0, 0.0), - const Offset(10.0, 10.0), - [black, yellow, yellow, black], - [0.25, 0.25, 0.75, 0.75], - TileMode.repeated, - ); - - @override - void paint(Canvas canvas, Size size) { - final bottomOverflow = OverflowSide.bottom == side; - final width = bottomOverflow ? size.width : this.size; - final height = !bottomOverflow ? size.height : this.size; - - final left = bottomOverflow ? 0.0 : size.width - width; - final top = side == OverflowSide.right ? 0.0 : size.height - height; - final rect = Rect.fromLTWH(left, top, width, height); - canvas.drawRect(rect, indicatorPaint); - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) { - return oldDelegate is OverflowIndicatorPainter && - (side != oldDelegate.side || size != oldDelegate.size); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/theme.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/theme.dart deleted file mode 100644 index 666a6d902ca..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/theme.dart +++ /dev/null @@ -1,139 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -const margin = 6.0; - -const arrowHeadSize = 8.0; -const arrowMargin = 4.0; -const arrowStrokeWidth = 1.5; - -/// Hardcoded sizes for scaling the flex children widget properly. -const minRenderWidth = 250.0; -const minRenderHeight = 250.0; - -const minPadding = 2.0; -const overflowTextHorizontalPadding = 8.0; - -/// The size to shrink a widget by when animating it in. -const entranceMargin = 50.0; - -const defaultMaxRenderWidth = 400.0; -const defaultMaxRenderHeight = 400.0; - -const widgetTitleMaxWidthPercentage = 0.75; - -/// Hardcoded arrow size respective to its cross axis (because it's unconstrained). -const heightAndConstraintIndicatorSize = 48.0; -const widthAndConstraintIndicatorSize = 56.0; -const mainAxisArrowIndicatorSize = 48.0; -const crossAxisArrowIndicatorSize = 48.0; - -const heightOnlyIndicatorSize = 72.0; - -/// Minimum size to display width/height inside the arrow -const minWidthToDisplayWidthInsideArrow = 200.0; -const minHeightToDisplayHeightInsideArrow = 200.0; - -const smallTextScaleFactor = 0.8; - -/// Height for limiting asset image (selected one in the drop down). -const axisAlignmentAssetImageHeight = 24.0; - -const minHeightToAllowTruncating = 375.0; -const minWidthToAllowTruncating = 375.0; - -// Story of Layout colors -const mainAxisLightColor = Color(0xff2c5daa); -const mainAxisDarkColor = Color(0xff2c5daa); - -const textColor = Color(0xff55767f); -const emphasizedTextColor = Color(0xff009aca); - -const crossAxisLightColor = Color(0xff8ac652); -const crossAxisDarkColor = Color(0xff8ac652); - -const mainAxisTextColorLight = Color(0xFF1375bc); -const mainAxisTextColorDark = Color(0xFF1375bc); - -const crossAxisTextColorLight = Color(0xFF66672C); -const crossAxisTextColorsDark = Color(0xFFB3D25A); - -const overflowBackgroundColorDark = Color(0xFFB00020); -const overflowBackgroundColorLight = Color(0xFFB00020); - -const overflowTextColorDark = Color(0xfff5846b); -const overflowTextColorLight = Color(0xffdea089); - -const backgroundColorSelectedDark = Color( - 0x4d474747, -); // TODO(jacobr): we would like Color(0x4dedeeef) but that makes the background show through. -const backgroundColorSelectedLight = Color(0x4dedeeef); - -extension LayoutExplorerColorScheme on ColorScheme { - Color get mainAxisColor => isLight ? mainAxisLightColor : mainAxisDarkColor; - - Color get widgetNameColor => isLight ? Colors.white : Colors.black; - - Color get crossAxisColor => - isLight ? crossAxisLightColor : crossAxisDarkColor; - - Color get mainAxisTextColor => - isLight ? mainAxisTextColorLight : mainAxisTextColorDark; - - Color get crossAxisTextColor => - isLight ? crossAxisTextColorLight : crossAxisTextColorsDark; - - Color get overflowBackgroundColor => - isLight ? overflowBackgroundColorLight : overflowBackgroundColorDark; - - Color get overflowTextColor => - isLight ? overflowTextColorLight : overflowTextColorDark; - - Color get backgroundColorSelected => - isLight ? backgroundColorSelectedLight : backgroundColorSelectedDark; - - Color get unconstrainedColor => - isLight ? unconstrainedLightColor : unconstrainedDarkColor; -} - -const backgroundColorDark = Color(0xff30302f); -const backgroundColorLight = Color(0xffffffff); - -const unconstrainedDarkColor = Color(0xffdea089); -const unconstrainedLightColor = Color(0xfff5846b); - -const widthIndicatorColor = textColor; -const heightIndicatorColor = textColor; - -const negativeSpaceDarkAssetName = - 'assets/img/layout_explorer/negative_space_dark.png'; -const negativeSpaceLightAssetName = - 'assets/img/layout_explorer/negative_space_light.png'; - -const dimensionIndicatorTextStyle = TextStyle( - height: 1.0, - letterSpacing: 1.1, - color: emphasizedTextColor, - fontSize: defaultFontSize, -); - -TextStyle overflowingDimensionIndicatorTextStyle(ColorScheme colorScheme) => - dimensionIndicatorTextStyle.merge( - TextStyle( - fontWeight: FontWeight.bold, - color: colorScheme.overflowTextColor, - ), - ); - -Widget buildUnderline() { - return Container( - height: 1.0, - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: textColor, width: 0.0)), - ), - ); -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/utils.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/utils.dart deleted file mode 100644 index 5b4b1ec2d4f..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/utils.dart +++ /dev/null @@ -1,615 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:ui'; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -import '../../../../shared/diagnostics/diagnostics_node.dart'; -import '../../../../shared/primitives/utils.dart'; -import '../../../inspector/layout_explorer/ui/dimension.dart'; -import '../../inspector_data_models.dart'; -import 'overflow_indicator_painter.dart'; -import 'theme.dart'; -import 'widgets_theme.dart'; - -/// A widget for positioning sized widgets that follows layout as follows: -/// | top | -/// left | center | right -/// | bottom | -@immutable -class BorderLayout extends StatelessWidget { - const BorderLayout({ - super.key, - this.left, - this.leftWidth, - this.top, - this.topHeight, - this.right, - this.rightWidth, - this.bottom, - this.bottomHeight, - this.center, - }) : assert( - left != null || - top != null || - right != null || - bottom != null || - center != null, - ); - - final Widget? center; - final Widget? top; - final Widget? left; - final Widget? right; - final Widget? bottom; - - final double? leftWidth; - final double? rightWidth; - final double? topHeight; - final double? bottomHeight; - - @override - Widget build(BuildContext context) { - return Stack( - children: [ - Center( - child: Container( - margin: EdgeInsets.only( - left: leftWidth ?? 0, - right: rightWidth ?? 0, - top: topHeight ?? 0, - bottom: bottomHeight ?? 0, - ), - child: center, - ), - ), - if (top != null) - Align( - alignment: Alignment.topCenter, - child: SizedBox(height: topHeight, child: top), - ), - if (left != null) - Align( - alignment: Alignment.centerLeft, - child: SizedBox(width: leftWidth, child: left), - ), - if (right != null) - Align( - alignment: Alignment.centerRight, - child: SizedBox(width: rightWidth, child: right), - ), - if (bottom != null) - Align( - alignment: Alignment.bottomCenter, - child: SizedBox(height: bottomHeight, child: bottom), - ), - ], - ); - } -} - -@immutable -class Truncateable extends StatelessWidget { - const Truncateable({super.key, this.truncate = false, required this.child}); - - final Widget child; - final bool truncate; - - @override - Widget build(BuildContext context) { - return Flexible(flex: truncate ? 1 : 0, child: child); - } -} - -/// Widget that draws bounding box with the title (usually widget name) in its -/// top left. -/// -/// * [hint] is an optional widget to be placed in the top right of the box. -/// * [child] is an optional widget to be placed in the center of the box. -class WidgetVisualizer extends StatelessWidget { - const WidgetVisualizer({ - super.key, - required this.title, - this.hint, - required this.isSelected, - required this.layoutProperties, - required this.child, - this.overflowSide, - this.largeTitle = false, - this.isFlex = false, - }); - - final LayoutProperties layoutProperties; - final String title; - final Widget child; - final Widget? hint; - final bool isSelected; - final bool largeTitle; - final bool isFlex; - - final OverflowSide? overflowSide; - - static const _overflowIndicatorSize = 20.0; - static const _borderUnselectedWidth = 1.0; - static const _borderSelectedWidth = 3.0; - static const _selectedPadding = 4.0; - - bool get drawOverflow => overflowSide != null; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final properties = layoutProperties; - final borderColor = WidgetTheme.fromName(properties.node.description).color; - final boxAdjust = isSelected ? _selectedPadding : 0.0; - - return LayoutBuilder( - builder: (context, constraints) { - return OverflowBox( - minWidth: constraints.minWidth + boxAdjust, - maxWidth: constraints.maxWidth + boxAdjust, - maxHeight: constraints.maxHeight + boxAdjust, - minHeight: constraints.minHeight + boxAdjust, - child: Container( - decoration: BoxDecoration( - border: Border.all( - color: borderColor, - width: isSelected - ? _borderSelectedWidth - : _borderUnselectedWidth, - ), - color: isSelected - ? theme.canvasColor.brighten() - : theme.canvasColor.darken(), - boxShadow: isSelected - ? [ - BoxShadow( - color: Colors.black.withAlpha(255 ~/ 2), - blurRadius: 10, - ), - ] - : null, - ), - child: Stack( - children: [ - if (drawOverflow) - Positioned.fill( - child: CustomPaint( - painter: OverflowIndicatorPainter( - overflowSide!, - _overflowIndicatorSize, - ), - ), - ), - Container( - margin: EdgeInsets.only( - right: overflowSide == OverflowSide.right - ? _overflowIndicatorSize - : 0.0, - bottom: overflowSide == OverflowSide.bottom - ? _overflowIndicatorSize - : 0.0, - ), - child: isFlex - ? FlexWidgetVisualizer( - title: title, - largeTitle: largeTitle, - borderColor: borderColor, - hint: hint, - child: child, - ) - : BoxWidgetVisualizer( - borderColor: borderColor, - title: title, - properties: properties, - ), - ), - ], - ), - ), - ); - }, - ); - } -} - -/// Visualizer display for a widget in a flex layout. -class FlexWidgetVisualizer extends StatelessWidget { - const FlexWidgetVisualizer({ - super.key, - required this.largeTitle, - required this.borderColor, - required this.title, - required this.hint, - required this.child, - }); - - final bool largeTitle; - final Color borderColor; - final String title; - final Widget? hint; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final hintLocal = hint; - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Container( - constraints: BoxConstraints( - maxWidth: largeTitle - ? defaultMaxRenderWidth - : minRenderWidth * widgetTitleMaxWidthPercentage, - ), - decoration: BoxDecoration(color: borderColor), - padding: const EdgeInsets.all(densePadding), - child: Center( - child: Text( - title, - style: theme.regularTextStyleWithColor( - colorScheme.widgetNameColor, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ), - ), - if (hintLocal != null) Flexible(child: hintLocal), - ], - ), - ), - Expanded(child: child), - ], - ); - } -} - -/// Visualizer display for a widget in a box layout. -class BoxWidgetVisualizer extends StatelessWidget { - const BoxWidgetVisualizer({ - super.key, - required this.borderColor, - required this.title, - required this.properties, - }); - - final Color borderColor; - final String title; - final LayoutProperties properties; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: WidgetLabel(labelColor: borderColor, labelText: title), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - dimensionDescription( - TextSpan(text: properties.describeHeight()), - false, - theme.colorScheme, - ), - dimensionDescription( - TextSpan(text: properties.describeWidth()), - false, - theme.colorScheme, - ), - ], - ), - ), - ], - ); - } -} - -/// A label for the widget in the layout explorer. -class WidgetLabel extends StatelessWidget { - const WidgetLabel({ - super.key, - required this.labelColor, - required this.labelText, - this.positionedAtBottom = false, - }); - - final Color labelColor; - final String labelText; - final bool positionedAtBottom; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - - return Container( - decoration: BoxDecoration(color: labelColor), - padding: EdgeInsets.fromLTRB( - densePadding, - positionedAtBottom ? borderPadding : 0.0, - densePadding, - positionedAtBottom ? 0.0 : borderPadding, - ), - child: Text( - labelText, - style: theme.regularTextStyleWithColor(colorScheme.widgetNameColor), - overflow: TextOverflow.ellipsis, - ), - ); - } -} - -class AnimatedLayoutProperties - implements LayoutProperties { - AnimatedLayoutProperties(this.begin, this.end, this.animation) - : assert(begin.children.length == end.children.length), - _children = [ - for (var i = 0; i < begin.children.length; i++) - AnimatedLayoutProperties( - begin.children[i], - end.children[i], - animation, - ), - ]; - - final T begin; - final T end; - final Animation animation; - final List _children; - - @override - LayoutProperties? get parent => end.parent; - - @override - set parent(LayoutProperties? parent) { - end.parent = parent; - } - - @override - LayoutProperties? get parentLayoutProperties => null; - - @override - WidgetSizes? get widgetWidths => null; - - @override - WidgetSizes? get widgetHeights => null; - - @override - List get children { - return _children; - } - - List _lerpList(List l1, List l2) { - assert(l1.length == l2.length); - if (l1.isEmpty) return []; - final animationLocal = animation; - return [ - for (var i = 0; i < children.length; i++) - lerpDouble(l1[i], l2[i], animationLocal.value)!, - ]; - } - - @override - List childrenDimensions(Axis axis) { - final beginDimensions = begin.childrenDimensions(axis); - final endDimensions = end.childrenDimensions(axis); - return _lerpList(beginDimensions, endDimensions); - } - - @override - List get childrenHeights => - _lerpList(begin.childrenHeights, end.childrenHeights); - - @override - List get childrenWidths => - _lerpList(begin.childrenWidths, end.childrenWidths); - - @override - BoxConstraints? get constraints { - try { - return BoxConstraints.lerp( - begin.constraints, - end.constraints, - animation.value, - ); - } catch (e) { - return end.constraints; - } - } - - @override - String describeWidthConstraints() { - final constraintsLocal = constraints!; - return constraintsLocal.hasBoundedWidth - ? LayoutProperties.describeAxis( - constraintsLocal.minWidth, - constraintsLocal.maxWidth, - 'w', - ) - : 'w=unconstrained'; - } - - @override - String describeHeightConstraints() { - final constraintsLocal = constraints!; - return constraintsLocal.hasBoundedHeight - ? LayoutProperties.describeAxis( - constraintsLocal.minHeight, - constraintsLocal.maxHeight, - 'h', - ) - : 'h=unconstrained'; - } - - @override - String describeWidth() => 'w=${toStringAsFixed(size.width)}'; - - @override - String describeHeight() => 'h=${toStringAsFixed(size.height)}'; - - @override - String? get description => end.description; - - @override - double dimension(Axis axis) { - return lerpDouble( - begin.dimension(axis), - end.dimension(axis), - animation.value, - )!; - } - - @override - num? get flexFactor => - lerpDouble(begin.flexFactor, end.flexFactor, animation.value); - - @override - bool get hasChildren => children.isNotEmpty; - - @override - double get height => size.height; - - @override - bool get isFlex => begin.isFlex && end.isFlex; - - @override - RemoteDiagnosticsNode get node => end.node; - - @override - Size get size { - return Size.lerp(begin.size, end.size, animation.value)!; - } - - @override - int get totalChildren => end.totalChildren; - - @override - double get width => size.width; - - @override - bool get hasFlexFactor => begin.hasFlexFactor && end.hasFlexFactor; - - @override - LayoutProperties copyWith({ - List? children, - BoxConstraints? constraints, - String? description, - int? flexFactor, - FlexFit? flexFit, - bool? isFlex, - Size? size, - }) { - return LayoutProperties.values( - node: node, - children: children ?? this.children, - constraints: constraints ?? this.constraints, - description: description ?? this.description, - flexFactor: flexFactor ?? this.flexFactor, - flexFit: flexFit ?? this.flexFit, - isFlex: isFlex ?? this.isFlex, - size: size ?? this.size, - ); - } - - @override - bool get isOverflowWidth => end.isOverflowWidth; - - @override - bool get isOverflowHeight => end.isOverflowHeight; - - @override - FlexFit? get flexFit => end.flexFit; - - @override - List get displayChildren => end.displayChildren; -} - -class LayoutExplorerBackground extends StatelessWidget { - const LayoutExplorerBackground({super.key, required this.colorScheme}); - - final ColorScheme colorScheme; - - @override - Widget build(BuildContext context) { - return Positioned.fill( - child: Opacity( - opacity: colorScheme.isLight ? 0.3 : 0.2, - child: Image.asset( - colorScheme.isLight - ? negativeSpaceLightAssetName - : negativeSpaceDarkAssetName, - fit: BoxFit.none, - repeat: ImageRepeat.repeat, - alignment: Alignment.topLeft, - ), - ), - ); - } -} - -/// Builds and positions a label for the [LayoutExplorerBackground] as -/// determined by the widget's padding. -class PositionedBackgroundLabel extends StatelessWidget { - const PositionedBackgroundLabel({ - super.key, - required this.labelText, - required this.labelColor, - required this.hasTopPadding, - required this.hasBottomPadding, - required this.hasLeftPadding, - required this.hasRightPadding, - }); - - final String labelText; - final Color labelColor; - final bool hasTopPadding; - final bool hasBottomPadding; - final bool hasLeftPadding; - final bool hasRightPadding; - - @override - Widget build(BuildContext context) { - return Column( - // Push to the bottom if there is no padding on the top. - mainAxisAlignment: !hasTopPadding && hasBottomPadding - ? MainAxisAlignment.end - : MainAxisAlignment.start, - children: [ - Row( - // Push to the right if there is no padding on the left. - mainAxisAlignment: (!hasLeftPadding && hasRightPadding) - ? MainAxisAlignment.end - : MainAxisAlignment.start, - children: [ - Flexible( - child: WidgetLabel( - labelColor: labelColor, - labelText: labelText, - positionedAtBottom: !hasTopPadding && hasBottomPadding, - ), - ), - ], - ), - ], - ); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/widget_constraints.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/widget_constraints.dart deleted file mode 100644 index 465a2cb6440..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/widget_constraints.dart +++ /dev/null @@ -1,177 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -import '../../../../shared/primitives/math_utils.dart'; -import '../../../../shared/primitives/utils.dart'; -import '../../inspector_data_models.dart'; -import 'arrow.dart'; -import 'dimension.dart'; -import 'theme.dart'; -import 'utils.dart'; - -class VisualizeWidthAndHeightWithConstraints extends StatelessWidget { - const VisualizeWidthAndHeightWithConstraints({ - super.key, - required this.properties, - double? arrowHeadSize, - required this.child, - this.warnIfUnconstrained = true, - }) : arrowHeadSize = arrowHeadSize ?? defaultIconSize; - - final Widget child; - final LayoutProperties properties; - final double arrowHeadSize; - final bool warnIfUnconstrained; - - @override - Widget build(BuildContext context) { - final propertiesLocal = properties; - final showChildrenWidthsSum = - propertiesLocal is FlexLayoutProperties && - propertiesLocal.isOverflowWidth; - const bottomHeight = widthAndConstraintIndicatorSize; - const rightWidth = heightAndConstraintIndicatorSize; - final colorScheme = Theme.of(context).colorScheme; - - final showOverflowHeight = - properties is FlexLayoutProperties && propertiesLocal.isOverflowHeight; - final heightDescription = RotatedBox( - quarterTurns: 1, - child: dimensionDescription( - TextSpan( - children: [ - TextSpan(text: propertiesLocal.describeHeight()), - if (propertiesLocal.constraints != null) ...[ - if (!showOverflowHeight) const TextSpan(text: '\n'), - TextSpan( - text: ' (${propertiesLocal.describeHeightConstraints()})', - style: - propertiesLocal.constraints!.hasBoundedHeight || - !warnIfUnconstrained - ? null - : TextStyle(color: colorScheme.unconstrainedColor), - ), - ], - if (showOverflowHeight) - TextSpan( - text: - '\nchildren take: ' - '${toStringAsFixed(sum(propertiesLocal.childrenHeights))}', - ), - ], - ), - propertiesLocal.isOverflowHeight, - colorScheme, - ), - ); - final right = Container( - margin: const EdgeInsets.only( - top: margin, - left: margin, - bottom: bottomHeight, - right: minPadding, // custom margin for not sticking to the corner - ), - child: LayoutBuilder( - builder: (context, constraints) { - final displayHeightOutsideArrow = - constraints.maxHeight < minHeightToDisplayHeightInsideArrow; - return Row( - children: [ - Truncateable( - truncate: !displayHeightOutsideArrow, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: arrowMargin), - child: ArrowWrapper.bidirectional( - arrowColor: heightIndicatorColor, - arrowStrokeWidth: arrowStrokeWidth, - arrowHeadSize: arrowHeadSize, - direction: Axis.vertical, - child: displayHeightOutsideArrow ? null : heightDescription, - ), - ), - ), - if (displayHeightOutsideArrow) Flexible(child: heightDescription), - ], - ); - }, - ), - ); - - final widthDescription = dimensionDescription( - TextSpan( - children: [ - TextSpan(text: '${propertiesLocal.describeWidth()}; '), - if (propertiesLocal.constraints != null) ...[ - if (!showChildrenWidthsSum) const TextSpan(text: '\n'), - TextSpan( - text: '(${propertiesLocal.describeWidthConstraints()})', - style: - propertiesLocal.constraints!.hasBoundedWidth || - !warnIfUnconstrained - ? null - : TextStyle(color: colorScheme.unconstrainedColor), - ), - ], - if (showChildrenWidthsSum) - TextSpan( - text: - '\nchildren take ' - '${toStringAsFixed(sum(propertiesLocal.childrenWidths))}', - ), - ], - ), - propertiesLocal.isOverflowWidth, - colorScheme, - ); - final bottom = Container( - margin: const EdgeInsets.only( - top: margin, - left: margin, - right: rightWidth, - bottom: minPadding, - ), - child: LayoutBuilder( - builder: (context, constraints) { - final maxWidth = constraints.maxWidth; - final displayWidthOutsideArrow = - maxWidth < minWidthToDisplayWidthInsideArrow; - return Column( - children: [ - Truncateable( - truncate: !displayWidthOutsideArrow, - child: Container( - margin: const EdgeInsets.symmetric(vertical: arrowMargin), - child: ArrowWrapper.bidirectional( - arrowColor: widthIndicatorColor, - arrowHeadSize: arrowHeadSize, - arrowStrokeWidth: arrowStrokeWidth, - direction: Axis.horizontal, - child: displayWidthOutsideArrow ? null : widthDescription, - ), - ), - ), - if (displayWidthOutsideArrow) - Flexible( - child: Container( - padding: const EdgeInsets.only(top: minPadding), - child: widthDescription, - ), - ), - ], - ); - }, - ), - ); - return BorderLayout( - center: child, - right: right, - rightWidth: rightWidth, - bottom: bottom, - bottomHeight: bottomHeight, - ); - } -} diff --git a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/widgets_theme.dart b/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/widgets_theme.dart deleted file mode 100644 index a492a1cddbb..00000000000 --- a/packages/devtools_app/lib/src/screens/inspector_v2/layout_explorer/ui/widgets_theme.dart +++ /dev/null @@ -1,248 +0,0 @@ -// Copyright 2024 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:flutter/material.dart'; - -class WidgetTheme { - const WidgetTheme({this.iconAsset, this.color = otherWidgetColor}); - - final String? iconAsset; - final Color color; - - static WidgetTheme fromName(String? widgetType) { - if (widgetType == null) { - return const WidgetTheme(); - } - - return themeMap[_stripBrackets(widgetType)] ?? const WidgetTheme(); - } - - /// Strips the brackets off the widget name. - /// - /// For example: `AnimatedBuilder` -> `AnimatedBuilder`. - static String _stripBrackets(String widgetType) { - final bracketIndex = widgetType.indexOf('<'); - if (bracketIndex == -1) { - return widgetType; - } - - return widgetType.substring(0, bracketIndex); - } - - static const contentWidgetColor = Color(0xff06AC3B); - static const highLevelWidgetColor = Color(0xffAEAEB1); - static const animationWidgetColor = Color(0xffE09D0E); - static const otherWidgetColor = Color(0xff0EA7E0); - - static const animatedTheme = WidgetTheme( - iconAsset: WidgetIcons.animated, - color: animationWidgetColor, - ); - - static const transitionTheme = WidgetTheme( - iconAsset: WidgetIcons.transition, - color: animationWidgetColor, - ); - - static const textTheme = WidgetTheme( - iconAsset: WidgetIcons.text, - color: contentWidgetColor, - ); - - static const imageTheme = WidgetTheme( - iconAsset: WidgetIcons.image, - color: contentWidgetColor, - ); - - static const tabTheme = WidgetTheme(iconAsset: WidgetIcons.tab); - static const scrollTheme = WidgetTheme(iconAsset: WidgetIcons.scroll); - static const highLevelTheme = WidgetTheme(color: highLevelWidgetColor); - static const listTheme = WidgetTheme(iconAsset: WidgetIcons.listView); - static const expandTheme = WidgetTheme(iconAsset: WidgetIcons.expand); - static const alignTheme = WidgetTheme(iconAsset: WidgetIcons.align); - static const gestureTheme = WidgetTheme(iconAsset: WidgetIcons.gesture); - static const textButtonTheme = WidgetTheme(iconAsset: WidgetIcons.textButton); - static const toggleTheme = WidgetTheme( - iconAsset: WidgetIcons.toggle, - color: contentWidgetColor, - ); - - static const themeMap = { - // High-level - 'RenderObjectToWidgetAdapter': WidgetTheme( - iconAsset: WidgetIcons.root, - color: highLevelWidgetColor, - ), - 'CupertinoApp': highLevelTheme, - 'MaterialApp': WidgetTheme(iconAsset: WidgetIcons.materialApp), - 'WidgetsApp': highLevelTheme, - - // Text - 'DefaultTextStyle': textTheme, - 'RichText': textTheme, - 'SelectableText': textTheme, - 'Text': textTheme, - - // Images - 'Icon': imageTheme, - 'Image': imageTheme, - 'RawImage': imageTheme, - - // Animations - 'AnimatedAlign': animatedTheme, - 'AnimatedBuilder': animatedTheme, - 'AnimatedContainer': animatedTheme, - 'AnimatedCrossFade': animatedTheme, - 'AnimatedDefaultTextStyle': animatedTheme, - 'AnimatedListState': animatedTheme, - 'AnimatedModalBarrier': animatedTheme, - 'AnimatedOpacity': animatedTheme, - 'AnimatedPhysicalModel': animatedTheme, - 'AnimatedPositioned': animatedTheme, - 'AnimatedSize': animatedTheme, - 'AnimatedWidget': animatedTheme, - - // Transitions - 'DecoratedBoxTransition': transitionTheme, - 'FadeTransition': transitionTheme, - 'PositionedTransition': transitionTheme, - 'RotationTransition': transitionTheme, - 'ScaleTransition': transitionTheme, - 'SizeTransition': transitionTheme, - 'SlideTransition': transitionTheme, - 'Hero': WidgetTheme( - iconAsset: WidgetIcons.hero, - color: animationWidgetColor, - ), - - // Scroll - 'CustomScrollView': scrollTheme, - 'DraggableScrollableSheet': scrollTheme, - 'SingleChildScrollView': scrollTheme, - 'Scrollable': scrollTheme, - 'Scrollbar': scrollTheme, - 'ScrollConfiguration': scrollTheme, - 'GridView': WidgetTheme(iconAsset: WidgetIcons.gridView), - 'ListView': listTheme, - 'ReorderableListView': listTheme, - 'NestedScrollView': listTheme, - - // Input - 'Checkbox': WidgetTheme( - iconAsset: WidgetIcons.checkbox, - color: contentWidgetColor, - ), - 'Radio': WidgetTheme( - iconAsset: WidgetIcons.radio, - color: contentWidgetColor, - ), - 'Switch': toggleTheme, - 'CupertinoSwitch': toggleTheme, - - // Layout - 'Container': WidgetTheme(iconAsset: WidgetIcons.container), - 'Center': WidgetTheme(iconAsset: WidgetIcons.center), - 'Row': WidgetTheme(iconAsset: WidgetIcons.row), - 'Column': WidgetTheme(iconAsset: WidgetIcons.column), - 'Padding': WidgetTheme(iconAsset: WidgetIcons.padding), - 'SizedBox': WidgetTheme(iconAsset: WidgetIcons.sizedBox), - 'ConstrainedBox': WidgetTheme(iconAsset: WidgetIcons.constrainedBox), - 'Align': alignTheme, - 'Positioned': alignTheme, - 'Expanded': expandTheme, - 'Flexible': expandTheme, - 'Stack': WidgetTheme(iconAsset: WidgetIcons.stack), - 'Wrap': WidgetTheme(iconAsset: WidgetIcons.wrap), - - // Buttons - 'FloatingActionButton': WidgetTheme( - iconAsset: WidgetIcons.floatingActionButton, - color: contentWidgetColor, - ), - 'InkWell': WidgetTheme(iconAsset: WidgetIcons.inkWell), - 'GestureDetector': gestureTheme, - 'RawGestureDetector': gestureTheme, - 'TextButton': textButtonTheme, - 'CupertinoButton': textButtonTheme, - 'ElevatedButton': textButtonTheme, - 'OutlinedButton': WidgetTheme(iconAsset: WidgetIcons.outlinedButton), - - // Tabs - 'Tab': tabTheme, - 'TabBar': tabTheme, - 'TabBarView': tabTheme, - 'BottomNavigationBar': WidgetTheme( - iconAsset: WidgetIcons.bottomNavigationBar, - ), - 'CupertinoTabScaffold': tabTheme, - 'CupertinoTabView': tabTheme, - - // Other - 'Scaffold': WidgetTheme(iconAsset: WidgetIcons.scaffold), - 'CircularProgressIndicator': WidgetTheme( - iconAsset: WidgetIcons.circularProgress, - ), - 'Card': WidgetTheme(iconAsset: WidgetIcons.card), - 'Divider': WidgetTheme(iconAsset: WidgetIcons.divider), - 'AlertDialog': WidgetTheme(iconAsset: WidgetIcons.alertDialog), - 'CircleAvatar': WidgetTheme(iconAsset: WidgetIcons.circleAvatar), - 'Opacity': WidgetTheme(iconAsset: WidgetIcons.opacity), - 'Drawer': WidgetTheme(iconAsset: WidgetIcons.drawer), - 'PageView': WidgetTheme(iconAsset: WidgetIcons.pageView), - 'Material': WidgetTheme(iconAsset: WidgetIcons.material), - 'AppBar': WidgetTheme(iconAsset: WidgetIcons.appBar), - }; -} - -class WidgetIcons { - static const root = 'icons/inspector/widget_icons/root.png'; - static const text = 'icons/inspector/widget_icons/text.png'; - static const icon = 'icons/inspector/widget_icons/icon.png'; - static const image = 'icons/inspector/widget_icons/image.png'; - static const floatingActionButton = - 'icons/inspector/widget_icons/floatingab.png'; - static const checkbox = 'icons/inspector/widget_icons/checkbox.png'; - static const radio = 'icons/inspector/widget_icons/radio.png'; - static const toggle = 'icons/inspector/widget_icons/toggle.png'; - static const animated = 'icons/inspector/widget_icons/animated.png'; - static const transition = 'icons/inspector/widget_icons/transition.png'; - static const hero = 'icons/inspector/widget_icons/hero.png'; - static const container = 'icons/inspector/widget_icons/container.png'; - static const center = 'icons/inspector/widget_icons/center.png'; - static const row = 'icons/inspector/widget_icons/row.png'; - static const column = 'icons/inspector/widget_icons/column.png'; - static const padding = 'icons/inspector/widget_icons/padding.png'; - static const scaffold = 'icons/inspector/widget_icons/scaffold.png'; - static const sizedBox = 'icons/inspector/widget_icons/sizedbox.png'; - static const align = 'icons/inspector/widget_icons/align.png'; - static const scroll = 'icons/inspector/widget_icons/scroll.png'; - static const stack = 'icons/inspector/widget_icons/stack.png'; - static const inkWell = 'icons/inspector/widget_icons/inkwell.png'; - static const gesture = 'icons/inspector/widget_icons/gesture.png'; - static const textButton = 'icons/inspector/widget_icons/textbutton.png'; - static const outlinedButton = - 'icons/inspector/widget_icons/outlinedbutton.png'; - static const gridView = 'icons/inspector/widget_icons/gridview.png'; - static const listView = 'icons/inspector/widget_icons/listView.png'; - - static const alertDialog = 'icons/inspector/widget_icons/alertdialog.png'; - static const card = 'icons/inspector/widget_icons/card.png'; - static const circleAvatar = 'icons/inspector/widget_icons/circleavatar.png'; - static const circularProgress = - 'icons/inspector/widget_icons/circularprogress.png'; - static const constrainedBox = - 'icons/inspector/widget_icons/constrainedbox.png'; - static const divider = 'icons/inspector/widget_icons/divider.png'; - static const drawer = 'icons/inspector/widget_icons/drawer.png'; - static const expand = 'icons/inspector/widget_icons/expand.png'; - static const material = 'icons/inspector/widget_icons/material.png'; - static const opacity = 'icons/inspector/widget_icons/opacity.png'; - static const tab = 'icons/inspector/widget_icons/tab.png'; - static const wrap = 'icons/inspector/widget_icons/wrap.png'; - static const pageView = 'icons/inspector/widget_icons/pageView.png'; - static const appBar = 'icons/inspector/widget_icons/appbar.png'; - static const materialApp = 'icons/inspector/widget_icons/materialapp.png'; - static const bottomNavigationBar = - 'icons/inspector/widget_icons/bottomnavigationbar.png'; -} diff --git a/packages/devtools_app/lib/src/screens/logging/_log_details.dart b/packages/devtools_app/lib/src/screens/logging/_log_details.dart index f277c54873c..452293c126b 100644 --- a/packages/devtools_app/lib/src/screens/logging/_log_details.dart +++ b/packages/devtools_app/lib/src/screens/logging/_log_details.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/globals.dart'; import '../../shared/preferences/preferences.dart'; @@ -43,6 +43,12 @@ class _LogDetailsState extends State unawaited(_computeLogDetails()); } + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + @override void didUpdateWidget(LogDetails oldWidget) { super.didUpdateWidget(oldWidget); diff --git a/packages/devtools_app/lib/src/screens/logging/_logs_table.dart b/packages/devtools_app/lib/src/screens/logging/_logs_table.dart index c406a428d45..28952153102 100644 --- a/packages/devtools_app/lib/src/screens/logging/_logs_table.dart +++ b/packages/devtools_app/lib/src/screens/logging/_logs_table.dart @@ -2,8 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/table/table.dart'; @@ -17,8 +16,6 @@ class LogsTable extends StatelessWidget { required this.controller, required this.data, required this.selectionNotifier, - required this.searchMatchesNotifier, - required this.activeSearchMatchNotifier, }); static const _logRowHeight = 45.0; @@ -26,8 +23,6 @@ class LogsTable extends StatelessWidget { final LoggingController controller; final List data; final ValueNotifier selectionNotifier; - final ValueListenable> searchMatchesNotifier; - final ValueListenable activeSearchMatchNotifier; static const whenColumn = WhenColumn(); static const messageColumn = MessageColumn(); diff --git a/packages/devtools_app/lib/src/screens/logging/_message_column.dart b/packages/devtools_app/lib/src/screens/logging/_message_column.dart index 96a6c916e70..23b63b2cad9 100644 --- a/packages/devtools_app/lib/src/screens/logging/_message_column.dart +++ b/packages/devtools_app/lib/src/screens/logging/_message_column.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/table/table.dart'; @@ -74,6 +74,7 @@ class MessageColumn extends ColumnData // The current summary is generally the first 200 chars of details. data.summary!, theme.regularTextStyle, + backgroundColor: theme.colorScheme.surface, ), if (hasSummary && hasDetails()) TextSpan(text: ' • ', style: theme.subtleTextStyle), @@ -81,6 +82,7 @@ class MessageColumn extends ColumnData ...textSpansFromAnsi( detailsComputed ? data.details! : '', theme.subtleTextStyle, + backgroundColor: theme.colorScheme.surface, ), ], ), diff --git a/packages/devtools_app/lib/src/screens/logging/logging_controller.dart b/packages/devtools_app/lib/src/screens/logging/logging_controller.dart index fa93986930c..0737a5a6c5a 100644 --- a/packages/devtools_app/lib/src/screens/logging/logging_controller.dart +++ b/packages/devtools_app/lib/src/screens/logging/logging_controller.dart @@ -27,7 +27,6 @@ import '../../shared/primitives/message_bus.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/ui/filter.dart'; import '../../shared/ui/search.dart'; -import '../inspector/inspector_tree_controller.dart'; import 'log_details_controller.dart'; import 'logging_screen.dart'; import 'metadata.dart'; @@ -40,12 +39,6 @@ final dateTimeFormat = DateFormat('HH:mm:ss.SSS (MM/dd/yy)'); bool _verboseDebugging = false; -typedef OnShowDetails = - void Function({String? text, InspectorTreeController? tree}); - -typedef CreateLoggingTree = - InspectorTreeController Function({VoidCallback? onSelectionChange}); - typedef ZoneDescription = ({String? name, int? identityHashCode}); Future _retrieveFullStringValue( @@ -240,6 +233,13 @@ class LoggingController extends DevToolsScreenController late final LogDetailsController logDetailsController; + /// Tracks the previous cumulative GC times (in seconds) for each active isolate. + /// + /// This is used to compute the actual duration of the current GC event (by taking + /// the delta between consecutive cumulative times), rather than displaying the + /// total cumulative GC time. + final _previousGcTimesByIsolate = {}; + List data = []; final selectedLog = ValueNotifier(null); @@ -288,18 +288,19 @@ class LoggingController extends DevToolsScreenController void clear() { _updateData([]); + _previousGcTimesByIsolate.clear(); serviceConnection.errorBadgeManager.clearErrorCount(LoggingScreen.id); } void _handleConnectionStart(VmServiceWrapper service) { // Log stdout events. - final stdoutHandler = _StdoutEventHandler(this, 'stdout'); + final stdoutHandler = StdoutEventHandler(this, 'stdout'); autoDisposeStreamSubscription( service.onStdoutEventWithHistorySafe.listen(stdoutHandler.handle), ); // Log stderr events. - final stderrHandler = _StdoutEventHandler(this, 'stderr', isError: true); + final stderrHandler = StdoutEventHandler(this, 'stderr', isError: true); autoDisposeStreamSubscription( service.onStderrEventWithHistorySafe.listen(stderrHandler.handle), ); @@ -455,11 +456,24 @@ class LoggingController extends DevToolsScreenController final usedBytes = newSpace.used! + oldSpace.used!; final capacityBytes = newSpace.capacity! + oldSpace.capacity!; - final time = ((newSpace.time! + oldSpace.time!) * 1000).round(); + final isolateId = e.isolate?.id; + // Cumulative time, in seconds. + final newCumulativeTime = newSpace.time! + oldSpace.time!; + + String durationText = ''; + if (isolateId != null) { + final previousGcTime = _previousGcTimesByIsolate[isolateId]; + _previousGcTimesByIsolate[isolateId] = newCumulativeTime; + if (previousGcTime != null && newCumulativeTime >= previousGcTime) { + // Multiply by 1000 to display in milliseconds. + final durationMs = (newCumulativeTime - previousGcTime) * 1000; + durationText = ' in ${durationMs.toStringAsFixed(1)} ms'; + } + } final summary = '${isolateRef['name']} • ' - '${e.json!['reason']} collection in $time ms • ' + '${e.json!['reason']} collection$durationText • ' '${printBytes(usedBytes, unit: ByteUnit.mb, includeUnit: true)} used of ' '${printBytes(capacityBytes, unit: ByteUnit.mb, includeUnit: true)}'; @@ -524,23 +538,23 @@ class LoggingController extends DevToolsScreenController _isNotNull(stackTrace)) { detailsComputer = () async { // Get the full string value of the message. - String result = await _retrieveFullStringValue( + final fullMessage = await _retrieveFullStringValue( service, e.isolate!, messageRef, ); + String? fullError; // Get information about the error object. Some users of the // dart:developer log call may pass a data payload in the `error` // field, encoded as a json encoded string, so handle that case. if (_isNotNull(error)) { if (error!.valueAsString != null) { - final errorString = await _retrieveFullStringValue( + fullError = await _retrieveFullStringValue( service, e.isolate!, error, ); - result += '\n\n$errorString'; } else { // Call `toString()` on the error object and display that. final toStringResult = await service!.invoke( @@ -552,25 +566,28 @@ class LoggingController extends DevToolsScreenController ); if (toStringResult is ErrorRef) { - final errorString = _valueAsString(error); - result += '\n\n$errorString'; + fullError = _valueAsString(error); } else if (toStringResult is InstanceRef) { - final str = await _retrieveFullStringValue( + fullError = await _retrieveFullStringValue( service, e.isolate!, toStringResult, ); - result += '\n\n$str'; } } } // Get info about the stackTrace object. - if (_isNotNull(stackTrace)) { - result += '\n\n${_valueAsString(stackTrace)}'; - } - - return result; + final fullStackTrace = _isNotNull(stackTrace) + ? _valueAsString(stackTrace) + : null; + + return computeDeveloperLogDetailsJson( + e.json!, + fullMessage: fullMessage, + fullError: fullError, + fullStackTrace: fullStackTrace, + ); }; } @@ -820,8 +837,6 @@ class LoggingController extends DevToolsScreenController } extension type _LogRecord(Map json) { - int? get sequenceNumber => json['sequenceNumber']; - int? get level => json['level']; Map get loggerName => json['loggerName']; @@ -841,44 +856,21 @@ extension type _LogRecord(Map json) { /// stdout message and its newline. Currently, `foo\n` is sent as two VM events; /// we wait for up to 1ms when we get the `foo` event, to see if the next event /// is a single newline. If so, we add the newline to the previous log message. -class _StdoutEventHandler { - _StdoutEventHandler( - this.loggingController, - this.name, { - this.isError = false, - }); +@visibleForTesting +class StdoutEventHandler { + StdoutEventHandler(this.loggingController, this.name, {this.isError = false}); final LoggingController loggingController; final String name; final bool isError; - LogData? buffer; - Timer? timer; + LogData? _buffer; + Timer? _timer; void handle(Event e) { final message = decodeBase64(e.bytes!); - if (buffer != null) { - timer?.cancel(); - - if (message == '\n') { - loggingController.log( - LogData( - buffer!.kind, - buffer!.details! + message, - buffer!.timestamp, - summary: buffer!.summary! + message, - isError: buffer!.isError, - isolateRef: e.isolateRef, - ), - ); - buffer = null; - return; - } - - loggingController.log(buffer!); - buffer = null; - } + if (_handleBufferedMessage(message, e)) return; const maxLength = 200; @@ -899,13 +891,64 @@ class _StdoutEventHandler { if (message == '\n') { loggingController.log(data); } else { - buffer = data; - timer = Timer(const Duration(milliseconds: 1), () { - loggingController.log(buffer!); - buffer = null; - }); + _setBuffer(data); } } + + bool _handleBufferedMessage(String message, Event e) { + if (_buffer case final currentBuffer?) { + _timer?.cancel(); + + if (message == '\n') { + loggingController.log( + LogData( + currentBuffer.kind, + currentBuffer.details! + message, + currentBuffer.timestamp, + summary: currentBuffer.summary! + message, + isError: currentBuffer.isError, + isolateRef: e.isolateRef, + ), + ); + _buffer = null; + return true; + } + + // If the buffered message ends with a newline, the next message is a + // continuation of the same print statement (e.g. debugPrint('line1\nline2') + // is sent by the VM as two events: 'line1\n' and 'line2'). Combine them + // into a single log entry. + // See: https://github.com/flutter/devtools/issues/9557 + if (currentBuffer.details!.endsWith('\n')) { + _setBuffer( + LogData( + currentBuffer.kind, + currentBuffer.details! + message, + currentBuffer.timestamp, + summary: currentBuffer.summary, + isError: currentBuffer.isError, + isolateRef: e.isolateRef, + ), + ); + return true; + } + + loggingController.log(currentBuffer); + _buffer = null; + } + return false; + } + + void _setBuffer(LogData data) { + _buffer = data; + _timer?.cancel(); + _timer = Timer(const Duration(milliseconds: 1), () { + if (_buffer case final currentBuffer?) { + loggingController.log(currentBuffer); + _buffer = null; + } + }); + } } bool _isNotNull(InstanceRef? serviceRef) { @@ -926,6 +969,66 @@ String? _valueAsString(InstanceRef? ref) { : ref.valueAsString; } +/// Returns a JSON-encoded Logging event with full string values applied. +@visibleForTesting +String computeDeveloperLogDetailsJson( + Map eventJson, { + required String fullMessage, + String? fullError, + String? fullStackTrace, +}) { + final detailsJson = Map.of(eventJson); + final logRecord = detailsJson['logRecord']; + if (logRecord is Map) { + final logRecordCopy = Map.of(logRecord); + + logRecordCopy['message'] = _applyFullStringToInstanceJson( + logRecordCopy['message'], + fullMessage, + ); + + if (fullError != null) { + final errorJson = logRecordCopy['error']; + if (errorJson is Map && + errorJson['valueAsString'] != null) { + logRecordCopy['error'] = _applyFullStringToInstanceJson( + errorJson, + fullError, + ); + } else { + logRecordCopy['errorAsString'] = fullError; + } + } + + if (fullStackTrace != null) { + final stackJson = logRecordCopy['stackTrace']; + if (stackJson is Map && + stackJson['valueAsString'] != null) { + logRecordCopy['stackTrace'] = _applyFullStringToInstanceJson( + stackJson, + fullStackTrace, + ); + } else { + logRecordCopy['stackTraceAsString'] = fullStackTrace; + } + } + + detailsJson['logRecord'] = logRecordCopy; + } + + return jsonEncode(detailsJson); +} + +Object? _applyFullStringToInstanceJson(Object? instanceJson, String fullValue) { + if (instanceJson is! Map) return instanceJson; + return { + ...instanceJson, + 'valueAsString': fullValue, + 'valueAsStringIsTruncated': false, + 'length': fullValue.length, + }; +} + /// A log data object that includes optional summary information about whether /// the log entry represents an error entry, the log entry kind, and more /// detailed data for the entry. @@ -943,7 +1046,6 @@ class LogData with SearchableDataMixin { int? level, this.isError = false, this.detailsComputer, - this.node, this.isolateRef, this.zone, }) : level = level ?? (isError ? Level.SEVERE.value : Level.INFO.value) { @@ -974,12 +1076,9 @@ class LogData with SearchableDataMixin { _levelName ??= LogLevelMetadataChip.generateLogLevel(level).name; String? _levelName; - final RemoteDiagnosticsNode? node; String? _details; Future Function()? detailsComputer; - static const prettyPrinter = JsonEncoder.withIndent(' '); - String? get details => _details; bool get needsComputing => !detailsComputed.isCompleted; @@ -997,15 +1096,30 @@ class LogData with SearchableDataMixin { } String? prettyPrinted() { + if (_prettyPrinted != null) return _prettyPrinted; + final text = _prettyPrintedWithAnsi(); + if (text == null) return null; + // JSON encoding escapes the ANSI escape character as `\u001b`. + final result = stripAnsi(text.replaceAll(_jsonEscapedAnsiEscape, '\x1B')); + + // Only cache the value if details have already been computed. + if (detailsComputed.isCompleted) _prettyPrinted = result; + return result; + } + + String? _prettyPrinted; + + static final _jsonEscapedAnsiEscape = RegExp(r'\\u001[bB]'); + + String? _prettyPrintedWithAnsi() { if (!detailsComputed.isCompleted) { return details?.trim(); } try { - return prettyPrinter - .convert(jsonDecode(details!)) - .replaceAll(r'\n', '\n') - .trim(); + return prettyPrintJson( + jsonDecode(details!) as Object?, + ).replaceAll(r'\n', '\n').trim(); } catch (_) { return details?.trim(); } diff --git a/packages/devtools_app/lib/src/screens/logging/logging_controls.dart b/packages/devtools_app/lib/src/screens/logging/logging_controls.dart index ff52b5cb601..4b34c82c354 100644 --- a/packages/devtools_app/lib/src/screens/logging/logging_controls.dart +++ b/packages/devtools_app/lib/src/screens/logging/logging_controls.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../service/service_extension_widgets.dart'; import '../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/logging/logging_screen.dart b/packages/devtools_app/lib/src/screens/logging/logging_screen.dart index 79d2c7cfbe2..cc4901a83cc 100644 --- a/packages/devtools_app/lib/src/screens/logging/logging_screen.dart +++ b/packages/devtools_app/lib/src/screens/logging/logging_screen.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; @@ -80,8 +80,6 @@ class _LoggingScreenState extends State controller: controller, data: controller.filteredData.value, selectionNotifier: controller.selectedLog, - searchMatchesNotifier: controller.searchMatches, - activeSearchMatchNotifier: controller.activeSearchMatch, ), ), ValueListenableBuilder( diff --git a/packages/devtools_app/lib/src/screens/logging/metadata.dart b/packages/devtools_app/lib/src/screens/logging/metadata.dart index 9ac216e67e0..7e07b94d0ae 100644 --- a/packages/devtools_app/lib/src/screens/logging/metadata.dart +++ b/packages/devtools_app/lib/src/screens/logging/metadata.dart @@ -6,8 +6,8 @@ import 'dart:convert'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart' show EventKind; import '../../shared/primitives/utils.dart'; @@ -97,8 +97,8 @@ class MetadataChips extends StatelessWidget { FrameElapsedMetaDataChip( elapsedTimeDisplay: elapsedFrameTimeAsString, ), - if (isolateChip != null) isolateChip, - if (zoneChip != null) zoneChip, + ?isolateChip, + ?zoneChip, ], ); } diff --git a/packages/devtools_app/lib/src/screens/memory/framework/memory_controller.dart b/packages/devtools_app/lib/src/screens/memory/framework/memory_controller.dart index 62c0bb92e6d..99eb26310fb 100644 --- a/packages/devtools_app/lib/src/screens/memory/framework/memory_controller.dart +++ b/packages/devtools_app/lib/src/screens/memory/framework/memory_controller.dart @@ -21,6 +21,7 @@ import '../panes/chart/controller/chart_pane_controller.dart'; import '../panes/diff/controller/diff_pane_controller.dart'; import '../panes/profile/profile_pane_controller.dart'; import '../panes/tracing/tracing_pane_controller.dart'; +import '../shared/primitives/memory_utils.dart'; import 'offline_data/offline_data.dart'; /// Screen controller for the Memory screen. @@ -129,7 +130,7 @@ class MemoryController extends DevToolsScreenController chart = MemoryChartPaneController(data: offlineData?.chart ?? ChartData()); final rootPackage = isConnected - ? serviceConnection.serviceManager.rootInfoNow().package! + ? serviceConnection.serviceManager.rootInfoNow().rootPackagePrefix! : null; diff = diff --git a/packages/devtools_app/lib/src/screens/memory/framework/memory_screen.dart b/packages/devtools_app/lib/src/screens/memory/framework/memory_screen.dart index cd6dccf5ce7..e215fb24c75 100644 --- a/packages/devtools_app/lib/src/screens/memory/framework/memory_screen.dart +++ b/packages/devtools_app/lib/src/screens/memory/framework/memory_screen.dart @@ -5,7 +5,7 @@ import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/analytics.dart' as ga; import '../../../shared/framework/screen.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/framework/memory_tabs.dart b/packages/devtools_app/lib/src/screens/memory/framework/memory_tabs.dart index ccce88cf564..2a647ab6569 100644 --- a/packages/devtools_app/lib/src/screens/memory/framework/memory_tabs.dart +++ b/packages/devtools_app/lib/src/screens/memory/framework/memory_tabs.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/constants.dart' as gac; import '../../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/framework/screen_body.dart b/packages/devtools_app/lib/src/screens/memory/framework/screen_body.dart index d2389f1cf90..470c7312a14 100644 --- a/packages/devtools_app/lib/src/screens/memory/framework/screen_body.dart +++ b/packages/devtools_app/lib/src/screens/memory/framework/screen_body.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/framework/screen.dart'; import '../../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/android_chart_controller.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/android_chart_controller.dart index 247615b6eae..4702fe72cf1 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/android_chart_controller.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/android_chart_controller.dart @@ -4,7 +4,7 @@ import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../../shared/charts/chart_controller.dart'; import '../../../../../../shared/charts/chart_trace.dart' as chart_trace; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/event_chart_controller.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/event_chart_controller.dart index 29bbf2e95eb..9e35f66545e 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/event_chart_controller.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/event_chart_controller.dart @@ -4,7 +4,7 @@ import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../../shared/charts/chart_controller.dart'; import '../../../../../../shared/charts/chart_trace.dart' as chart_trace; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/vm_chart_controller.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/vm_chart_controller.dart index a84a8dc67aa..25735607a12 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/vm_chart_controller.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/controller/charts/vm_chart_controller.dart @@ -4,7 +4,7 @@ import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../../shared/charts/chart_controller.dart'; import '../../../../../../shared/charts/chart_trace.dart' as chart_trace; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/data/charts.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/data/charts.dart index 35d74718e76..0ea8e170120 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/data/charts.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/data/charts.dart @@ -5,11 +5,10 @@ import 'package:devtools_app_shared/service.dart' show FlutterEvent; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/charts/chart_trace.dart'; import '../../../../../shared/primitives/byte_utils.dart'; -import '../../../../../shared/primitives/utils.dart'; import '../../../shared/primitives/memory_timeline.dart'; /// Name of each trace being charted, index order is the trace index @@ -48,13 +47,6 @@ const customEvent = 'custom'; const customEventName = 'name'; const customEventData = 'data'; -const indexPayloadJson = 'index'; -const timestampPayloadJson = 'timestamp'; -const prettyTimestampPayloadJson = 'prettyTimestamp'; -const eventPayloadJson = 'event'; -const vmPayloadJson = 'vm'; -const androidPayloadJson = 'android'; - /// VM Data const rssJsonName = 'rss'; const capacityJsonName = 'capacity'; @@ -156,17 +148,6 @@ class ChartsValues { final _android = {}; - Map toJson() { - return { - indexPayloadJson: index, - timestampPayloadJson: timestamp, - prettyTimestampPayloadJson: prettyTimestamp(timestamp), - eventPayloadJson: _event, - vmPayloadJson: _vm, - androidPayloadJson: _android, - }; - } - int get eventCount => _event.entries.length - (extensionEventsLength > 0 ? 1 : 0) + diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/chart_control_pane.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/chart_control_pane.dart index de7fe0fa695..d19f6e5be97 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/chart_control_pane.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/chart_control_pane.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/chart_pane.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/chart_pane.dart index 1a5f0e243b8..41b9e1fe30d 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/chart_pane.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/chart_pane.dart @@ -4,8 +4,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/charts/chart_controller.dart'; import '../../../../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/interval_dropdown.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/interval_dropdown.dart index 9773fcc5ab6..f601170f086 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/interval_dropdown.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/interval_dropdown.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/legend.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/legend.dart index 5c43dd6c3e6..13189419893 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/legend.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/legend.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/primitives/painting.dart'; import '../controller/chart_pane_controller.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/memory_chart.dart b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/memory_chart.dart index ec4dc81c3f9..348a0ccf8c2 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/memory_chart.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/chart/widgets/memory_chart.dart @@ -6,7 +6,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/charts/chart.dart'; import '../../../../../shared/charts/chart_controller.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/control_pane.dart b/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/control_pane.dart index 334d9c3dbcd..3ebac1c62e0 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/control_pane.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/control_pane.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/framework/screen.dart'; import '../../../../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/primary_controls.dart b/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/primary_controls.dart index 4b702790e2d..dd5a5efe072 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/primary_controls.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/primary_controls.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/constants.dart' as gac; import '../../../../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/secondary_controls.dart b/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/secondary_controls.dart index af539bd9d45..2951c8a8551 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/secondary_controls.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/secondary_controls.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/constants.dart' as gac; import '../../../../../shared/feature_flags.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/settings_dialog.dart b/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/settings_dialog.dart index 196fd1ce6b3..adf3e346171 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/settings_dialog.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/control/widgets/settings_dialog.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/globals.dart'; import '../../../../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/diff_pane.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/diff_pane.dart index 2342b95fc20..cf946cb3a41 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/diff_pane.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/diff_pane.dart @@ -6,8 +6,8 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/constants.dart' as gac; import '../../../../shared/primitives/simple_items.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/class_details/path.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/class_details/path.dart index 7821f0c3bbb..e43e502d355 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/class_details/path.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/class_details/path.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_diff.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_diff.dart index a40c9ebc2e2..c46a531978c 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_diff.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_diff.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../shared/analytics/constants.dart' as gac; @@ -12,6 +12,7 @@ import '../../../../../shared/memory/classes.dart'; import '../../../../../shared/primitives/utils.dart'; import '../../../../../shared/table/table.dart'; import '../../../../../shared/table/table_data.dart'; +import '../../../shared/primitives/memory_utils.dart'; import '../../../shared/primitives/simple_elements.dart'; import '../../../shared/widgets/class_filter.dart'; import '../../../shared/widgets/shared_memory_widgets.dart'; @@ -53,7 +54,9 @@ class _ClassNameColumn extends ColumnData theClass: data.className, showCopyButton: isRowSelected, copyGaItem: gac.MemoryEvents.diffClassDiffCopy.name, - rootPackage: serviceConnection.serviceManager.rootInfoNow().package, + rootPackage: serviceConnection.serviceManager + .rootInfoNow() + .rootPackagePrefix, ); } diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_single.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_single.dart index 24b9cebbdd1..3e5b022da66 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_single.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/classes_table_single.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../shared/analytics/constants.dart' as gac; @@ -12,6 +12,7 @@ import '../../../../../shared/primitives/byte_utils.dart'; import '../../../../../shared/primitives/utils.dart'; import '../../../../../shared/table/table.dart'; import '../../../../../shared/table/table_data.dart'; +import '../../../shared/primitives/memory_utils.dart'; import '../../../shared/primitives/simple_elements.dart'; import '../../../shared/widgets/class_filter.dart'; import '../../../shared/widgets/shared_memory_widgets.dart'; @@ -50,7 +51,9 @@ class _ClassNameColumn extends ColumnData theClass: data.className, showCopyButton: isRowSelected, copyGaItem: gac.MemoryEvents.diffClassSingleCopy.name, - rootPackage: serviceConnection.serviceManager.rootInfoNow().package, + rootPackage: serviceConnection.serviceManager + .rootInfoNow() + .rootPackagePrefix, ); } diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/instances.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/instances.dart index 38a1af79c46..d33a600fa75 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/instances.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/instances.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/constants.dart'; import '../../../../../shared/memory/class_name.dart'; @@ -66,8 +66,7 @@ class _StoreAllAsVariableMenu extends StatelessWidget { final SnapshotClassSampler sampler; - // TODO(https://github.com/flutter/devtools/issues/7905): this is a bug that - // this is unused. + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/7905) this is a bug that this is unused. final bool liveItemsEnabled; @override diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_control_pane.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_control_pane.dart index a1da1ad78c3..5c457c1d545 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_control_pane.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_control_pane.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_list.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_list.dart index 63f7554fa14..524e495b415 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_list.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_list.dart @@ -7,8 +7,8 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; import '../../../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_view.dart b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_view.dart index 090d1006c23..283cc5c19a4 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_view.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/diff/widgets/snapshot_view.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/memory/classes.dart'; import '../../../../../shared/primitives/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/instances.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/instances.dart index 4c11d85e80b..0d49fc37ece 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/profile/instances.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/instances.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/constants.dart'; import '../../../../shared/memory/class_name.dart'; @@ -25,8 +25,7 @@ class ProfileInstanceTableCell extends StatelessWidget { required this.count, }) : _shouldShowMenu = isSelected && count > 0; - // TODO(https://github.com/flutter/devtools/issues/7905): this is a bug that - // this is unused. + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/7905): this is a bug that this is unused. final MemoryAreas gaContext; final int count; final bool _shouldShowMenu; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart index b1c412b5724..36f7ec019f7 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/model.dart @@ -34,13 +34,21 @@ class AdaptedProfile with Serializable { factory AdaptedProfile.fromAllocationProfile( AllocationProfile profile, ClassFilter filter, - String? rootPackage, - ) { + String? rootPackage, { + Set pinnedClassFullNames = const {}, + }) { final adaptedProfile = AdaptedProfile._( total: ProfileRecord.total(profile), items: (profile.members ?? []) .where((e) => (e.instancesCurrent ?? 0) > 0) - .map((e) => ProfileRecord.fromClassHeapStats(e)) + .map( + (e) => ProfileRecord.fromClassHeapStats( + e, + userPinned: pinnedClassFullNames.contains( + HeapClassName.fromClassRef(e.classRef).fullName, + ), + ), + ) .toList(), newSpaceGCStats: profile.newSpaceGCStats, oldSpaceGCStats: profile.oldSpaceGCStats, @@ -70,6 +78,31 @@ class AdaptedProfile with Serializable { ); } + /// Returns a copy of [profile] with [pinnedClassFullNames] applied to items. + factory AdaptedProfile.withPinnedClasses( + AdaptedProfile profile, + Set pinnedClassFullNames, + String? rootPackage, + ) { + final itemsWithPins = profile._items + .map( + (record) => record.copyWith( + userPinned: pinnedClassFullNames.contains( + record.heapClass.fullName, + ), + ), + ) + .toList(); + final updated = AdaptedProfile._( + total: profile._total, + items: itemsWithPins, + newSpaceGCStats: profile.newSpaceGCStats, + oldSpaceGCStats: profile.oldSpaceGCStats, + totalGCStats: profile.totalGCStats, + ); + return AdaptedProfile.withNewFilter(updated, profile.filter, rootPackage); + } + factory AdaptedProfile.fromJson(Map json) { return AdaptedProfile._( total: ProfileRecord.fromJson(json[_ProfileJson.total]), @@ -117,6 +150,7 @@ class AdaptedProfile with Serializable { class _RecordJson { static const isTotal = 'it'; static const heapClass = 'c'; + static const userPinned = 'p'; static const totalInstances = 'ti'; static const totalSize = 'ts'; static const totalDartHeapSize = 'tds'; @@ -135,6 +169,7 @@ class ProfileRecord with PinnableListEntry, Serializable { ProfileRecord._({ required this.isTotal, required this.heapClass, + this.userPinned = false, required this.totalInstances, required this.totalSize, required this.totalDartHeapSize, @@ -151,7 +186,10 @@ class ProfileRecord with PinnableListEntry, Serializable { _verifyIntegrity(); } - factory ProfileRecord.fromClassHeapStats(ClassHeapStats stats) { + factory ProfileRecord.fromClassHeapStats( + ClassHeapStats stats, { + bool userPinned = false, + }) { assert( stats.bytesCurrent! == stats.newSpace.size + stats.oldSpace.size, '${stats.bytesCurrent}, ${stats.newSpace.size}, ${stats.oldSpace.size}', @@ -159,6 +197,7 @@ class ProfileRecord with PinnableListEntry, Serializable { return ProfileRecord._( isTotal: false, heapClass: HeapClassName.fromClassRef(stats.classRef), + userPinned: userPinned, totalInstances: stats.instancesCurrent ?? 0, totalSize: stats.bytesCurrent! + @@ -180,6 +219,7 @@ class ProfileRecord with PinnableListEntry, Serializable { ProfileRecord.total(AllocationProfile profile) : isTotal = true, + userPinned = false, heapClass = HeapClassName.fromPath(className: 'All Classes', library: ''), totalInstances = null, totalSize = @@ -202,6 +242,7 @@ class ProfileRecord with PinnableListEntry, Serializable { return ProfileRecord._( isTotal: json[_RecordJson.isTotal] as bool, heapClass: HeapClassName.fromJson(json[_RecordJson.heapClass]), + userPinned: json[_RecordJson.userPinned] as bool? ?? false, totalInstances: json[_RecordJson.totalInstances] as int?, totalSize: json[_RecordJson.totalSize] as int, totalDartHeapSize: json[_RecordJson.totalDartHeapSize] as int, @@ -217,11 +258,32 @@ class ProfileRecord with PinnableListEntry, Serializable { ); } + ProfileRecord copyWith({bool? userPinned}) { + return ProfileRecord._( + isTotal: isTotal, + heapClass: heapClass, + userPinned: userPinned ?? this.userPinned, + totalInstances: totalInstances, + totalSize: totalSize, + totalDartHeapSize: totalDartHeapSize, + totalExternalSize: totalExternalSize, + newSpaceInstances: newSpaceInstances, + newSpaceSize: newSpaceSize, + newSpaceDartHeapSize: newSpaceDartHeapSize, + newSpaceExternalSize: newSpaceExternalSize, + oldSpaceInstances: oldSpaceInstances, + oldSpaceSize: oldSpaceSize, + oldSpaceDartHeapSize: oldSpaceDartHeapSize, + oldSpaceExternalSize: oldSpaceExternalSize, + ); + } + @override Map toJson() { return { _RecordJson.isTotal: isTotal, _RecordJson.heapClass: heapClass, + _RecordJson.userPinned: userPinned, _RecordJson.totalInstances: totalInstances, _RecordJson.totalSize: totalSize, _RecordJson.totalDartHeapSize: totalDartHeapSize, @@ -239,6 +301,8 @@ class ProfileRecord with PinnableListEntry, Serializable { final bool isTotal; + final bool userPinned; + final HeapClassName heapClass; final int? totalInstances; @@ -257,7 +321,7 @@ class ProfileRecord with PinnableListEntry, Serializable { final int? oldSpaceExternalSize; @override - bool get pinToTop => isTotal; + bool get pinToTop => isTotal || userPinned; void _verifyIntegrity() { assert(() { diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart index d469c23a8ab..0b5007e2290 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_pane_controller.dart @@ -11,20 +11,28 @@ import 'package:vm_service/vm_service.dart'; import '../../../../shared/config_specific/import_export/import_export.dart'; import '../../../../shared/globals.dart'; +import '../../../../shared/memory/class_name.dart'; import '../../shared/heap/class_filter.dart'; import 'model.dart'; @visibleForTesting -enum Json { profile, rootPackage } +enum Json { profile, rootPackage, pinnedClasses } class ProfilePaneController extends DisposableController with AutoDisposeControllerMixin, Serializable { - ProfilePaneController({required this.rootPackage, AdaptedProfile? profile}) { + ProfilePaneController({ + required this.rootPackage, + AdaptedProfile? profile, + Set? pinnedClassFullNames, + }) { + if (pinnedClassFullNames != null) { + _pinnedClassFullNames.addAll(pinnedClassFullNames); + } // [profile] should only be non-null when loading offline data. if (profile != null) { - _currentAllocationProfile.value = AdaptedProfile.withNewFilter( - profile, - classFilter.value, + _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses( + AdaptedProfile.withNewFilter(profile, classFilter.value, rootPackage), + _pinnedClassFullNames, rootPackage, ); } @@ -34,6 +42,9 @@ class ProfilePaneController extends DisposableController return ProfilePaneController( profile: deserialize(json[Json.profile.name], AdaptedProfile.fromJson), rootPackage: json[Json.rootPackage.name], + pinnedClassFullNames: (json[Json.pinnedClasses.name] as List?) + ?.cast() + .toSet(), ); } @@ -42,11 +53,35 @@ class ProfilePaneController extends DisposableController return { Json.profile.name: _currentAllocationProfile.value, Json.rootPackage.name: rootPackage, + Json.pinnedClasses.name: _pinnedClassFullNames.toList(), }; } bool _initialized = false; + /// Classes pinned to the top of the Profile Memory table. + final _pinnedClassFullNames = {}; + + void togglePin(HeapClassName heapClass) { + final key = heapClass.fullName; + if (_pinnedClassFullNames.contains(key)) { + _pinnedClassFullNames.remove(key); + } else { + _pinnedClassFullNames.add(key); + } + _reapplyPinnedState(); + } + + void _reapplyPinnedState() { + final currentProfile = _currentAllocationProfile.value; + if (currentProfile == null) return; + _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses( + currentProfile, + _pinnedClassFullNames, + rootPackage, + ); + } + /// Initializes the controller if it is not initialized yet. @override void init() { @@ -84,6 +119,7 @@ class ProfilePaneController extends DisposableController profile, classFilter.value, rootPackage, + pinnedClassFullNames: _pinnedClassFullNames, ); _initializeSelection(); } @@ -105,9 +141,13 @@ class ProfilePaneController extends DisposableController _classFilter.value = filter; final currentProfile = _currentAllocationProfile.value; if (currentProfile == null) return; - _currentAllocationProfile.value = AdaptedProfile.withNewFilter( - currentProfile, - classFilter.value, + _currentAllocationProfile.value = AdaptedProfile.withPinnedClasses( + AdaptedProfile.withNewFilter( + currentProfile, + classFilter.value, + rootPackage, + ), + _pinnedClassFullNames, rootPackage, ); } diff --git a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart index 7325b420604..fd274c065ff 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/profile/profile_view.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../../shared/analytics/analytics.dart' as ga; @@ -18,6 +18,7 @@ import '../../../../shared/table/table_controller.dart'; import '../../../../shared/table/table_data.dart'; import '../../../../shared/ui/common_widgets.dart'; import '../../shared/heap/class_filter.dart'; +import '../../shared/primitives/memory_utils.dart'; import '../../shared/primitives/simple_elements.dart'; import '../../shared/widgets/class_filter.dart'; import '../../shared/widgets/shared_memory_widgets.dart'; @@ -32,12 +33,97 @@ import 'profile_pane_controller.dart'; /// instances, memory). const _defaultNumberFieldWidth = 80.0; +@visibleForTesting +const allocationProfilePinButtonKey = Key('allocation-profile-pin-button'); + +class _PinColumn extends ColumnData + implements ColumnRenderer { + _PinColumn({required this.controller}) + : super( + '', + titleTooltip: 'Pin class to the top of the table', + fixedWidthPx: 10.0, + alignment: ColumnAlignment.center, + ); + + final ProfilePaneController controller; + + @override + bool get supportsSorting => false; + + @override + Widget build( + BuildContext context, + ProfileRecord item, { + bool isRowSelected = false, + bool isRowHovered = false, + }) { + if (item.isTotal) return const SizedBox.shrink(); + + final pinned = item.userPinned; + return _HoverPinButton( + pinned: pinned, + onPressed: () { + ga.select( + gac.memory, + '${gac.MemoryEvents.profilePinClass.name}-$pinned', + ); + controller.togglePin(item.heapClass); + }, + ); + } + + @override + bool? getValue(ProfileRecord _) => null; + + @override + int compare(ProfileRecord a, ProfileRecord b) => + a.userPinned.boolCompare(b.userPinned); +} + +class _HoverPinButton extends StatefulWidget { + const _HoverPinButton({required this.pinned, required this.onPressed}); + + final bool pinned; + final VoidCallback onPressed; + + @override + State<_HoverPinButton> createState() => _HoverPinButtonState(); +} + +class _HoverPinButtonState extends State<_HoverPinButton> { + bool _hovering = false; + + @override + Widget build(BuildContext context) { + final showIcon = widget.pinned || _hovering; + + return MouseRegion( + key: allocationProfilePinButtonKey, + onEnter: (_) => setState(() => _hovering = true), + onExit: (_) => setState(() => _hovering = false), + child: showIcon + ? IconButton( + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(minWidth: 32, minHeight: 32), + iconSize: 18, + icon: Icon( + widget.pinned ? Icons.push_pin : Icons.push_pin_outlined, + ), + tooltip: widget.pinned ? 'Unpin class' : 'Pin class to top', + onPressed: widget.onPressed, + ) + : const SizedBox(width: 32, height: 32), + ); + } +} + class _FieldClassNameColumn extends ColumnData implements ColumnRenderer, ColumnHeaderRenderer { - const _FieldClassNameColumn(this.classFilterData) - : super('Class', fixedWidthPx: 200); + const _FieldClassNameColumn(this.classFilterData) : super.wide('Class'); @override String? getValue(ProfileRecord dataObject) => dataObject.heapClass.className; @@ -61,7 +147,9 @@ class _FieldClassNameColumn extends ColumnData theClass: data.heapClass, showCopyButton: isRowSelected, copyGaItem: gac.MemoryEvents.diffClassSingleCopy.name, - rootPackage: serviceConnection.serviceManager.rootInfoNow().package, + rootPackage: serviceConnection.serviceManager + .rootInfoNow() + .rootPackagePrefix, ); } @@ -479,40 +567,39 @@ class AllocationProfileTableViewState } } -class _AllocationProfileTable extends StatelessWidget { - _AllocationProfileTable({required this.controller}); +class _AllocationProfileTable extends StatefulWidget { + const _AllocationProfileTable({required this.controller}); + + final ProfilePaneController controller; + + @override + State<_AllocationProfileTable> createState() => + _AllocationProfileTableState(); +} +class _AllocationProfileTableState extends State<_AllocationProfileTable> { /// List of columns displayed in advanced developer mode state. static final _vmModeColumnGroups = [ ColumnGroup.fromText(title: '', range: const Range(0, 1)), + ColumnGroup.fromText(title: '', range: const Range(1, 2)), ColumnGroup.fromText( title: HeapGeneration.total.toString(), - range: const Range(1, 5), + range: const Range(2, 6), ), ColumnGroup.fromText( title: HeapGeneration.newSpace.toString(), - range: const Range(5, 9), + range: const Range(6, 10), ), ColumnGroup.fromText( title: HeapGeneration.oldSpace.toString(), - range: const Range(9, 13), + range: const Range(10, 14), ), ]; static const _fieldSizeColumn = _FieldSizeColumn(heap: HeapGeneration.total); - late final _columns = >[ - _FieldClassNameColumn( - ClassFilterData( - filter: controller.classFilter, - onChanged: controller.setFilter, - rootPackage: controller.rootPackage, - ), - ), - const _FieldInstanceCountColumn(heap: HeapGeneration.total), - _fieldSizeColumn, - _FieldDartHeapSizeColumn(heap: HeapGeneration.total), - ]; + late _PinColumn _pinColumn; + late List> _columns; late final _advancedDeveloperModeColumns = [ const _FieldExternalSizeColumn(heap: HeapGeneration.total), @@ -526,12 +613,41 @@ class _AllocationProfileTable extends StatelessWidget { const _FieldExternalSizeColumn(heap: HeapGeneration.oldSpace), ]; - final ProfilePaneController controller; + @override + void initState() { + super.initState(); + _initColumns(); + } + + @override + void didUpdateWidget(covariant _AllocationProfileTable oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _initColumns(); + } + } + + void _initColumns() { + _pinColumn = _PinColumn(controller: widget.controller); + _columns = >[ + _pinColumn, + _FieldClassNameColumn( + ClassFilterData( + filter: widget.controller.classFilter, + onChanged: widget.controller.setFilter, + rootPackage: widget.controller.rootPackage, + ), + ), + const _FieldInstanceCountColumn(heap: HeapGeneration.total), + _fieldSizeColumn, + _FieldDartHeapSizeColumn(heap: HeapGeneration.total), + ]; + } @override Widget build(BuildContext context) { return ValueListenableBuilder( - valueListenable: controller.currentAllocationProfile, + valueListenable: widget.controller.currentAllocationProfile, builder: (context, profile, _) { // TODO(bkonyi): make this an overlay so the table doesn't // disappear when we're retrieving new data, especially since the @@ -547,18 +663,18 @@ class _AllocationProfileTable extends StatelessWidget { data: profile.records, dataKey: 'allocation-profile', columnGroups: advancedDeveloperModeEnabled - ? _AllocationProfileTable._vmModeColumnGroups + ? _AllocationProfileTableState._vmModeColumnGroups : null, columns: [ ..._columns, if (advancedDeveloperModeEnabled) ..._advancedDeveloperModeColumns, ], - defaultSortColumn: _AllocationProfileTable._fieldSizeColumn, + defaultSortColumn: _AllocationProfileTableState._fieldSizeColumn, defaultSortDirection: SortDirection.descending, pinBehavior: FlatTablePinBehavior.pinOriginalToTop, includeColumnGroupHeaders: false, - selectionNotifier: controller.selection, + selectionNotifier: widget.controller.selection, ); }, ); diff --git a/packages/devtools_app/lib/src/screens/memory/panes/tracing/class_table.dart b/packages/devtools_app/lib/src/screens/memory/panes/tracing/class_table.dart index 2d342d1d4e5..329500046fc 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/tracing/class_table.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/tracing/class_table.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/analytics.dart' as ga; import '../../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_pane_controller.dart b/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_pane_controller.dart index 675da928e4f..93f3fa3ec37 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_pane_controller.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_pane_controller.dart @@ -68,6 +68,7 @@ class TracePaneController extends DisposableController ); /// A Future tracking whether the controller has been initialized. + @visibleForTesting Future get initialized => _initialized.future; final _initialized = Completer(); diff --git a/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_tree.dart b/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_tree.dart index 981ebf06aab..6e6029f4d7f 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_tree.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_tree.dart @@ -3,8 +3,8 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/constants.dart' as gac; import '../../../../shared/primitives/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_view.dart b/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_view.dart index e923d9c7012..9b0ebabd9e5 100644 --- a/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_view.dart +++ b/packages/devtools_app/lib/src/screens/memory/panes/tracing/tracing_view.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/constants.dart' as gac; import '../../../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/shared/heap/sampler.dart b/packages/devtools_app/lib/src/screens/memory/shared/heap/sampler.dart index 36b72c1c638..f322719ceb3 100644 --- a/packages/devtools_app/lib/src/screens/memory/shared/heap/sampler.dart +++ b/packages/devtools_app/lib/src/screens/memory/shared/heap/sampler.dart @@ -13,6 +13,7 @@ import '../../../../shared/memory/classes.dart'; import '../../../../shared/memory/heap_data.dart'; import '../../../../shared/memory/heap_object.dart'; import '../../../../shared/utils/vm_utils.dart'; +import '../primitives/memory_utils.dart'; class _HeapObjects { _HeapObjects(this.objects, this.heap); @@ -106,7 +107,7 @@ class LiveClassSampler { bool get isEvalEnabled => heapClass.classType( - serviceConnection.serviceManager.rootInfoNow().package, + serviceConnection.serviceManager.rootInfoNow().rootPackagePrefix, ) != ClassType.runtime; diff --git a/packages/devtools_app/lib/src/screens/memory/shared/primitives/instance_context_menu.dart b/packages/devtools_app/lib/src/screens/memory/shared/primitives/instance_context_menu.dart index 5c3fa7d1895..af3fee85298 100644 --- a/packages/devtools_app/lib/src/screens/memory/shared/primitives/instance_context_menu.dart +++ b/packages/devtools_app/lib/src/screens/memory/shared/primitives/instance_context_menu.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/primitives/utils.dart'; import '../../../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/memory/shared/primitives/memory_timeline.dart b/packages/devtools_app/lib/src/screens/memory/shared/primitives/memory_timeline.dart index 8300073fd8f..45925c1f838 100644 --- a/packages/devtools_app/lib/src/screens/memory/shared/primitives/memory_timeline.dart +++ b/packages/devtools_app/lib/src/screens/memory/shared/primitives/memory_timeline.dart @@ -30,8 +30,6 @@ class MemoryTimeline extends Disposable with Serializable { static const _jsonData = 'data'; - int get endingIndex => data.isNotEmpty ? data.length - 1 : -1; - /// Raw Heap sampling data from the VM. late final List data; diff --git a/packages/devtools_app/lib/src/screens/memory/shared/primitives/memory_utils.dart b/packages/devtools_app/lib/src/screens/memory/shared/primitives/memory_utils.dart index b14327f5aff..f3ebf7ccc5d 100644 --- a/packages/devtools_app/lib/src/screens/memory/shared/primitives/memory_utils.dart +++ b/packages/devtools_app/lib/src/screens/memory/shared/primitives/memory_utils.dart @@ -2,10 +2,43 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +/// @docImport '../../../../shared/memory/class_name.dart'; +library; + +import 'package:devtools_app_shared/service.dart' show RootInfo; import 'package:vm_service/vm_service.dart'; import '../../../../shared/globals.dart'; +/// Memory screen specific information derived from the [RootInfo] of the +/// connected app. +extension RootInfoMemoryExtension on RootInfo { + /// The prefix of the URIs of the libraries that belong to the root package of + /// the connected app, or `null` if the root library is unknown. + /// + /// The Memory screen passes this prefix as `rootPackage` to + /// [HeapClassName.classType], which classifies a class as + /// [ClassType.rootPackage] (a "project" class) if the URI of its library + /// starts with the prefix. + /// + /// For the root library `package:my_app/main.dart`, the prefix is + /// `package:my_app`. + /// + /// Unlike [RootInfo.package], the prefix is not `null` when the root library + /// is not a `package:` URI. For example, the root library of a Dart CLI app + /// started with `dart run` is a `file:` URI like `file:///app/bin/main.dart`, + /// and the prefix is `file:`. Without a prefix, the classes defined in the + /// files of such an app would be classified as [ClassType.runtime], which + /// disables evaluating them in the live app. + String? get rootPackagePrefix { + final library = this.library; + if (library == null) return null; + + final slashIndex = library.indexOf('/'); + return slashIndex == -1 ? library : library.substring(0, slashIndex); + } +} + Future snapshotMemoryInSelectedIsolate() async { final isolate = serviceConnection.serviceManager.isolateManager.selectedIsolate.value; diff --git a/packages/devtools_app/lib/src/screens/memory/shared/primitives/painting.dart b/packages/devtools_app/lib/src/screens/memory/shared/primitives/painting.dart index 91b9c2b5782..0e9c0d7297e 100644 --- a/packages/devtools_app/lib/src/screens/memory/shared/primitives/painting.dart +++ b/packages/devtools_app/lib/src/screens/memory/shared/primitives/painting.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; // TODO(polina-c): use the same constants as for dash in the chart. const _dashHeight = 2.0; diff --git a/packages/devtools_app/lib/src/screens/memory/shared/widgets/class_filter.dart b/packages/devtools_app/lib/src/screens/memory/shared/widgets/class_filter.dart index afae49d1454..586d503c7c9 100644 --- a/packages/devtools_app/lib/src/screens/memory/shared/widgets/class_filter.dart +++ b/packages/devtools_app/lib/src/screens/memory/shared/widgets/class_filter.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/analytics.dart' as ga; import '../../../../shared/analytics/constants.dart' as gac; @@ -82,6 +82,13 @@ class _ClassFilterDialogState extends State { _loadStateFromFilter(widget.classFilter); } + @override + void dispose() { + _except.dispose(); + _only.dispose(); + super.dispose(); + } + @override void didUpdateWidget(covariant ClassFilterDialog oldWidget) { super.didUpdateWidget(oldWidget); diff --git a/packages/devtools_app/lib/src/screens/network/constants.dart b/packages/devtools_app/lib/src/screens/network/constants.dart index e8fa703e4be..be66652e00d 100644 --- a/packages/devtools_app/lib/src/screens/network/constants.dart +++ b/packages/devtools_app/lib/src/screens/network/constants.dart @@ -7,15 +7,8 @@ enum NetworkEventKeys { version, creator, name, - pages, startedDateTime, - id, - title, - pageTimings, - onContentLoad, - onLoad, entries, - pageref, time, request, method, @@ -44,7 +37,6 @@ enum NetworkEventKeys { wait, receive, ssl, - serverIPAddress, connection, comment, value, @@ -62,8 +54,6 @@ enum NetworkEventKeys { class NetworkEventDefaults { static const logVersion = '1.2'; static const creatorName = 'devtools'; - static const onContentLoad = -1; - static const onLoad = -1; static const httpVersion = 'HTTP/1.1'; static const responseHttpVersion = 'http/2.0'; static const blocked = -1; diff --git a/packages/devtools_app/lib/src/screens/network/har_data_entry.dart b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart index bd9316d0ec3..0176d96256f 100644 --- a/packages/devtools_app/lib/src/screens/network/har_data_entry.dart +++ b/packages/devtools_app/lib/src/screens/network/har_data_entry.dart @@ -6,6 +6,7 @@ import 'dart:convert'; import 'dart:typed_data'; import '../../screens/network/utils/http_utils.dart'; +import '../../shared/http/constants.dart' as shared_http; import '../../shared/http/http_request_data.dart'; import 'constants.dart'; @@ -45,6 +46,15 @@ class HarDataEntry { final requestPostData = responseData[NetworkEventKeys.postData.name]; final responseContent = responseData[NetworkEventKeys.content.name]; + final statusValue = responseData[NetworkEventKeys.status.name]; + final statusCode = switch (statusValue) { + int() => statusValue, + String() => int.tryParse(statusValue), + _ => null, + }; + responseData[shared_http.HttpRequestDataKeys.statusCode.name] = + statusCode ?? -1; + return HarDataEntry( DartIOHttpRequestData.fromJson( modifiedRequestData, @@ -148,7 +158,7 @@ class HarDataEntry { }, // Response NetworkEventKeys.response.name: { - NetworkEventKeys.status.name: e.status, + NetworkEventKeys.status.name: int.tryParse(e.status ?? '') ?? -1, NetworkEventKeys.statusText.name: e.general[NetworkEventKeys.reasonPhrase.name] ?? '', NetworkEventKeys.httpVersion.name: diff --git a/packages/devtools_app/lib/src/screens/network/har_network_data.dart b/packages/devtools_app/lib/src/screens/network/har_network_data.dart index 7895e008e04..f8519bcf91b 100644 --- a/packages/devtools_app/lib/src/screens/network/har_network_data.dart +++ b/packages/devtools_app/lib/src/screens/network/har_network_data.dart @@ -3,6 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_shared/devtools_shared.dart'; +import 'package:meta/meta.dart'; import '../../shared/http/http_request_data.dart'; import 'constants.dart'; import 'har_builder.dart'; @@ -28,6 +29,7 @@ class HarNetworkData with Serializable { /// ```dart /// final harData = HarNetworkData.fromJson(json); /// ``` + @visibleForTesting factory HarNetworkData.fromJson(Map json) { final entries = ((json[NetworkEventKeys.log.name] diff --git a/packages/devtools_app/lib/src/screens/network/network_controller.dart b/packages/devtools_app/lib/src/screens/network/network_controller.dart index deecab62c7e..b340f9cc6b3 100644 --- a/packages/devtools_app/lib/src/screens/network/network_controller.dart +++ b/packages/devtools_app/lib/src/screens/network/network_controller.dart @@ -192,6 +192,15 @@ class NetworkController extends DevToolsScreenController _currentNetworkRequests, _filterAndRefreshSearchMatches, ); + autoDisposeStreamSubscription( + serviceConnection.serviceManager.isolateManager.onIsolateCreated.listen(( + _, + ) async { + if (_recordingNotifier.value) { + await allowedError(_enableNetworkTrafficRecordingOnAllIsolates()); + } + }), + ); } @override @@ -203,6 +212,10 @@ class NetworkController extends DevToolsScreenController selectedRequest.dispose(); _recordingNotifier.dispose(); _currentNetworkRequests.dispose(); + for (final r in _httpRequests ?? []) { + r.dispose(); + } + _httpRequests?.clear(); super.dispose(); } @@ -233,6 +246,7 @@ class NetworkController extends DevToolsScreenController ..updateOrAddAll( requests: httpProfileData, sockets: socketStatsData, + webSockets: const [], timelineMicrosOffset: offlineData.timelineMicrosOffset ?? 0, ); _filterAndRefreshSearchMatches(); @@ -252,12 +266,14 @@ class NetworkController extends DevToolsScreenController void processNetworkTrafficHelper( List sockets, List? httpRequests, + List webSockets, int timelineMicrosOffset, { required CurrentNetworkRequests currentRequests, }) { currentRequests.updateOrAddAll( requests: httpRequests!, sockets: sockets, + webSockets: webSockets, timelineMicrosOffset: timelineMicrosOffset, ); @@ -274,11 +290,13 @@ class NetworkController extends DevToolsScreenController void processNetworkTraffic({ required List sockets, required List? httpRequests, + required List webSockets, }) { // Trigger refresh. processNetworkTrafficHelper( sockets, httpRequests, + webSockets, _timelineMicrosOffset, currentRequests: _currentNetworkRequests, ); @@ -323,9 +341,14 @@ class NetworkController extends DevToolsScreenController // Cancel existing polling timer before starting recording. _updatePollingState(false); - networkService.updateLastHttpDataRefreshTime( + await networkService.updateLastHttpDataRefreshTime( alreadyRecordingHttp: alreadyRecordingHttp, ); + unawaited( + networkService.updateLastWebSocketDataRefreshTime( + alreadyRecordingWebSocket: alreadyRecordingHttp, + ), + ); final timestamp = await networkService.updateLastSocketDataRefreshTime( alreadyRecordingSocketData: alreadyRecordingSocketData, ); @@ -348,11 +371,16 @@ class NetworkController extends DevToolsScreenController // TODO(kenz): only call these if http logging and socket profiling are not // already enabled. Listen to service manager streams for this info. - await [ + await _enableNetworkTrafficRecordingOnAllIsolates(); + await togglePolling(true); + } + + /// Enables HTTP timeline logging and socket profiling on all isolates. + Future _enableNetworkTrafficRecordingOnAllIsolates() async { + await Future.wait([ http_service.toggleHttpRequestLogging(true), networkService.toggleSocketProfiling(true), - ].wait; - await togglePolling(true); + ]); } Future stopRecording() async { @@ -373,12 +401,13 @@ class NetworkController extends DevToolsScreenController _recordingNotifier.value = state; } - /// Updates the last refresh time of the socket and http data refresh times. + /// Updates the last refresh time of the HTTP, WebSocket, and Socket data. /// - /// This will ensure that future fetches for http and socket requests will at - /// most fetch requests since [updateLastRefreshTime] was called. + /// This will ensure that future fetches for http, socket and websocket + /// requests will atmost fetch requests since [updateLastRefreshTime] was + /// called. Future updateLastRefreshTime() async { - networkService.updateLastHttpDataRefreshTime(); + await networkService.updateLastHttpDataRefreshTime(); await networkService.updateLastSocketDataRefreshTime(); } @@ -502,17 +531,21 @@ class NetworkController extends DevToolsScreenController OfflineScreenData prepareOfflineScreenData() { final httpRequestData = []; final socketData = []; + final webSocketData = []; for (final request in _currentNetworkRequests.value) { if (request is DartIOHttpRequestData) { httpRequestData.add(request); } else if (request is Socket) { socketData.add(request); + } else if (request is WebSocket) { + webSocketData.add(request); } } final offlineData = OfflineNetworkData( httpRequestData: httpRequestData, socketData: socketData, + webSocketData: webSocketData, selectedRequestId: selectedRequest.value?.id, timelineMicrosOffset: _timelineMicrosOffset, ); @@ -553,10 +586,12 @@ class CurrentNetworkRequests extends ValueNotifier> { void updateOrAddAll({ required List requests, required List sockets, + required List webSockets, required int timelineMicrosOffset, }) { _updateOrAddRequests(requests); _updateSocketProfiles(sockets, timelineMicrosOffset); + _updateWebSocketProfiles(webSockets); notifyListeners(); } @@ -613,6 +648,20 @@ class CurrentNetworkRequests extends ValueNotifier> { } } + void _updateWebSocketProfiles(List webSockets) { + for (final connection in webSockets) { + final webSocket = WebSocket(connection); + final existingRequest = _requestsById[webSocket.id]; + + if (existingRequest == null) { + _requestsById[webSocket.id] = webSocket; + value.add(webSocket); + } else { + (existingRequest as WebSocket).update(webSocket); + } + } + } + void clear({bool partial = false}) { if (partial) { _requestsById.keys diff --git a/packages/devtools_app/lib/src/screens/network/network_model.dart b/packages/devtools_app/lib/src/screens/network/network_model.dart index 8a6697fbe65..1f1751697bf 100644 --- a/packages/devtools_app/lib/src/screens/network/network_model.dart +++ b/packages/devtools_app/lib/src/screens/network/network_model.dart @@ -3,12 +3,14 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_shared/devtools_shared.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../shared/primitives/utils.dart'; import '../../shared/ui/search.dart'; +export 'package:vm_service/vm_service.dart' show WebSocketEvent; + abstract class NetworkRequest with ChangeNotifier, SearchableDataMixin, Serializable { String get method; @@ -29,6 +31,9 @@ abstract class NetworkRequest int? get port; + int? get requestBytes => null; + int? get responseBytes => null; + bool get didFail; /// True if the request hasn't completed yet. @@ -36,14 +41,6 @@ abstract class NetworkRequest String get id; - String get durationDisplay { - final duration = this.duration; - final text = duration != null - ? durationText(duration, unit: DurationDisplayUnit.milliseconds) - : 'Pending'; - return 'Duration: $text'; - } - @override bool matchesSearchToken(RegExp regExpSearch) { return uri.caseInsensitiveContains(regExpSearch); @@ -160,6 +157,12 @@ class Socket extends NetworkRequest { @override int get port => _socket.port; + @override + int get requestBytes => writeBytes; + + @override + int get responseBytes => readBytes; + // TODO(kenz): what determines a web socket request failure? @override bool get didFail => false; @@ -183,8 +186,6 @@ class Socket extends NetworkRequest { @override int get hashCode => id.hashCode; - SocketStatistic get socketData => _socket; - @override Map toJson() { return { @@ -232,3 +233,153 @@ extension SocketExtension on List { return map((socket) => socket._socket).toList(); } } + +class WebSocket extends NetworkRequest { + WebSocket(this._connection); + + factory WebSocket.fromJson(Map json) { + return WebSocket( + WebSocketConnection( + isolateId: json['isolateId'] as String, + id: json['connectionId'] as String, + uri: Uri.parse(json['uri'] as String), + protocol: json['protocol'] as String?, + state: json['state'] as String, + connectTimestamp: DateTime.parse(json['connectTimestamp'] as String), + openTimestamp: json['openTimestamp'] != null + ? DateTime.parse(json['openTimestamp'] as String) + : null, + closeTimestamp: json['closeTimestamp'] != null + ? DateTime.parse(json['closeTimestamp'] as String) + : null, + bytesSent: json['bytesSent'] as int, + bytesReceived: json['bytesReceived'] as int, + framesSent: json['framesSent'] as int, + framesReceived: json['framesReceived'] as int, + pingCount: json['pingCount'] as int, + pongCount: json['pongCount'] as int, + closeCode: json['closeCode'] as int?, + closeReason: json['closeReason'] as String?, + error: json['error'] as String?, + lastUpdated: DateTime.parse(json['lastUpdated'] as String), + events: (json['events'] as List? ?? []) + .whereType>() + .map(WebSocketEvent.parse) + .whereType() + .toList(), + ), + ); + } + + WebSocketConnection _connection; + List get events => _connection.events; + + String get connectionId => _connection.id; + String? get protocol => _connection.protocol; + String get state => _connection.state; + + DateTime get lastUpdated => _connection.lastUpdated; + + int get bytesSent => _connection.bytesSent; + int get bytesReceived => _connection.bytesReceived; + int get framesSent => _connection.framesSent; + int get framesReceived => _connection.framesReceived; + int get pingCount => _connection.pingCount; + int get pongCount => _connection.pongCount; + int? get closeCode => _connection.closeCode; + String? get closeReason => _connection.closeReason; + String? get error => _connection.error; + + void update(WebSocket other) { + _connection = other._connection; + notifyListeners(); + } + + @override + String get id => 'websocket:${_connection.isolateId}:${_connection.id}'; + + @override + String get method => 'WEBSOCKET'; + + @override + String get uri => _connection.uri.toString(); + + @override + String? get contentType => 'websocket'; + + @override + String get type => 'WebSocket'; + + @override + Duration? get duration => + _connection.closeTimestamp?.difference(_connection.connectTimestamp); + + @override + DateTime get startTimestamp => _connection.connectTimestamp; + + @override + DateTime? get endTimestamp => _connection.closeTimestamp; + + @override + String get status => _connection.state; + + @override + int? get port => _connection.uri.hasPort ? _connection.uri.port : null; + + @override + int get requestBytes => _connection.bytesSent; + + @override + int get responseBytes => _connection.bytesReceived; + + @override + bool get didFail => _connection.state == 'error'; + + @override + bool get inProgress => + _connection.state != 'closed' && _connection.state != 'error'; + + @override + bool operator ==(Object other) => other is WebSocket && id == other.id; + + @override + int get hashCode => id.hashCode; + + @override + Map toJson() { + return { + 'isolateId': _connection.isolateId, + 'connectionId': connectionId, + 'uri': uri, + 'protocol': protocol, + 'state': state, + 'connectTimestamp': startTimestamp.toIso8601String(), + 'openTimestamp': _connection.openTimestamp?.toIso8601String(), + 'closeTimestamp': endTimestamp?.toIso8601String(), + 'bytesSent': bytesSent, + 'bytesReceived': bytesReceived, + 'framesSent': framesSent, + 'framesReceived': framesReceived, + 'pingCount': pingCount, + 'pongCount': pongCount, + 'closeCode': closeCode, + 'closeReason': closeReason, + 'error': error, + 'lastUpdated': lastUpdated.toIso8601String(), + 'events': events + .map( + (event) => { + 'timestamp': event.timestamp.microsecondsSinceEpoch, + 'event': event.event, + 'frameNumber': event.frameNumber, + 'direction': event.direction, + 'opcode': event.opcode, + 'payloadSize': event.payloadSize, + 'errorType': event.errorType, + 'errorMessage': event.errorMessage, + }, + ) + .toList(), + }; + } +} diff --git a/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart b/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart index a2f476487a8..da3bf207a5e 100644 --- a/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart +++ b/packages/devtools_app/lib/src/screens/network/network_request_inspector.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/constants.dart' as gac; import '../../shared/globals.dart'; @@ -18,6 +18,7 @@ class NetworkRequestInspector extends StatelessWidget { const NetworkRequestInspector({super.key}); static const _overviewTabTitle = 'Overview'; + static const _framesTabTitle = 'Frames'; static const _headersTabTitle = 'Headers'; static const _requestTabTitle = 'Request'; static const _responseTabTitle = 'Response'; @@ -68,6 +69,11 @@ class NetworkRequestInspector extends StatelessWidget { tab: _buildTab(tabName: NetworkRequestInspector._overviewTabTitle), tabView: NetworkRequestOverviewView(data), ), + if (data is WebSocket) + ( + tab: _buildTab(tabName: NetworkRequestInspector._framesTabTitle), + tabView: WebSocketFramesView(data), + ), if (data is DartIOHttpRequestData) ...[ ( tab: _buildTab(tabName: NetworkRequestInspector._headersTabTitle), diff --git a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart index 831b3e52052..46d35a92151 100644 --- a/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart +++ b/packages/devtools_app/lib/src/screens/network/network_request_inspector_views.dart @@ -6,17 +6,20 @@ import 'dart:convert'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:image/image.dart' as image; +import 'package:material_ui/material_ui.dart'; import '../../shared/http/http.dart'; import '../../shared/http/http_request_data.dart'; import '../../shared/primitives/byte_utils.dart'; import '../../shared/primitives/utils.dart'; +import '../../shared/table/table.dart'; +import '../../shared/table/table_data.dart'; import '../../shared/ui/colors.dart'; import '../../shared/ui/common_widgets.dart'; import 'network_controller.dart'; import 'network_model.dart'; +import 'utils/http_utils.dart'; // Approximately double the indent of the expandable tile's title. const _rowIndentPadding = 30.0; @@ -595,6 +598,129 @@ class HttpRequestCookiesView extends StatelessWidget { } } +class _WebSocketFrameColumn extends ColumnData { + const _WebSocketFrameColumn() : super('Frame', fixedWidthPx: 60); + + @override + int? getValue(WebSocketEvent dataObject) => dataObject.frameNumber; + + @override + String getDisplayValue(WebSocketEvent dataObject) => + dataObject.frameNumber?.toString() ?? '--'; +} + +class _WebSocketTimestampColumn extends ColumnData { + const _WebSocketTimestampColumn() : super('Timestamp', fixedWidthPx: 150); + + @override + DateTime getValue(WebSocketEvent dataObject) => dataObject.timestamp; + + @override + String getDisplayValue(WebSocketEvent dataObject) => + formatDateTime(dataObject.timestamp); +} + +class _WebSocketDirectionColumn extends ColumnData { + const _WebSocketDirectionColumn() : super('Direction', fixedWidthPx: 100); + + @override + String getValue(WebSocketEvent dataObject) => dataObject.direction ?? '--'; + + @override + String getDisplayValue(WebSocketEvent dataObject) => + dataObject.direction ?? '--'; +} + +class _WebSocketEventColumn extends ColumnData { + const _WebSocketEventColumn() : super('Event', fixedWidthPx: 120); + + @override + String getValue(WebSocketEvent dataObject) { + final event = dataObject.event; + return event.startsWith('WebSocket.') + ? event.substring('WebSocket.'.length) + : event; + } + + @override + String getDisplayValue(WebSocketEvent dataObject) => getValue(dataObject); +} + +class _WebSocketOpcodeColumn extends ColumnData { + const _WebSocketOpcodeColumn() : super('Opcode', fixedWidthPx: 100); + + @override + String getValue(WebSocketEvent dataObject) => + dataObject.opcode?.toString() ?? '--'; + + @override + String getDisplayValue(WebSocketEvent dataObject) => + dataObject.opcode?.toString() ?? '--'; +} + +class _WebSocketSizeColumn extends ColumnData { + const _WebSocketSizeColumn() : super('Size', fixedWidthPx: 100); + + @override + int? getValue(WebSocketEvent dataObject) => dataObject.payloadSize; + + @override + String getDisplayValue(WebSocketEvent dataObject) => + dataObject.payloadSize != null + ? formatBytes(dataObject.payloadSize!) + : '--'; +} + +class _WebSocketErrorColumn extends ColumnData { + const _WebSocketErrorColumn() : super('Error', fixedWidthPx: 180); + + @override + String getValue(WebSocketEvent dataObject) => + dataObject.errorMessage ?? dataObject.errorType ?? '--'; + + @override + String getDisplayValue(WebSocketEvent dataObject) => + dataObject.errorMessage ?? dataObject.errorType ?? '--'; +} + +class WebSocketFramesView extends StatelessWidget { + const WebSocketFramesView(this.data, {super.key}); + + final WebSocket data; + + static const _columns = >[ + _WebSocketFrameColumn(), + _WebSocketTimestampColumn(), + _WebSocketDirectionColumn(), + _WebSocketEventColumn(), + _WebSocketOpcodeColumn(), + _WebSocketSizeColumn(), + _WebSocketErrorColumn(), + ]; + + @override + Widget build(BuildContext context) { + final events = data.events; + + if (events.isEmpty) { + return const Center(child: Text('No WebSocket events')); + } + + return Padding( + padding: const EdgeInsets.all(defaultSpacing), + child: FlatTable( + keyFactory: (event) => ValueKey(event), + data: events, + dataKey: 'websocket-frames', + columns: _columns, + defaultSortColumn: _columns.first, + defaultSortDirection: SortDirection.ascending, + sizeColumnsToFit: false, + ), + ); + } +} + class NetworkRequestOverviewView extends StatelessWidget { const NetworkRequestOverviewView(this.data, {super.key}); @@ -604,6 +730,8 @@ class NetworkRequestOverviewView extends StatelessWidget { static const httpTimingGraphKey = Key('Http Timing Graph Key'); @visibleForTesting static const socketTimingGraphKey = Key('Socket Timing Graph Key'); + @visibleForTesting + static const webSocketTimingGraphKey = Key('WebSocket Timing Graph Key'); final NetworkRequest data; @@ -614,7 +742,10 @@ class NetworkRequestOverviewView extends StatelessWidget { padding: const EdgeInsets.all(defaultSpacing), children: [ ..._buildGeneralRows(context), - if (data is Socket) ..._buildSocketOverviewRows(context), + if (data is Socket) + ..._buildSocketOverviewRows(context) + else if (data is WebSocket) + ..._buildWebSocketOverviewRows(context), const PaddedDivider( padding: EdgeInsets.only(bottom: denseRowSpacing), ), @@ -625,6 +756,7 @@ class NetworkRequestOverviewView extends StatelessWidget { } List _buildGeneralRows(BuildContext context) { + final bytes = data.responseBytes; return [ // TODO(kenz): show preview for requests (png, response body, proto) _buildRow( @@ -658,7 +790,17 @@ class NetworkRequestOverviewView extends StatelessWidget { ), const SizedBox(height: defaultSpacing), ], - if (data.contentType != null) ...[ + + if (data is! WebSocket) ...[ + _buildRow( + context: context, + title: 'Response Size', + child: _valueText(bytes != null ? formatBytes(bytes) : '-'), + ), + const SizedBox(height: defaultSpacing), + ], + + if (data.contentType != null && data is! WebSocket) ...[ _buildRow( context: context, title: 'Content type', @@ -674,20 +816,28 @@ class NetworkRequestOverviewView extends StatelessWidget { _buildRow( context: context, title: 'Timing', - child: data is Socket - ? _buildSocketTimeGraph(context) - : _buildHttpTimeGraph(), + child: switch (data) { + Socket() => _buildSocketTimeGraph(context), + WebSocket() => _buildWebSocketTimeGraph(context), + _ => _buildHttpTimeGraph(), + }, ), const SizedBox(height: denseSpacing), _buildRow( context: context, title: '', - child: _valueText(data.durationDisplay), + child: _valueText( + data.duration != null + ? durationText(data.duration!, fractionDigits: 0) + : 'Pending', + ), ), const SizedBox(height: defaultSpacing), - ...data is Socket - ? _buildSocketTimingRows(context) - : _buildHttpTimingRows(context), + ...switch (data) { + Socket() => _buildSocketTimingRows(context), + WebSocket() => _buildWebSocketTimingRows(context), + _ => _buildHttpTimingRows(context), + }, const SizedBox(height: defaultSpacing), _buildRow( context: context, @@ -707,13 +857,21 @@ class NetworkRequestOverviewView extends StatelessWidget { ]; } - Widget _buildTimingRow(Color color, String label, Duration duration) { - final flex = (duration.inMicroseconds / data.duration!.inMicroseconds * 100) - .round(); + Duration? get _totalDuration => data.duration; + + Widget _buildTimingRow( + Color color, + String segmentLabel, + Duration segmentDuration, + ) { + final totalDuration = _totalDuration!; + final flex = + (segmentDuration.inMicroseconds / totalDuration.inMicroseconds * 100) + .round(); return Flexible( flex: flex, child: DevToolsTooltip( - message: '$label - ${durationText(duration)}', + message: '$segmentLabel - ${durationText(segmentDuration)}', child: Container(height: _timingGraphHeight, color: color), ), ); @@ -721,7 +879,9 @@ class NetworkRequestOverviewView extends StatelessWidget { Widget _buildHttpTimeGraph() { final data = this.data as DartIOHttpRequestData; - if (data.duration == null || data.instantEvents.isEmpty) { + if (_totalDuration == null || + _totalDuration!.inMicroseconds == 0 || + data.instantEvents.isEmpty) { return Container( key: httpTimingGraphKey, height: 18.0, @@ -819,6 +979,116 @@ class NetworkRequestOverviewView extends StatelessWidget { ]; } + List _buildWebSocketOverviewRows(BuildContext context) { + final webSocket = data as WebSocket; + + return [ + _buildRow( + context: context, + title: 'Connection id', + child: _valueText(webSocket.connectionId), + ), + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Protocol', + child: _valueText(webSocket.protocol ?? '--'), + ), + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Bytes sent', + child: _valueText(formatBytes(webSocket.bytesSent)), + ), + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Bytes received', + child: _valueText(formatBytes(webSocket.bytesReceived)), + ), + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Frames sent', + child: _valueText('${webSocket.framesSent}'), + ), + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Frames received', + child: _valueText('${webSocket.framesReceived}'), + ), + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Ping count', + child: _valueText('${webSocket.pingCount}'), + ), + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Pong count', + child: _valueText('${webSocket.pongCount}'), + ), + if (webSocket.closeCode case final closeCode?) ...[ + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Close code', + child: _valueText('$closeCode'), + ), + ], + if (webSocket.closeReason case final closeReason?) ...[ + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Close reason', + child: _valueText(closeReason), + ), + ], + if (webSocket.error case final error?) ...[ + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Error', + child: _valueText( + error, + TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + ]; + } + + List _buildWebSocketTimingRows(BuildContext context) { + final webSocket = data as WebSocket; + + return [ + _buildRow( + context: context, + title: 'Last updated', + child: _valueText(formatDateTime(webSocket.lastUpdated)), + ), + const SizedBox(height: defaultSpacing), + _buildRow( + context: context, + title: 'Frames', + child: _valueText( + '${webSocket.framesSent} sent, ' + '${webSocket.framesReceived} received', + ), + ), + ]; + } + + Widget _buildWebSocketTimeGraph(BuildContext context) { + return Container( + key: webSocketTimingGraphKey, + height: _timingGraphHeight, + color: Theme.of(context).colorScheme.primary, + ); + } + Widget _buildSocketTimeGraph(BuildContext context) { return Container( key: socketTimingGraphKey, diff --git a/packages/devtools_app/lib/src/screens/network/network_screen.dart b/packages/devtools_app/lib/src/screens/network/network_screen.dart index 5171c5e6736..3e6d120c163 100644 --- a/packages/devtools_app/lib/src/screens/network/network_screen.dart +++ b/packages/devtools_app/lib/src/screens/network/network_screen.dart @@ -6,15 +6,13 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; import '../../shared/config_specific/copy_to_clipboard/copy_to_clipboard.dart'; import '../../shared/config_specific/import_export/import_export.dart'; -import '../../shared/feature_flags.dart'; import '../../shared/framework/screen.dart'; import '../../shared/globals.dart'; import '../../shared/http/curl_command.dart'; @@ -30,6 +28,7 @@ import '../../shared/ui/utils.dart'; import 'network_controller.dart'; import 'network_model.dart'; import 'network_request_inspector.dart'; +import 'utils/http_utils.dart'; class NetworkScreen extends Screen { NetworkScreen() : super.fromMetaData(ScreenMetaData.network); @@ -39,9 +38,6 @@ class NetworkScreen extends Screen { @override String get docPageId => screenId; - @override - bool showAiAssistant() => true; - @override Widget buildScreenBody(BuildContext context) => const NetworkScreenBody(); @@ -73,10 +69,13 @@ class NetworkScreen extends Screen { return Row( mainAxisSize: MainAxisSize.min, children: [ - Text( - 'Showing ${nf.format(filteredCount)} of ' - '${nf.format(totalCount)} ' - '${pluralize('request', totalCount)}', + Flexible( + child: Text( + 'Showing ${nf.format(filteredCount)} of ' + '${nf.format(totalCount)} ' + '${pluralize('request', totalCount)}', + overflow: TextOverflow.ellipsis, + ), ), const SizedBox(width: denseSpacing), child!, @@ -187,6 +186,13 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> bool _recording = false; + // The search and filter fields move between rows as the screen width + // crosses the responsive breakpoints below. The stable keys keep each + // field's state (text, selection, and focus) attached to the field when it + // is reparented to a different row in the same frame. + final _searchFieldKey = GlobalKey(debugLabel: 'networkSearchField'); + final _filterFieldKey = GlobalKey(debugLabel: 'networkFilterField'); + @override void initState() { super.initState(); @@ -197,8 +203,6 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> _recording = controller.recordingNotifier.value; }); }); - - addAutoDisposeListener(controller.filteredData); } @override @@ -208,7 +212,26 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> } final screenWidth = ScreenSize(context).width; - final hasRequests = controller.filteredData.value.isNotEmpty; + // The search field's navigation controls and the filter field's prefix and + // suffix controls need more width than is left beside the other controls + // on narrow screens. Below [MediaSize.s] the filter field moves to its own + // row, and below [MediaSize.xs] the search field does too. The threshold + // for the filter row is [MediaSize.s] because the filter field's suffix + // controls overflowed by 16 pixels at a 600 pixel width. + final searchOnOwnRow = screenWidth <= MediaSize.xs; + final filterOnOwnRow = screenWidth <= MediaSize.s; + final searchField = SearchField( + key: _searchFieldKey, + searchController: controller, + searchFieldWidth: screenWidth <= MediaSize.xs + ? defaultSearchFieldWidth + : wideSearchFieldWidth, + ); + final filterField = StandaloneFilterField( + key: _filterFieldKey, + controller: controller, + filteredItem: 'request', + ); return Column( children: [ Row( @@ -233,53 +256,48 @@ class _NetworkProfilerControlsState extends State<_NetworkProfilerControls> ), const SizedBox(width: denseSpacing), // TODO(kenz): fix focus issue when state is refreshed - Expanded( - child: SearchField( - searchController: controller, - searchFieldEnabled: hasRequests, - searchFieldWidth: screenWidth <= MediaSize.xs - ? defaultSearchFieldWidth - : wideSearchFieldWidth, - ), - ), - const SizedBox(width: denseSpacing), - Expanded( - child: StandaloneFilterField( - controller: controller, - filteredItem: 'request', - ), - ), + if (searchOnOwnRow) + const Spacer() + else + Expanded(child: searchField), + if (!filterOnOwnRow) ...[ + const SizedBox(width: denseSpacing), + Expanded(child: filterField), + ], const SizedBox(width: denseSpacing), - if (FeatureFlags.networkSaveLoad.isEnabled) - OpenSaveButtonGroup( - screenId: ScreenMetaData.network.id, - saveFormats: const [SaveFormat.devtools, SaveFormat.har], - gaItemForSaveFormatSelection: (SaveFormat format) => - switch (format) { - SaveFormat.devtools => gac.saveFile, - SaveFormat.har => gac.NetworkEvent.downloadAsHar.name, - }, - onSave: (SaveFormat format) async { + OpenSaveButtonGroup( + screenId: ScreenMetaData.network.id, + saveFormats: const [SaveFormat.devtools, SaveFormat.har], + gaItemForSaveFormatSelection: (SaveFormat format) => switch (format) { - case SaveFormat.devtools: - await controller.fetchFullDataBeforeExport(); - controller.exportData(); - case SaveFormat.har: - await controller.exportAsHarFile(); - } - }, - ) - else - DownloadButton( - tooltip: 'Download as .har file', - minScreenWidthForText: - _NetworkProfilerControls._includeTextWidth, - onPressed: controller.exportAsHarFile, - gaScreen: gac.network, - gaSelection: gac.NetworkEvent.downloadAsHar.name, - ), + SaveFormat.devtools => gac.saveFile, + SaveFormat.har => gac.NetworkEvent.downloadAsHar.name, + }, + onSave: (SaveFormat format) async { + switch (format) { + case SaveFormat.devtools: + await controller.fetchFullDataBeforeExport(); + controller.exportData(); + case SaveFormat.har: + await controller.exportAsHarFile(); + } + }, + ), ], ), + // On narrow screens, place the search and filter fields on their own + // rows so that each has enough space for its navigation, prefix, and + // suffix controls without overflowing. + if (searchOnOwnRow) + Padding( + padding: const EdgeInsets.only(top: denseSpacing), + child: Row(children: [Expanded(child: searchField)]), + ), + if (filterOnOwnRow) + Padding( + padding: const EdgeInsets.only(top: denseSpacing), + child: filterField, + ), if (!_recording) const Padding( padding: EdgeInsets.only(top: intermediateSpacing), @@ -337,11 +355,7 @@ class _NetworkProfilerBody extends StatelessWidget { ValueListenableBuilder>( valueListenable: controller.filteredData, builder: (context, filteredRequests, _) { - return NetworkRequestsTable( - requests: filteredRequests, - searchMatchesNotifier: controller.searchMatches, - activeSearchMatchNotifier: controller.activeSearchMatch, - ); + return NetworkRequestsTable(requests: filteredRequests); }, ), const NetworkRequestInspector(), @@ -351,18 +365,14 @@ class _NetworkProfilerBody extends StatelessWidget { } class NetworkRequestsTable extends StatelessWidget { - const NetworkRequestsTable({ - super.key, - required this.requests, - required this.searchMatchesNotifier, - required this.activeSearchMatchNotifier, - }); + const NetworkRequestsTable({super.key, required this.requests}); static const methodColumn = MethodColumn(); static final addressColumn = AddressColumn(); static const statusColumn = StatusColumn(); static const typeColumn = TypeColumn(); static const durationColumn = DurationColumn(); + static const responseSizeColumn = ResponseSizeColumn(); static final timestampColumn = TimestampColumn(); static const actionsColumn = ActionsColumn(); static final columns = >[ @@ -371,13 +381,12 @@ class NetworkRequestsTable extends StatelessWidget { statusColumn, typeColumn, durationColumn, + responseSizeColumn, timestampColumn, actionsColumn, ]; final List requests; - final ValueListenable> searchMatchesNotifier; - final ValueListenable activeSearchMatchNotifier; @override Widget build(BuildContext context) { @@ -405,6 +414,20 @@ class NetworkRequestsTable extends StatelessWidget { } } +class ResponseSizeColumn extends ColumnData { + const ResponseSizeColumn() + : super('Size', alignment: ColumnAlignment.right, fixedWidthPx: 90); + + @override + int? getValue(NetworkRequest dataObject) => dataObject.responseBytes; + + @override + String getDisplayValue(NetworkRequest dataObject) { + final bytes = dataObject.responseBytes; + return bytes != null ? formatBytes(bytes) : '-'; + } +} + class AddressColumn extends ColumnData implements ColumnRenderer { AddressColumn() @@ -489,17 +512,25 @@ class ActionsColumn extends ColumnData MenuItemButton( child: const Text('Copy as cURL'), onPressed: () { - unawaited( - copyToClipboard( - CurlCommand.from(data).toString(), - successMessage: 'Copied the cURL command to the clipboard', - ), - ); + unawaited(_copyAsCurl(data)); }, ), ], ); } + + Future _copyAsCurl(DartIOHttpRequestData data) async { + try { + await data.getFullRequestData(); + } catch (_) { + // Ignore errors fetching full data so we can still attempt to copy + // the partial request data we already have. + } + await copyToClipboard( + CurlCommand.from(data).toString(), + successMessage: 'Copied the cURL command to the clipboard', + ); + } } class StatusColumn extends ColumnData diff --git a/packages/devtools_app/lib/src/screens/network/network_service.dart b/packages/devtools_app/lib/src/screens/network/network_service.dart index a93f35dc05a..34132bbee7e 100644 --- a/packages/devtools_app/lib/src/screens/network/network_service.dart +++ b/packages/devtools_app/lib/src/screens/network/network_service.dart @@ -14,10 +14,17 @@ class NetworkService { NetworkController get networkController => screenControllers.lookup(); - /// Tracks the time (microseconds since epoch) that the HTTP profile was last - /// retrieved for a given isolate ID. + /// Tracks the VM timeline timestamp (microseconds) that the HTTP profile was + /// last retrieved for a given isolate ID. + /// + /// These values are passed to `getHttpProfile` as `updatedSince`, which must + /// use the VM's monotonic timeline clock — not wall-clock time. final lastHttpDataRefreshTimePerIsolate = {}; + /// Tracks the time (microseconds since epoch) that the WebSocket profile was + /// last retrieved for a given isolate ID. + final lastWebSocketDataRefreshTimePerIsolate = {}; + /// Updates the last Socket data refresh time to the current time. /// /// If [alreadyRecordingSocketData] is true, it's unclear when the last @@ -41,21 +48,45 @@ class NetworkService { return timestamp; } - /// Updates the last HTTP data refresh time to the current time. + /// Updates the last HTTP data refresh time to the current VM timeline time. /// /// If [alreadyRecordingHttp] is true it's unclear when the last refresh time /// would have occurred, so the refresh time is not updated. Otherwise, - /// [lastHttpDataRefreshTimePerIsolate] is updated to the current - /// time. - void updateLastHttpDataRefreshTime({bool alreadyRecordingHttp = false}) { + /// [lastHttpDataRefreshTimePerIsolate] is updated to the current VM timeline + /// timestamp for each known isolate. + /// + /// Wall-clock time must not be used here: `getHttpProfile`'s `updatedSince` + /// expects the VM timeline clock. A wall-clock value would filter out all + /// subsequent requests. + Future updateLastHttpDataRefreshTime({ + bool alreadyRecordingHttp = false, + }) async { if (!alreadyRecordingHttp) { + final service = serviceConnection.serviceManager.service; + if (service == null) return; + final timestamp = (await service.getVMTimelineMicros()).timestamp!; for (final isolateId in lastHttpDataRefreshTimePerIsolate.keys.toList()) { - // It's safe to use `DateTime.now()` here since we don't need to worry - // about dropping data between the time the last profile was generated - // by the target application and the time `DateTime.now()` is called - // here. - lastHttpDataRefreshTimePerIsolate[isolateId] = - DateTime.now().microsecondsSinceEpoch; + lastHttpDataRefreshTimePerIsolate[isolateId] = timestamp; + } + } + } + + /// Updates the last WebSocket data refresh time to the current time. + /// + /// WebSocket profiling is controlled by HttpClient.enableTimelineLogging, + /// so this timestamp follows the HTTP timeline logging lifecycle. + Future updateLastWebSocketDataRefreshTime({ + bool alreadyRecordingWebSocket = false, + }) async { + if (!alreadyRecordingWebSocket) { + final service = serviceConnection.serviceManager.service; + if (service == null) return; + + final timestamp = (await service.getVMTimelineMicros()).timestamp!; + + for (final isolateId + in lastWebSocketDataRefreshTimePerIsolate.keys.toList()) { + lastWebSocketDataRefreshTimePerIsolate[isolateId] = timestamp; } } } @@ -79,6 +110,10 @@ class NetworkService { if (cancelledCallback?.call() ?? false) return; networkController.lastSocketDataRefreshMicros = timestamp; + + final webSockets = await _refreshWebSocketProfile(); + if (cancelledCallback?.call() ?? false) return; + List? httpRequests; httpRequests = await _refreshHttpProfile(); if (cancelledCallback?.call() ?? false) return; @@ -86,6 +121,7 @@ class NetworkService { networkController.processNetworkTraffic( sockets: sockets, httpRequests: httpRequests, + webSockets: webSockets, ); } on RPCError catch (e) { if (!e.isServiceDisposedError) { @@ -138,6 +174,67 @@ class NetworkService { }); } + Future> _refreshWebSocketProfile() async { + final service = serviceConnection.serviceManager.service; + if (service == null) return const []; + + final connections = []; + + await service.forEachIsolate((isolate) async { + final isolateId = isolate.id!; + + if (!await service.isWebSocketProfilingAvailable(isolateId)) { + return; + } + + final profile = await service.getWebSocketProfile( + isolateId, + updatedSince: DateTime.fromMicrosecondsSinceEpoch( + lastWebSocketDataRefreshTimePerIsolate.putIfAbsent( + isolateId, + // If a new isolate has spawned, request all WebSocket connections + // from the start of the profile. + () => 0, + ), + ), + ); + + final fullConnections = await Future.wait( + profile.connections.map( + (connection) => + service.getWebSocketConnection(isolateId, connection.id), + ), + ); + + connections.addAll(fullConnections); + + // Use the profile timestamp rather than DateTime.now() so that we don't + // miss updates between the profile snapshot and this assignment. + lastWebSocketDataRefreshTimePerIsolate[isolateId] = + profile.timestamp.microsecondsSinceEpoch; + }); + + return connections; + } + + Future _clearWebSocketProfile() async { + final service = serviceConnection.serviceManager.service; + if (service == null) return; + + await service.forEachIsolate((isolate) async { + final isolateId = isolate.id!; + + if (!await service.isWebSocketProfilingAvailable(isolateId)) { + return; + } + + final future = service.clearWebSocketProfile(isolateId); + + // The call may not complete immediately if the isolate is paused. + await timeout(future, 500); + }); + } + Future> _refreshSockets() async { final service = serviceConnection.serviceManager.service; if (service == null) return []; @@ -202,9 +299,16 @@ class NetworkService { } Future clearData() async { - await updateLastSocketDataRefreshTime(); - updateLastHttpDataRefreshTime(); - await _clearSocketProfile(); - await _clearHttpProfile(); + try { + await updateLastSocketDataRefreshTime(); + await updateLastHttpDataRefreshTime(); + await _clearSocketProfile(); + await _clearHttpProfile(); + await _clearWebSocketProfile(); + } on RPCError catch (e) { + if (!e.isServiceDisposedError) { + rethrow; + } + } } } diff --git a/packages/devtools_app/lib/src/screens/network/offline_network_data.dart b/packages/devtools_app/lib/src/screens/network/offline_network_data.dart index ce6855ac382..09c12a0974d 100644 --- a/packages/devtools_app/lib/src/screens/network/offline_network_data.dart +++ b/packages/devtools_app/lib/src/screens/network/offline_network_data.dart @@ -3,6 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_shared/devtools_shared.dart'; +import 'package:meta/meta.dart'; import '../../shared/http/http_request_data.dart'; import '../network/network_controller.dart'; @@ -15,6 +16,7 @@ class OfflineNetworkData with Serializable { OfflineNetworkData({ required this.httpRequestData, required this.socketData, + required this.webSocketData, this.selectedRequestId, required this.timelineMicrosOffset, }); @@ -61,11 +63,24 @@ class OfflineNetworkData with Serializable { selectedRequestId: json[_OfflineDataKeys.selectedRequestId.name] as String?, socketData: socketData, + webSocketData: + (json[_OfflineDataKeys.webSocketData.name] as List?) + ?.map((e) { + if (e is Map) { + return WebSocket.fromJson(e); + } + return null; + }) + .whereType() + .toList() ?? + [], timelineMicrosOffset: timelineMicrosOffset as int? ?? 0, ); } - bool get isEmpty => httpRequestData.isEmpty && socketData.isEmpty; + @visibleForTesting + bool get isEmpty => + httpRequestData.isEmpty && socketData.isEmpty && webSocketData.isEmpty; /// List of current [DartIOHttpRequestData] network requests. final List httpRequestData; @@ -79,6 +94,9 @@ class OfflineNetworkData with Serializable { /// The list of socket statistics for the offline network data. final List socketData; + /// The list of WebSocket connections for the offline network data. + final List webSocketData; + /// Converts the current offline data to a JSON format. @override Map toJson() { @@ -90,6 +108,9 @@ class OfflineNetworkData with Serializable { _OfflineDataKeys.socketData.name: socketData .map((e) => e.toJson()) .toList(), + _OfflineDataKeys.webSocketData.name: webSocketData + .map((e) => e.toJson()) + .toList(), _OfflineDataKeys.timelineMicrosOffset.name: timelineMicrosOffset, }; } @@ -99,6 +120,7 @@ enum _OfflineDataKeys { httpRequestData, selectedRequestId, socketData, + webSocketData, request, timelineMicrosOffset, } diff --git a/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart index 9efd5482a73..b4c30797df0 100644 --- a/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart +++ b/packages/devtools_app/lib/src/screens/network/utils/http_utils.dart @@ -29,3 +29,18 @@ int calculateHeadersSize(Map? headers) { // Calculate the byte length of the headers string return utf8.encode(headersString).length; } + +// Output Formats: +// - 512 → "512 B" +// - 2000 → "2.0 kB" +// - 1000000 → "1.0 MB" +// Values are rounded to one decimal place for kB and MB. +// Uses decimal (base-10) units to match Chrome DevTools. +String formatBytes(int? bytes) { + if (bytes == null || bytes < 0) return '-'; + if (bytes < 1000) return '$bytes B'; + if (bytes < 1000 * 1000) { + return '${(bytes / 1000).toStringAsFixed(1)} kB'; + } + return '${(bytes / (1000 * 1000)).toStringAsFixed(1)} MB'; +} diff --git a/packages/devtools_app/lib/src/screens/performance/panes/controls/enhance_tracing/enhance_tracing.dart b/packages/devtools_app/lib/src/screens/performance/panes/controls/enhance_tracing/enhance_tracing.dart index 1e708a31f77..eebec44b2ea 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/controls/enhance_tracing/enhance_tracing.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/controls/enhance_tracing/enhance_tracing.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../service/service_extension_widgets.dart'; import '../../../../../service/service_extensions.dart' as extensions; diff --git a/packages/devtools_app/lib/src/screens/performance/panes/controls/more_debugging_options.dart b/packages/devtools_app/lib/src/screens/performance/panes/controls/more_debugging_options.dart index fe865ae1319..1f36790cefd 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/controls/more_debugging_options.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/controls/more_debugging_options.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../service/service_extension_widgets.dart'; import '../../../../service/service_extensions.dart' as extensions; diff --git a/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart b/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart index 66953bd5b9b..6da95046cd0 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_controls.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../service/service_extension_widgets.dart'; import '../../../../service/service_extensions.dart' as extensions; @@ -148,7 +148,10 @@ class _SecondaryPerformanceControls extends StatelessWidget { const SizedBox(width: denseSpacing), OpenSaveButtonGroup( screenId: ScreenMetaData.performance.id, - onSave: (_) => controller.exportData(), + onSave: (_) async { + await controller.fetchFullDataBeforeExport(); + controller.exportData(); + }, ), if (isFlutterApp) ...[ const SizedBox(width: denseSpacing), diff --git a/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_settings.dart b/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_settings.dart index f6db51401be..fe1a73593d1 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_settings.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/controls/performance_settings.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/globals.dart'; import '../../../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frame_model.dart b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frame_model.dart index 9b199dbea87..04b4cf790a5 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frame_model.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frame_model.dart @@ -165,10 +165,6 @@ class FlutterFrame { timelineEventData.rasterEvent?.writeTrackEventsToBuffer(buf); return buf.toString(); } - - FlutterFrame shallowCopy() { - return FlutterFrame.fromJson(json); - } } class FrameTimelineEventData { diff --git a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_chart.dart b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_chart.dart index 82bee01d071..e38487f9405 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_chart.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_chart.dart @@ -8,7 +8,7 @@ import 'dart:math' as math; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/analytics.dart' as ga; import '../../../../shared/analytics/constants.dart' as gac; @@ -249,6 +249,14 @@ class _FramesChartState extends State with AutoDisposeMixin { _framesScrollController = ScrollController( initialScrollOffset: initialScrollOffset, ); + + // Snap after layout so [atScrollBottom] matches for live follow. + if (_selectedFrameIndex == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_framesScrollController.hasClients) return; + unawaited(_framesScrollController.autoScrollToBottom(jump: true)); + }); + } } @override @@ -261,13 +269,20 @@ class _FramesChartState extends State with AutoDisposeMixin { } double _calculateInitialHorizontalScrollOffset() { - final selectedIndex = _selectedFrameIndex; - if (selectedIndex == null) return 0.0; - - final chartWidthWithoutAxisLabels = - widget.constraints.maxWidth - _yAxisUnitsSpace; + final chartWidthWithoutAxisLabels = math.max( + 0.0, + widget.constraints.maxWidth - _yAxisUnitsSpace, + ); final totalFramesInView = chartWidthWithoutAxisLabels ~/ _defaultFrameWidthWithPadding; + + final selectedIndex = _selectedFrameIndex; + if (selectedIndex == null) { + // Dock to the live edge when remounting with no selection (#9525). + final framesOutOfView = widget.frames.length - totalFramesInView; + return math.max(0.0, framesOutOfView * _defaultFrameWidthWithPadding); + } + final fullFrameRangeInView = Range(0, totalFramesInView); if (fullFrameRangeInView.contains(selectedIndex)) return 0.0; @@ -573,7 +588,7 @@ class FlutterFramesChartItem extends StatelessWidget { child: Column( children: [ // Dummy child so that the InkWell does not take up the entire column. - const Expanded(child: SizedBox()), + const Spacer(), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ui, raster], diff --git a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_controller.dart b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_controller.dart index ff970282c2c..63ee814e46b 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_controller.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/flutter_frames/flutter_frames_controller.dart @@ -22,7 +22,7 @@ class FlutterFramesController extends PerformanceFeatureController { /// The currently selected timeline frame. ValueListenable get selectedFrame => _selectedFrameNotifier; - final _selectedFrameNotifier = ValueNotifier(null); + final _selectedFrameNotifier = _SelectedFrameNotifier(null); /// The flutter frames in the chart. ValueListenable> get flutterFrames => _flutterFrames; @@ -61,11 +61,6 @@ class FlutterFramesController extends PerformanceFeatureController { /// frame id in the corresponding [FlutterTimelineEvent]s. final _unassignedFlutterFrames = {}; - /// Tracks the current frame undergoing selection so that we can equality - /// check after async operations and bail out early if another frame has been - /// selected during awaits. - FlutterFrame? currentFrameBeingSelected; - @override Future init() async { if (!offlineDataController.showingOfflineData.value) { @@ -128,6 +123,12 @@ class FlutterFramesController extends PerformanceFeatureController { _unassignedFlutterFrames.remove(frameNumber); _updateFirstWellFormedFrameMicros(frame); } + if (_selectedFrameNotifier.value == frame) { + // [frame] was selected before its timeline events were available. Notify + // listeners again so that views built from [selectedFrame], like the + // Frame Analysis tab, rebuild with the data that is now available. + _selectedFrameNotifier.notifyFrameDataChanged(); + } } bool hasUnassignedFlutterFrame(int frameNumber) { @@ -184,8 +185,6 @@ class FlutterFramesController extends PerformanceFeatureController { @override void handleSelectedFrame(FlutterFrame frame) { - currentFrameBeingSelected = frame; - // Unselect [frame] if is already selected. if (_selectedFrameNotifier.value == frame) { _selectedFrameNotifier.value = null; @@ -230,3 +229,16 @@ class FlutterFramesController extends PerformanceFeatureController { super.dispose(); } } + +/// Notifier for the selected [FlutterFrame]. +/// +/// Timeline events are assigned to a [FlutterFrame] in place, which can happen +/// after the frame has been selected. [ValueNotifier] only notifies when +/// [value] changes, and [notifyListeners] is protected, so this subclass +/// exposes [notifyFrameDataChanged] for that case. +class _SelectedFrameNotifier extends ValueNotifier { + _SelectedFrameNotifier(super.value); + + /// Notifies listeners that the selected frame's data changed in place. + void notifyFrameDataChanged() => notifyListeners(); +} diff --git a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis.dart b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis.dart index 4ebc6896ea3..e9b513104bc 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis.dart @@ -5,7 +5,7 @@ import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../service/service_extension_widgets.dart'; import '../../../../service/service_extensions.dart' as extensions; diff --git a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis_model.dart b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis_model.dart index d1316e41ad0..b2495ef211d 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis_model.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_analysis_model.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'package:meta/meta.dart'; + import '../../../../shared/primitives/trees.dart'; import '../../../../shared/primitives/utils.dart'; import '../../performance_model.dart'; @@ -92,9 +94,7 @@ class FrameAnalysis { final paintEvent = uiEvent.firstChildWithCondition( (event) => FramePhaseType.paint.isMatchForEventName(event.name), ); - return FramePhase.paint( - events: [if (paintEvent != null) paintEvent], - ); + return FramePhase.paint(events: [?paintEvent]); } /// Data for the raster phase of [frame]. @@ -130,6 +130,7 @@ class FrameAnalysis { return longest; } + @visibleForTesting bool get hasExpensiveOperations => saveLayerCount + intrinsicOperationsCount > 0; diff --git a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_hints.dart b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_hints.dart index 1bb06ddbc88..f77a19f7374 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_hints.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_hints.dart @@ -7,7 +7,7 @@ library; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../service/connected_app/connected_app.dart'; import '../../../../service/service_extensions.dart' as extensions; diff --git a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_time_visualizer.dart b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_time_visualizer.dart index b0c09e8f56f..d9e8c612c71 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_time_visualizer.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/frame_analysis/frame_time_visualizer.dart @@ -6,7 +6,7 @@ import 'dart:math' as math; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/primitives/utils.dart'; import '../../../../shared/ui/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/performance/panes/rebuild_stats/rebuild_stats.dart b/packages/devtools_app/lib/src/screens/performance/panes/rebuild_stats/rebuild_stats.dart index a19d2502276..6b2b12f0a1e 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/rebuild_stats/rebuild_stats.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/rebuild_stats/rebuild_stats.dart @@ -6,7 +6,7 @@ import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../service/service_extension_widgets.dart'; import '../../../../service/service_extensions.dart' as extensions; @@ -20,6 +20,12 @@ import '../../../../shared/ui/common_widgets.dart'; import '../flutter_frames/flutter_frame_model.dart'; import 'rebuild_stats_model.dart'; +@visibleForTesting +const debugModeOnlyMessage = + 'Rebuild information is not available for this frame.\n' + 'Widget rebuild counts are only available when running ' + 'an app in debug mode.'; + class RebuildStatsView extends StatefulWidget { const RebuildStatsView({ super.key, @@ -92,6 +98,12 @@ class _RebuildStatsViewState extends State @override Widget build(BuildContext context) { + final isProfileBuild = + serviceConnection.serviceManager.connectedApp?.isProfileBuildNow ?? + false; + if (isProfileBuild) { + return const CenteredMessage(message: debugModeOnlyMessage); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/packages/devtools_app/lib/src/screens/performance/panes/rebuild_stats/rebuild_stats_model.dart b/packages/devtools_app/lib/src/screens/performance/panes/rebuild_stats/rebuild_stats_model.dart index 153778fa520..91c1d0a63c4 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/rebuild_stats/rebuild_stats_model.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/rebuild_stats/rebuild_stats_model.dart @@ -211,8 +211,6 @@ class RebuildCountModel { return _rebuildsForFrame[frameNumber]; } - bool get isNotEmpty => _rebuildsForFrame.isNotEmpty; - Map? toJson() { if (_rebuildsForFrame.isEmpty) { // No need to encode data unless there were actually rebuilds reported. diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_controller_web.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_controller_web.dart index ed9bc3a6f3e..e284d9d2ebf 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_controller_web.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_controller_web.dart @@ -159,6 +159,7 @@ class PerfettoControllerImpl extends PerfettoController { final perfettoPostEventStream = StreamController.broadcast(); + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9907) false positive. bool _initialized = false; @override @@ -191,6 +192,7 @@ class PerfettoControllerImpl extends PerfettoController { await perfettoPostEventStream.close(); processor.dispose(); _activeScrollToTimeRange.dispose(); + activeTrace.dispose(); super.dispose(); } diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_desktop.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_desktop.dart index 1315d9589b2..de792cf4ee8 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_desktop.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_desktop.dart @@ -2,14 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'perfetto_controller.dart'; class Perfetto extends StatelessWidget { - const Perfetto({super.key, required this.perfettoController}); - - final PerfettoController perfettoController; + const Perfetto({super.key, required PerfettoController perfettoController}); @override Widget build(BuildContext context) { diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_web.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_web.dart index 2d5cafa0c84..39db92831f9 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_web.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/_perfetto_web.dart @@ -10,8 +10,8 @@ import 'dart:typed_data'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_app_shared/web_utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:web/web.dart'; import '../../../../../shared/analytics/analytics.dart' as ga; @@ -34,14 +34,14 @@ class Perfetto extends StatefulWidget { } class _PerfettoState extends State with AutoDisposeMixin { - late final PerfettoControllerImpl _perfettoController; + PerfettoControllerImpl get _perfettoController => + widget.perfettoController as PerfettoControllerImpl; late final _PerfettoViewController _viewController; @override void initState() { super.initState(); - _perfettoController = widget.perfettoController as PerfettoControllerImpl; _viewController = _PerfettoViewController(_perfettoController)..init(); // If [_perfettoController.activeTrace.trace] has a null value, the trace diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/perfetto.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/perfetto.dart index fff957cf6a4..0e7d03d6434 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/perfetto.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/perfetto.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../../shared/analytics/constants.dart' as gac; import '../../../../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/tracing/model.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/tracing/model.dart index c2ec2f4d12b..e3ddac11beb 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/tracing/model.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/perfetto/tracing/model.dart @@ -101,6 +101,7 @@ class PerfettoTrackEvent extends _PerfettoTracePacket ].nonNulls, ); + @visibleForTesting List get categories => event.categories; /// The id of the Perfetto track that this event is included in. diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart index 53830788d5d..1dfb79897b1 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_controller.dart @@ -49,7 +49,6 @@ class TimelineEventsController extends PerformanceFeatureController static const uiThreadSuffix = '.ui'; static const rasterThreadSuffix = '.raster'; - static const gpuThreadSuffix = '.gpu'; static const platformThreadSuffix = '.platform'; static const flutterTestThreadSuffix = '.flutter.test..ui'; static final _refreshWorkTrackerDelay = const Duration( @@ -119,6 +118,9 @@ class TimelineEventsController extends PerformanceFeatureController int _nextPollStartMicros = 0; + /// The most recent pull of the VM timeline, if any. + Future? _latestPull; + /// The number of requests per second that we allow for pulling the VM /// timeline. /// @@ -171,7 +173,26 @@ class TimelineEventsController extends PerformanceFeatureController }); } - Future _pullPerfettoVmTimeline({bool isInitialPull = false}) async { + /// Pulls the VM timeline events that have not been received yet. + /// + /// Pulls can be started by polling, [forceRefresh], and + /// [pullLatestTimelineEvents], so each pull waits for the previous one to + /// finish. Otherwise two pulls could request the same time range and add + /// the same events to the trace twice. + Future _pullPerfettoVmTimeline({bool isInitialPull = false}) { + final previousPull = _latestPull; + return _latestPull = () async { + if (previousPull != null) { + // Errors from the previous pull are handled by its own caller. + await previousPull.catchError((_) {}); + } + await _pullPerfettoVmTimelineHelper(isInitialPull: isInitialPull); + }(); + } + + Future _pullPerfettoVmTimelineHelper({ + bool isInitialPull = false, + }) async { final service = serviceConnection.serviceManager.service; if (service == null) return; final currentVmTime = await service.getVMTimelineMicros(); @@ -343,6 +364,18 @@ class TimelineEventsController extends PerformanceFeatureController await loadPerfettoTrace(); } + /// Pulls any timeline events from the VM that have not been received yet. + /// + /// Unlike [forceRefresh], this does not reload the Perfetto trace viewer. + Future pullLatestTimelineEvents() async { + try { + await _pullPerfettoVmTimeline(); + } catch (e, st) { + // Don't block the export of the data we already have. + _log.warning('Failed to pull the latest timeline events.', e, st); + } + } + void processTrackEvents() { if (!_isFlutterAppHelper()) { debugTraceCallback( @@ -499,11 +532,12 @@ class TimelineEventsController extends PerformanceFeatureController @override Future setOfflineData(OfflinePerformanceData offlineData) async { - if (offlineData.perfettoTraceBinary != null) { - _updatePerfettoTrace(offlineData.perfettoTraceBinary!); + final perfettoTraceBinary = offlineData.perfettoTraceBinary; + if (perfettoTraceBinary != null && perfettoTraceBinary.isNotEmpty) { + _updatePerfettoTrace(perfettoTraceBinary); + processTrackEvents(); + await loadPerfettoTrace(); } - processTrackEvents(); - await loadPerfettoTrace(); if (offlineData.selectedFrame != null) { perfettoController.scrollToTimeRange( diff --git a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_view.dart b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_view.dart index 682b3702817..0b75ef2ea1c 100644 --- a/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_view.dart +++ b/packages/devtools_app/lib/src/screens/performance/panes/timeline_events/timeline_events_view.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/constants.dart' as gac; import '../../../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/performance/performance_controller.dart b/packages/devtools_app/lib/src/screens/performance/performance_controller.dart index 8bed01cc548..bc4d295efd0 100644 --- a/packages/devtools_app/lib/src/screens/performance/performance_controller.dart +++ b/packages/devtools_app/lib/src/screens/performance/performance_controller.dart @@ -54,6 +54,7 @@ class PerformanceController extends DevToolsScreenController late final RebuildStatsController rebuildStatsController; + // ignore: dispose-class-fields, false positive. See `applyToFeatureControllers` in the dispose() method. late List _featureControllers; // TODO(jacobr): add the recount controller to [_featureControllers] once your @@ -185,6 +186,7 @@ class PerformanceController extends DevToolsScreenController Future _loadOfflineData(OfflinePerformanceData data) async { await clearData(); offlinePerformanceData = data; + selectedFeatureTabIndex = data.selectedTab; await _applyToFeatureControllersAsync( (c) => c.setOfflineData(offlinePerformanceData!), ); @@ -285,9 +287,24 @@ class PerformanceController extends DevToolsScreenController selectedFrame: flutterFramesController.selectedFrame.value, rebuildCountModel: rebuildCountModel, displayRefreshRate: flutterFramesController.displayRefreshRate.value, + selectedTab: selectedFeatureTabIndex, ).toJson(), ); + /// Pulls the latest timeline events so that they are included in the export. + /// + /// Timeline events are only polled periodically, so the most recent events + /// would otherwise be missing from the export. + Future fetchFullDataBeforeExport() async { + final serviceManager = serviceConnection.serviceManager; + if (offlineDataController.showingOfflineData.value || + !serviceManager.connectedState.value.connected || + (serviceManager.connectedApp?.isDartWebAppNow ?? false)) { + return; + } + await timelineEventsController.pullLatestTimelineEvents(); + } + @override FutureOr releaseMemory({bool partial = false}) async { await clearData(partial: partial, clearVmTimeline: !partial); diff --git a/packages/devtools_app/lib/src/screens/performance/performance_model.dart b/packages/devtools_app/lib/src/screens/performance/performance_model.dart index 637eae39969..84ce55a8457 100644 --- a/packages/devtools_app/lib/src/screens/performance/performance_model.dart +++ b/packages/devtools_app/lib/src/screens/performance/performance_model.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:convert'; + import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; @@ -18,6 +20,7 @@ class OfflinePerformanceData { this.frames = const [], this.selectedFrame, this.rebuildCountModel, + this.selectedTab = 0, double? displayRefreshRate, }) : displayRefreshRate = displayRefreshRate ?? defaultRefreshRate; @@ -36,14 +39,30 @@ class OfflinePerformanceData { selectedFrame: selectedFrame, rebuildCountModel: json.rebuildCountModel, displayRefreshRate: json.displayRefreshRate, + selectedTab: json.selectedTab, ); } + /// Legacy key for the Perfetto trace, stored as a JSON array of numbers. + /// + /// Encoding every byte as a JSON number is extremely memory intensive for + /// large traces (see https://github.com/flutter/devtools/issues/10010), so + /// this key is only read for backwards compatibility with files exported by + /// older versions of DevTools. New exports use [traceBinaryBase64Key]. static const traceBinaryKey = 'traceBinary'; + + /// Key for the Perfetto trace, stored as a base64 encoded string once the + /// data is encoded as JSON. + /// + /// This is intentionally a different key than [traceBinaryKey] so that older + /// versions of DevTools, which expect a JSON array under [traceBinaryKey], + /// treat a new file as having no trace instead of throwing a type error. + static const traceBinaryBase64Key = 'traceBinaryBase64'; static const rebuildCountModelKey = 'rebuildCountModel'; static const displayRefreshRateKey = 'displayRefreshRate'; static const flutterFramesKey = 'flutterFrames'; static const selectedFrameIdKey = 'selectedFrameId'; + static const selectedTabKey = 'selectedTab'; final Uint8List? perfettoTraceBinary; @@ -56,27 +75,60 @@ class OfflinePerformanceData { final FlutterFrame? selectedFrame; - bool get isEmpty => perfettoTraceBinary == null; + /// The index of the feature tab that was selected when the data was exported. + /// + /// This is restored when loading offline data so the user lands on the same + /// tab they exported from. + final int selectedTab; + + bool get isEmpty => + (perfettoTraceBinary == null || perfettoTraceBinary!.isEmpty) && + frames.isEmpty && + rebuildCountModel == null; + /// The JSON serializable form of this data. + /// + /// The trace is stored as a [ByteData] view of [perfettoTraceBinary] rather + /// than being encoded here, so that this method does not copy the trace. + /// `toEncodable` converts the [ByteData] to a base64 string when the data is + /// encoded as JSON for export. Map toJson() => { - traceBinaryKey: perfettoTraceBinary, + traceBinaryBase64Key: perfettoTraceBinary == null + ? null + : ByteData.sublistView(perfettoTraceBinary!), flutterFramesKey: frames.map((frame) => frame.json).toList(), selectedFrameIdKey: selectedFrame?.id, displayRefreshRateKey: displayRefreshRate, rebuildCountModelKey: rebuildCountModel?.toJson(), + selectedTabKey: selectedTab, }; } extension type _PerformanceDataJson(Map json) { Uint8List? get traceBinary { - final value = (json[OfflinePerformanceData.traceBinaryKey] as List?) - ?.cast(); - return value == null ? null : Uint8List.fromList(value); + final base64Trace = json[OfflinePerformanceData.traceBinaryBase64Key]; + switch (base64Trace) { + case final String encoded: + return base64Decode(encoded); + case final ByteData data: + return Uint8List.sublistView(data); + } + + // Files exported by older versions of DevTools store the trace as a list of + // numbers. + final legacyTrace = json[OfflinePerformanceData.traceBinaryKey]; + return switch (legacyTrace) { + final List numbers => Uint8List.fromList(numbers.cast()), + _ => null, + }; } int? get selectedFrameId => json[OfflinePerformanceData.selectedFrameIdKey] as int?; + int get selectedTab => + json[OfflinePerformanceData.selectedTabKey] as int? ?? 0; + List get frames => (json[OfflinePerformanceData.flutterFramesKey] as List? ?? []) .cast() @@ -150,16 +202,6 @@ class FlutterTimelineEvent extends TreeNode { timeBuilder: _timeBuilder.copy(), ); - @visibleForTesting - FlutterTimelineEvent deepCopy() { - final copy = shallowCopy(); - copy.parent = parent; - for (final child in children) { - copy.addChild(child.deepCopy()); - } - return copy; - } - @override String toString() { final buf = StringBuffer(); diff --git a/packages/devtools_app/lib/src/screens/performance/performance_screen.dart b/packages/devtools_app/lib/src/screens/performance/performance_screen.dart index ac10baf610d..f78ab1d9140 100644 --- a/packages/devtools_app/lib/src/screens/performance/performance_screen.dart +++ b/packages/devtools_app/lib/src/screens/performance/performance_screen.dart @@ -6,8 +6,8 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../../shared/analytics/analytics.dart' as ga; diff --git a/packages/devtools_app/lib/src/screens/performance/tabbed_performance_view.dart b/packages/devtools_app/lib/src/screens/performance/tabbed_performance_view.dart index 05eef54cd1c..793daa3d161 100644 --- a/packages/devtools_app/lib/src/screens/performance/tabbed_performance_view.dart +++ b/packages/devtools_app/lib/src/screens/performance/tabbed_performance_view.dart @@ -6,14 +6,13 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/constants.dart' as gac; import '../../shared/globals.dart'; import '../../shared/ui/common_widgets.dart'; import '../../shared/ui/tab.dart'; import 'panes/flutter_frames/flutter_frame_model.dart'; -import 'panes/flutter_frames/flutter_frames_controller.dart'; import 'panes/frame_analysis/frame_analysis.dart'; import 'panes/rebuild_stats/rebuild_stats.dart'; import 'panes/timeline_events/timeline_events_view.dart'; @@ -32,20 +31,18 @@ class _TabbedPerformanceViewState extends State late PerformanceController controller; - late FlutterFramesController _flutterFramesController; - FlutterFrame? _selectedFlutterFrame; @override void initState() { super.initState(); controller = screenControllers.lookup(); - _flutterFramesController = controller.flutterFramesController; + final flutterFramesController = controller.flutterFramesController; - _selectedFlutterFrame = _flutterFramesController.selectedFrame.value; - addAutoDisposeListener(_flutterFramesController.selectedFrame, () { + _selectedFlutterFrame = flutterFramesController.selectedFrame.value; + addAutoDisposeListener(flutterFramesController.selectedFrame, () { setState(() { - _selectedFlutterFrame = _flutterFramesController.selectedFrame.value; + _selectedFlutterFrame = flutterFramesController.selectedFrame.value; }); }); } @@ -80,17 +77,27 @@ class _TabbedPerformanceViewState extends State .map((t) => t.featureController) .toList(); - // If there is not an active feature, activate the first. + // The set of visible tabs can differ between when offline data was exported + // and when it is loaded (e.g. the Frame Analysis and Rebuild Stats tabs are + // only shown for Flutter apps with the relevant data). Clamp the restored + // tab index to the tabs that are actually available to avoid an + // out-of-bounds selection. + final selectedTabIndex = controller.selectedFeatureTabIndex.clamp( + 0, + tabs.length - 1, + ); + + // If there is not an active feature, activate the selected one. if (featureControllers.firstWhereOrNull( (controller) => controller?.isActiveFeature ?? false, ) == null) { - _setActiveFeature(0, featureControllers[0]); + _setActiveFeature(selectedTabIndex, featureControllers[selectedTabIndex]); } return AnalyticsTabbedView( tabs: tabs, - initialSelectedIndex: controller.selectedFeatureTabIndex, + initialSelectedIndex: selectedTabIndex, gaScreen: gac.performance, onTabChanged: (int index) { _setActiveFeature(index, featureControllers[index]); diff --git a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart index 038bf98459a..82b0f1fc975 100644 --- a/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart +++ b/packages/devtools_app/lib/src/screens/profiler/cpu_profile_model.dart @@ -577,7 +577,6 @@ class CpuProfileData with Serializable { final stackFrames = await _CpuStackFrameGenerator( isolateId: isolateId, - cpuSamples: cpuSamples, profileMetaData: profileMetaData, ).generate( treeRoot: _CpuProfileTimelineTree.fromCpuSamples( @@ -745,13 +744,11 @@ class CpuProfileData with Serializable { List? _bottomUpRoots; late final userTags = { - for (final cpuSample in cpuSamples) - if (cpuSample.userTag case final userTag?) userTag, + for (final cpuSample in cpuSamples) ?cpuSample.userTag, }; late final vmTags = { - for (final cpuSample in cpuSamples) - if (cpuSample.vmTag case final vmTag?) vmTag, + for (final cpuSample in cpuSamples) ?cpuSample.vmTag, }; CpuStackFrame? selectedStackFrame; @@ -1177,11 +1174,6 @@ class CpuProfileStore { _profilesByTime.clear(); _profilesByLabel.clear(); } - - void debugPrintKeys() { - _log.info('_profilesByLabel: ${_profilesByLabel.keys}'); - _log.info('_profilesByTime: ${_profilesByTime.keys}'); - } } class _CpuProfileTimelineTree { @@ -1331,7 +1323,7 @@ class _CpuProfileTimelineTree { } /// A generator class for creating a set of [CpuStackFrame]s from a -/// [vm_service.CpuSamples] object. +/// [_CpuProfileTimelineTree] object. /// /// This class is responsible for traversing the call stacks of a CPU profile, /// creating a [CpuStackFrame] for each unique frame, and assigning a unique @@ -1341,12 +1333,10 @@ class _CpuProfileTimelineTree { class _CpuStackFrameGenerator { _CpuStackFrameGenerator({ required this.isolateId, - required this.cpuSamples, required this.profileMetaData, }); final String isolateId; - final vm_service.CpuSamples cpuSamples; final CpuProfileMetaData profileMetaData; final _stackFrames = {}; diff --git a/packages/devtools_app/lib/src/screens/profiler/cpu_profiler.dart b/packages/devtools_app/lib/src/screens/profiler/cpu_profiler.dart index cf2b32f0b23..969f5dc5474 100644 --- a/packages/devtools_app/lib/src/screens/profiler/cpu_profiler.dart +++ b/packages/devtools_app/lib/src/screens/profiler/cpu_profiler.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/constants.dart' as gac; import '../../shared/charts/flame_chart.dart'; diff --git a/packages/devtools_app/lib/src/screens/profiler/cpu_profiler_controller.dart b/packages/devtools_app/lib/src/screens/profiler/cpu_profiler_controller.dart index 4caea9b1072..4274eb2422f 100644 --- a/packages/devtools_app/lib/src/screens/profiler/cpu_profiler_controller.dart +++ b/packages/devtools_app/lib/src/screens/profiler/cpu_profiler_controller.dart @@ -411,6 +411,7 @@ class CpuProfilerController extends DisposableController return '$label${filterTag.isNotEmpty ? '-$filterTag' : ''}'; } + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9910) seems like a bug. Future loadAppStartUpProfile() async { Future loadAppStartUpProfileHelper() async { // Look up the stored app start up profiles before calling [reset]. This diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/bottom_up.dart b/packages/devtools_app/lib/src/screens/profiler/panes/bottom_up.dart index d240dfebb13..615ed41cdca 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/bottom_up.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/bottom_up.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/primitives/utils.dart'; import '../../../shared/table/table.dart'; diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/call_tree.dart b/packages/devtools_app/lib/src/screens/profiler/panes/call_tree.dart index 1169427a81c..1c63746b3e2 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/call_tree.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/call_tree.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/primitives/utils.dart'; import '../../../shared/table/table.dart'; diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/controls/cpu_profiler_controls.dart b/packages/devtools_app/lib/src/screens/profiler/panes/controls/cpu_profiler_controls.dart index 642b4e928d6..db6894bd1cb 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/controls/cpu_profiler_controls.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/controls/cpu_profiler_controls.dart @@ -5,7 +5,7 @@ import 'dart:developer'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/globals.dart'; import '../../cpu_profiler_controller.dart'; diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart b/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart index fbc7cb06d38..8a0082c83cd 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/controls/profiler_screen_controls.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/analytics/constants.dart' as gac; import '../../../../shared/framework/screen.dart'; @@ -18,7 +18,6 @@ class ProfilerScreenControls extends StatelessWidget { required this.controller, required this.recording, required this.processing, - required this.offline, }); final ProfilerScreenController controller; @@ -27,8 +26,6 @@ class ProfilerScreenControls extends StatelessWidget { final bool processing; - final bool offline; - @override Widget build(BuildContext context) { return OfflineAwareControls( diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/cpu_flame_chart.dart b/packages/devtools_app/lib/src/screens/profiler/panes/cpu_flame_chart.dart index f0fc0d023dd..d3b72c341d0 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/cpu_flame_chart.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/cpu_flame_chart.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/charts/flame_chart.dart'; import '../../../shared/ui/colors.dart'; diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/cpu_profile_columns.dart b/packages/devtools_app/lib/src/screens/profiler/panes/cpu_profile_columns.dart index b2a97206424..77b4da48345 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/cpu_profile_columns.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/cpu_profile_columns.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/table/table.dart'; import '../../../shared/table/table_data.dart'; diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table.dart b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table.dart index 589e36d0385..e2b018b659b 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../shared/primitives/utils.dart'; import '../../../../shared/table/table.dart'; diff --git a/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_model.dart b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_model.dart index d7e78bed259..93b7cc9c236 100644 --- a/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_model.dart +++ b/packages/devtools_app/lib/src/screens/profiler/panes/method_table/method_table_model.dart @@ -3,6 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; import '../../../../shared/primitives/graph.dart'; import '../../../../shared/primitives/utils.dart'; @@ -136,6 +137,7 @@ $display ($totalCount samples) '''; } + @visibleForTesting MethodTableGraphNode copy() { return MethodTableGraphNode( name: name, diff --git a/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart b/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart index cf473f55f24..df33371c5a6 100644 --- a/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart +++ b/packages/devtools_app/lib/src/screens/profiler/profiler_screen.dart @@ -5,7 +5,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:vm_service/vm_service.dart' hide Stack; @@ -142,7 +142,6 @@ class _ProfilerScreenBodyState extends State controller: controller, recording: recording, processing: profilerBusy, - offline: offlineDataController.showingOfflineData.value, ), const SizedBox(height: intermediateSpacing), Expanded( diff --git a/packages/devtools_app/lib/src/screens/profiler/profiler_status.dart b/packages/devtools_app/lib/src/screens/profiler/profiler_status.dart index f4fd5aeacb9..3bf5028e1fd 100644 --- a/packages/devtools_app/lib/src/screens/profiler/profiler_status.dart +++ b/packages/devtools_app/lib/src/screens/profiler/profiler_status.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/ui/common_widgets.dart'; import 'cpu_profiler_controller.dart'; diff --git a/packages/devtools_app/lib/src/screens/provider/provider_screen.dart b/packages/devtools_app/lib/src/screens/provider/provider_screen.dart deleted file mode 100644 index e4f077ad8f2..00000000000 --- a/packages/devtools_app/lib/src/screens/provider/provider_screen.dart +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright 2021 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; - -import '../../shared/framework/screen.dart'; -import '../../shared/ui/common_widgets.dart'; - -class ProviderScreen extends Screen { - ProviderScreen() : super.fromMetaData(ScreenMetaData.provider); - - static final id = ScreenMetaData.provider.id; - - @override - Widget buildScreenBody(BuildContext context) { - return CenteredMessage( - richMessage: [ - const TextSpan( - text: - 'The Provider screen is now shipped as a DevTools extension.\n' - 'If you want to use this tool, please upgrade your ', - ), - TextSpan( - text: 'package:provider', - style: Theme.of(context).fixedFontStyle, - ), - const TextSpan( - text: ' dependency to the latest version, and then re-open DevTools.', - ), - ], - ); - } -} diff --git a/packages/devtools_app/lib/src/screens/vm_developer/isolate_statistics/isolate_statistics_view.dart b/packages/devtools_app/lib/src/screens/vm_developer/isolate_statistics/isolate_statistics_view.dart index 77bd8d42c2f..dd59a5f657d 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/isolate_statistics/isolate_statistics_view.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/isolate_statistics/isolate_statistics_view.dart @@ -4,8 +4,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../shared/analytics/constants.dart' as gac; @@ -340,6 +340,12 @@ class _IsolatePortsWidgetState extends State { final selectedPort = ValueNotifier(null); + @override + void dispose() { + selectedPort.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final ports = widget.controller.ports; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/class_hierarchy_explorer.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/class_hierarchy_explorer.dart index 08cd597357e..8465be22811 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/class_hierarchy_explorer.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/class_hierarchy_explorer.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/ui/common_widgets.dart'; import '../../../shared/ui/tree_view.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/inbound_references_tree.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/inbound_references_tree.dart index c48c3c32a83..56d31243d55 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/inbound_references_tree.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/inbound_references_tree.dart @@ -26,6 +26,7 @@ class InboundReferencesTreeNode extends TreeNode { @override bool get isExpandable => ref.source != null; + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9910) this seems like a bug. late final description = _inboundRefDescription(ref, null); /// Wrapper to get the name of an [ObjRef] depending on its type. diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_inspector_view.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_inspector_view.dart index 76249c3402e..b4711b5fc6f 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_inspector_view.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_inspector_view.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/constants.dart' as gac; import '../../../shared/globals.dart'; @@ -78,6 +78,7 @@ class ObjectInspectorSelector extends StatefulWidget { class _ObjectInspectorSelectorState extends State { String value = ObjectInspectorSelector.kProgramExplorer; + late ObjectInspectorViewController controller; @override diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_store.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_store.dart index fe42a3177a6..275e82d6ccf 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_store.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_store.dart @@ -53,7 +53,7 @@ class _ObjectColumn extends ColumnData return VmServiceObjectLink( // TODO(srawlins): What type is `data` at runtime? If cast to `int`, no // tests fail, but that can't be right... - // ignore: avoid-dynamic + // ignore: avoid-dynamic, avoid-unnecessary-type-casts object: (data as dynamic).value, onTap: onTap, ); diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_viewport.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_viewport.dart index 37084d17a67..67a4512fb93 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_viewport.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/object_viewport.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../shared/primitives/history_manager.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_class_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_class_display.dart index cf01742658e..4ea3e7795c1 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_class_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_class_display.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../vm_developer_common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_code_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_code_display.dart index 0e24710b7c8..095ca1cc81a 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_code_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_code_display.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:string_scanner/string_scanner.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_field_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_field_display.dart index c30182d6407..3e88cd3f336 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_field_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_field_display.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../vm_developer_common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_function_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_function_display.dart index dfbbb8e41f2..fbc28cd878b 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_function_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_function_display.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../vm_developer_common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_ic_data_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_ic_data_display.dart index c718d5368d9..8270b4281ba 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_ic_data_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_ic_data_display.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_instance_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_instance_display.dart index 8bd7e660189..f9fc8edbac8 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_instance_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_instance_display.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../shared/console/widgets/expandable_variable.dart'; @@ -171,6 +171,7 @@ class DisplayProvider extends StatelessWidget { children: textSpansFromAnsi( variable.text ?? '', theme.subtleFixedFontStyle, + backgroundColor: theme.colorScheme.surface, ), ), ), diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_library_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_library_display.dart index faa3d3efb42..4d1a3355e41 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_library_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_library_display.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../vm_developer_common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_object_pool_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_object_pool_display.dart index 0f10967d25e..c2d763ba603 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_object_pool_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_object_pool_display.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../shared/primitives/utils.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_script_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_script_display.dart index 5063de46dec..ad008b4bc9f 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_script_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_script_display.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../vm_developer_common_widgets.dart'; import 'object_inspector_view_controller.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_simple_list_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_simple_list_display.dart index bde64b1ab15..74b9a5f51da 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_simple_list_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_simple_list_display.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../shared/globals.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_unknown_object_display.dart b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_unknown_object_display.dart index 9ed7fdfede5..b3bfcef23b4 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_unknown_object_display.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/object_inspector/vm_unknown_object_display.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../vm_developer_common_widgets.dart'; import 'object_inspector_view_controller.dart'; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/process_memory/process_memory_view.dart b/packages/devtools_app/lib/src/screens/vm_developer/process_memory/process_memory_view.dart index 1b453e0fa9c..d970490433a 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/process_memory/process_memory_view.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/process_memory/process_memory_view.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/constants.dart' as gac; import '../../../shared/charts/treemap.dart'; @@ -87,6 +87,16 @@ class _VMProcessMemoryViewBodyState extends State _initTabController(); } + @override + void dispose() { + if (_tabControllerInitialized) { + _tabController.removeListener(_onTabChanged); + _tabController.dispose(); + } + controller.dispose(); + super.dispose(); + } + @override void didUpdateWidget(VMProcessMemoryViewBody oldWidget) { super.didUpdateWidget(oldWidget); @@ -194,7 +204,7 @@ class _ProcessMemoryTree extends StatelessWidget { keyFactory: (e) => PageStorageKey('${e.name}+${e.depth}+${e.byteSize}'), displayTreeGuidelines: true, - dataRoots: [if (root != null) root], + dataRoots: [?root], dataKey: 'process-memory-tree', columns: columns, treeColumn: categoryColumn, diff --git a/packages/devtools_app/lib/src/screens/vm_developer/queued_microtasks/queued_microtasks_view.dart b/packages/devtools_app/lib/src/screens/vm_developer/queued_microtasks/queued_microtasks_view.dart index 692c48e65a5..516b3492139 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/queued_microtasks/queued_microtasks_view.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/queued_microtasks/queued_microtasks_view.dart @@ -4,8 +4,8 @@ import 'package:collection/collection.dart' show ListExtensions; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:intl/intl.dart' show DateFormat; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/vm_developer_common_widgets.dart b/packages/devtools_app/lib/src/screens/vm_developer/vm_developer_common_widgets.dart index ac39521f9ab..88d62888cf1 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/vm_developer_common_widgets.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/vm_developer_common_widgets.dart @@ -8,7 +8,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:stack_trace/stack_trace.dart' as stack_trace; import 'package:vm_service/vm_service.dart'; @@ -155,7 +155,7 @@ class VMInfoList extends StatelessWidget { ), ), ), - if (table != null) table, + ?table, ], ); } diff --git a/packages/devtools_app/lib/src/screens/vm_developer/vm_developer_tools_screen.dart b/packages/devtools_app/lib/src/screens/vm_developer/vm_developer_tools_screen.dart index e30f8cdae62..ded2471ed0b 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/vm_developer_tools_screen.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/vm_developer_tools_screen.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../service/vm_flags.dart' as vm_flags; import '../../shared/framework/screen.dart'; @@ -37,8 +37,6 @@ abstract class VMDeveloperView { class VMDeveloperToolsScreen extends Screen { VMDeveloperToolsScreen() : super.fromMetaData(ScreenMetaData.vmTools); - static final id = ScreenMetaData.vmTools.id; - @override ValueListenable get showIsolateSelector => VMDeveloperToolsController.showIsolateSelector; diff --git a/packages/devtools_app/lib/src/screens/vm_developer/vm_service_private_extensions.dart b/packages/devtools_app/lib/src/screens/vm_developer/vm_service_private_extensions.dart index 7b8c3c4d9fb..79122387acd 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/vm_service_private_extensions.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/vm_service_private_extensions.dart @@ -55,33 +55,7 @@ extension IsolatePrivateViewExtension on Isolate { (json!['_heaps'] as Map).cast(); } -/// An extension on [Class] which allows for access to VM internal fields. -extension ClassPrivateViewExtension on Class { - /// The internal name of the [Class]. - String get vmName => json![_vmNameKey]; -} - -/// An extension on [InboundReferences] which allows for access to -/// VM internal fields. -extension InboundReferenceExtension on InboundReferences { - static const _referencesKey = 'references'; - static const _parentWordOffsetKey = '_parentWordOffset'; - - int? parentWordOffset(int inboundReferenceIndex) { - final references = (json![_referencesKey] as List?)?.cast(); - final inboundReference = (references?[inboundReferenceIndex] as Map?) - ?.cast(); - return inboundReference?[_parentWordOffsetKey] as int?; - } -} - class HeapStats { - const HeapStats({ - required this.count, - required this.size, - required this.externalSize, - }); - const HeapStats.empty() : count = 0, size = 0, externalSize = 0; HeapStats.parse(List stats) @@ -172,10 +146,6 @@ extension ObjRefPrivateViewExtension on ObjRef { /// `true` if this object is an instance of [SubtypeTestCacheRef]. bool get isSubtypeTestCache => vmType == _subtypeTestCache; - /// Casts the current [ObjRef] into an instance of [SubtypeTestCacheRef]. - SubtypeTestCacheRef get asSubtypeTestCache => - SubtypeTestCacheRef.fromJson(json!); - /// `true` if this object is an instance of [WeakArrayRef]. bool get isWeakArray => vmType == _weakArrayType; @@ -273,9 +243,6 @@ class WeakArray extends WeakArrayRef implements Obj { class SubtypeTestCacheRef implements ObjRef { SubtypeTestCacheRef({required this.id, required this.json}); - factory SubtypeTestCacheRef.fromJson(Map json) => - SubtypeTestCacheRef(id: json['id'], json: json); - @override bool? fixedId; @@ -540,22 +507,15 @@ enum FunctionKind { /// An extension on [Code] which allows for access to VM internal fields. extension CodePrivateViewExtension on Code { static const _disassemblyKey = '_disassembly'; - static const _kindKey = 'kind'; static const _objectPoolKey = '_objectPool'; /// Returns the disassembly of the [Code], which is the generated assembly /// instructions for the code's function. Disassembly get disassembly => Disassembly.parse(json![_disassemblyKey]); + @visibleForTesting set disassembly(Disassembly disassembly) => json![_disassemblyKey] = disassembly.toJson(); - /// The kind of code object represented by this instance. - /// - /// Can be one of: - /// - Dart - /// - Stub - String get kind => json![_kindKey]; - ObjectPoolRef get objectPool => ObjectPoolRef.parse(json![_objectPoolKey]); bool get hasInliningData => json!.containsKey(InliningData.kInlinedFunctions); @@ -676,20 +636,6 @@ enum ObjectPoolEntryKind { static const _kObject = 'Object'; static const _kImm = 'Immediate'; - static const _kNativeFunction = 'NativeFunction'; - - static ObjectPoolEntryKind fromString(String type) { - switch (type) { - case _kObject: - return object; - case _kImm: - return immediate; - case _kNativeFunction: - return nativeFunction; - default: - throw UnsupportedError('Unsupported ObjectPoolType: $type'); - } - } @override String toString() { @@ -705,26 +651,18 @@ enum ObjectPoolEntryKind { } class ObjectPoolEntry { - const ObjectPoolEntry({ - required this.offset, - required this.kind, - required this.value, - }); + const ObjectPoolEntry({required this.offset, required this.value}); static const _offsetKey = 'offset'; - static const _kindKey = 'kind'; static const _valueKey = 'value'; static ObjectPoolEntry parse(Map json) => ObjectPoolEntry( offset: json[_offsetKey], - kind: ObjectPoolEntryKind.fromString(json[_kindKey]), value: createServiceObject(json[_valueKey], [])!, ); final int offset; - final ObjectPoolEntryKind kind; - final Object value; } @@ -850,14 +788,6 @@ class ObjectStore { /// /// See [CpuSamples]. class ProfileCode { - ProfileCode({ - this.kind, - this.inclusiveTicks, - this.exclusiveTicks, - this.code, - this.ticks, - }); - ProfileCode._fromJson(Map json) { kind = json['kind'] ?? ''; inclusiveTicks = json['inclusiveTicks'] ?? -1; @@ -883,6 +813,7 @@ class ProfileCode { List? ticks; + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9910) seems like a bug. Map toJson() { final json = {}; json.addAll({ @@ -915,10 +846,6 @@ extension CpuSamplesPrivateView on CpuSamples { static const _kCodesKey = '_codes'; - bool get hasCodes { - return _expando[this] != null || json!.containsKey(_kCodesKey); - } - List get codes { return _expando[this] ??= (json![_kCodesKey] as List) .cast>() @@ -936,13 +863,11 @@ extension ProfileDataRanges on SourceReport { class ProfileReportEntry { const ProfileReportEntry({ required this.sampleCount, - required this.line, required this.inclusive, required this.exclusive, }); final int sampleCount; - final int line; final int inclusive; final int exclusive; @@ -970,7 +895,6 @@ class ProfileReportRange { final line = lines[i]; entries[line] = ProfileReportEntry( sampleCount: json.sampleCount, - line: line, inclusive: inclusiveTicks[i], exclusive: exclusiveTicks[i], ); diff --git a/packages/devtools_app/lib/src/screens/vm_developer/vm_statistics/vm_statistics_view.dart b/packages/devtools_app/lib/src/screens/vm_developer/vm_statistics/vm_statistics_view.dart index a2b9d49ceac..c25fd1ccac1 100644 --- a/packages/devtools_app/lib/src/screens/vm_developer/vm_statistics/vm_statistics_view.dart +++ b/packages/devtools_app/lib/src/screens/vm_developer/vm_statistics/vm_statistics_view.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/service/_message_port_connection_stub.dart b/packages/devtools_app/lib/src/service/_message_port_connection_stub.dart new file mode 100644 index 00000000000..f324258255d --- /dev/null +++ b/packages/devtools_app/lib/src/service/_message_port_connection_stub.dart @@ -0,0 +1,25 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +// The stub must have the same parameters as the web implementation. +// ignore_for_file: avoid-unused-parameters + +/// @docImport 'message_port_connection.dart'; +library; + +import 'dart:async'; + +import 'vm_service_wrapper.dart'; + +/// Connects to a VM service over a `MessagePort`, as described in +/// [messagePortUriScheme]. +/// +/// This is only supported on the web, so this always throws an +/// [UnsupportedError]. +Future connectWithMessagePort({ + required Uri uri, + required Completer finishedCompleter, +}) => throw UnsupportedError( + 'Connecting to a VM service over a MessagePort is only supported on the web.', +); diff --git a/packages/devtools_app/lib/src/service/_message_port_connection_web.dart b/packages/devtools_app/lib/src/service/_message_port_connection_web.dart new file mode 100644 index 00000000000..c2a968ea57a --- /dev/null +++ b/packages/devtools_app/lib/src/service/_message_port_connection_web.dart @@ -0,0 +1,108 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +/// @docImport 'message_port_connection.dart'; +library; + +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:logging/logging.dart'; +import 'package:web/web.dart'; + +import '../shared/globals.dart'; +import 'vm_service_wrapper.dart'; + +final _log = Logger('message_port_connection'); + +/// How long the embedder has to connect a new port and answer `getVersion`. +const _connectTimeout = Duration(seconds: 10); + +/// Connects to a VM service over a `MessagePort`, as described in +/// [messagePortUriScheme]. +/// +/// [finishedCompleter] is completed when the returned service is disposed. +Future connectWithMessagePort({ + required Uri uri, + required Completer finishedCompleter, +}) async { + final embedder = window.openerCrossOrigin ?? window.parentCrossOrigin; + if (embedder == null) { + throw UnsupportedError( + 'Connecting to a VM service over a MessagePort requires window.opener ' + 'or window.parent.', + ); + } + final targetOrigin = uri.path; + if (targetOrigin.isEmpty) { + throw ArgumentError( + 'The messageport URI must specify a target origin, e.g., ' + 'messageport:https://example.com', + ); + } + // A new channel per connect, so reconnects and reloads just work. + final channel = MessageChannel(); + embedder.postMessage( + _ConnectMessage(action: 'connect', port: channel.port2), + targetOrigin.toJS, + [channel.port2].toJS, + ); + + final port = channel.port1; + final messages = StreamController(); + port.onmessage = (MessageEvent event) { + final data = event.data; + if (data == null) { + // The embedder closed the connection. The service disposes itself when + // its stream is done. + unawaited(messages.close()); + } else if (data.isA()) { + messages.add((data as JSString).toDart); + } else if (data.isA()) { + messages.add((data as JSUint8Array).toDart); + } else { + _log.warning('Ignoring VM service message of unsupported type: $data'); + } + }.toJS; + + late final StreamSubscription pageHideSubscription; + void closePort() { + if (finishedCompleter.isCompleted) return; + unawaited(pageHideSubscription.cancel()); + port + ..postMessage(null) + ..onmessage = null + ..close(); + unawaited(messages.close()); + finishedCompleter.complete(); + } + + // Close the port on pagehide, matching browser WebSocket behavior. + pageHideSubscription = const EventStreamProvider( + 'pagehide', + ).forTarget(window).listen((_) => closePort()); + + final service = VmServiceWrapper.defaultFactory( + inStream: messages.stream, + writeMessage: (message) => port.postMessage(message.toJS), + disposeHandler: () async => closePort(), + wsUri: uri.toString(), + trackFutures: integrationTestMode, + ); + + // Verify the connection, like `connect` from `package:devtools_shared` does. + try { + await service.getVersion().timeout(_connectTimeout); + } catch (_) { + await service.dispose(); + rethrow; + } + return service; +} + +/// The message that asks the embedder to connect the transferred `port` to a +/// VM service. +extension type _ConnectMessage._(JSObject _) implements JSObject { + external factory _ConnectMessage({String action, MessagePort port}); +} diff --git a/packages/devtools_app/lib/src/service/connected_app/connected_app.dart b/packages/devtools_app/lib/src/service/connected_app/connected_app.dart index 04c25f58c83..f6e64194c71 100644 --- a/packages/devtools_app/lib/src/service/connected_app/connected_app.dart +++ b/packages/devtools_app/lib/src/service/connected_app/connected_app.dart @@ -87,6 +87,7 @@ class AppState extends DisposableController with AutoDisposeControllerMixin { void dispose() { _variables.dispose(); _currentFrame.dispose(); + _dapVariables.dispose(); super.dispose(); } } diff --git a/packages/devtools_app/lib/src/service/connected_app/connection_info.dart b/packages/devtools_app/lib/src/service/connected_app/connection_info.dart index c651fe0a885..f38abae5922 100644 --- a/packages/devtools_app/lib/src/service/connected_app/connection_info.dart +++ b/packages/devtools_app/lib/src/service/connected_app/connection_info.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/constants.dart' as gac; import '../../shared/framework/routing.dart'; diff --git a/packages/devtools_app/lib/src/service/message_port_connection.dart b/packages/devtools_app/lib/src/service/message_port_connection.dart new file mode 100644 index 00000000000..cfa7e452ada --- /dev/null +++ b/packages/devtools_app/lib/src/service/message_port_connection.dart @@ -0,0 +1,32 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +export '_message_port_connection_stub.dart' + if (dart.library.js_interop) '_message_port_connection_web.dart'; + +/// URI scheme for connecting to a VM service over a [`MessagePort`][1] from +/// the page embedding or opening DevTools. +/// +/// For `?uri=messageport:`, DevTools sends a new `port` to its +/// embedder on every connect, including reconnects and reloads: +/// +/// ```js +/// const embedder = window.opener ?? window.parent; +/// embedder.postMessage({action: 'connect', port}, targetOrigin, [port]); +/// ``` +/// +/// * `targetOrigin`: the embedder's origin, or `*`. Browsers silently drop the +/// message on a mismatch. +/// * `port`: the embedder connects it to a VM service. Messages are: +/// * [VM service protocol][2] JSON-RPC strings, +/// * binary frames as `Uint8Array`, or, +/// * `null` to close the connection. +/// * Only the latest `port` is used; older ones may be closed. +/// +/// [1]: https://developer.mozilla.org/en-US/docs/Web/API/MessagePort +/// [2]: https://github.com/dart-lang/sdk/blob/main/runtime/vm/service/service.md +const messagePortUriScheme = 'messageport'; + +/// Whether [uri] has the [messagePortUriScheme]. +bool isMessagePortUri(Uri uri) => uri.isScheme(messagePortUriScheme); diff --git a/packages/devtools_app/lib/src/service/service_extension_widgets.dart b/packages/devtools_app/lib/src/service/service_extension_widgets.dart index 2c631484552..07d8fd3086f 100644 --- a/packages/devtools_app/lib/src/service/service_extension_widgets.dart +++ b/packages/devtools_app/lib/src/service/service_extension_widgets.dart @@ -13,8 +13,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import '../shared/analytics/analytics.dart' as ga; @@ -534,16 +534,20 @@ class _ServiceExtensionCheckboxState extends State _initExtensionState(); } + @override + void dispose() { + value.dispose(); + extensionAvailable.dispose(); + super.dispose(); + } + @override void _onMainIsolateChanged() => _initExtensionState(); void _initExtensionState() { - if (serviceConnection.serviceManager.serviceExtensionManager - .isServiceExtensionAvailable(widget.serviceExtension.extension)) { - final state = serviceConnection.serviceManager.serviceExtensionManager - .getServiceExtensionState(widget.serviceExtension.extension); - _setValueFromState(state.value); - } + final state = serviceConnection.serviceManager.serviceExtensionManager + .getServiceExtensionState(widget.serviceExtension.extension); + _setValueFromState(state.value); unawaited( serviceConnection.serviceManager.serviceExtensionManager @@ -707,6 +711,12 @@ class _ServiceExtensionCheckboxGroupButtonState _initExtensionState(); } + @override + void dispose() { + _enabled.dispose(); + super.dispose(); + } + @override void _onMainIsolateChanged() => _initExtensionState(); diff --git a/packages/devtools_app/lib/src/service/service_extensions.dart b/packages/devtools_app/lib/src/service/service_extensions.dart index 7e20efaeb01..4057d9499ee 100644 --- a/packages/devtools_app/lib/src/service/service_extensions.dart +++ b/packages/devtools_app/lib/src/service/service_extensions.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/service_extensions.dart' as extensions; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../shared/analytics/constants.dart' as gac; @@ -226,15 +226,6 @@ class ServiceExtensionDescription extends extensions.ServiceExtension String get gaItemTooltipLink => '${gaItem}TooltipLink'; } -final debugAllowBanner = ToggleableServiceExtensionDescription.from( - extensions.debugAllowBanner, - title: 'Debug Banner', - iconAsset: 'icons/debug_banner@2x.png', - gaScreenName: gac.inspector, - gaItem: gac.debugBanner, - tooltip: 'Toggle Debug Banner', -); - final invertOversizedImages = ToggleableServiceExtensionDescription.from( extensions.invertOversizedImages, title: 'Highlight Oversized Images', @@ -363,6 +354,8 @@ final slowAnimations = ToggleableServiceExtensionDescription.from( gaDocsItem: gac.slowAnimationDocs, ); +// TODO(https://github.com/flutter/devtools/issues/2780): hook this up in the UI. +// ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/2780). final togglePlatformMode = ServiceExtensionDescription.from( extensions.togglePlatformMode, title: 'Override target platform', @@ -379,6 +372,16 @@ final togglePlatformMode = ServiceExtensionDescription.from( tooltip: 'Override Target Platform', ); +/// Service extension description for overriding brightness in accessibility controls. +final brightnessMode = ServiceExtensionDescription.from( + extensions.brightnessMode, + title: 'Override Brightness', + iconData: Icons.brightness_6, + gaScreenName: gac.accessibility, + gaItem: gac.brightnessOverride, + tooltip: 'Override Brightness', +); + final disableClipLayers = ToggleableServiceExtensionDescription.from( extensions.disableClipLayers, title: 'Render Clip layers', diff --git a/packages/devtools_app/lib/src/service/service_manager.dart b/packages/devtools_app/lib/src/service/service_manager.dart index d1358379263..14f2b68d417 100644 --- a/packages/devtools_app/lib/src/service/service_manager.dart +++ b/packages/devtools_app/lib/src/service/service_manager.dart @@ -125,11 +125,9 @@ class ServiceConnectionManager { serviceTrafficLogger = VmServiceTrafficLogger(service!); } - unawaited( - server.notifyForVmServiceConnection( - vmServiceUri: serviceManager.serviceUri!, - connected: true, - ), + await server.notifyForVmServiceConnection( + vmServiceUri: serviceManager.serviceUri!, + connected: true, ); } diff --git a/packages/devtools_app/lib/src/service/service_registrations.dart b/packages/devtools_app/lib/src/service/service_registrations.dart index 108503ad60e..52c049f7983 100644 --- a/packages/devtools_app/lib/src/service/service_registrations.dart +++ b/packages/devtools_app/lib/src/service/service_registrations.dart @@ -5,7 +5,7 @@ import 'package:devtools_app_shared/service_extensions.dart' as extensions; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_shared/devtools_shared.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../shared/analytics/constants.dart' as gac; import '../shared/constants.dart'; @@ -54,11 +54,19 @@ const displayRefreshRate = '_flutter.getDisplayRefreshRate'; String get flutterEngineEstimateRasterCache => flutterEngineRasterCache; -const renderFrameWithRasterStats = '_flutter.renderFrameWithRasterStats'; - /// Dwds listens to events for recording end-to-end analytics. const dwdsSendEvent = 'ext.dwds.sendEvent'; /// Service extension that returns whether or not the Impeller rendering engine /// is being used (if false, the app is using SKIA). const isImpellerEnabled = 'ext.ui.window.impellerEnabled'; + +/// Service extension to enable or disable semantics on the connected app. +const enableSemantics = 'ext.flutter.accessibility.enableSemantics'; + +/// Service extension to fetch the accessibility semantics tree and any accessibility issues. +const getIssuesAndSemanticsTree = + 'ext.flutter.accessibility.getIssuesAndSemanticsTree'; + +/// Service extension to dispose semantics on the connected app. +const disposeSemantics = 'ext.flutter.accessibility.disposeSemantics'; diff --git a/packages/devtools_app/lib/src/service/timeline_streams.dart b/packages/devtools_app/lib/src/service/timeline_streams.dart index 57e51d1d12f..6d669a1f097 100644 --- a/packages/devtools_app/lib/src/service/timeline_streams.dart +++ b/packages/devtools_app/lib/src/service/timeline_streams.dart @@ -63,6 +63,7 @@ class TimelineStreamManager with DisposerMixin { List get advancedStreams => _streamsWhere((stream) => stream.advanced); + @visibleForTesting List get recordedStreams => _streamsWhere((stream) => stream.recorded.value); diff --git a/packages/devtools_app/lib/src/service/vm_service_wrapper.dart b/packages/devtools_app/lib/src/service/vm_service_wrapper.dart index a49971fa9d9..87724a96d85 100644 --- a/packages/devtools_app/lib/src/service/vm_service_wrapper.dart +++ b/packages/devtools_app/lib/src/service/vm_service_wrapper.dart @@ -94,8 +94,9 @@ class VmServiceWrapper extends VmService { final _activeStreams = >{}; - final activeFutures = >{}; + final activeFutureNames = []; + @visibleForTesting Future get allFuturesCompleted => _allFuturesCompleter.future; Completer _allFuturesCompleter = Completer() @@ -326,20 +327,6 @@ class VmServiceWrapper extends VmService { // End Dart IO extension method wrappers. - /// Testing only method to indicate that we don't really need to await all - /// currently pending futures. - /// - /// If you use this method be sure to indicate why you believe all pending - /// futures are safe to ignore. Currently the theory is this method should be - /// used after a hot restart to avoid bugs where we have zombie futures lying - /// around causing tests to flake. - @visibleForTesting - void doNotWaitForPendingFuturesBeforeExit() { - _allFuturesCompleter = Completer(); - _allFuturesCompleter.complete(true); - activeFutures.clear(); - } - @visibleForTesting int vmServiceCallCount = 0; @@ -383,15 +370,14 @@ class VmServiceWrapper extends VmService { vmServiceCallCount++; vmServiceCalls.add(name); - final trackedFuture = TrackedFuture(name, localFuture as Future); if (_allFuturesCompleter.isCompleted) { _allFuturesCompleter = Completer(); } - activeFutures.add(trackedFuture); + activeFutureNames.add(name); void futureComplete() { - activeFutures.remove(trackedFuture); - if (activeFutures.isEmpty) { + activeFutureNames.remove(name); + if (activeFutureNames.isEmpty) { _allFuturesCompleter.safeComplete(true); } } @@ -428,10 +414,6 @@ class VmServiceWrapper extends VmService { return parser(result.json); } - /// Forces the VM to perform a full garbage collection. - Future collectAllGarbage() => - _privateRpcInvoke('collectAllGarbage', parser: Success.parse); - Future getReachableSize(String isolateId, String targetId) => _privateRpcInvoke( 'getReachableSize', @@ -512,10 +494,3 @@ class VmServiceWrapper extends VmService { return response.dapResponse.body; } } - -class TrackedFuture { - TrackedFuture(this.name, this.future); - - final String name; - final Future future; -} diff --git a/packages/devtools_app/lib/src/shared/ai_assistant/ai_controller.dart b/packages/devtools_app/lib/src/shared/ai_assistant/ai_controller.dart deleted file mode 100644 index 879e9b2b6e4..00000000000 --- a/packages/devtools_app/lib/src/shared/ai_assistant/ai_controller.dart +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright 2026 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app_shared/utils.dart'; - -import 'ai_message_types.dart'; - -class AiController extends DisposableController - with AutoDisposeControllerMixin { - AiController(); - - Future sendMessage(ChatMessage _) async { - await Future.delayed(const Duration(seconds: 3)); - return const ChatMessage(text: _loremIpsum, isUser: false); - } -} - -const _loremIpsum = ''' -Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor -incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis -nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. -Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu -fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in -culpa qui officia deserunt mollit anim id est laborum. -'''; diff --git a/packages/devtools_app/lib/src/shared/ai_assistant/ai_message_types.dart b/packages/devtools_app/lib/src/shared/ai_assistant/ai_message_types.dart deleted file mode 100644 index c71223651fd..00000000000 --- a/packages/devtools_app/lib/src/shared/ai_assistant/ai_message_types.dart +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2026 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -class ChatMessage { - const ChatMessage({required this.text, required this.isUser}); - final String text; - final bool isUser; -} diff --git a/packages/devtools_app/lib/src/shared/ai_assistant/widgets/ai_assistant_pane.dart b/packages/devtools_app/lib/src/shared/ai_assistant/widgets/ai_assistant_pane.dart deleted file mode 100644 index d8c8c5f6174..00000000000 --- a/packages/devtools_app/lib/src/shared/ai_assistant/widgets/ai_assistant_pane.dart +++ /dev/null @@ -1,226 +0,0 @@ -// Copyright 2025 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:math' as math; - -import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../../../framework/scaffold/bottom_pane.dart'; -import '../../ui/tab.dart'; -import '../../utils/utils.dart'; -import '../ai_controller.dart'; -import '../ai_message_types.dart'; - -class AiAssistantPane extends StatefulWidget implements TabbedPane { - const AiAssistantPane({super.key}); - - @override - DevToolsTab get tab => DevToolsTab.create( - tabName: AiAssistantPane._tabName, - gaPrefix: AiAssistantPane._gaPrefix, - ); - - static const _tabName = 'AI Assistant'; - static const _gaPrefix = 'aiAssistant'; - - @override - State createState() => _AiAssistantPaneState(); -} - -class _AiAssistantPaneState extends State { - static const _baseOverscrollPadding = 125.0; - static const _spinnerHeight = 50.0; - static const _scrollDuration = Duration(milliseconds: 250); - - final _textController = TextEditingController(); - final _messages = []; - final _scrollController = ScrollController(); - final _aiController = AiController(); - late final FocusNode _focusNode; - - bool _isThinking = false; - double _overscrollPadding = _baseOverscrollPadding; - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(onKeyEvent: _handleEnterKey); - } - - @override - void dispose() { - _focusNode.dispose(); - _textController.dispose(); - super.dispose(); - } - - KeyEventResult _handleEnterKey(FocusNode node, KeyEvent event) { - final isEnterKey = - event is KeyDownEvent && event.logicalKey == LogicalKeyboardKey.enter; - - if (isEnterKey && !HardwareKeyboard.instance.isShiftPressed) { - if (!_isThinking) { - safeUnawaited(_sendMessage()); - } - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - - Future _sendMessage() async { - final messageText = _textController.text; - if (messageText.isEmpty) return; - _textController.clear(); - - final userMessage = ChatMessage(text: messageText, isUser: true); - setState(() { - _overscrollPadding = _calculateOverscrollPadding(userMessage); - _isThinking = true; - _messages.add(userMessage); - }); - _scrollToBottom(); - - final aiResponse = await _aiController.sendMessage(userMessage); - setState(() { - _isThinking = false; - _overscrollPadding = _calculateOverscrollPadding(aiResponse); - _messages.add(aiResponse); - }); - _scrollToBottom(); - } - - void _scrollToBottom() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollController.hasClients) { - safeUnawaited( - _scrollController.animateTo( - _scrollController.position.maxScrollExtent, - duration: _scrollDuration, - curve: Curves.ease, - ), - ); - } - }); - } - - double _calculateOverscrollPadding(ChatMessage message) { - final messageHeight = - message.text.split('\n').length * (defaultFontSize + densePadding); - final overscrollPadding = _baseOverscrollPadding + messageHeight; - return message.isUser - ? overscrollPadding + _spinnerHeight - : overscrollPadding; - } - - @override - Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - return Column( - children: [ - Expanded( - child: ListView.builder( - padding: EdgeInsets.only( - bottom: math.max( - 0, - constraints.maxHeight - _overscrollPadding, - ), - ), - controller: _scrollController, - itemCount: _isThinking - ? _messages.length + 1 - : _messages.length, - itemBuilder: (context, index) { - if (_isThinking && index == _messages.length) { - return const _ThinkingSpinner(); - } - return _ChatMessageBubble(message: _messages[index]); - }, - ), - ), - ConstrainedBox( - constraints: BoxConstraints(maxHeight: constraints.maxHeight), - child: Padding( - padding: const EdgeInsets.all(denseSpacing), - child: RoundedOutlinedBorder( - child: Padding( - // ignore: prefer-correct-edge-insets-constructor, false positive. - padding: const EdgeInsets.fromLTRB( - defaultSpacing, - noPadding, - defaultSpacing, - densePadding, - ), - child: TextField( - controller: _textController, - focusNode: _focusNode, - keyboardType: TextInputType.multiline, - textAlignVertical: TextAlignVertical.center, - minLines: 1, - maxLines: 10, - decoration: InputDecoration( - hintText: 'Ask a question...', - border: InputBorder.none, - suffixIcon: IconButton( - icon: const Icon(Icons.send), - onPressed: _isThinking ? null : _sendMessage, - ), - ), - ), - ), - ), - ), - ), - ], - ); - }, - ); - } -} - -class _ChatMessageBubble extends StatelessWidget { - const _ChatMessageBubble({required this.message}); - - final ChatMessage message; - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Align( - alignment: message.isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - decoration: BoxDecoration( - color: message.isUser - ? colorScheme.primaryContainer - : colorScheme.secondaryContainer, - borderRadius: defaultBorderRadius, - ), - padding: const EdgeInsets.all(defaultSpacing), - margin: const EdgeInsets.all(denseSpacing), - child: Text(message.text), - ), - ); - } -} - -class _ThinkingSpinner extends StatelessWidget { - const _ThinkingSpinner(); - - @override - Widget build(BuildContext context) { - return const Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: EdgeInsets.symmetric( - vertical: denseSpacing, - horizontal: extraLargeSpacing, - ), - child: CircularProgressIndicator(), - ), - ); - } -} diff --git a/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart b/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart index cca43844c35..a6ba1a07086 100644 --- a/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart +++ b/packages/devtools_app/lib/src/shared/analytics/_analytics_stub.dart @@ -21,14 +21,6 @@ final _log = Logger('_analytics_stub'); /// both web and desktop, and manipulate this value for tests running on the VM. String ideLaunched = ''; -FutureOr isAnalyticsEnabled() => false; - -FutureOr shouldShowAnalyticsConsentMessage() => false; - -void initializeGA() {} - -void jsHookupListenerForGA() {} - void screen(String screenName, [int value = 0]) { _log.fine('Event: screen(screenName:$screenName, value:$value)'); } @@ -104,9 +96,3 @@ Future setupDimensions() async {} void setupUserApplicationDimensions() {} Map generateSurveyQueryParameters() => {}; - -// TODO(https://github.com/flutter/devtools/issues/7083): remove these -// when the legacy analytics are fully removed. -FutureOr legacyOnEnableAnalytics() {} -FutureOr legacyOnDisableAnalytics() {} -void legacyOnSetupAnalytics() {} diff --git a/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart b/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart index 1ab55cb6367..a6f371a3590 100644 --- a/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart +++ b/packages/devtools_app/lib/src/shared/analytics/_analytics_web.dart @@ -3,85 +3,94 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. // ignore_for_file: non_constant_identifier_names - -@JS() -library; +// ignore_for_file: unused-code, platform specific imports. import 'dart:async'; -import 'dart:js_interop'; -import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/ui.dart' as app_ui; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:stack_trace/stack_trace.dart' as stack_trace; import 'package:unified_analytics/unified_analytics.dart' as ua; import 'package:web/web.dart'; -import '../globals.dart'; +import '../globals.dart' as globals; import '../managers/dtd_manager_extensions.dart'; import '../primitives/query_parameters.dart'; import '../server/server.dart' as server; import '../utils/utils.dart'; import 'analytics_common.dart'; import 'constants.dart' as gac; -import 'gtags.dart'; import 'metrics.dart'; -// Dimensions1 AppType values: -const appTypeFlutter = 'flutter'; -const appTypeWeb = 'web'; -const appTypeFlutterWeb = 'flutter_web'; -const appTypeDartCLI = 'dart_cli'; -// Dimensions2 BuildType values: -const buildTypeDebug = 'debug'; -const buildTypeProfile = 'profile'; +/// userApp values. +enum _UserAppType { + flutter, + web, + flutterWeb, + dartCLI; + + String get value { + switch (this) { + case _UserAppType.flutter: + return 'flutter'; + case _UserAppType.web: + return 'web'; + case _UserAppType.flutterWeb: + return 'flutter_web'; + case _UserAppType.dartCLI: + return 'dart_cli'; + } + } +} + +/// userBuild values. +enum _UserBuildType { debug, profile } + // Start with Android_n.n.n const devToolsPlatformTypeAndroid = 'Android_'; -// Dimension5 devToolsChrome starts with -const devToolsChromeName = 'Chrome/'; // starts with and ends with n.n.n -const devToolsChromeIos = 'Crios/'; // starts with and ends with n.n.n -const devToolsChromeOS = 'CrOS'; // Chrome OS -// Dimension6 devToolsVersion -// Dimension7 ideLaunched -const ideLaunchedCLI = 'CLI'; // Command Line Interface +/// devToolsChrome prefix identifiers. +enum _ChromePrefix { + chrome, + crios, + cros; + + String get value { + switch (this) { + case _ChromePrefix.chrome: + return 'Chrome/'; // starts with and ends with n.n.n + case _ChromePrefix.crios: + return 'Crios/'; // starts with and ends with n.n.n + case _ChromePrefix.cros: + return 'CrOS'; // Chrome OS + } + } +} final _log = Logger('_analytics_web'); -@JS('initializeGA') -external void initializeGA(); - -extension type GtagEventDevTools._(JSObject _) implements GtagEvent { - // TODO(kenz): try to make this accept a JSON map of extra parameters rather - // than a fixed list of fields. See - // https://github.com/flutter/devtools/pull/3281#discussion_r692376353. - external factory GtagEventDevTools({ - String? screen, - String? event_category, - String? event_label, // Event e.g., gaScreenViewEvent, gaSelectEvent, etc. - String? send_to, // UA ID of target GA property to receive event data. - - int value, - bool non_interaction, - JSObject? custom_map, - - // NOTE: Do not reorder any of these. Order here must match the order in the - // Google Analytics console. +class DevToolsAnalyticsEvent { + DevToolsAnalyticsEvent._({ + required this.screen, + required this.eventCategory, + required this.eventLabel, + required this.value, + required this.nonInteraction, // IMPORTANT! Only string and int values are supported. All other value // types will be ignored in GA4. - String? user_app, // dimension1 (flutter or web) - String? user_build, // dimension2 (debug or profile) - String? user_platform, // dimension3 (android/ios/fuchsia/linux/mac/windows) - String? devtools_platform, // dimension4 linux/android/mac/windows - String? devtools_chrome, // dimension5 Chrome version # - String? devtools_version, // dimension6 DevTools version # - String? ide_launched, // dimension7 Devtools launched (CLI, VSCode, Android) - String? - flutter_client_id, // dimension8 Flutter tool client_id (~/.flutter). - String? is_external_build, // dimension9 External build or google3 - String? is_embedded, // dimension10 Whether devtools is embedded - String? g3_username, // dimension11 g3 username (null for external users) - // dimension12 IDE feature that launched Devtools + this.userApp, // [_UserAppType] + this.userBuild, // [_UserBuildType] + this.userPlatform, // (android/ios/fuchsia/linux/mac/windows) + this.devtoolsPlatform, // linux/android/mac/windows + this.devtoolsChrome, // Chrome version # + this.devtoolsVersion, // DevTools version # + this.ideLaunched, // Devtools launched (CLI, VSCode, Android) + this.flutterClientId, // Flutter tool client_id (~/.flutter). + this.isExternalBuild, // External build or google3 + this.isEmbedded, // Whether devtools is embedded + this.g3Username, // g3 username (null for external users) + // IDE feature that launched Devtools // The following is a non-exhaustive list of possible values for this dimension: // "command" - VS Code command palette // "sidebarContent" - the content of the sidebar (e.g. the DevTools dropdown for a debug session) @@ -91,173 +100,177 @@ extension type GtagEventDevTools._(JSObject _) implements GtagEvent { // "onDebugAutomatic" - configured to always run on debug session start // "onDebugPrompt" - user responded to prompt when running a debug session // "languageStatus" - launched from the language status popout - String? ide_launched_feature, - String? is_wasm, // dimension13 whether DevTools is running with WASM. + this.ideLaunchedFeature, + this.isWasm, // whether DevTools is running with WASM. // Performance screen metrics. See [PerformanceScreenMetrics]. - int? ui_duration_micros, // metric1 - int? raster_duration_micros, // metric2 - int? shader_compilation_duration_micros, // metric3 + this.uiDurationMicros, // metric1 + this.rasterDurationMicros, // metric2 + this.shaderCompilationDurationMicros, // metric3 // Profiler screen metrics. See [ProfilerScreenMetrics]. - int? cpu_sample_count, // metric4 - int? cpu_stack_depth, // metric5 + this.cpuSampleCount, // metric4 + this.cpuStackDepth, // metric5 // Performance screen metric. See [PerformanceScreenMetrics]. - int? trace_event_count, // metric6 + this.traceEventCount, // metric6 // Memory screen metric. See [MemoryScreenMetrics]. - int? heap_diff_objects_before, // metric7 - int? heap_diff_objects_after, // metric8 - int? heap_objects_total, // metric9 + this.heapDiffObjectsBefore, // metric7 + this.heapDiffObjectsAfter, // metric8 + this.heapObjectsTotal, // metric9 // Inspector screen metrics. See [InspectorScreenMetrics]. - int? root_set_count, // metric10 - int? row_count, // metric11 - int? inspector_tree_controller_id, // metric12 + this.rootSetCount, // metric10 + this.rowCount, // metric11 + this.inspectorTreeControllerId, // metric12 // Deep Link screen metrics. See [DeepLinkScreenMetrics]. - String? android_app_id, //metric13 - String? ios_bundle_id, //metric14 + this.androidAppId, //metric13 + this.iosBundleId, //metric14 // Inspector screen metrics. See [InspectorScreenMetrics]. - String? is_v2_inspector, // metric15 + this.isV2Inspector, // metric15 }); - factory GtagEventDevTools._create({ + factory DevToolsAnalyticsEvent._create({ required String screen, - required String event_category, - required String event_label, - String? send_to, - bool non_interaction = false, + required String eventCategory, + required String eventLabel, + bool nonInteraction = false, int value = 0, ScreenAnalyticsMetrics? screenMetrics, }) { - return GtagEventDevTools( + return DevToolsAnalyticsEvent._( screen: screen, - event_category: event_category, - event_label: event_label, - send_to: send_to, - non_interaction: non_interaction, + eventCategory: eventCategory, + eventLabel: eventLabel, + nonInteraction: nonInteraction, value: value, - user_app: userAppType, - user_build: userBuildType, - user_platform: userPlatformType, - devtools_platform: devtoolsPlatformType, - devtools_chrome: devtoolsChrome, - devtools_version: devtoolsVersion, - ide_launched: ideLaunched, - flutter_client_id: flutterClientId, - is_external_build: isExternalBuild.toString(), - is_embedded: isEmbedded().toString(), - g3_username: devToolsEnvironmentParameters.username(), - ide_launched_feature: ideLaunchedFeature, - is_wasm: kIsWasm.toString(), + userApp: userAppType, + userBuild: userBuildType, + userPlatform: userPlatformType, + devtoolsPlatform: _devtoolsPlatformType, + devtoolsChrome: _devtoolsChrome, + devtoolsVersion: devToolsVersion, + ideLaunched: _ideLaunched, + flutterClientId: _flutterClientId, + isExternalBuild: globals.isExternalBuild.toString(), + isEmbedded: app_ui.isEmbedded().toString(), + g3Username: globals.devToolsEnvironmentParameters.username(), + ideLaunchedFeature: _ideLaunchedFeature, + isWasm: kIsWasm.toString(), // [PerformanceScreenMetrics] - ui_duration_micros: screenMetrics is PerformanceScreenMetrics + uiDurationMicros: screenMetrics is PerformanceScreenMetrics ? screenMetrics.uiDuration?.inMicroseconds : null, - raster_duration_micros: screenMetrics is PerformanceScreenMetrics + rasterDurationMicros: screenMetrics is PerformanceScreenMetrics ? screenMetrics.rasterDuration?.inMicroseconds : null, - shader_compilation_duration_micros: - screenMetrics is PerformanceScreenMetrics + shaderCompilationDurationMicros: screenMetrics is PerformanceScreenMetrics ? screenMetrics.shaderCompilationDuration?.inMicroseconds : null, - trace_event_count: screenMetrics is PerformanceScreenMetrics + traceEventCount: screenMetrics is PerformanceScreenMetrics ? screenMetrics.traceEventCount : null, // [ProfilerScreenMetrics] - cpu_sample_count: screenMetrics is ProfilerScreenMetrics + cpuSampleCount: screenMetrics is ProfilerScreenMetrics ? screenMetrics.cpuSampleCount : null, - cpu_stack_depth: screenMetrics is ProfilerScreenMetrics + cpuStackDepth: screenMetrics is ProfilerScreenMetrics ? screenMetrics.cpuStackDepth : null, // [MemoryScreenMetrics] - heap_diff_objects_before: screenMetrics is MemoryScreenMetrics + heapDiffObjectsBefore: screenMetrics is MemoryScreenMetrics ? screenMetrics.heapDiffObjectsBefore : null, - heap_diff_objects_after: screenMetrics is MemoryScreenMetrics + heapDiffObjectsAfter: screenMetrics is MemoryScreenMetrics ? screenMetrics.heapDiffObjectsAfter : null, - heap_objects_total: screenMetrics is MemoryScreenMetrics + heapObjectsTotal: screenMetrics is MemoryScreenMetrics ? screenMetrics.heapObjectsTotal : null, // [InspectorScreenMetrics] - root_set_count: screenMetrics is InspectorScreenMetrics + rootSetCount: screenMetrics is InspectorScreenMetrics ? screenMetrics.rootSetCount : null, - row_count: screenMetrics is InspectorScreenMetrics + rowCount: screenMetrics is InspectorScreenMetrics ? screenMetrics.rowCount : null, - inspector_tree_controller_id: screenMetrics is InspectorScreenMetrics + inspectorTreeControllerId: screenMetrics is InspectorScreenMetrics ? screenMetrics.inspectorTreeControllerId : null, // [DeepLinkScreenMetrics] - android_app_id: screenMetrics is DeepLinkScreenMetrics + androidAppId: screenMetrics is DeepLinkScreenMetrics ? screenMetrics.androidAppId : null, - ios_bundle_id: screenMetrics is DeepLinkScreenMetrics + iosBundleId: screenMetrics is DeepLinkScreenMetrics ? screenMetrics.iosBundleId : null, // [InspectorScreenMetrics] - is_v2_inspector: screenMetrics is InspectorScreenMetrics - ? screenMetrics.isV2.toString() + // TODO(https://github.com/flutter/devtools/issues/9563): Remove this + // dimension after dashboards have been updated to not include it. The + // legacy inspector will be removed in Flutter 3.47 (Aug 2026), leaving + // the V2 inspector the only inspector. + isV2Inspector: screenMetrics is InspectorScreenMetrics + ? true.toString() : null, ); } - external String? get screen; + String? screen; + String? eventCategory; + String? eventLabel; + bool nonInteraction; + int value; // Custom dimensions: - external String? get user_app; - external String? get user_build; - external String? get user_platform; - external String? get devtools_platform; - external String? get devtools_chrome; - external String? get devtools_version; - external String? get ide_launched; - external String? get flutter_client_id; - external String? get is_external_build; - external String? get is_embedded; - external String? get g3_username; - external String? get ide_launched_feature; - external String? get is_wasm; + String? userApp; + String? userBuild; + String? userPlatform; + String? devtoolsPlatform; + String? devtoolsChrome; + String? devtoolsVersion; + String? ideLaunched; + String? flutterClientId; + String? isExternalBuild; + String? isEmbedded; + String? g3Username; + String? ideLaunchedFeature; + String? isWasm; // Custom metrics: - external int? get ui_duration_micros; - external int? get raster_duration_micros; - external int? get shader_compilation_duration_micros; - external int? get cpu_sample_count; - external int? get cpu_stack_depth; - external int? get trace_event_count; - external int? get heap_diff_objects_before; - external int? get heap_diff_objects_after; - external int? get heap_objects_total; - external int? get root_set_count; - external int? get row_count; - external int? get inspector_tree_controller_id; - external String? get android_app_id; - external String? get ios_bundle_id; - external String? get is_v2_inspector; -} - -extension type GtagExceptionDevTools._(JSObject _) implements GtagException { - external factory GtagExceptionDevTools({ - String? screen, - String? description, - bool fatal, + int? uiDurationMicros; + int? rasterDurationMicros; + int? shaderCompilationDurationMicros; + int? cpuSampleCount; + int? cpuStackDepth; + int? traceEventCount; + int? heapDiffObjectsBefore; + int? heapDiffObjectsAfter; + int? heapObjectsTotal; + int? rootSetCount; + int? rowCount; + int? inspectorTreeControllerId; + String? androidAppId; + String? iosBundleId; + String? isV2Inspector; +} + +class DevToolsAnalyticsException { + DevToolsAnalyticsException._({ + this.description, + required this.fatal, // NOTE: Do not reorder any of these. Order here must match the order in the // Google Analytics console. // IMPORTANT! Only string and int values are supported. All other value // types will be ignored in GA4. - String? user_app, // dimension1 (flutter or web) - String? user_build, // dimension2 (debug or profile) - String? user_platform, // dimension3 (android or ios) - String? devtools_platform, // dimension4 linux/android/mac/windows - String? devtools_chrome, // dimension5 Chrome version # - String? devtools_version, // dimension6 DevTools version # - String? ide_launched, // dimension7 IDE launched DevTools - String? flutter_client_id, // dimension8 Flutter tool clientId - String? is_external_build, // dimension9 External build or google3 - String? is_embedded, // dimension10 Whether devtools is embedded - String? g3_username, // dimension11 g3 username (null for external users) - // dimension12 IDE feature that launched Devtools + this.userApp, // [_UserAppType] + this.userBuild, // [_UserBuildType] + this.userPlatform, // (android/ios/fuchsia/linux/mac/windows) + this.devtoolsPlatform, // linux/android/mac/windows + this.devtoolsChrome, // Chrome version # + this.devtoolsVersion, // DevTools version # + this.ideLaunched, // IDE launched DevTools + this.flutterClientId, // Flutter tool clientId + this.isExternalBuild, // External build or google3 + this.isEmbedded, // Whether devtools is embedded + this.g3Username, // g3 username (null for external users) + // IDE feature that launched Devtools // The following is a non-exhaustive list of possible values for this dimension: // "command" - VS Code command palette // "sidebarContent" - the content of the sidebar (e.g. the DevTools dropdown for a debug session) @@ -267,186 +280,150 @@ extension type GtagExceptionDevTools._(JSObject _) implements GtagException { // "onDebugAutomatic" - configured to always run on debug session start // "onDebugPrompt" - user responded to prompt when running a debug session // "languageStatus" - launched from the language status popout - String? ide_launched_feature, - String? is_wasm, // dimension13 whether DevTools is running with WASM. + this.ideLaunchedFeature, + this.isWasm, // whether DevTools is running with WASM. // Performance screen metrics. See [PerformanceScreenMetrics]. - int? ui_duration_micros, // metric1 - int? raster_duration_micros, // metric2 - int? shader_compilation_duration_micros, // metric3 + this.uiDurationMicros, // metric1 + this.rasterDurationMicros, // metric2 + this.shaderCompilationDurationMicros, // metric3 // Profiler screen metrics. See [ProfilerScreenMetrics]. - int? cpu_sample_count, // metric4 - int? cpu_stack_depth, // metric5 + this.cpuSampleCount, // metric4 + this.cpuStackDepth, // metric5 // Performance screen metric. See [PerformanceScreenMetrics]. - int? trace_event_count, // metric6 + this.traceEventCount, // metric6 // Memory screen metric. See [MemoryScreenMetrics]. - int? heap_diff_objects_before, // metric7 - int? heap_diff_objects_after, // metric8 - int? heap_objects_total, // metric9 + this.heapDiffObjectsBefore, // metric7 + this.heapDiffObjectsAfter, // metric8 + this.heapObjectsTotal, // metric9 // Inspector screen metrics. See [InspectorScreenMetrics]. - int? root_set_count, // metric10 - int? row_count, // metric11 - int? inspector_tree_controller_id, // metric12 + this.rootSetCount, // metric10 + this.rowCount, // metric11 + this.inspectorTreeControllerId, // metric12 // Deep Link screen metrics. See [DeepLinkScreenMetrics]. - String? android_app_id, //metric13 - String? ios_bundle_id, //metric14 + this.androidAppId, //metric13 + this.iosBundleId, //metric14 // Inspector screen metrics. See [InspectorScreenMetrics]. - String? is_v2_inspector, // metric15 + this.isV2Inspector, // metric15 }); - factory GtagExceptionDevTools._create( + factory DevToolsAnalyticsException._create( String errorMessage, { bool fatal = false, ScreenAnalyticsMetrics? screenMetrics, }) { - return GtagExceptionDevTools( + return DevToolsAnalyticsException._( description: errorMessage, fatal: fatal, - user_app: userAppType, - user_build: userBuildType, - user_platform: userPlatformType, - devtools_platform: devtoolsPlatformType, - devtools_chrome: devtoolsChrome, - devtools_version: devtoolsVersion, - ide_launched: _ideLaunched, - flutter_client_id: flutterClientId, - is_external_build: isExternalBuild.toString(), - is_embedded: isEmbedded().toString(), - g3_username: devToolsEnvironmentParameters.username(), - ide_launched_feature: ideLaunchedFeature, - is_wasm: kIsWasm.toString(), + userApp: userAppType, + userBuild: userBuildType, + userPlatform: userPlatformType, + devtoolsPlatform: _devtoolsPlatformType, + devtoolsChrome: _devtoolsChrome, + devtoolsVersion: devToolsVersion, + ideLaunched: _ideLaunched, + flutterClientId: _flutterClientId, + isExternalBuild: globals.isExternalBuild.toString(), + isEmbedded: app_ui.isEmbedded().toString(), + g3Username: globals.devToolsEnvironmentParameters.username(), + ideLaunchedFeature: _ideLaunchedFeature, + isWasm: kIsWasm.toString(), // [PerformanceScreenMetrics] - ui_duration_micros: screenMetrics is PerformanceScreenMetrics + uiDurationMicros: screenMetrics is PerformanceScreenMetrics ? screenMetrics.uiDuration?.inMicroseconds : null, - raster_duration_micros: screenMetrics is PerformanceScreenMetrics + rasterDurationMicros: screenMetrics is PerformanceScreenMetrics ? screenMetrics.rasterDuration?.inMicroseconds : null, - trace_event_count: screenMetrics is PerformanceScreenMetrics + traceEventCount: screenMetrics is PerformanceScreenMetrics ? screenMetrics.traceEventCount : null, - shader_compilation_duration_micros: - screenMetrics is PerformanceScreenMetrics + shaderCompilationDurationMicros: screenMetrics is PerformanceScreenMetrics ? screenMetrics.shaderCompilationDuration?.inMicroseconds : null, // [ProfilerScreenMetrics] - cpu_sample_count: screenMetrics is ProfilerScreenMetrics + cpuSampleCount: screenMetrics is ProfilerScreenMetrics ? screenMetrics.cpuSampleCount : null, - cpu_stack_depth: screenMetrics is ProfilerScreenMetrics + cpuStackDepth: screenMetrics is ProfilerScreenMetrics ? screenMetrics.cpuStackDepth : null, // [MemoryScreenMetrics] - heap_diff_objects_before: screenMetrics is MemoryScreenMetrics + heapDiffObjectsBefore: screenMetrics is MemoryScreenMetrics ? screenMetrics.heapDiffObjectsBefore : null, - heap_diff_objects_after: screenMetrics is MemoryScreenMetrics + heapDiffObjectsAfter: screenMetrics is MemoryScreenMetrics ? screenMetrics.heapDiffObjectsAfter : null, - heap_objects_total: screenMetrics is MemoryScreenMetrics + heapObjectsTotal: screenMetrics is MemoryScreenMetrics ? screenMetrics.heapObjectsTotal : null, // [InspectorScreenMetrics] - root_set_count: screenMetrics is InspectorScreenMetrics + rootSetCount: screenMetrics is InspectorScreenMetrics ? screenMetrics.rootSetCount : null, - row_count: screenMetrics is InspectorScreenMetrics + rowCount: screenMetrics is InspectorScreenMetrics ? screenMetrics.rowCount : null, - inspector_tree_controller_id: screenMetrics is InspectorScreenMetrics + inspectorTreeControllerId: screenMetrics is InspectorScreenMetrics ? screenMetrics.inspectorTreeControllerId : null, // [DeepLinkScreenMetrics] - android_app_id: screenMetrics is DeepLinkScreenMetrics + androidAppId: screenMetrics is DeepLinkScreenMetrics ? screenMetrics.androidAppId : null, - ios_bundle_id: screenMetrics is DeepLinkScreenMetrics + iosBundleId: screenMetrics is DeepLinkScreenMetrics ? screenMetrics.iosBundleId : null, // [InspectorScreenMetrics] - is_v2_inspector: screenMetrics is InspectorScreenMetrics - ? screenMetrics.isV2.toString() + isV2Inspector: screenMetrics is InspectorScreenMetrics + ? true.toString() : null, ); } + String? description; + bool fatal; + // Custom dimensions: - external String? get user_app; - external String? get user_build; - external String? get user_platform; - external String? get devtools_platform; - external String? get devtools_chrome; - external String? get devtools_version; - external String? get ide_launched; - external String? get flutter_client_id; - external String? get is_external_build; - external String? get is_embedded; - external String? get g3_username; - external String? get ide_launched_feature; - external String? get is_wasm; + String? userApp; + String? userBuild; + String? userPlatform; + String? devtoolsPlatform; + String? devtoolsChrome; + String? devtoolsVersion; + String? ideLaunched; + String? flutterClientId; + String? isExternalBuild; + String? isEmbedded; + String? g3Username; + String? ideLaunchedFeature; + String? isWasm; // Custom metrics: - external int? get ui_duration_micros; - external int? get raster_duration_micros; - external int? get shader_compilation_duration_micros; - external int? get cpu_sample_count; - external int? get cpu_stack_depth; - external int? get trace_event_count; - external int? get heap_diff_objects_before; - external int? get heap_diff_objects_after; - external int? get heap_objects_total; - external int? get root_set_count; - external int? get row_count; - external int? get inspector_tree_controller_id; - external String? get android_app_id; - external String? get ios_bundle_id; - external bool? get is_v2_inspector; -} - -/// Whether google analytics are enabled. -Future isAnalyticsEnabled() async { - bool enabled = false; - if (kReleaseMode) { - enabled = await dtdManager.analyticsTelemetryEnabled(); - } - - // TODO(https://github.com/flutter/devtools/issues/7083): remove this block - // when the legacy analytics are fully removed. For now, check that both - // unified analytics are enabled and the legacy analytics are enabled. - if (enabled) { - enabled = await server.isAnalyticsEnabled(); - } - - return enabled; -} - -/// Whether the google analytics consent message should be shown. -Future shouldShowAnalyticsConsentMessage() async { - bool shouldShow = false; - if (kReleaseMode) { - // When asked if the consent message should be shown, - // package:unified_analytics will return true if this the user's first run - // of DevTools with package:unified_analytics support or when the consent - // message version has been updated. - shouldShow = await dtdManager.shouldShowAnalyticsConsentMessage(); - } - - // TODO(https://github.com/flutter/devtools/issues/7083): remove this block - // when the legacy analytics are fully removed. - if (!shouldShow) { - shouldShow = await server.isFirstRun(); - } - - return shouldShow; + int? uiDurationMicros; + int? rasterDurationMicros; + int? shaderCompilationDurationMicros; + int? cpuSampleCount; + int? cpuStackDepth; + int? traceEventCount; + int? heapDiffObjectsBefore; + int? heapDiffObjectsAfter; + int? heapObjectsTotal; + int? rootSetCount; + int? rowCount; + int? inspectorTreeControllerId; + String? androidAppId; + String? iosBundleId; + String? isV2Inspector; } void screen(String screenName, [int value = 0]) { _log.fine('Event: Screen(screenName:$screenName, value:$value)'); - final gtagEvent = GtagEventDevTools._create( + final gtagEvent = DevToolsAnalyticsEvent._create( screen: screenName, - event_category: gac.screenViewEvent, - event_label: gac.init, + eventCategory: gac.screenViewEvent, + eventLabel: gac.init, value: value, - send_to: gaDevToolsPropertyId(), ); _sendEvent(gtagEvent); } @@ -582,12 +559,11 @@ void _timing( 'timedOperation:$timedOperation, ' 'durationMicros:$durationMicros)', ); - final gtagEvent = GtagEventDevTools._create( + final gtagEvent = DevToolsAnalyticsEvent._create( screen: screenName, - event_category: gac.timingEvent, - event_label: timedOperation, + eventCategory: gac.timingEvent, + eventLabel: timedOperation, value: durationMicros, - send_to: gaDevToolsPropertyId(), screenMetrics: screenMetrics, ); _sendEvent(gtagEvent); @@ -608,13 +584,12 @@ void select( 'value:$value, ' 'nonInteraction:$nonInteraction)', ); - final gtagEvent = GtagEventDevTools._create( + final gtagEvent = DevToolsAnalyticsEvent._create( screen: screenName, - event_category: gac.selectEvent, - event_label: selectedItem, + eventCategory: gac.selectEvent, + eventLabel: selectedItem, value: value, - non_interaction: nonInteraction, - send_to: gaDevToolsPropertyId(), + nonInteraction: nonInteraction, screenMetrics: screenMetricsProvider != null ? screenMetricsProvider() : null, @@ -635,12 +610,11 @@ void impression( 'screenName:$screenName, ' 'item:$item)', ); - final gtagEvent = GtagEventDevTools._create( + final gtagEvent = DevToolsAnalyticsEvent._create( screen: screenName, - event_category: gac.impressionEvent, - event_label: item, - non_interaction: true, - send_to: gaDevToolsPropertyId(), + eventCategory: gac.impressionEvent, + eventLabel: item, + nonInteraction: true, screenMetrics: screenMetricsProvider != null ? screenMetricsProvider() : null, @@ -663,41 +637,37 @@ void reportError( if (_lastGaError == errorMessage) return; _lastGaError = errorMessage; - final gTagExceptionWithStackTrace = GtagExceptionDevTools._create( - // Include the stack trace in the message for legacy analytics. - '$errorMessage\n${stackTrace?.toString() ?? ''}', - fatal: fatal, - ); - GTag.exception(gaExceptionProvider: () => gTagExceptionWithStackTrace); - - final uaEvent = _uaEventFromGtagException( - GtagExceptionDevTools._create(errorMessage, fatal: fatal), + final uaEvent = _uaEventFromDevToolsException( + DevToolsAnalyticsException._create(errorMessage, fatal: fatal), stackTrace: stackTrace, ); - unawaited(dtdManager.sendAnalyticsEvent(uaEvent)); + unawaited(globals.dtdManager.sendAnalyticsEvent(uaEvent)); } //////////////////////////////////////////////////////////////////////////////// // Utilities to collect all platform and DevTools state for Analytics. //////////////////////////////////////////////////////////////////////////////// -// GA dimensions: -String _userAppType = ''; // dimension1 -String _userBuildType = ''; // dimension2 -String _userPlatformType = ''; // dimension3 +String _userAppType = ''; +String _userBuildType = ''; +String _userPlatformType = ''; -String _devtoolsPlatformType = - ''; // dimension4 MacIntel/Linux/Windows/Android_n -String _devtoolsChrome = ''; // dimension5 Chrome/n.n.n or Crios/n.n.n +/// MacIntel/Linux/Windows/Android_n +String _devtoolsPlatformType = ''; -final devtoolsVersion = devToolsVersion; //dimension6 n.n.n +/// Chrome/n.n.n or Crios/n.n.n +String _devtoolsChrome = ''; -String _ideLaunched = ''; // dimension7 IDE launched DevTools (VSCode, CLI, ...) +/// IDE launched DevTools (VSCode, CLI, ...) +String _ideLaunched = ''; -// dimension12 IDE feature that launched DevTools +/// The IDE feature that launched DevTools. +/// +/// Defaults to [_ideLaunchedCLI] if DevTools was not launched from the IDE. String _ideLaunchedFeature = ''; -String _flutterClientId = ''; // dimension8 Flutter tool clientId. +/// Flutter tool clientId. +String _flutterClientId = ''; String get userAppType => _userAppType; @@ -717,21 +687,6 @@ set userPlatformType(String newUserPlatformType) { _userPlatformType = newUserPlatformType; } -String get devtoolsPlatformType => _devtoolsPlatformType; - -set devtoolsPlatformType(String newDevtoolsPlatformType) { - _devtoolsPlatformType = newDevtoolsPlatformType; -} - -String get devtoolsChrome => _devtoolsChrome; - -set devtoolsChrome(String newDevtoolsChrome) { - _devtoolsChrome = newDevtoolsChrome; -} - -/// The IDE that DevTools was launched from. -/// -/// Defaults to [ideLaunchedCLI] if DevTools was not launched from the IDE. String get ideLaunched => _ideLaunched; String get ideLaunchedFeature => _ideLaunchedFeature; @@ -740,17 +695,11 @@ set ideLaunchedFeature(String newIdeLaunchedFeature) { _ideLaunchedFeature = newIdeLaunchedFeature; } -String get flutterClientId => _flutterClientId; - -set flutterClientId(String newFlutterClientId) { - _flutterClientId = newFlutterClientId; -} - Completer? _computingDimensionsCompleter; -// Computes the running application. +/// Computes the running application. void _computeUserApplicationCustomGTagData() { - final connectedApp = serviceConnection.serviceManager.connectedApp!; + final connectedApp = globals.serviceConnection.serviceManager.connectedApp!; assert(connectedApp.isFlutterAppNow != null); assert(connectedApp.isDartWebAppNow != null); assert(connectedApp.isProfileBuildNow != null); @@ -758,59 +707,56 @@ void _computeUserApplicationCustomGTagData() { const unknownOS = 'unknown'; if (connectedApp.isFlutterAppNow!) { userPlatformType = - serviceConnection.serviceManager.vm?.operatingSystem ?? unknownOS; + globals.serviceConnection.serviceManager.vm?.operatingSystem ?? + unknownOS; } if (connectedApp.isFlutterWebAppNow) { - userAppType = appTypeFlutterWeb; + userAppType = _UserAppType.flutterWeb.value; } else if (connectedApp.isFlutterAppNow!) { - userAppType = appTypeFlutter; + userAppType = _UserAppType.flutter.value; } else if (connectedApp.isDartWebAppNow!) { - userAppType = appTypeWeb; + userAppType = _UserAppType.web.value; } else { - userAppType = appTypeDartCLI; + userAppType = _UserAppType.dartCLI.value; } userBuildType = connectedApp.isProfileBuildNow! - ? buildTypeProfile - : buildTypeDebug; + ? _UserBuildType.profile.name + : _UserBuildType.debug.name; } -@JS('getDevToolsPropertyID') -external String gaDevToolsPropertyId(); - -@JS('hookupListenerForGA') -external void jsHookupListenerForGA(); - -/// Computes the DevTools application. Fills in the devtoolsPlatformType and -/// devtoolsChrome. -void computeDevToolsCustomGTagsData() { - // Platform - final platform = window.navigator.platform; - platform.replaceAll(' ', '_'); - devtoolsPlatformType = platform; - +/// Computes the DevTools application data. +/// +/// Fills in the [_devtoolsPlatformType] and [_devtoolsChrome]. +void computeDevToolsCustomData() { + _devtoolsPlatformType = window.navigator.platform.replaceAll(' ', '_'); final appVersion = window.navigator.appVersion; final splits = appVersion.split(' '); final len = splits.length; for (int index = 0; index < len; index++) { final value = splits[index]; // Chrome or Chrome iOS - if (value.startsWith(devToolsChromeName) || - value.startsWith(devToolsChromeIos)) { - devtoolsChrome = value; - } else if (value.startsWith('Android')) { + if (value.startsWith(_ChromePrefix.chrome.value) || + value.startsWith(_ChromePrefix.crios.value)) { + _devtoolsChrome = value; + } else if (value.startsWith('Android') && index + 1 < splits.length) { // appVersion for Android is 'Android n.n.n' - devtoolsPlatformType = '$devToolsPlatformTypeAndroid${splits[index + 1]}'; - } else if (value == devToolsChromeOS) { + _devtoolsPlatformType = + '$devToolsPlatformTypeAndroid${splits[index + 1]}'; + } else if (value == _ChromePrefix.cros.value) { // Chrome OS will return a platform e.g., CrOS_Linux_x86_64 - devtoolsPlatformType = '${devToolsChromeOS}_$platform'; + _devtoolsPlatformType = + '${_ChromePrefix.cros.value}_$_devtoolsPlatformType'; } } } -// Look at the query parameters '&ide=' and record in GA. +const _ideLaunchedCLI = 'CLI'; + +/// Look at the query parameters '&ide=' and set values. void computeDevToolsQueryParams() { - _ideLaunched = ideLaunchedCLI; // Default is Command Line launch. + // Default is Command Line launch. + _ideLaunched = _ideLaunchedCLI; final queryParams = DevToolsQueryParams.load(); final ide = queryParams.ide; @@ -825,7 +771,7 @@ void computeDevToolsQueryParams() { } Future computeFlutterClientId() async { - flutterClientId = await server.flutterGAClientID(); + _flutterClientId = await server.flutterGAClientID(); } Future setupDimensions() async { @@ -835,7 +781,7 @@ Future setupDimensions() async { _computingDimensionsCompleter = Completer(); try { - computeDevToolsCustomGTagsData(); + computeDevToolsCustomData(); computeDevToolsQueryParams(); await computeFlutterClientId(); } catch (e, st) { @@ -846,7 +792,7 @@ Future setupDimensions() async { } void setupUserApplicationDimensions() { - if (serviceConnection.serviceManager.connectedApp == null) { + if (globals.serviceConnection.serviceManager.connectedApp == null) { return; } @@ -863,78 +809,63 @@ Map generateSurveyQueryParameters() { const internalKey = 'Internal'; return { ideKey: _ideLaunched, - versionKey: devtoolsVersion, - internalKey: (!isExternalBuild).toString(), + versionKey: devToolsVersion, + internalKey: (!globals.isExternalBuild).toString(), }; } -FutureOr legacyOnEnableAnalytics() async { - await server.setAnalyticsEnabled(); -} - -FutureOr legacyOnDisableAnalytics() async { - await server.setAnalyticsEnabled(false); -} - -void legacyOnSetupAnalytics() { - initializeGA(); - jsHookupListenerForGA(); -} - -void _sendEvent(GtagEventDevTools gtagEvent) { - GTag.event(gtagEvent.screen!, gaEventProvider: () => gtagEvent); - final uaEvent = _uaEventFromGtagEvent(gtagEvent); - unawaited(dtdManager.sendAnalyticsEvent(uaEvent)); +void _sendEvent(DevToolsAnalyticsEvent event) { + final uaEvent = _uaEventFromDevToolsEvent(event); + unawaited(globals.dtdManager.sendAnalyticsEvent(uaEvent)); } -ua.Event _uaEventFromGtagEvent(GtagEventDevTools gtagEvent) { +ua.Event _uaEventFromDevToolsEvent(DevToolsAnalyticsEvent event) { // Any dimensions or metrics that have a null value will be removed from // the event data in the [ua.Event.devtoolsEvent] constructor. return ua.Event.devtoolsEvent( - screen: gtagEvent.screen!, - eventCategory: gtagEvent.event_category!, - label: gtagEvent.event_label!, - value: gtagEvent.value, - userInitiatedInteraction: !gtagEvent.non_interaction, - userApp: gtagEvent.user_app, - userBuild: gtagEvent.user_build, - userPlatform: gtagEvent.user_platform, - devtoolsPlatform: gtagEvent.devtools_platform, - devtoolsChrome: gtagEvent.devtools_chrome, - devtoolsVersion: gtagEvent.devtools_version, - ideLaunched: gtagEvent.ide_launched, - ideLaunchedFeature: gtagEvent.ide_launched_feature, - isExternalBuild: gtagEvent.is_external_build, - isEmbedded: gtagEvent.is_embedded, - isWasm: gtagEvent.is_wasm, - g3Username: gtagEvent.g3_username, + screen: event.screen!, + eventCategory: event.eventCategory!, + label: event.eventLabel!, + value: event.value, + userInitiatedInteraction: !event.nonInteraction, + userApp: event.userApp, + userBuild: event.userBuild, + userPlatform: event.userPlatform, + devtoolsPlatform: event.devtoolsPlatform, + devtoolsChrome: event.devtoolsChrome, + devtoolsVersion: event.devtoolsVersion, + ideLaunched: event.ideLaunched, + ideLaunchedFeature: event.ideLaunchedFeature, + isExternalBuild: event.isExternalBuild, + isEmbedded: event.isEmbedded, + isWasm: event.isWasm, + g3Username: event.g3Username, // Only 25 entries are permitted for GA4 event parameters, but since not // all of the below metrics will be non-null at the same time, it is okay to // include all the metrics here. The [ua.Event.devtoolsEvent] constructor // will remove any entries with a null value from the sent event parameters. additionalMetrics: _DevToolsEventMetrics( - uiDurationMicros: gtagEvent.ui_duration_micros, - rasterDurationMicros: gtagEvent.raster_duration_micros, - shaderCompilationDurationMicros: - gtagEvent.shader_compilation_duration_micros, - traceEventCount: gtagEvent.trace_event_count, - cpuSampleCount: gtagEvent.cpu_sample_count, - cpuStackDepth: gtagEvent.cpu_stack_depth, - heapDiffObjectsBefore: gtagEvent.heap_diff_objects_before, - heapDiffObjectsAfter: gtagEvent.heap_diff_objects_after, - heapObjectsTotal: gtagEvent.heap_objects_total, - rootSetCount: gtagEvent.root_set_count, - rowCount: gtagEvent.row_count, - inspectorTreeControllerId: gtagEvent.inspector_tree_controller_id, - isV2Inspector: gtagEvent.is_v2_inspector, - androidAppId: gtagEvent.android_app_id, - iosBundleId: gtagEvent.ios_bundle_id, + uiDurationMicros: event.uiDurationMicros, + rasterDurationMicros: event.rasterDurationMicros, + shaderCompilationDurationMicros: event.shaderCompilationDurationMicros, + traceEventCount: event.traceEventCount, + cpuSampleCount: event.cpuSampleCount, + cpuStackDepth: event.cpuStackDepth, + heapDiffObjectsBefore: event.heapDiffObjectsBefore, + heapDiffObjectsAfter: event.heapDiffObjectsAfter, + heapObjectsTotal: event.heapObjectsTotal, + rootSetCount: event.rootSetCount, + rowCount: event.rowCount, + inspectorTreeControllerId: event.inspectorTreeControllerId, + isV2Inspector: event.isV2Inspector, + androidAppId: event.androidAppId, + iosBundleId: event.iosBundleId, ), ); } -ua.Event _uaEventFromGtagException( - GtagExceptionDevTools gtagException, { +ua.Event _uaEventFromDevToolsException( + DevToolsAnalyticsException exception, { stack_trace.Trace? stackTrace, }) { final stackTraceAsMap = createStackTraceForAnalytics(stackTrace); @@ -942,22 +873,22 @@ ua.Event _uaEventFromGtagException( // Any data entries that have a null value will be removed from the event data // in the [ua.Event.exception] constructor. return ua.Event.exception( - exception: gtagException.description ?? 'unknown exception', + exception: exception.description ?? 'unknown exception', data: { - 'fatal': gtagException.fatal, + 'fatal': exception.fatal, ...stackTraceAsMap, - 'userApp': gtagException.user_app, - 'userBuild': gtagException.user_build, - 'userPlatform': gtagException.user_platform, - 'devtoolsPlatform': gtagException.devtools_platform, - 'devtoolsChrome': gtagException.devtools_chrome, - 'devtoolsVersion': gtagException.devtools_version, - 'ideLaunched': gtagException.ide_launched, - 'ideLaunchedFeature': gtagException.ide_launched_feature, - 'isExternalBuild': gtagException.is_external_build, - 'isEmbedded': gtagException.is_embedded, - 'isWasm': gtagException.is_wasm, - 'g3Username': gtagException.g3_username, + 'userApp': exception.userApp, + 'userBuild': exception.userBuild, + 'userPlatform': exception.userPlatform, + 'devtoolsPlatform': exception.devtoolsPlatform, + 'devtoolsChrome': exception.devtoolsChrome, + 'devtoolsVersion': exception.devtoolsVersion, + 'ideLaunched': exception.ideLaunched, + 'ideLaunchedFeature': exception.ideLaunchedFeature, + 'isExternalBuild': exception.isExternalBuild, + 'isEmbedded': exception.isEmbedded, + 'isWasm': exception.isWasm, + 'g3Username': exception.g3Username, // Do not include metrics in exceptions because GA4 event parameter are // limited to 25 entries, and we need to reserve entries for the stack // trace chunks. diff --git a/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart b/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart index a76196f497b..6f3f66edba4 100644 --- a/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart +++ b/packages/devtools_app/lib/src/shared/analytics/analytics_common.dart @@ -29,8 +29,8 @@ import '../primitives/utils.dart'; /// } /// ``` /// -/// Then, add your fields to the [GtagEventDevTools] factory constructor and add -/// a corresponding getter in the class. +/// Then, add your fields to the [DevToolsAnalyticsEvent] factory constructor +/// and add a corresponding getter in the class. abstract class ScreenAnalyticsMetrics {} /// The character limit for each event parameter value sent to GA4. diff --git a/packages/devtools_app/lib/src/shared/analytics/analytics_controller.dart b/packages/devtools_app/lib/src/shared/analytics/analytics_controller.dart index 271101be541..9d7c9684284 100644 --- a/packages/devtools_app/lib/src/shared/analytics/analytics_controller.dart +++ b/packages/devtools_app/lib/src/shared/analytics/analytics_controller.dart @@ -9,22 +9,19 @@ import 'package:flutter/foundation.dart'; import '../development_helpers.dart'; import '../globals.dart'; import '../managers/dtd_manager_extensions.dart'; -import 'analytics.dart' as ga; Future get analyticsController async { if (_analyticsController != null) return _analyticsController!; - // TODO(https://github.com/flutter/devtools/issues/7083): when the legacy - // analytics are fully removed, this try-catch is unnecessary because we will - // get these values directly from [dtdManater], like how the consentMessage - // parameter is specified below. var enabled = false; var shouldShowConsentMessage = false; try { - enabled = await ga.isAnalyticsEnabled(); + enabled = + debugSendAnalytics || + (kReleaseMode && await dtdManager.analyticsTelemetryEnabled()); shouldShowConsentMessage = debugShowAnalyticsConsentMessage || - await ga.shouldShowAnalyticsConsentMessage(); + (kReleaseMode && await dtdManager.shouldShowAnalyticsConsentMessage()); } catch (_) { // Ignore issues if analytics could not be initialized. } @@ -32,26 +29,25 @@ Future get analyticsController async { enabled: enabled, shouldShowConsentMessage: shouldShowConsentMessage, consentMessage: await dtdManager.analyticsConsentMessage(), - // TODO(https://github.com/flutter/devtools/issues/7083): remove these - // when the legacy analytics are fully removed. - legacyOnEnableAnalytics: ga.legacyOnEnableAnalytics, - legacyOnDisableAnalytics: ga.legacyOnDisableAnalytics, - legacyOnSetupAnalytics: ga.legacyOnSetupAnalytics, ); } AnalyticsController? _analyticsController; -typedef AsyncAnalyticsCallback = FutureOr Function(); +/// A synchronous check to see if analytics are enabled. +/// +/// Returns `false` if analytics are disabled or not yet initialized. +bool get isAnalyticsEnabled => + _analyticsController?.analyticsEnabled.value ?? false; + +/// Whether the analytics controller has been initialized. +bool get isAnalyticsControllerInitialized => _analyticsController != null; class AnalyticsController { AnalyticsController({ required bool enabled, required bool shouldShowConsentMessage, required this.consentMessage, - this.legacyOnEnableAnalytics, - this.legacyOnDisableAnalytics, - this.legacyOnSetupAnalytics, }) : analyticsEnabled = ValueNotifier(enabled), _shouldPrompt = ValueNotifier( shouldShowConsentMessage && consentMessage.isNotEmpty, @@ -69,6 +65,7 @@ class AnalyticsController { ValueListenable get shouldPrompt => _shouldPrompt; final ValueNotifier _shouldPrompt; + @visibleForTesting bool get analyticsInitialized => _analyticsInitialized; bool _analyticsInitialized = false; @@ -80,12 +77,6 @@ class AnalyticsController { /// Consent message for package:unified_analytics to be shown on first run. final String consentMessage; - // TODO(https://github.com/flutter/devtools/issues/7083): remove these - // when the legacy analytics are fully removed. - final AsyncAnalyticsCallback? legacyOnEnableAnalytics; - final AsyncAnalyticsCallback? legacyOnDisableAnalytics; - final VoidCallback? legacyOnSetupAnalytics; - /// Sets whether google analytics are enabled. Future toggleAnalyticsEnabled(bool? enable) async { if (enable == true) { @@ -96,21 +87,18 @@ class AnalyticsController { if (kReleaseMode) { await dtdManager.setAnalyticsTelemetry(true); } - await legacyOnEnableAnalytics?.call(); } else { analyticsEnabled.value = false; hidePrompt(); if (kReleaseMode) { await dtdManager.setAnalyticsTelemetry(false); } - await legacyOnDisableAnalytics?.call(); } } void setUpAnalytics() { if (_analyticsInitialized) return; assert(analyticsEnabled.value = true); - legacyOnSetupAnalytics?.call(); _analyticsInitialized = true; } diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart index 45450f842d9..a19a5f1db14 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart @@ -8,7 +8,6 @@ library; import 'package:devtools_shared/devtools_extensions.dart'; import '../framework/screen.dart'; -import '../preferences/preferences.dart'; part 'constants/_cpu_profiler_constants.dart'; part 'constants/_debugger_constants.dart'; @@ -45,6 +44,7 @@ final vmTools = ScreenMetaData.vmTools.id; const console = 'console'; final simple = ScreenMetaData.simple.id; final deeplink = ScreenMetaData.deepLinks.id; +final accessibility = ScreenMetaData.accessibility.id; // GA events not associated with a any screen e.g., hotReload, hotRestart, etc const devToolsMain = 'main'; @@ -85,8 +85,8 @@ const slowAnimation = 'slowAnimation'; const slowAnimationDocs = 'slowAnimationDocs'; const repaintRainbow = 'repaintRainbow'; const repaintRainbowDocs = 'repaintRainbowDocs'; -const debugBanner = 'debugBanner'; const togglePlatform = 'togglePlatform'; +const brightnessOverride = 'brightnessOverride'; const highlightOversizedImages = 'highlightOversizedImages'; const highlightOversizedImagesDocs = 'highlightOversizedImagesDocs'; const selectWidgetMode = 'selectWidgetMode'; @@ -97,10 +97,6 @@ const onDeviceSelection = 'onDeviceSelection'; const inspectorSettings = 'inspectorSettings'; const loggingSettings = 'loggingSettings'; const refreshPubRoots = 'refreshPubRoots'; -final defaultDetailsViewToLayoutExplorer = - InspectorDetailsViewType.layoutExplorer.name; -final defaultDetailsViewToWidgetDetails = - InspectorDetailsViewType.widgetDetailsTree.name; enum HomeScreenEvents { connectToApp, connectToNewApp, viewVmFlags } @@ -132,8 +128,6 @@ const vmDeveloperMode = 'vmDeveloperMode'; const wasm = 'wasm'; const verboseLogging = 'verboseLogging'; const inspectorHoverEvalMode = 'inspectorHoverEvalMode'; -const inspectorV2Enabled = 'inspectorV2Enabled'; -const inspectorV2Disabled = 'inspectorV2Disabled'; const inspectorAutoRefreshEnabled = 'inspectorAutoRefreshEnabled'; const clearLogs = 'clearLogs'; const copyLogs = 'copyLogs'; diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart index 9920f33bb01..4a7c7ced742 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants/_cpu_profiler_constants.dart @@ -7,7 +7,6 @@ part of '../constants.dart'; enum CpuProfilerEvents { profileGranularity, loadAllCpuSamples, - profileAppStartUp, cpuProfileFlameChartHelp, cpuProfileProcessingTime, openDataFile, diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart index 6549ec693aa..bce8a49ae29 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants/_memory_constants.dart @@ -53,6 +53,7 @@ enum MemoryEvents { profileHelp, profileRefreshManual, profileRefreshOnGc, + profilePinClass, // 'Tracing' tab events tracingClear, diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart index 1b548e82749..3b9bafb1a62 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants/_performance_constants.dart @@ -20,9 +20,6 @@ enum PerformanceEvents { disableOpacityLayers, disablePhysicalShapeLayers, countWidgetBuilds('trackRebuildWidgets'), - collectRasterStats, - clearRasterStats, - fullScreenLayerImage, clearRebuildStats, perfettoLoadTrace, perfettoScrollToTimeRange, @@ -55,6 +52,5 @@ enum PerformanceDocs { shaderCompilationDocs, shaderCompilationDocsTooltipLink, impellerDocsLink, - impellerDocsLinkFromRasterStats, platformChannelsDocs, } diff --git a/packages/devtools_app/lib/src/shared/analytics/gtags.dart b/packages/devtools_app/lib/src/shared/analytics/gtags.dart deleted file mode 100644 index 4e43b03772b..00000000000 --- a/packages/devtools_app/lib/src/shared/analytics/gtags.dart +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -// ignore_for_file: non_constant_identifier_names - -@JS() -library; - -import 'dart:js_interop'; - -import 'package:flutter/foundation.dart'; - -import '../../shared/development_helpers.dart'; -import 'analytics.dart' as ga; - -/// For gtags API see https://developers.google.com/gtagjs/reference/api -/// For debugging install the Chrome Plugin "Google Analytics Debugger". - -@JS('gtag') -external void _gTagCommandName(String command, String name, [JSObject? params]); - -// TODO(jacobr): refactor this code if we do not migrate off gtags. -/// A namespace for gtags. -extension GTag on Never { - static const _event = 'event'; - static const _exception = 'exception'; - - /// Collect the analytic's event and its parameters. - static void event( - String eventName, { - required GtagEvent Function() gaEventProvider, - }) async { - if (debugSendAnalytics || (kReleaseMode && await ga.isAnalyticsEnabled())) { - _gTagCommandName(_event, eventName, gaEventProvider()); - } - } - - static void exception({ - required GtagException Function() gaExceptionProvider, - }) async { - if (debugSendAnalytics || (kReleaseMode && await ga.isAnalyticsEnabled())) { - _gTagCommandName(_event, _exception, gaExceptionProvider()); - } - } -} - -extension type GtagEvent._(JSObject _) implements JSObject { - external factory GtagEvent({ - String? event_category, - String? event_label, // Event e.g., gaScreenViewEvent, gaSelectEvent, etc. - String? send_to, // UA ID of target GA property to receive event data. - - int value, - bool non_interaction, - JSObject? custom_map, - }); - - external String? get event_category; - external String? get event_label; - external String? get send_to; - external int get value; // Positive number. - external bool get non_interaction; - external JSObject? get custom_map; // Custom metrics -} - -extension type GtagException._(JSObject _) implements JSObject { - external factory GtagException({String? description, bool fatal}); - - external String? get description; // Description of the error. - external bool get fatal; // Fatal error. -} diff --git a/packages/devtools_app/lib/src/shared/analytics/metrics.dart b/packages/devtools_app/lib/src/shared/analytics/metrics.dart index fa0195a9021..595c0a1de61 100644 --- a/packages/devtools_app/lib/src/shared/analytics/metrics.dart +++ b/packages/devtools_app/lib/src/shared/analytics/metrics.dart @@ -67,23 +67,13 @@ class ProfilerScreenMetrics extends ScreenAnalyticsMetrics { } class InspectorScreenMetrics extends ScreenAnalyticsMetrics { - InspectorScreenMetrics.legacy({ + InspectorScreenMetrics({ this.rootSetCount, this.rowCount, this.inspectorTreeControllerId, - }) : isV2 = false; - - InspectorScreenMetrics.v2({ - this.rootSetCount, - this.rowCount, - this.inspectorTreeControllerId, - }) : isV2 = true; + }); static const summaryTreeGaId = 0; - static const detailsTreeGaId = 1; - - /// Whether these metrics are for the V2 inspector. - final bool isV2; /// The number of times the root has been set, since the /// [InspectorTreeController] with id [inspectorTreeControllerId], has been diff --git a/packages/devtools_app/lib/src/shared/analytics/prompt.dart b/packages/devtools_app/lib/src/shared/analytics/prompt.dart index 9b4aca268b9..af89a28b63a 100644 --- a/packages/devtools_app/lib/src/shared/analytics/prompt.dart +++ b/packages/devtools_app/lib/src/shared/analytics/prompt.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/shared/charts/chart.dart b/packages/devtools_app/lib/src/shared/charts/chart.dart index 6c93000254e..652675f6fcf 100644 --- a/packages/devtools_app/lib/src/shared/charts/chart.dart +++ b/packages/devtools_app/lib/src/shared/charts/chart.dart @@ -7,8 +7,8 @@ import 'dart:ui'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/utils.dart'; import 'chart_controller.dart'; diff --git a/packages/devtools_app/lib/src/shared/charts/chart_controller.dart b/packages/devtools_app/lib/src/shared/charts/chart_controller.dart index 923ac1b2630..7f8fa379e57 100644 --- a/packages/devtools_app/lib/src/shared/charts/chart_controller.dart +++ b/packages/devtools_app/lib/src/shared/charts/chart_controller.dart @@ -4,7 +4,7 @@ import 'package:collection/collection.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/utils.dart'; import 'chart_trace.dart'; diff --git a/packages/devtools_app/lib/src/shared/charts/chart_trace.dart b/packages/devtools_app/lib/src/shared/charts/chart_trace.dart index d95f0b2e861..c13d67930d5 100644 --- a/packages/devtools_app/lib/src/shared/charts/chart_trace.dart +++ b/packages/devtools_app/lib/src/shared/charts/chart_trace.dart @@ -4,7 +4,7 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'chart_controller.dart'; @@ -54,6 +54,7 @@ class PaintCharacteristics { /// If specified Y scale is computed and min value is fixed. /// Will assert if new data point is less than min specified. + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9910) seems like a bug. double? fixedMinY; /// If specified Y scale is computed and max value is fixed. @@ -88,11 +89,7 @@ class PaintCharacteristics { } class Trace { - Trace(this.controller, this._chartType, this.characteristics) { - final minY = characteristics.fixedMinY ?? 0.0; - final maxY = characteristics.fixedMaxY ?? 0.0; - yAxis = AxisScale(minY, maxY, 30); - } + Trace(this.controller, this._chartType, this.characteristics); final ChartController controller; @@ -113,6 +110,7 @@ class Trace { /// ------------------------------- late bool stacked; + // ignore: unused-code, false positive used in asserts. String? name; double dataYMax = 0; @@ -155,8 +153,6 @@ class Trace { ChartType get chartType => _chartType; - AxisScale? yAxis; - void clearData() { _data.clear(); controller.dirty = true; @@ -173,7 +169,6 @@ class Trace { ); } else if (datum.y > dataYMax) { dataYMax = datum.y.toDouble(); - yAxis = AxisScale(0, dataYMax, 30); } if (datum.y > controller.yMaxValue) { @@ -298,7 +293,6 @@ class AxisScale { fraction = 0; } return AxisScale._( - minPoint: minPoint, maxPoint: maxPoint, maxTicks: maxTicks, tickSpacing: tickSpacing, @@ -308,7 +302,6 @@ class AxisScale { } AxisScale._({ - required this.minPoint, required this.maxPoint, required this.maxTicks, required this.tickSpacing, @@ -316,8 +309,10 @@ class AxisScale { required this.labelTicks, }); - final double minPoint, maxPoint; + @visibleForTesting + final double maxPoint; + @visibleForTesting final double maxTicks; final double tickSpacing; diff --git a/packages/devtools_app/lib/src/shared/charts/flame_chart.dart b/packages/devtools_app/lib/src/shared/charts/flame_chart.dart index 37200e06ffd..a3210fba4fe 100644 --- a/packages/devtools_app/lib/src/shared/charts/flame_chart.dart +++ b/packages/devtools_app/lib/src/shared/charts/flame_chart.dart @@ -10,14 +10,13 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/extent_delegate_list.dart'; import '../primitives/flutter_widgets/linked_scroll_controller.dart'; import '../primitives/trees.dart'; import '../primitives/utils.dart'; -import '../ui/colors.dart'; import '../ui/common_widgets.dart'; import '../ui/search.dart'; import '../ui/utils.dart'; @@ -60,16 +59,8 @@ abstract class FlameChart extends StatefulWidget { }); static const minZoomLevel = 1.0; - static const zoomMultiplier = 0.01; static const minScrollOffset = 0.0; static const rowOffsetForBottomPadding = 1; - static const rowOffsetForSectionSpacer = 1; - - /// Maximum scroll delta allowed for scroll wheel based zooming. - /// - /// This isn't really needed but is a reasonable for safety in case we - /// aren't handling some mouse based scroll wheel behavior well, etc. - static const maxScrollWheelDelta = 20.0; final T data; @@ -103,7 +94,7 @@ abstract class FlameChartState< V extends FlameChartDataMixin > extends State - with AutoDisposeMixin, FlameChartColorMixin, TickerProviderStateMixin { + with AutoDisposeMixin, TickerProviderStateMixin { int get rowOffsetForTopPadding => 2; // The "top" positional value for each flame chart node will be 0.0 because @@ -112,8 +103,7 @@ abstract class FlameChartState< final rows = >[]; - final sections = []; - + // ignore: dispose-fields, false positive. Disposed via autoDisposeFocusNode. final focusNode = FocusNode(debugLabel: 'flame-chart'); double? mouseHoverX; @@ -157,31 +147,6 @@ abstract class FlameChartState< double get widthWithZoom => contentWidthWithZoom + widget.startInset + widget.endInset; - TimeRange get visibleTimeRange { - final horizontalScrollOffset = horizontalControllerGroup.offset; - final startMicros = horizontalScrollOffset < widget.startInset - ? startTimeOffset - : startTimeOffset + - (horizontalScrollOffset - widget.startInset) / - currentZoom / - startingPxPerMicro; - - final endMicros = - startTimeOffset + - (horizontalScrollOffset - widget.startInset + widget.containerWidth) / - currentZoom / - startingPxPerMicro; - - return TimeRange(start: startMicros.round(), end: endMicros.round()); - } - - /// Starting pixels per microsecond in order to fit all the data in view at - /// start. - double get startingPxPerMicro => - widget.startingContentWidth / widget.time.duration.inMicroseconds; - - int get startTimeOffset => widget.time.start; - double get maxZoomLevel { // The max zoom level is hit when 1 microsecond is the width of each grid // interval (this may bottom out at 2 micros per interval due to rounding). @@ -268,6 +233,9 @@ abstract class FlameChartState< verticalControllerGroup.resetScroll(); zoomController.reset(); verticalExtentDelegate.recompute(); + } else if (widget.containerWidth != oldWidget.containerWidth) { + initFlameChartElements(); + verticalExtentDelegate.recompute(); } FocusScope.of(context).requestFocus(focusNode); super.didUpdateWidget(oldWidget); @@ -277,6 +245,7 @@ abstract class FlameChartState< void dispose() { zoomController.dispose(); _verticalFlameChartScrollController.dispose(); + _hoveredNodeNotifier.dispose(); super.dispose(); } @@ -366,13 +335,12 @@ abstract class FlameChartState< @mustCallSuper void initFlameChartElements() { rows.clear(); - sections.clear(); } void expandRows(int newRowLength) { final currentLength = rows.length; for (int i = currentLength; i < newRowLength; i++) { - rows.add(FlameChartRow(i)); + rows.add(FlameChartRow()); } } @@ -578,46 +546,6 @@ abstract class FlameChartState< await scrollToX(offset); } - Future zoomAndScrollToData({ - required int startMicros, - required int durationMicros, - required V data, - bool scrollVertically = true, - bool jumpZoom = false, - }) async { - await zoomToTimeRange( - startMicros: startMicros, - durationMicros: durationMicros, - jump: jumpZoom, - ); - // Call these in a post frame callback so that the scroll controllers have - // had time to update their scroll extents. Otherwise, we can hit a race - // where are trying to scroll to an offset that is beyond what the scroll - // controller thinks its max scroll extent is. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - unawaited(scrollHorizontallyToData(data)); - if (scrollVertically) unawaited(scrollVerticallyToData(data)); - } - }); - } - - Future zoomToTimeRange({ - required int startMicros, - required int durationMicros, - double? targetWidth, - bool jump = false, - }) async { - targetWidth ??= widget.containerWidth * 0.8; - final startingWidth = durationMicros * startingPxPerMicro; - final zoom = targetWidth / startingWidth; - final mouseXForZoom = - (startMicros - startTimeOffset + durationMicros / 2) * - startingPxPerMicro + - widget.startInset; - await zoomTo(zoom, forceMouseX: mouseXForZoom, jump: jump); - } - bool isDataVerticallyInView(V data); bool isDataHorizontallyInView(V data); @@ -694,9 +622,6 @@ class ScrollingFlameChartRowState> chartStartInset: widget.startInset, chartWidth: widget.width, ), - zoom: widget.zoom, - chartStartInset: widget.startInset, - chartWidth: widget.width, ); _initNodeDataList(); @@ -746,9 +671,6 @@ class ScrollingFlameChartRowState> chartStartInset: widget.startInset, chartWidth: widget.width, ), - zoom: widget.zoom, - chartStartInset: widget.startInset, - chartWidth: widget.width, ); } _resetHovered(); @@ -974,25 +896,9 @@ extension FlameChartUtils on Never { } } -class FlameChartSection { - FlameChartSection(this.index, {required this.startRow, required this.endRow}); - - final int index; - - /// Start row (inclusive) for this section. - final int startRow; - - /// End row (exclusive) for this section. - final int endRow; -} - class FlameChartRow> { - FlameChartRow(this.index); - final nodes = >[]; - final int index; - /// Adds a node to [nodes] and assigns `this` to the node's `row` property. /// /// If [index] is specified and in range of the list, [node] will be added at @@ -1003,7 +909,6 @@ class FlameChartRow> { } else { nodes.add(node); } - node.row = this; } } @@ -1041,8 +946,6 @@ class FlameChartNode> { final void Function(T) onSelected; final bool selectable; - late FlameChartRow row; - int sectionIndex; Widget buildWidget({ @@ -1145,54 +1048,17 @@ class FlameChartNode> { } } -mixin FlameChartColorMixin { - ColorPair nextUiColor(int row) { - return uiColorPalette[row % uiColorPalette.length]; - } - - ColorPair nextRasterColor(int row) { - return rasterColorPalette[row % rasterColorPalette.length]; - } - - ColorPair nextAsyncColor(int row) { - return asyncColorPalette[row % asyncColorPalette.length]; - } - - ColorPair nextUnknownColor(int row) { - return unknownColorPalette[row % unknownColorPalette.length]; - } -} - /// [ExtentDelegate] implementation for the case where size and position is /// known for each list item. class _ScrollingFlameChartRowExtentDelegate extends ExtentDelegate { - _ScrollingFlameChartRowExtentDelegate({ - required this.nodeIntervals, - required this.zoom, - required this.chartStartInset, - required this.chartWidth, - }) { + _ScrollingFlameChartRowExtentDelegate({required this.nodeIntervals}) { recompute(); } List nodeIntervals = []; - double zoom; - - double chartStartInset; - - double chartWidth; - - void recomputeWith({ - required List nodeIntervals, - required double zoom, - required double chartStartInset, - required double chartWidth, - }) { + void recomputeWith({required List nodeIntervals}) { this.nodeIntervals = nodeIntervals; - this.zoom = zoom; - this.chartStartInset = chartStartInset; - this.chartWidth = chartWidth; recompute(); } diff --git a/packages/devtools_app/lib/src/shared/charts/treemap.dart b/packages/devtools_app/lib/src/shared/charts/treemap.dart index 9b0db9a44ac..7580a0e4036 100644 --- a/packages/devtools_app/lib/src/shared/charts/treemap.dart +++ b/packages/devtools_app/lib/src/shared/charts/treemap.dart @@ -5,8 +5,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/byte_utils.dart'; import '../primitives/trees.dart'; @@ -15,8 +14,6 @@ import '../ui/common_widgets.dart'; enum PivotType { pivotByMiddle, pivotBySize } -final _log = Logger('charts/treemap'); - class Treemap extends StatefulWidget { // TODO(peterdjlee): Consider auto-expanding rootNode named 'src'. const Treemap.fromRoot({ @@ -404,6 +401,7 @@ class _TreemapState extends State { richMessage: hoveredNode?.displayTextSpan( defaultTextStyle: defaultTextStyle, + color: Theme.of(context).colorScheme.tooltipTextColor, ) ?? const TextSpan(text: ''), waitDuration: tooltipWaitLong, @@ -683,7 +681,10 @@ class TreemapNode extends TreeNode { }); final String name; + + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9910) seems like a bug. final Map childrenMap; + int byteSize; final bool showDiff; @@ -794,17 +795,6 @@ class TreemapNode extends TreeNode { return []; } - void printTree() { - printTreeHelper(this, ''); - } - - void printTreeHelper(TreemapNode root, String tabs) { - _log.info('$tabs$root'); - for (final child in root.children) { - printTreeHelper(child, '$tabs\t'); - } - } - @override String toString() { return '{name: $name, size: $byteSize}'; diff --git a/packages/devtools_app/lib/src/shared/config_specific/drag_and_drop/drag_and_drop.dart b/packages/devtools_app/lib/src/shared/config_specific/drag_and_drop/drag_and_drop.dart index 2b26dd69e65..b195a21bcb6 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/drag_and_drop/drag_and_drop.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/drag_and_drop/drag_and_drop.dart @@ -2,8 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:material_ui/material_ui.dart'; import '../../primitives/utils.dart'; import '_drag_and_drop_desktop.dart' @@ -24,8 +24,6 @@ abstract class DragAndDropManager { int get viewId => _viewId; final int _viewId; - final _dragAndDropStates = {}; - DragAndDropState? activeState; /// The method is abstract, because we want to force descendants to define it. @@ -37,15 +35,7 @@ abstract class DragAndDropManager { @mustCallSuper void dispose() { - _dragAndDropStates.clear(); - } - - void registerDragAndDrop(DragAndDropState state) { - _dragAndDropStates.add(state); - } - - void unregisterDragAndDrop(DragAndDropState state) { - _dragAndDropStates.remove(state); + activeState = null; } void dragOver(double x, double y) { @@ -113,6 +103,7 @@ class DragAndDrop extends StatefulWidget { class DragAndDropState extends State { final _dragging = ValueNotifier(false); + DragAndDropManager? _dragAndDropManager; bool _isActive = false; @@ -124,12 +115,10 @@ class DragAndDropState extends State { // Already registered to the right drag and drop manager, so do nothing. if (oldViewId == viewId) return; - _dragAndDropManager?.unregisterDragAndDrop(this); _dragAndDropManager = null; } _dragAndDropManager = DragAndDropManager.getInstance(viewId); - _dragAndDropManager!.registerDragAndDrop(this); } @override @@ -139,7 +128,8 @@ class DragAndDropState extends State { @override void dispose() { - _dragAndDropManager?.unregisterDragAndDrop(this); + _dragAndDropManager = null; + _dragging.dispose(); super.dispose(); } diff --git a/packages/devtools_app/lib/src/shared/config_specific/framework_initialize/_framework_initialize_desktop.dart b/packages/devtools_app/lib/src/shared/config_specific/framework_initialize/_framework_initialize_desktop.dart index b1f9630f12a..1c8710aa9d8 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/framework_initialize/_framework_initialize_desktop.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/framework_initialize/_framework_initialize_desktop.dart @@ -10,6 +10,7 @@ import 'package:logging/logging.dart'; import 'package:path/path.dart' as path; import '../../primitives/storage.dart'; +import '../../primitives/utils.dart'; final _log = Logger('_framework_initialize_desktop'); @@ -32,12 +33,11 @@ class FlutterDesktopStorage implements Storage { Future setValue(String key, String value) async { _values[key] = value; - const encoder = JsonEncoder.withIndent(' '); if (!_fileAndDirVerified) { File(_preferencesFile.path).createSync(recursive: true); _fileAndDirVerified = true; } - _preferencesFile.writeAsStringSync('${encoder.convert(_values)}\n'); + _preferencesFile.writeAsStringSync('${prettyPrintJson(_values)}\n'); } Map _readValues() { diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/_export_web.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/_export_web.dart index d78708747ce..f000ebc1a8b 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/import_export/_export_web.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/_export_web.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:async'; import 'dart:js_interop'; import 'dart:typed_data'; @@ -13,6 +14,8 @@ ExportControllerWeb createExportController() { return ExportControllerWeb(); } +const _revokeObjectUrlDelay = Duration(seconds: 1); + class ExportControllerWeb extends ExportController { ExportControllerWeb() : super.impl(); @@ -29,11 +32,16 @@ class ExportControllerWeb extends ExportController { throw 'Unsupported content type: $T'; } - element.setAttribute('href', URL.createObjectURL(blob)); + final objectUrl = URL.createObjectURL(blob); + element.setAttribute('href', objectUrl); element.setAttribute('download', fileName); element.style.display = 'none'; (document.body as HTMLBodyElement).append(element); element.click(); element.remove(); + + // Release the memory held by the blob. Revoking synchronously can cancel + // the download in some browsers, so wait for the download to start. + Timer(_revokeObjectUrlDelay, () => URL.revokeObjectURL(objectUrl)); } } diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/_file_desktop.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/_file_desktop.dart index 11179bde55d..eaa737091e2 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/import_export/_file_desktop.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/_file_desktop.dart @@ -6,18 +6,11 @@ import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:file/local.dart'; -import 'package:logging/logging.dart'; -import 'package:path/path.dart' as path; - -final _log = Logger('_file_desktop'); /// Abstracted local file system access for Flutter Desktop. class FileSystemDesktop { final _fs = const LocalFileSystem(); - String exportDirectoryName({bool isMemory = false}) => - _exportDirectory(isMemory: isMemory).path; - /// Flutter Desktop MacOS systemTempDirectory is in Downloads /// for memory files location is $TMPDIR. /// Flutter Desktop Linux systemTempDirectory is \tmp @@ -54,70 +47,4 @@ class FileSystemDesktop { throw StateError('Unsupported content type: $T'); } } - - /// Returns content of filename or null if file is unknown or content empty. - String readStringFromFile(String filename, {bool isMemory = false}) { - final previousCurrentDirectory = _fs.currentDirectory; - - // TODO(terry): Use path_provider when available? - _fs.currentDirectory = _exportDirectory(isMemory: isMemory); - - final logFile = _fs.currentDirectory.childFile(filename); - - final jsonPayload = logFile.readAsStringSync(); - - _fs.currentDirectory = previousCurrentDirectory; - - return jsonPayload; - } - - /// List of files (basename only). - List list({required String prefix, bool isMemory = false}) { - final logs = []; - - try { - // TODO(terry): Use path_provider when available? - final directory = _exportDirectory(isMemory: isMemory); - - if (!directory.existsSync()) { - return logs; - } - - final allFiles = directory.listSync(followLinks: false); - for (final entry in allFiles) { - final basename = path.basename(entry.path); - if (_fs.isFileSync(entry.path) && basename.startsWith(prefix)) { - logs.add(basename); - } - } - - // Sort by newest file top-most (DateTime is in the filename). - logs.sort((a, b) => b.compareTo(a)); - } on FileSystemException catch (e, st) { - // TODO(jacobr): prompt the user to grant permission to access the - // directory if Flutter ever provides that option or consider using an - // alternate directory. This error should generally only occur on MacOS - // desktop Catalina and later where access to the Downloads folder - // is not granted by default. - _log.info(e, e, st); - } - - return logs; - } - - /// Delete exported files created for testing only. - bool deleteFile(String path, {bool isMemory = false}) { - final previousCurrentDirectory = _fs.currentDirectory; - - // TODO(terry): Use path_provider when available? - _fs.currentDirectory = _exportDirectory(isMemory: isMemory); - - if (!_fs.isFileSync(path)) return false; - - _fs.file(path).deleteSync(); - - _fs.currentDirectory = previousCurrentDirectory; - - return true; - } } diff --git a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart index 442151fb282..2f3f0e949fa 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/import_export/import_export.dart @@ -69,6 +69,14 @@ class ImportController { final devToolsOfflineData = _DevToolsOfflineData(json); // TODO(kenz): support imports for more than one screen at a time. final activeScreenId = devToolsOfflineData.activeScreenId; + if (activeScreenId == null) { + notificationService.push( + 'The imported file is not a valid DevTools snapshot because it does ' + 'not contain an activeScreenId field.', + ); + return; + } + if (expectedScreenId != null && activeScreenId != expectedScreenId) { notificationService.push( 'Expected a data file for screen \'$expectedScreenId\' but received one' @@ -89,6 +97,14 @@ class ImportController { } } + if (!devToolsOfflineData.json.containsKey(activeScreenId) || + devToolsOfflineData.json[activeScreenId] == null) { + notificationService.push( + 'The imported file does not contain data for screen \'$activeScreenId\'.', + ); + return; + } + final connectedApp = OfflineConnectedApp.parse( devToolsOfflineData.connectedApp, ); @@ -106,14 +122,15 @@ extension type _DevToolsOfflineData(Map json) { return connectedApp == null ? {} : connectedApp.cast(); } - String get activeScreenId => - json[DevToolsExportKeys.activeScreenId.name] as String; + String? get activeScreenId { + final value = json[DevToolsExportKeys.activeScreenId.name]; + return value is String ? value : null; + } } enum ExportFileType { json, csv, - yaml, data, har; diff --git a/packages/devtools_app/lib/src/shared/config_specific/post_message/_post_message_stub.dart b/packages/devtools_app/lib/src/shared/config_specific/post_message/_post_message_stub.dart index a84be62e287..75b018d7ef4 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/post_message/_post_message_stub.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/post_message/_post_message_stub.dart @@ -7,5 +7,6 @@ import 'post_message.dart'; Stream get onPostMessage => throw UnsupportedError('unsupported platform'); +// ignore: unused-code, the web implementation is a useful helper for posting messages on the window's parent; this stub is necessary for config specific imports. void postMessage(Object? _, String _) => throw UnsupportedError('unsupported platform'); diff --git a/packages/devtools_app/lib/src/shared/config_specific/post_message/_post_message_web.dart b/packages/devtools_app/lib/src/shared/config_specific/post_message/_post_message_web.dart index b59605a3f95..423c7da52f3 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/post_message/_post_message_web.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/post_message/_post_message_web.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, platform specific imports. + import 'dart:js_interop'; import 'package:web/web.dart'; @@ -10,8 +12,7 @@ import 'post_message.dart'; Stream get onPostMessage { return window.onMessage.map( - (message) => - PostMessageEvent(origin: message.origin, data: message.data.dartify()), + (message) => PostMessageEvent(data: message.data.dartify()), ); } diff --git a/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message.dart b/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message.dart index bf9ba98cc7c..fc3071dd9d1 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/post_message/post_message.dart @@ -6,8 +6,7 @@ export '_post_message_stub.dart' if (dart.library.js_interop) '_post_message_web.dart'; class PostMessageEvent { - PostMessageEvent({required this.origin, required this.data}); + PostMessageEvent({required this.data}); - final String origin; final Object? data; } diff --git a/packages/devtools_app/lib/src/shared/console/console.dart b/packages/devtools_app/lib/src/shared/console/console.dart index c9840ce9312..0a04528a62c 100644 --- a/packages/devtools_app/lib/src/shared/console/console.dart +++ b/packages/devtools_app/lib/src/shared/console/console.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/utils.dart'; import '../ui/common_widgets.dart'; @@ -49,7 +49,7 @@ class ConsoleFrame extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (title != null) title!, + ?title, Expanded(child: child), ], ), @@ -90,6 +90,12 @@ class _ConsoleOutputState extends State<_ConsoleOutput> _initHelper(); } + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + void _onScrollChanged() { // Detect if the user has scrolled up and stop scrolling to the bottom if // they have scrolled up. @@ -190,6 +196,7 @@ class _ConsoleOutputState extends State<_ConsoleOutput> children: textSpansFromAnsi( line.text, theme.regularTextStyle, + backgroundColor: theme.colorScheme.surface, ), ), ); diff --git a/packages/devtools_app/lib/src/shared/console/eval/auto_complete.dart b/packages/devtools_app/lib/src/shared/console/eval/auto_complete.dart index 5cfc2aa3ab7..f0277067c3a 100644 --- a/packages/devtools_app/lib/src/shared/console/eval/auto_complete.dart +++ b/packages/devtools_app/lib/src/shared/console/eval/auto_complete.dart @@ -94,9 +94,7 @@ Future> autoCompleteResultsFor( } } catch (_) {} } - return result.nonNulls - .where((name) => name.startsWith(parts.activeWord)) - .toList(); + return result.where((name) => name.startsWith(parts.activeWord)).toList(); } // Due to https://github.com/dart-lang/sdk/issues/46221 @@ -197,7 +195,7 @@ Future> _libraryMemberAutocompletes( final members = functions.map( (funcRef) => funcRef.name!.replaceAll('=', ''), ); - result.addAll(members.nonNulls); + result.addAll(members); } final classes = library.classes; if (classes != null) { diff --git a/packages/devtools_app/lib/src/shared/console/eval/inspector_tree.dart b/packages/devtools_app/lib/src/shared/console/eval/inspector_tree.dart index 1afbf488668..2d699439e92 100644 --- a/packages/devtools_app/lib/src/shared/console/eval/inspector_tree.dart +++ b/packages/devtools_app/lib/src/shared/console/eval/inspector_tree.dart @@ -9,8 +9,6 @@ /// This allows tests of the complicated logic in this class to run on the VM. library; -import 'package:flutter/foundation.dart'; - import '../../diagnostics/diagnostics_node.dart'; import '../../ui/search.dart'; @@ -27,43 +25,22 @@ typedef TreeEventCallback = void Function(InspectorTreeNode node); const iconPadding = 4.0; const chartLineStrokeWidth = 1.0; -const inspectorColumnWidth = 12.0; -const inspectorRowHeight = 16.0; +const inspectorColumnIndent = 36.0; +const inspectorRowHeight = 20.0; /// This class could be refactored out to be a reasonable generic collapsible /// tree ui node class but we choose to instead make it widget inspector /// specific as that is the only case we care about. // TODO(kenz): extend TreeNode class to share tree logic. class InspectorTreeNode { - InspectorTreeNode({InspectorTreeNode? parent, bool expandChildren = true}) + InspectorTreeNode({this.parent, bool expandChildren = true}) : _children = [], - _parent = parent, _isExpanded = expandChildren; bool get showLinesToChildren { return _children.length > 1 && !_children.last.isProperty; } - bool get isDirty => _isDirty; - bool _isDirty = true; - - set isDirty(bool dirty) { - if (dirty) { - _isDirty = true; - _shouldShow = null; - if (_childrenCount == null) { - // Already dirty. - return; - } - _childrenCount = null; - if (parent != null) { - parent!.isDirty = true; - } - } else { - _isDirty = false; - } - } - /// Returns whether the node is currently visible in the tree. void updateShouldShow(bool value) { if (value != _shouldShow) { @@ -98,17 +75,16 @@ class InspectorTreeNode { bool get isExpanded => _isExpanded; bool _isExpanded; - bool allowExpandCollapse = true; - bool get showExpandCollapse { - return (diagnostic?.hasChildren == true || children.isNotEmpty) && - allowExpandCollapse; + final hasChildren = diagnostic?.hasChildren == true || children.isNotEmpty; + final isHideableGroupLeader = diagnostic?.isHideableGroupLeader ?? false; + final isHidden = diagnostic?.groupIsHidden ?? false; + return hasChildren && (!isHideableGroupLeader || !isHidden); } set isExpanded(bool value) { if (value != _isExpanded) { _isExpanded = value; - isDirty = true; if (_shouldShow ?? false) { for (final child in children) { child.updateShouldShow(value); @@ -117,13 +93,7 @@ class InspectorTreeNode { } } - InspectorTreeNode? get parent => _parent; - InspectorTreeNode? _parent; - - set parent(InspectorTreeNode? value) { - _parent = value; - _parent?.isDirty = true; - } + InspectorTreeNode? parent; RemoteDiagnosticsNode? get diagnostic => _diagnostic; @@ -131,125 +101,25 @@ class InspectorTreeNode { final value = v!; _diagnostic = value; _isExpanded = value.childrenReady; - isDirty = true; - } - - int get childrenCount { - if (!isExpanded) { - _childrenCount = 0; - } - final childrenCountLocal = _childrenCount; - if (childrenCountLocal != null) { - return childrenCountLocal; - } - int count = 0; - for (final child in _children) { - count += child.subtreeSize; - } - return _childrenCount = count; } bool get hasPlaceholderChildren { return children.length == 1 && children.first.diagnostic == null; } - int? _childrenCount; - - int get subtreeSize => childrenCount + 1; - - // TODO(jacobr): move getRowIndex to the InspectorTree class. - int getRowIndex(InspectorTreeNode node) { - int index = 0; - while (true) { - final parent = node.parent; - if (parent == null) { - break; - } - for (final sibling in parent._children) { - if (sibling == node) { - break; - } - index += sibling.subtreeSize; - } - index += 1; // For parent itself. - node = parent; - } - return index; - } - - // TODO(jacobr): move this method to the InspectorTree class. - // TODO: optimize this method. - InspectorTreeRow? getRow(int index) { - if (subtreeSize <= index) { - return null; - } - - final ticks = []; - InspectorTreeNode node = this; - int current = 0; - int depth = 0; - - // Iterate till getting the result to return. - while (true) { - final style = node.diagnostic?.style; - final indented = - style != DiagnosticsTreeStyle.flat && - style != DiagnosticsTreeStyle.error; - if (current == index) { - return InspectorTreeRow( - node: node, - index: index, - ticks: ticks, - depth: depth, - lineToParent: - !node.isProperty && - index != 0 && - node.parent!.showLinesToChildren, - ); - } - assert(index > current); - current++; - final children = node._children; - int i; - for (i = 0; i < children.length; ++i) { - final child = children[i]; - final subtreeSize = child.subtreeSize; - if (current + subtreeSize > index) { - node = child; - if (children.length > 1 && - i + 1 != children.length && - !children.last.isProperty) { - if (indented) { - ticks.add(depth); - } - } - break; - } - current += subtreeSize; - } - assert(i < children.length); - if (indented) { - depth++; - } - } - } - void removeChild(InspectorTreeNode child) { child.parent = null; final removed = _children.remove(child); assert(removed); - isDirty = true; } void appendChild(InspectorTreeNode child) { _children.add(child); child.parent = this; - isDirty = true; } void clearChildren() { _children.clear(); - isDirty = true; } } @@ -261,6 +131,7 @@ class InspectorTreeRow with SearchableDataMixin { required this.ticks, required this.depth, required this.lineToParent, + required this.hasSingleChild, }); final InspectorTreeNode node; @@ -270,6 +141,7 @@ class InspectorTreeRow with SearchableDataMixin { final int depth; final int index; final bool lineToParent; + final bool hasSingleChild; bool get isSelected => node.selected; } @@ -290,7 +162,7 @@ class InspectorTreeConfig { }); final NodeAddedCallback? onNodeAdded; - final VoidCallback? onSelectionChange; + final void Function({bool notifyFlutterInspector})? onSelectionChange; final void Function(bool added)? onClientActiveChange; final TreeEventCallback? onExpand; } diff --git a/packages/devtools_app/lib/src/shared/console/eval/inspector_tree_v2.dart b/packages/devtools_app/lib/src/shared/console/eval/inspector_tree_v2.dart deleted file mode 100644 index c505152acad..00000000000 --- a/packages/devtools_app/lib/src/shared/console/eval/inspector_tree_v2.dart +++ /dev/null @@ -1,207 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -/// Inspector specific tree rendering support. -/// -/// This library must not have direct dependencies on web-only libraries. -/// -/// This allows tests of the complicated logic in this class to run on the VM. -library; - -import '../../diagnostics/diagnostics_node.dart'; -import '../../ui/search.dart'; - -/// Split text into two groups, word characters at the start of a string and all -/// other characters. -final treeNodePrimaryDescriptionPattern = RegExp(r'^([\w ]+)(.*)$'); -// TODO(jacobr): temporary workaround for missing structure from assertion thrown building -// widget errors. -final assertionThrownBuildingError = RegExp( - r'^(The following assertion was thrown building [a-zA-Z]+)(\(.*\))(:)$', -); - -typedef TreeEventCallback = void Function(InspectorTreeNode node); - -const iconPadding = 4.0; -const chartLineStrokeWidth = 1.0; -const inspectorColumnIndent = 36.0; -const inspectorRowHeight = 20.0; - -/// This class could be refactored out to be a reasonable generic collapsible -/// tree ui node class but we choose to instead make it widget inspector -/// specific as that is the only case we care about. -// TODO(kenz): extend TreeNode class to share tree logic. -class InspectorTreeNode { - InspectorTreeNode({ - InspectorTreeNode? parent, - bool expandChildren = true, - this.whenDirty, - }) : _children = [], - _parent = parent, - _isExpanded = expandChildren; - - /// Callback that is called when the node is marked as dirty. - void Function(InspectorTreeNode node)? whenDirty; - - bool get showLinesToChildren { - return _children.length > 1 && !_children.last.isProperty; - } - - bool get isDirty => _isDirty; - bool _isDirty = true; - - set isDirty(bool dirty) { - if (dirty) { - _isDirty = true; - _shouldShow = null; - if (parent != null) { - parent!.isDirty = true; - } - whenDirty?.call(this); - } else { - _isDirty = false; - } - } - - /// Returns whether the node is currently visible in the tree. - void updateShouldShow(bool value) { - if (value != _shouldShow) { - _shouldShow = value; - for (final child in children) { - child.updateShouldShow(value); - } - } - } - - bool get shouldShow { - final parentLocal = parent; - _shouldShow ??= - parentLocal == null || parentLocal.isExpanded && parentLocal.shouldShow; - return _shouldShow!; - } - - bool? _shouldShow; - - bool selected = false; - - RemoteDiagnosticsNode? _diagnostic; - final List _children; - - Iterable get children => _children; - - bool get isProperty { - final diagnosticLocal = diagnostic; - return diagnosticLocal == null || diagnosticLocal.isProperty; - } - - bool get isExpanded => _isExpanded; - bool _isExpanded; - - bool get showExpandCollapse { - final hasChildren = diagnostic?.hasChildren == true || children.isNotEmpty; - final isHideableGroupLeader = diagnostic?.isHideableGroupLeader ?? false; - final isHidden = diagnostic?.groupIsHidden ?? false; - return hasChildren && (!isHideableGroupLeader || !isHidden); - } - - set isExpanded(bool value) { - if (value != _isExpanded) { - _isExpanded = value; - isDirty = true; - if (_shouldShow ?? false) { - for (final child in children) { - child.updateShouldShow(value); - } - } - } - } - - InspectorTreeNode? get parent => _parent; - InspectorTreeNode? _parent; - - set parent(InspectorTreeNode? value) { - _parent = value; - _parent?.isDirty = true; - } - - RemoteDiagnosticsNode? get diagnostic => _diagnostic; - - set diagnostic(RemoteDiagnosticsNode? v) { - final value = v!; - _diagnostic = value; - _isExpanded = value.childrenReady; - isDirty = true; - } - - bool get hasPlaceholderChildren { - return children.length == 1 && children.first.diagnostic == null; - } - - void removeChild(InspectorTreeNode child) { - child.parent = null; - final removed = _children.remove(child); - assert(removed); - isDirty = true; - } - - void appendChild(InspectorTreeNode child) { - _children.add(child); - child.parent = this; - isDirty = true; - } - - void clearChildren() { - _children.clear(); - isDirty = true; - } -} - -/// A row in the tree with all information required to render it. -class InspectorTreeRow with SearchableDataMixin { - InspectorTreeRow({ - required this.node, - required this.index, - required this.ticks, - required this.depth, - required this.lineToParent, - required this.hasSingleChild, - }); - - final InspectorTreeNode node; - - /// Column indexes of ticks to draw lines from parents to children. - final List ticks; - final int depth; - final int index; - final bool lineToParent; - final bool hasSingleChild; - - bool get isSelected => node.selected; -} - -/// Callback issued every time a node is added to the tree. -typedef NodeAddedCallback = - void Function( - InspectorTreeNode node, - RemoteDiagnosticsNode diagnosticsNode, - ); - -class InspectorTreeConfig { - InspectorTreeConfig({ - this.onNodeAdded, - this.onClientActiveChange, - this.onSelectionChange, - this.onExpand, - }); - - final NodeAddedCallback? onNodeAdded; - final void Function({bool notifyFlutterInspector})? onSelectionChange; - final void Function(bool added)? onClientActiveChange; - final TreeEventCallback? onExpand; -} - -enum SearchTargetType { - widget, - // TODO(https://github.com/flutter/devtools/issues/3489) implement other search scopes: details, all etc -} diff --git a/packages/devtools_app/lib/src/shared/console/primitives/eval_history.dart b/packages/devtools_app/lib/src/shared/console/primitives/eval_history.dart index a94ec5f1a21..39c7763af88 100644 --- a/packages/devtools_app/lib/src/shared/console/primitives/eval_history.dart +++ b/packages/devtools_app/lib/src/shared/console/primitives/eval_history.dart @@ -2,11 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'package:meta/meta.dart'; + /// Store and manipulate the expression evaluation history. class EvalHistory { var _historyPosition = -1; /// Get the expression evaluation history. + @visibleForTesting List get evalHistory => _evalHistory.toList(); final _evalHistory = []; diff --git a/packages/devtools_app/lib/src/shared/console/widgets/console_pane.dart b/packages/devtools_app/lib/src/shared/console/widgets/console_pane.dart index 4d661cc881d..a8ffe08a7a2 100644 --- a/packages/devtools_app/lib/src/shared/console/widgets/console_pane.dart +++ b/packages/devtools_app/lib/src/shared/console/widgets/console_pane.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../framework/scaffold/bottom_pane.dart'; import '../../globals.dart'; diff --git a/packages/devtools_app/lib/src/shared/console/widgets/description.dart b/packages/devtools_app/lib/src/shared/console/widgets/description.dart index 7ea83892fd9..92472a2baf3 100644 --- a/packages/devtools_app/lib/src/shared/console/widgets/description.dart +++ b/packages/devtools_app/lib/src/shared/console/widgets/description.dart @@ -6,7 +6,7 @@ library; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../diagnostics/dart_object_node.dart'; import '../../diagnostics/diagnostics_node.dart'; @@ -41,7 +41,6 @@ class DiagnosticsNodeDescription extends StatelessWidget { this.multiline = false, this.style, this.nodeDescriptionHighlightStyle, - this.emphasizeNodesFromLocalProject = false, this.actionLabel, this.actionCallback, this.customDescription, @@ -57,10 +56,6 @@ class DiagnosticsNodeDescription extends StatelessWidget { final bool multiline; final TextStyle? style; final TextStyle? nodeDescriptionHighlightStyle; - // TODO(https://github.com/flutter/devtools/issues/7860): Remove and default - // to true when turning on inspector V2. This is currently true for the V2 - // inspector and false for the legacy inspector. - final bool emphasizeNodesFromLocalProject; final String? actionLabel; final VoidCallback? actionCallback; final String? customDescription; @@ -116,7 +111,7 @@ class DiagnosticsNodeDescription extends StatelessWidget { TextSpan(text: name, style: textStyle), ); } else { - final approximateIconWidth = IconKind.info.icon.width + iconPadding; + final approximateIconWidth = infoIcon.width + iconPadding; // When there is no name, an icon will be shown with the text spans. spanWidth += approximateIconWidth; @@ -307,7 +302,10 @@ class DiagnosticsNodeDescription extends StatelessWidget { ); // provide some contrast between the name and description if both are // present. - textStyle = textStyle.merge(theme.subtleTextStyle); + final fontSize = textStyle.fontSize; + textStyle = textStyle.merge( + theme.subtleTextStyle.copyWith(fontSize: fontSize), + ); } if (diagnosticLocal.isCreatedByLocalProject) { @@ -399,21 +397,14 @@ class DiagnosticsNodeDescription extends StatelessWidget { } } - // TODO(https://github.com/flutter/devtools/issues/7860): Remove this - // if-block once the widget details tree is gone. This bolding is only - // used there. - if (!emphasizeNodesFromLocalProject && - !diagnosticLocal.isSummaryTree && - diagnosticLocal.isCreatedByLocalProject) { - textStyle = textStyle.merge(DiagnosticsTextStyles.regularBold); - } - // Grey out nodes that were not created by the local project to emphasize // those that were: - if (emphasizeNodesFromLocalProject && - !diagnosticLocal.isCreatedByLocalProject && + if (!diagnosticLocal.isCreatedByLocalProject && diagnosticLocal.description != '[root]') { - textStyle = textStyle.merge(theme.subtleTextStyle); + final fontSize = textStyle.fontSize; + textStyle = textStyle.merge( + theme.subtleTextStyle.copyWith(fontSize: fontSize), + ); } var diagnosticDescription = buildDescription( diff --git a/packages/devtools_app/lib/src/shared/console/widgets/display_provider.dart b/packages/devtools_app/lib/src/shared/console/widgets/display_provider.dart index 2fed4f1c9b4..ec3932dcf10 100644 --- a/packages/devtools_app/lib/src/shared/console/widgets/display_provider.dart +++ b/packages/devtools_app/lib/src/shared/console/widgets/display_provider.dart @@ -6,8 +6,8 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' hide Stack; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart' hide Stack; import 'package:vm_service/vm_service.dart'; import '../../diagnostics/dap_object_node.dart'; @@ -36,6 +36,30 @@ class DisplayProvider extends StatefulWidget { State createState() => _DisplayProviderState(); } +class OverflowingText extends StatelessWidget { + const OverflowingText({ + super.key, + required this.textSpan, + this.tooltipMessage, + }); + + final InlineSpan textSpan; + final String? tooltipMessage; + + @override + Widget build(BuildContext context) { + final textWidget = Text.rich( + textSpan, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ); + final message = tooltipMessage; + if (message == null) return textWidget; + return DevToolsTooltip(message: message, child: textWidget); + } +} + class _DisplayProviderState extends State { bool isHovered = false; @override @@ -46,11 +70,12 @@ class _DisplayProviderState extends State { return InteractivityWrapper( onTap: widget.onTap, menuButtons: _getMenuButtons(context), - child: Text.rich( - TextSpan( + child: OverflowingText( + textSpan: TextSpan( children: textSpansFromAnsi( widget.variable.text!, theme.subtleFixedFontStyle, + backgroundColor: theme.colorScheme.surface, ), ), ), @@ -79,44 +104,40 @@ class _DisplayProviderState extends State { final contents = InteractivityWrapper( onTap: widget.onTap, menuButtons: _getMenuButtons(context), - child: DevToolsTooltip( - message: originalDisplayValue, - child: Container( - color: isHovered ? Theme.of(context).highlightColor : null, - child: Row( - children: [ - Expanded( - child: Text.rich( - maxLines: 1, - overflow: TextOverflow.ellipsis, - TextSpan( - text: hasName ? widget.variable.name : null, - style: widget.variable.artificialName - ? theme.subtleFixedFontStyle - : theme.fixedFontStyle.apply( - color: theme.colorScheme.controlFlowSyntaxColor, - ), - children: [ - if (hasName) - TextSpan(text: ': ', style: theme.fixedFontStyle), - TextSpan( - text: displayValue, - style: widget.variable.artificialValue - ? theme.subtleFixedFontStyle - : _variableDisplayStyle(theme, widget.variable), - ), - ], - ), + child: Container( + color: isHovered ? Theme.of(context).highlightColor : null, + child: Row( + children: [ + Expanded( + child: OverflowingText( + tooltipMessage: originalDisplayValue, + textSpan: TextSpan( + text: hasName ? widget.variable.name : null, + style: widget.variable.artificialName + ? theme.subtleFixedFontStyle + : theme.fixedFontStyle.apply( + color: theme.colorScheme.controlFlowSyntaxColor, + ), + children: [ + if (hasName) + TextSpan(text: ': ', style: theme.fixedFontStyle), + TextSpan( + text: displayValue, + style: widget.variable.artificialValue + ? theme.subtleFixedFontStyle + : _variableDisplayStyle(theme, widget.variable), + ), + ], ), ), - if (isHovered && widget.onCopy != null) - DevToolsButton( - icon: Icons.copy_outlined, - outlined: false, - onPressed: () => widget.onCopy!.call(widget.variable), - ), - ], - ), + ), + if (isHovered && widget.onCopy != null) + DevToolsButton( + icon: Icons.copy_outlined, + outlined: false, + onPressed: () => widget.onCopy!.call(widget.variable), + ), + ], ), ), ); @@ -235,6 +256,7 @@ class DapDisplayProvider extends StatelessWidget { }); final DapObjectNode node; + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/6056) implement onTap. final VoidCallback onTap; @override @@ -248,8 +270,9 @@ class DapDisplayProvider extends StatelessWidget { // TODO(https://github.com/flutter/devtools/issues/6056): Wrap in // interactivity wrapper to provide inspect and re-root functionality. Add // tooltip on hover to provide type information. - return Text.rich( - TextSpan( + return OverflowingText( + tooltipMessage: value, + textSpan: TextSpan( text: name, style: theme.fixedFontStyle.apply( color: theme.colorScheme.controlFlowSyntaxColor, diff --git a/packages/devtools_app/lib/src/shared/console/widgets/evaluate.dart b/packages/devtools_app/lib/src/shared/console/widgets/evaluate.dart index e7c07d9704a..44e4a7e71f6 100644 --- a/packages/devtools_app/lib/src/shared/console/widgets/evaluate.dart +++ b/packages/devtools_app/lib/src/shared/console/widgets/evaluate.dart @@ -7,8 +7,8 @@ import 'dart:math'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../analytics/analytics.dart' as ga; @@ -46,8 +46,6 @@ class ExpressionEvalFieldState extends State final _autoCompleteController = AutoCompleteController(evalTextFieldKey); - int historyPosition = -1; - String _activeWord = ''; List _matches = []; @@ -318,7 +316,6 @@ class ExpressionEvalFieldState extends State serviceConnection.consoleService.appendStdio('> $expressionText\n'); setState(() { - historyPosition = -1; serviceConnection.appState.evalHistory.pushEvalHistory(expressionText); }); diff --git a/packages/devtools_app/lib/src/shared/console/widgets/expandable_variable.dart b/packages/devtools_app/lib/src/shared/console/widgets/expandable_variable.dart index 58f1dcbad8b..9004fe125c6 100644 --- a/packages/devtools_app/lib/src/shared/console/widgets/expandable_variable.dart +++ b/packages/devtools_app/lib/src/shared/console/widgets/expandable_variable.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/material.dart' hide Stack; +import 'package:material_ui/material_ui.dart' hide Stack; import '../../../shared/primitives/listenable.dart'; import '../../diagnostics/dart_object_node.dart'; diff --git a/packages/devtools_app/lib/src/shared/console/widgets/help_dialog.dart b/packages/devtools_app/lib/src/shared/console/widgets/help_dialog.dart index 96d35b1545a..ce6c843c90f 100644 --- a/packages/devtools_app/lib/src/shared/console/widgets/help_dialog.dart +++ b/packages/devtools_app/lib/src/shared/console/widgets/help_dialog.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../analytics/constants.dart' as gac; import '../../ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/shared/constants.dart b/packages/devtools_app/lib/src/shared/constants.dart index 36c55461073..03679c29cfd 100644 --- a/packages/devtools_app/lib/src/shared/constants.dart +++ b/packages/devtools_app/lib/src/shared/constants.dart @@ -2,8 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; const verboseLoggingLevel = Level.FINEST; diff --git a/packages/devtools_app/lib/src/shared/development_helpers.dart b/packages/devtools_app/lib/src/shared/development_helpers.dart index 421e6102f39..e514582db5f 100644 --- a/packages/devtools_app/lib/src/shared/development_helpers.dart +++ b/packages/devtools_app/lib/src/shared/development_helpers.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, intentional helpers for development only. import 'dart:async'; import 'package:devtools_shared/devtools_extensions.dart'; @@ -38,8 +39,6 @@ String? _debugDtdUri; /// Enable this flag to send and debug analytics when DevTools is run in debug /// or profile mode, otherwise analytics will only be sent in release builds. -/// -/// `ga.isAnalyticsEnabled()` still must return true for analytics to be sent. bool debugSendAnalytics = false; /// Enable this flag to always show the analytics consent message, regardless @@ -92,6 +91,7 @@ extension StubDevToolsExtensions on Never { /// connected app. static final fooExtension = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'foo', + DevToolsExtensionConfig.packageNameKey: 'foo', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: '1.0.0', DevToolsExtensionConfig.materialIconCodePointKey: '0xe0b1', @@ -106,6 +106,7 @@ extension StubDevToolsExtensions on Never { /// connected app. static final providerExtension = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.packageNameKey: 'provider', DevToolsExtensionConfig.issueTrackerKey: 'https://github.com/rrousselGit/provider/issues', DevToolsExtensionConfig.versionKey: '3.0.0', @@ -122,6 +123,7 @@ extension StubDevToolsExtensions on Never { /// not require a connected app. static final someToolExtension = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'some_tool', + DevToolsExtensionConfig.packageNameKey: 'some_tool', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: '1.0.0', DevToolsExtensionConfig.materialIconCodePointKey: '0xe00c', @@ -138,6 +140,7 @@ extension StubDevToolsExtensions on Never { /// require a connected app. static final barExtension = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.packageNameKey: 'bar', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: '2.0.0', DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, @@ -154,6 +157,7 @@ extension StubDevToolsExtensions on Never { /// extension. static final newerBarExtension = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.packageNameKey: 'bar', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: '2.1.0', // Newer version. DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, @@ -169,6 +173,7 @@ extension StubDevToolsExtensions on Never { /// connected app. static final bazExtension = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'baz', + DevToolsExtensionConfig.packageNameKey: 'baz', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: '1.0.0', DevToolsExtensionConfig.materialIconCodePointKey: 0xe716, @@ -183,6 +188,7 @@ extension StubDevToolsExtensions on Never { /// of a runtime extension [fooExtension], which requires a connected app. static final duplicateFooExtension = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'foo', + DevToolsExtensionConfig.packageNameKey: 'foo', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: '1.0.0', DevToolsExtensionConfig.materialIconCodePointKey: '0xe0b1', diff --git a/packages/devtools_app/lib/src/shared/diagnostics/dart_object_node.dart b/packages/devtools_app/lib/src/shared/diagnostics/dart_object_node.dart index f6cc1a7f01f..5b0ac2a9129 100644 --- a/packages/devtools_app/lib/src/shared/diagnostics/dart_object_node.dart +++ b/packages/devtools_app/lib/src/shared/diagnostics/dart_object_node.dart @@ -8,7 +8,6 @@ library; import 'dart:async'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/foundation.dart'; import 'package:vm_service/vm_service.dart'; import '../globals.dart'; @@ -33,9 +32,7 @@ class DartObjectNode extends TreeNode { this.artificialValue = false, this.isRerootable = false, }) : _offset = offset, - _childCount = childCount { - indentChildren = ref?.diagnostic?.style != DiagnosticsTreeStyle.flat; - } + _childCount = childCount; /// Creates a variable from a value that must be an VM service type or a /// primitive type. diff --git a/packages/devtools_app/lib/src/shared/diagnostics/diagnostics_node.dart b/packages/devtools_app/lib/src/shared/diagnostics/diagnostics_node.dart index b149944769e..1942742d599 100644 --- a/packages/devtools_app/lib/src/shared/diagnostics/diagnostics_node.dart +++ b/packages/devtools_app/lib/src/shared/diagnostics/diagnostics_node.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, contains the full model class for DiagnosticsNode. + /// @docImport '../console/widgets/description.dart'; library; @@ -9,11 +11,11 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; -import '../../screens/inspector_v2/inspector_data_models.dart'; +import '../../screens/inspector/inspector_data_models.dart'; import '../primitives/utils.dart'; import '../ui/icons.dart'; import 'object_group_api.dart'; @@ -83,8 +85,6 @@ class RemoteDiagnosticsNode extends DiagnosticableTree { /// This node's parent (if it's been set). RemoteDiagnosticsNode? parent; - Future? propertyDocFuture; - List? cachedProperties; /// Service used to retrieve more detailed information about the value of @@ -101,8 +101,6 @@ class RemoteDiagnosticsNode extends DiagnosticableTree { // TODO(albertusangga): Refactor to cleaner/more robust solution bool get isFlex => ['Row', 'Column', 'Flex'].contains(widgetRuntimeType); - bool get isBox => json['isBox'] == true; - int? get flexFactor => json['flexFactor'] as int?; FlexFit get flexFit => deserializeFlexFit(json['flexFit'] as String?); diff --git a/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart b/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart index 805e83f22ef..58b5ac420e8 100644 --- a/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart +++ b/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart @@ -14,11 +14,11 @@ import 'dart:async'; import 'dart:convert'; import 'dart:developer'; +import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/service_extensions.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:vm_service/vm_service.dart'; @@ -29,7 +29,6 @@ import 'diagnostics_node.dart'; import 'generic_instance_reference.dart'; import 'object_group_api.dart'; import 'primitives/instance_ref.dart'; -import 'primitives/source_location.dart'; const _inspectorLibraryUri = 'package:flutter/src/widgets/widget_inspector.dart'; @@ -245,6 +244,7 @@ class InspectorService extends InspectorServiceBase { void dispose() { _cachedSelectionGroups?.clear(false); _cachedSelectionGroups = null; + _rootDirectories.dispose(); super.dispose(); } @@ -465,6 +465,7 @@ class InspectorService extends InspectorServiceBase { ); } + @visibleForTesting Future isWidgetCreationTracked() { return invokeBoolServiceMethodNoArgs( WidgetInspectorServiceExtensions.isWidgetCreationTracked.name, @@ -535,6 +536,7 @@ abstract class InspectorObjectGroupBase ); } + // ignore: unused-code, this is used in g3. Future invokeServiceMethodWithArgReturningNode( String methodName, String arg, @@ -833,27 +835,6 @@ abstract class InspectorObjectGroupBase return properties; } - Future getPropertyLocationHelper( - ClassRef classRef, - String name, - ) async { - final clazz = await inspectorLibrary.getClass(classRef, this) as Class; - for (final f in clazz.functions!) { - // TODO(pq): check for properties that match name. - if (f.name == name) { - final func = await inspectorLibrary.getFunc(f, this) as Func; - final location = func.location; - throw UnimplementedError( - 'getSourcePosition not implemented. $location', - ); - } - } - final superClass = clazz.superClass; - return superClass == null - ? null - : getPropertyLocationHelper(superClass, name); - } - Future> getListHelper( InspectorInstanceRef? instanceRef, String methodName, @@ -940,6 +921,24 @@ abstract class InspectorObjectGroupBase ); } + @override + Future getDetailsSubtree( + RemoteDiagnosticsNode? node, { + int subtreeDepth = 2, + }) async { + if (node == null || node.valueRef.id == null) return null; + return parseDiagnosticsNodeDaemon( + invokeServiceMethodDaemonParams( + WidgetInspectorServiceExtensions.getDetailsSubtree.name, + { + 'objectGroup': groupName, + 'arg': node.valueRef.id, + 'subtreeDepth': subtreeDepth.toString(), + }, + ), + ); + } + @override bool isLocalClass(RemoteDiagnosticsNode node) => inspectorService.isLocalClass(node); @@ -1134,23 +1133,6 @@ class ObjectGroup extends InspectorObjectGroupBase { ); } - Future getDetailsSubtree( - RemoteDiagnosticsNode? node, { - int subtreeDepth = 2, - }) async { - if (node == null) return null; - final args = { - 'objectGroup': groupName, - 'arg': node.valueRef.id, - 'subtreeDepth': subtreeDepth.toString(), - }; - final json = await invokeServiceMethodDaemonParams( - WidgetInspectorServiceExtensions.getDetailsSubtree.name, - args, - ); - return parseDiagnosticsNodeHelper(json as Map?); - } - Future invokeSetFlexProperties( InspectorInstanceRef ref, MainAxisAlignment? mainAxisAlignment, @@ -1202,15 +1184,6 @@ class ObjectGroup extends InspectorObjectGroupBase { ), ); } - - Future> getPubRootDirectories() async { - final invocationResult = await invokeServiceMethodDaemonParams( - WidgetInspectorServiceExtensions.getPubRootDirectories.name, - {}, - ); - final directories = (invocationResult as List?)?.cast(); - return List.from(directories ?? []); - } } abstract class InspectorServiceClient { @@ -1297,7 +1270,7 @@ class InspectorObjectGroupManager { void _setNextNull() { _next = null; if (_pendingNext != null) { - _pendingNext!.complete(null); + _pendingNext!.complete(); _pendingNext = null; } } diff --git a/packages/devtools_app/lib/src/shared/diagnostics/object_group_api.dart b/packages/devtools_app/lib/src/shared/diagnostics/object_group_api.dart index b1195a1823a..328d50b4649 100644 --- a/packages/devtools_app/lib/src/shared/diagnostics/object_group_api.dart +++ b/packages/devtools_app/lib/src/shared/diagnostics/object_group_api.dart @@ -39,4 +39,10 @@ abstract class InspectorObjectGroupApi ); Future> getProperties(InspectorInstanceRef instanceRef); + + /// Returns a details subtree for [node], including creation location data. + /// + /// Pass a small [subtreeDepth] (for example `0`) when only node-level details + /// such as creation location are needed. + Future getDetailsSubtree(T? node, {int subtreeDepth = 2}); } diff --git a/packages/devtools_app/lib/src/shared/diagnostics/variable_factory.dart b/packages/devtools_app/lib/src/shared/diagnostics/variable_factory.dart index 5ffd61b7fca..6945ec8252f 100644 --- a/packages/devtools_app/lib/src/shared/diagnostics/variable_factory.dart +++ b/packages/devtools_app/lib/src/shared/diagnostics/variable_factory.dart @@ -455,7 +455,7 @@ List createVariablesForBytes( variables.add( DartObjectNode.fromValue( name: '[$name]', - value: result[i], + value: _formatSimdValue(result[i]) ?? result[i], isolateRef: isolateRef, artificialName: true, ), @@ -464,6 +464,38 @@ List createVariablesForBytes( return variables; } +/// The number of decimal places used to display the lanes of a [Float32x4] or +/// [Float64x2] value. +const _simdFloatFractionDigits = 6; + +/// The number of hex digits used to display a 32-bit lane of an [Int32x4] +/// value. +const _simdInt32HexDigits = 8; + +/// Returns a display string for [value] if it is a SIMD value ([Int32x4], +/// [Float32x4], or [Float64x2]), or null otherwise. +/// +/// We format these values manually instead of relying on `toString()` because +/// the output of `toString()` for SIMD types is platform dependent (the Dart +/// VM returns the opaque string 'V128', and the web returns a different format +/// than the VM historically did). Formatting the lanes ourselves ensures that +/// users see the actual values on every platform. +String? _formatSimdValue(Object? value) { + String formatFloat(double lane) => + lane.toStringAsFixed(_simdFloatFractionDigits); + String formatInt32(int lane) => + lane.toUnsigned(32).toRadixString(16).padLeft(_simdInt32HexDigits, '0'); + + return switch (value) { + Int32x4(:final x, :final y, :final z, :final w) => + '[${[x, y, z, w].map(formatInt32).join(', ')}]', + Float32x4(:final x, :final y, :final z, :final w) => + '[${[x, y, z, w].map(formatFloat).join(', ')}]', + Float64x2(:final x, :final y) => '[${[x, y].map(formatFloat).join(', ')}]', + _ => null, + }; +} + List createVariablesForSets( Instance instance, IsolateRef? isolateRef, diff --git a/packages/devtools_app/lib/src/shared/editor/api_classes.dart b/packages/devtools_app/lib/src/shared/editor/api_classes.dart index b30b3a32c20..3231269b698 100644 --- a/packages/devtools_app/lib/src/shared/editor/api_classes.dart +++ b/packages/devtools_app/lib/src/shared/editor/api_classes.dart @@ -139,7 +139,6 @@ abstract class Field { static const isEditable = 'isEditable'; static const isNullable = 'isNullable'; static const isRequired = 'isRequired'; - static const kind = 'kind'; static const line = 'line'; static const name = 'name'; static const options = 'options'; diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart index ef03c446f47..9dab74c5fad 100644 --- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart +++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, this file represents the full editor API. + import 'dart:async'; import 'package:devtools_app_shared/service.dart'; @@ -120,6 +122,15 @@ class EditorClient extends DisposableController } } + @override + void dispose() { + _editableArgumentsApiIsRegistered.dispose(); + unawaited(_activeLocationChangedController.close()); + unawaited(_eventController.close()); + unawaited(_editorServiceChangedController.close()); + super.dispose(); + } + void _handleServiceRegistration({ required String service, required String method, diff --git a/packages/devtools_app/lib/src/shared/environment_parameters/environment_parameters_base.dart b/packages/devtools_app/lib/src/shared/environment_parameters/environment_parameters_base.dart index e960fa1bb70..ed2a4c2b70d 100644 --- a/packages/devtools_app/lib/src/shared/environment_parameters/environment_parameters_base.dart +++ b/packages/devtools_app/lib/src/shared/environment_parameters/environment_parameters_base.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../screens/debugger/codeview.dart'; import '../diagnostics/inspector_service.dart'; diff --git a/packages/devtools_app/lib/src/shared/environment_parameters/environment_parameters_external.dart b/packages/devtools_app/lib/src/shared/environment_parameters/environment_parameters_external.dart index 30cd68fb0f5..a8f551c3f9e 100644 --- a/packages/devtools_app/lib/src/shared/environment_parameters/environment_parameters_external.dart +++ b/packages/devtools_app/lib/src/shared/environment_parameters/environment_parameters_external.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../screens/debugger/codeview.dart'; import '../analytics/constants.dart' as gac; @@ -134,10 +134,7 @@ Uri _newDevToolsGitHubIssueUri({ String? additionalInfo, String? issueTitle, }) { - final issueBody = [ - if (additionalInfo != null) additionalInfo, - ...environment, - ].join('\n'); + final issueBody = [?additionalInfo, ...environment].join('\n'); return Uri.parse( 'https://$_newDevToolsIssueUriDisplay', diff --git a/packages/devtools_app/lib/src/shared/feature_flags.dart b/packages/devtools_app/lib/src/shared/feature_flags.dart index 88a8b248878..ca86bb35c1b 100644 --- a/packages/devtools_app/lib/src/shared/feature_flags.dart +++ b/packages/devtools_app/lib/src/shared/feature_flags.dart @@ -52,14 +52,6 @@ extension FeatureFlags on Never { enabled: enableExperiments, ); - /// Flag to enable save/load for the Network screen. - /// - /// https://github.com/flutter/devtools/issues/4470 - static final networkSaveLoad = BooleanFeatureFlag( - name: 'networkSaveLoad', - enabled: true, - ); - /// Flag to enable DevTools extensions. /// /// TODO(https://github.com/flutter/devtools/issues/6443): remove this flag @@ -77,19 +69,9 @@ extension FeatureFlags on Never { enabled: enableExperiments, ); - /// Flag to enable the new Inspector panel. - /// - /// https://github.com/flutter/devtools/issues/7854 - static final inspectorV2 = BooleanFeatureFlag( - name: 'inspectorV2', - enabled: true, - ); - - /// Flag to enable the AI Assistant. - /// - /// https://github.com/flutter/devtools/issues/9590 - static final aiAssistant = BooleanFeatureFlag( - name: 'aiAssistant', + /// Flag to enable the Accessibility page. + static final accessibility = BooleanFeatureFlag( + name: 'accessibility', enabled: enableExperiments, ); @@ -99,11 +81,9 @@ extension FeatureFlags on Never { /// map as well. static final _booleanFlags = { memorySaveLoad, - networkSaveLoad, devToolsExtensions, dapDebugging, - inspectorV2, - aiAssistant, + accessibility, }; /// A set of all the Flutter channel feature flags for debugging purposes. @@ -113,6 +93,7 @@ extension FeatureFlags on Never { static final _flutterChannelFlags = {}; /// A helper to print the status of all the feature flags. + // ignore: unused-code, debug only method to be used as needed. static void debugPrintFeatureFlags({ConnectedApp? connectedApp}) { for (final entry in _booleanFlags) { _log.config(entry.toString()); @@ -161,6 +142,7 @@ class BooleanFeatureFlag { /// TODO(https://github.com/flutter/devtools/issues/9439): Restrict features /// based on the user's Dart version instead of Flutter version to allow for /// shared experiments across Dart and Flutter. +// ignore: unused-code, we want to support this type of feature flag. class FlutterChannelFeatureFlag { const FlutterChannelFeatureFlag({ required this.name, diff --git a/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart b/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart index 68ad3953e21..a21539e9300 100644 --- a/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart +++ b/packages/devtools_app/lib/src/shared/framework/app_error_handling.dart @@ -33,7 +33,7 @@ void setupErrorHandling(Future Function() appStartCallback) { () async { WidgetsFlutterBinding.ensureInitialized(); - await _initializeSourceMapping(); + await _fetchSourceMapping(); final FlutterExceptionHandler? oldHandler = FlutterError.onError; @@ -109,15 +109,18 @@ Future _reportError( } } -SingleMapping? _cachedJsSourceMapping; -SingleMapping? _cachedWasmSourceMapping; +Future? _cachedSourceMapping; Future _fetchSourceMapping() async { - final cachedSourceMapping = kIsWasm - ? _cachedWasmSourceMapping - : _cachedJsSourceMapping; - - return cachedSourceMapping ?? (await _initializeSourceMapping()); + final pendingSourceMapping = _cachedSourceMapping ??= + _initializeSourceMapping(); + final sourceMapping = await pendingSourceMapping; + // Retry failed loads without letting an older failure clear a newer request. + if (sourceMapping == null && + identical(_cachedSourceMapping, pendingSourceMapping)) { + _cachedSourceMapping = null; + } + return sourceMapping; } Future _initializeSourceMapping() async { diff --git a/packages/devtools_app/lib/src/shared/framework/routing.dart b/packages/devtools_app/lib/src/shared/framework/routing.dart index d72d7e1c196..e9929720dd5 100644 --- a/packages/devtools_app/lib/src/shared/framework/routing.dart +++ b/packages/devtools_app/lib/src/shared/framework/routing.dart @@ -7,8 +7,8 @@ import 'dart:collection'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import '../../framework/framework_core.dart'; import '../globals.dart'; @@ -34,6 +34,7 @@ class DevToolsRouteInformationParser DevToolsRouteInformationParser() : _testQueryParams = null; @visibleForTesting + // ignore: unused-code, used in packages/devtools_test DevToolsRouteInformationParser.test(this._testQueryParams); /// Query parameters that can be set on DevTools routes for testing purposes. @@ -103,6 +104,7 @@ class DevToolsRouterDelegate extends RouterDelegate _isTestMode = false; @visibleForTesting + // ignore: unused-code, used in packages/devtools_test DevToolsRouterDelegate.test(this._getPage, [GlobalKey? key]) : navigatorKey = key ?? GlobalKey(), _isTestMode = true; @@ -273,6 +275,7 @@ class DevToolsRouterDelegate extends RouterDelegate await updateArgsIfChanged({'uri': null}); } + @visibleForTesting Future replaceState(DevToolsNavigationState state) async { final currentConfig = currentConfiguration!; await _replaceStack( @@ -381,6 +384,7 @@ class DevToolsNavigationState { @override String toString() => _state.toString(); + // ignore: unused-code, implicit method used in JSON serialization. Map toJson() => _state; } diff --git a/packages/devtools_app/lib/src/shared/framework/screen.dart b/packages/devtools_app/lib/src/shared/framework/screen.dart index 0cdc840653a..e0ec4c614a6 100644 --- a/packages/devtools_app/lib/src/shared/framework/screen.dart +++ b/packages/devtools_app/lib/src/shared/framework/screen.dart @@ -9,11 +9,12 @@ library; import 'package:collection/collection.dart'; import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/service_extensions.dart' as extensions; import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import '../globals.dart'; import '../primitives/listenable.dart'; @@ -27,6 +28,8 @@ const _kNetworkDisconnectExperience = bool.fromEnvironment( defaultValue: true, ); +const _deepLinksTutorialVideoUrl = 'https://youtu.be/d7sZL6h1Elw'; + enum ScreenMetaData { home( 'home', @@ -73,6 +76,8 @@ enum ScreenMetaData { debugger( 'debugger', title: 'Debugger', + // TODO(kenz): see if we can use a meterial icon or an asset icon so that + // we can remove the Octicons font family. icon: Octicons.bug, requiresDebugBuild: true, tutorialVideoTimestamp: '?t=513', @@ -94,12 +99,11 @@ enum ScreenMetaData { supportsWebServerDevice: true, tutorialVideoTimestamp: '?t=558', ), - provider( - 'provider', - title: 'Provider', - icon: Icons.attach_file, - requiresLibrary: 'package:provider/', - requiresDebugBuild: true, + accessibility( + 'accessibility', + title: 'Accessibility', + icon: Icons.accessibility, + requiresFlutter: true, ), appSize( 'app-size', @@ -115,6 +119,8 @@ enum ScreenMetaData { iconAsset: 'icons/app_bar/deep_links.png', requiresConnection: false, requiresDartVm: true, + requiresFlutter: true, + tutorialVideoUrl: _deepLinksTutorialVideoUrl, ), vmTools( 'vm-tools', @@ -143,8 +149,10 @@ enum ScreenMetaData { this.requiresAdvancedDeveloperMode = false, this.supportsWebServerDevice = false, this.worksWithOfflineData = false, + // ignore: unused_element_parameter, this is used in internally. this.requiresLibrary, this.tutorialVideoTimestamp, + this.tutorialVideoUrl, }) : assert( icon == null || iconAsset == null, 'Only one of icon or iconAsset may be specified.', @@ -170,6 +178,13 @@ enum ScreenMetaData { /// a particular chapter. final String? tutorialVideoTimestamp; + /// A custom video tutorial URL for a screen. + /// + /// If provided, this URL will be used instead of the default + /// "Dive in to DevTools" YouTube video timestamp. This is used for + /// screens that have their own dedicated video tutorials. + final String? tutorialVideoUrl; + /// Looks up the [ScreenMetaData] value for the screen [id]. static ScreenMetaData? lookup(String id) { return ScreenMetaData.values.firstWhereOrNull((screen) => screen.id == id); @@ -290,9 +305,6 @@ abstract class Screen { /// Whether to show the console for this screen. bool showConsole(EmbedMode embedMode) => false; - /// Whether to show the AI Assistant for this screen. - bool showAiAssistant() => false; - /// Which keyboard shortcuts should be enabled for this screen. ShortcutsConfiguration buildKeyboardShortcuts(BuildContext context) => ShortcutsConfiguration.empty(); @@ -547,6 +559,18 @@ abstract class Screen { return (show: false, disabledReason: ScreenDisabledReason.requiresFlutter); } if (screen.requiresDebugBuild && connectedApp.isProfileBuildNow == true) { + // Build mode is inferred from expression evaluation. A debug-only service + // extension identifies a debug app whose expressions could not be compiled, + // for example when connecting without Flutter Tools. + if (connectedApp.isFlutterAppNow == true && + serviceManager.serviceExtensionManager.isServiceExtensionAvailable( + extensions.debugAllowBanner.extension, + )) { + return ( + show: false, + disabledReason: ScreenDisabledReason.expressionEvaluationUnavailable, + ); + } _log.finest('screen requires debug build: returning false'); return ( show: false, @@ -587,6 +611,12 @@ enum ScreenDisabledReason { requiresDartLibrary(null), requiresDartVm('requires the Dart VM, but it is not available.'), requiresDebugBuild('only supports debug builds.'), + + /// A Flutter debug app was detected, but expression evaluation failed. + expressionEvaluationUnavailable( + 'could not evaluate expressions. Try running "flutter attach" and ' + 'reconnecting DevTools.', + ), requiresFlutter('only supports Flutter applications.'), requiresAdvancedDeveloperMode( 'only works when Advanced Developer Mode is enabled', diff --git a/packages/devtools_app/lib/src/shared/framework/screen_controllers.dart b/packages/devtools_app/lib/src/shared/framework/screen_controllers.dart index 427254c8bd5..7baade8416e 100644 --- a/packages/devtools_app/lib/src/shared/framework/screen_controllers.dart +++ b/packages/devtools_app/lib/src/shared/framework/screen_controllers.dart @@ -51,6 +51,14 @@ class ScreenControllers { controllers[T] = _LazyController(creator: controllerCreator); } + /// Whether a controller of type [T] has been registered for the active mode. + bool isRegistered() { + final controllers = offlineDataController.showingOfflineData.value + ? offlineControllers + : this.controllers; + return controllers.containsKey(T); + } + /// Returns the active screen controller of type [T]. /// /// When DevTools is showing offline data, the offline screen controller will diff --git a/packages/devtools_app/lib/src/shared/globals.dart b/packages/devtools_app/lib/src/shared/globals.dart index d4f70552d42..23f23257e7f 100644 --- a/packages/devtools_app/lib/src/shared/globals.dart +++ b/packages/devtools_app/lib/src/shared/globals.dart @@ -5,7 +5,6 @@ import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import '../extensions/extension_service.dart'; import '../screens/debugger/breakpoint_manager.dart'; @@ -26,6 +25,8 @@ import 'primitives/storage.dart'; /// Whether this DevTools build is external. bool get isExternalBuild => _isExternalBuild; bool _isExternalBuild = true; + +// ignore: unused-code, used in g3. void setInternalBuild() => _isExternalBuild = false; ScreenControllers get screenControllers => @@ -70,9 +71,6 @@ EvalService get evalService => globals[EvalService] as EvalService; ExtensionService get extensionService => globals[ExtensionService] as ExtensionService; -GlobalKey get navigatorKey => - globals[GlobalKey] as GlobalKey; - /// Whether DevTools is being run in integration test mode. bool get integrationTestMode => _integrationTestMode; bool _integrationTestMode = false; @@ -83,6 +81,7 @@ void setIntegrationTestMode() { /// Whether DevTools is being run in a test environment. bool get testMode => _testMode; bool _testMode = false; +@visibleForTesting void setTestMode() { _testMode = true; } @@ -90,6 +89,7 @@ void setTestMode() { /// Whether DevTools is being run as a stager app. bool get stagerMode => _stagerMode; bool _stagerMode = false; +@visibleForTesting void setStagerMode() { if (!kReleaseMode) { _stagerMode = true; diff --git a/packages/devtools_app/lib/src/shared/http/_http_cookies.dart b/packages/devtools_app/lib/src/shared/http/_http_cookies.dart index ca49ed825c7..d0274d393b2 100644 --- a/packages/devtools_app/lib/src/shared/http/_http_cookies.dart +++ b/packages/devtools_app/lib/src/shared/http/_http_cookies.dart @@ -21,7 +21,6 @@ class Cookie { String? _name; String? _value; DateTime? expires; - int? maxAge; String? domain; String? path; bool httpOnly = false; @@ -30,16 +29,6 @@ class Cookie { String? get name => _name; String? get value => _value; - set name(String? newName) { - _validateName(newName); - _name = newName; - } - - set value(String? newValue) { - _validateValue(newValue); - _value = newValue; - } - Cookie.fromSetCookieValue(String value) { // Parse the 'set-cookie' header value. _parseSetCookieValue(value); @@ -105,8 +94,6 @@ class Cookie { } if (name == "expires") { expires = HttpDate._parseCookieDate(value); - } else if (name == "max-age") { - maxAge = int.parse(value); } else if (name == "domain") { domain = value; } else if (name == "path") { diff --git a/packages/devtools_app/lib/src/shared/http/constants.dart b/packages/devtools_app/lib/src/shared/http/constants.dart index abc7c09b687..7695a56be60 100644 --- a/packages/devtools_app/lib/src/shared/http/constants.dart +++ b/packages/devtools_app/lib/src/shared/http/constants.dart @@ -4,47 +4,20 @@ enum HttpRequestDataKeys { connectionInfo, - remoteAddress, - localPort, contentLength, - startedDateTime, - time, request, method, - url, - httpVersion, cookies, headers, - queryString, - postData, - mimeType, text, - headersSize, - bodySize, followRedirects, maxRedirects, persistentConnection, proxyDetails, - proxy, - type, error, response, - status, statusCode, - statusText, redirects, - redirectURL, - cache, - timings, - blocked, - dns, - connect, - send, - wait, - receive, - ssl, - connection, - comment, isolateId, uri, id, @@ -55,10 +28,6 @@ enum HttpRequestDataKeys { compressionState, isRedirect, reasonPhrase, - queryParameters, - content, - size, - connectionId, requestBody, responseBody, endTime, @@ -67,13 +36,3 @@ enum HttpRequestDataKeys { username, isDirect, } - -enum HttpRequestDataValues { json } - -class HttpRequestDataDefaults { - static const none = 'None'; - static const error = 'Error'; - static const httpVersion = 'HTTP/2.0'; - static const json = 'json'; - static const httpProfileRequest = '@HttpProfileRequest'; -} diff --git a/packages/devtools_app/lib/src/shared/http/http_request_data.dart b/packages/devtools_app/lib/src/shared/http/http_request_data.dart index 6347ffdbf8d..0277cbe6e4f 100644 --- a/packages/devtools_app/lib/src/shared/http/http_request_data.dart +++ b/packages/devtools_app/lib/src/shared/http/http_request_data.dart @@ -42,8 +42,15 @@ class DartIOHttpRequestData extends NetworkRequest { this._request, { bool requestFullDataFromVmService = true, }) { - if (requestFullDataFromVmService && _request.isResponseComplete) { - unawaited(getFullRequestData()); + if (requestFullDataFromVmService && + (_request.isResponseComplete || + _request.isRequestComplete || + (_request.request?.hasError ?? false))) { + unawaited( + getFullRequestData().catchError((Object e, StackTrace st) { + _log.warning('Failed to fetch full request data: $e', e, st); + }), + ); } } @@ -120,6 +127,8 @@ class DartIOHttpRequestData extends NetworkRequest { } notifyListeners(); } + } catch (e, st) { + _log.warning('Failed to fetch full request data: $e', e, st); } finally { isFetchingFullData = false; } @@ -135,14 +144,38 @@ class DartIOHttpRequestData extends NetworkRequest { bool get _hasError => _request.request?.hasError ?? false; - DateTime? get _endTime => - _hasError ? _request.endTime : _request.response?.endTime; + DateTime? get _endTime => (_hasError || _isCancelled) + ? _request.endTime + : _request.response?.endTime; + + bool _matchesCancellationMarker(String? value) { + if (value == null) return false; + final normalized = value.toLowerCase(); + + // Markers used for substring matching against request / response errors + // and request event names to classify cancelled requests. + // + // Derived from observed cancellation wording in HTTP profiler payloads, + // keeping specific terms to reduce false positives. + const cancellationMarkers = ['canceled', 'cancelled', 'aborted']; + + return cancellationMarkers.any(normalized.contains); + } + + bool get _hasCancellationError { + final requestError = _request.request?.error; + final responseError = _request.response?.error; + return _matchesCancellationMarker(requestError) || + _matchesCancellationMarker(responseError); + } + + bool get _hasCancellationEvent => + _request.events.any((event) => _matchesCancellationMarker(event.event)); @override Duration? get duration { if (inProgress || !isValid) return null; - // Timestamps are in microseconds - return _endTime!.difference(_request.startTime); + return _endTime?.difference(_request.startTime); } /// Whether the request is safe to display in the UI. @@ -156,7 +189,7 @@ class DartIOHttpRequestData extends NetworkRequest { return { 'method': _request.method, 'uri': _request.uri.toString(), - if (!didFail) ...{ + if (!didFail && !_isCancelled) ...{ 'connectionInfo': _request.request?.connectionInfo, 'contentLength': _request.request?.contentLength, }, @@ -227,11 +260,35 @@ class DartIOHttpRequestData extends NetworkRequest { return connectionInfo != null ? connectionInfo[_localPortKey] : null; } - /// True if the HTTP request hasn't completed yet, determined by the lack of - /// an end time in the response data. @override - bool get inProgress => - _hasError ? !_request.isRequestComplete : !_request.isResponseComplete; + int? get responseBytes { + final headers = _request.response?.headers; + if (headers == null) return null; + + final contentLength = headers['content-length']; + + if (contentLength is String) { + return int.tryParse(contentLength); + } + if (contentLength is List && contentLength.isNotEmpty) { + final first = contentLength.first; + + if (first is int) return first; + if (first is String) return int.tryParse(first); + } + return null; + } + + /// True if the HTTP request hasn't completed yet, determined by + /// `isRequestComplete` / `isResponseComplete` from the profile data. + + @override + bool get inProgress { + if (_isCancelled) return false; + return _hasError + ? !_request.isRequestComplete + : !_request.isResponseComplete; + } /// All instant events logged to the timeline for this HTTP request. List get instantEvents { @@ -260,19 +317,30 @@ class DartIOHttpRequestData extends NetworkRequest { DartIOHttpRequestData._parseCookies(_request.response?.cookies); /// The request headers for the HTTP request. - Map? get requestHeaders => - _hasError ? null : _request.request?.headers; + /// + /// Returned even when the request failed, so failed / timed-out requests can + /// still be replayed (e.g. Copy as cURL). Accessing headers on some error + /// profiles throws, so failures fall back to `null`. + Map? get requestHeaders { + try { + return _request.request?.headers; + } catch (_) { + return null; + } + } /// The response headers for the HTTP request. Map? get responseHeaders => _request.response?.headers; /// The query parameters for the request. + @visibleForTesting Map? get queryParameters => _request.uri.queryParameters; @override bool get didFail { if (status == null) return false; if (status == 'Error') return true; + if (status == 'Cancelled') return false; try { final code = int.parse(status!); @@ -301,12 +369,19 @@ class DartIOHttpRequestData extends NetworkRequest { DateTime get startTimestamp => _request.startTime; @override - String? get status => - _hasError ? 'Error' : _request.response?.statusCode.toString(); + String? get status { + if (_isCancelled) return 'Cancelled'; + + if (_hasError) return 'Error'; + + return _request.response?.statusCode.toString(); + } @override String get uri => _request.uri.toString(); + bool get _isCancelled => _hasCancellationError || _hasCancellationEvent; + String? get responseBody { if (_request is! HttpProfileRequest) { return null; @@ -344,7 +419,8 @@ class DartIOHttpRequestData extends NetworkRequest { } final fullRequest = _request as HttpProfileRequest; try { - if (!_request.isResponseComplete) return null; + // Request body is independent of whether a response arrived. Timed-out + // and cancelled POSTs should still expose the body for Copy as cURL. final acceptedMethods = {'POST', 'PUT', 'PATCH'}; if (!acceptedMethods.contains(_request.method)) return null; if (_requestBody != null) return _requestBody; diff --git a/packages/devtools_app/lib/src/shared/managers/banner_messages.dart b/packages/devtools_app/lib/src/shared/managers/banner_messages.dart index 536dc11c2ef..28c41091ca1 100644 --- a/packages/devtools_app/lib/src/shared/managers/banner_messages.dart +++ b/packages/devtools_app/lib/src/shared/managers/banner_messages.dart @@ -7,7 +7,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../screens/performance/performance_utils.dart'; import '../../service/connected_app/connected_app.dart'; @@ -319,6 +319,7 @@ class BannerWarning extends BannerMessage { }) : super(messageType: BannerMessageType.warning); } +// ignore: unused-code, this is a type of message we want to support. class BannerInfo extends BannerMessage { const BannerInfo({ required super.key, @@ -521,34 +522,6 @@ The $codeType DevTools debugger is in maintenance mode. For the best debugging e ); } -class LegacyInspectorWarningMessage extends BannerWarning { - LegacyInspectorWarningMessage({required super.screenId}) - : super( - key: buildKey(screenId), - buildTextSpans: (context) => [ - const TextSpan( - text: - 'The legacy inspector will be removed in a future release. ' - 'Please enable the new inspector in the inspector settings. ' - 'If there is an issue preventing you from using the new ' - 'inspector, please file a ', - ), - GaLinkTextSpan( - link: const GaLink( - display: 'bug', - url: 'https://github.com/flutter/devtools/issues/new', - ), - context: context, - style: Theme.of(context).linkTextStyle, - ), - const TextSpan(text: '.'), - ], - ); - - static Key buildKey(String screenId) => - Key('LegacyInspectorWarningMessage - $screenId'); -} - void maybePushDebugModePerformanceMessage(String screenId) { if (offlineDataController.showingOfflineData.value) return; if (serviceConnection.serviceManager.connectedApp?.isDebugFlutterAppNow ?? @@ -577,10 +550,6 @@ void pushDebuggerIdeRecommendationMessage(String screenId) { ); } -void pushLegacyInspectorWarning(String screenId) { - bannerMessages.addMessage(LegacyInspectorWarningMessage(screenId: screenId)); -} - extension BannerMessageThemeExtension on ThemeData { TextStyle get warningMessageLinkStyle => regularTextStyle.copyWith( decoration: TextDecoration.underline, diff --git a/packages/devtools_app/lib/src/shared/managers/error_badge_manager.dart b/packages/devtools_app/lib/src/shared/managers/error_badge_manager.dart index d3d3f7dc8bc..2689cd48c81 100644 --- a/packages/devtools_app/lib/src/shared/managers/error_badge_manager.dart +++ b/packages/devtools_app/lib/src/shared/managers/error_badge_manager.dart @@ -3,40 +3,41 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:async'; -import 'dart:collection'; -import 'package:collection/collection.dart' show IterableExtension; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; import 'package:vm_service/vm_service.dart'; -import '../../screens/inspector_shared/inspector_screen.dart'; +import '../../screens/inspector/inspector_screen.dart'; import '../../screens/logging/logging_screen.dart'; import '../../screens/network/network_screen.dart'; import '../../screens/performance/performance_screen.dart'; import '../../service/service_extensions.dart' as extensions; import '../../service/vm_service_wrapper.dart'; -import '../diagnostics/diagnostics_node.dart'; import '../globals.dart'; import '../primitives/listenable.dart'; -import '../primitives/query_parameters.dart'; +/// Manages error badge counts for DevTools screen tabs. +/// +/// This is a generic counter that tracks unread error counts per screen. +/// Screen-specific error tracking logic (e.g., detailed error objects for the +/// Inspector screen) should live in the respective screen controllers. class ErrorBadgeManager extends DisposableController with AutoDisposeControllerMixin { - // TODO(https://github.com/flutter/devtools/issues/9105): Separate out - // Inspector-specific logic from this file. final _activeErrorCounts = >{ InspectorScreen.id: ValueNotifier(0), PerformanceScreen.id: ValueNotifier(0), NetworkScreen.id: ValueNotifier(0), }; - final _activeErrors = - >>{ - InspectorScreen.id: ValueNotifier>( - LinkedHashMap(), - ), - }; + + /// Screen ids whose unread badge count is owned by the screen controller. + /// + /// For these screens, [clearErrorCount] is a no-op so navigating to the tab + /// (see scaffold) does not drop the unread badge. Controllers should update + /// the count via [incrementBadgeCount], [decrementBadgeCount], and + /// [resetErrorCount] instead. + final _managedErrorCounts = {}; void vmServiceOpened(VmServiceWrapper service) { // Ensure structured errors are enabled. @@ -63,131 +64,55 @@ class ErrorBadgeManager extends DisposableController void _handleExtensionEvent(Event e) { if (e.extensionKind == FlutterEvent.error) { incrementBadgeCount(LoggingScreen.id); - - final inspectableError = _extractInspectableError(e); - if (inspectableError != null) { - incrementBadgeCount(InspectorScreen.id); - appendError(InspectorScreen.id, inspectableError); - } - } - } - - InspectableWidgetError? _extractInspectableError(Event error) { - // TODO(https://github.com/flutter/devtools/issues/9105): Switch to using - // the inspectorService from the serviceManager once Jacob's change to add - // it lands. - final node = RemoteDiagnosticsNode( - error.extensionData!.data, - null, - false, - null, - ); - - final errorSummaryNode = node.inlineProperties.firstWhereOrNull( - (p) => p.type == 'ErrorSummary', - ); - final errorMessage = errorSummaryNode?.description; - if (errorMessage == null) { - return null; - } - - final devToolsUrlNode = node.inlineProperties.firstWhereOrNull( - (p) => - p.type == 'DevToolsDeepLinkProperty' && - p.getStringMember('value') != null, - ); - if (devToolsUrlNode == null) { - return null; } - - final queryParams = DevToolsQueryParams.fromUrl( - devToolsUrlNode.getStringMember('value')!, - ); - final inspectorRef = queryParams.inspectorRef ?? ''; - - return InspectableWidgetError(errorMessage, inspectorRef); } void _handleStdErr(Event _) { incrementBadgeCount(LoggingScreen.id); } + /// Marks [screenId] as managing its own unread badge count. + /// + /// After this is called, [clearErrorCount] will not reset the badge for + /// [screenId] (preserving unread state across tab switches). + void manageErrorCount(String screenId) { + _managedErrorCounts.add(screenId); + } + void incrementBadgeCount(String screenId) { final notifier = _errorCountNotifier(screenId); if (notifier == null) return; - final currentCount = notifier.value; - notifier.value = currentCount + 1; + notifier.value = notifier.value + 1; } - void appendError(String screenId, DevToolsError error) { - final errors = _activeErrors[screenId]; - if (errors == null) return; - - // Build a new map with the new error. Adding to the existing map - // won't cause the ValueNotifier to fire (and it's not permitted to call - // notifyListeners() directly). - final newValue = LinkedHashMap.of(errors.value); - newValue[error.id] = error; - errors.value = newValue; + void decrementBadgeCount(String screenId) { + final notifier = _errorCountNotifier(screenId); + if (notifier == null) return; + if (notifier.value == 0) return; + notifier.value = notifier.value - 1; } ValueListenable errorCountNotifier(String screenId) { return _errorCountNotifier(screenId) ?? const FixedValueListenable(0); } - ValueListenable> erroredItemsForPage( - String screenId, - ) { - return _activeErrors[screenId] ?? - FixedValueListenable>( - LinkedHashMap(), - ); - } - ValueNotifier? _errorCountNotifier(String screenId) { return _activeErrorCounts[screenId]; } + /// Clears the badge count for [screenId], unless it is [manageErrorCount]d. void clearErrorCount(String screenId) { + if (_managedErrorCounts.contains(screenId)) return; _activeErrorCounts[screenId]?.value = 0; } - void clearErrors(String screenId) { - clearErrorCount(screenId); - _activeErrors[screenId]?.value = LinkedHashMap(); - } - - void filterErrors(String screenId, bool Function(String id) isValid) { - final errors = _activeErrors[screenId]; - if (errors == null) return; - - final oldCount = errors.value.length; - final newValue = Map.fromEntries( - errors.value.entries.where((e) => isValid(e.key)), - ); - if (newValue.length != oldCount) { - errors.value = newValue as LinkedHashMap; - } - } - - void markErrorAsRead(String screenId, DevToolsError error) { - final errors = _activeErrors[screenId]; - if (errors == null) return; - - // If this error doesn't exist anymore or is already read, nothing to do. - if (errors.value[error.id]?.read ?? true) { - return; - } - - // Otherwise, replace the map with a new one that has the error marked - // as read. - errors.value = LinkedHashMap.fromEntries( - errors.value.entries.map((e) { - if (e.value != error) return e; - return MapEntry(e.key, e.value.asRead()); - }), - ); + /// Unconditionally resets the badge count for [screenId]. + /// + /// Use from screen controllers that call [manageErrorCount] when they need + /// to clear their own unread state (e.g. on hot restart). + void resetErrorCount(String screenId) { + _activeErrorCounts[screenId]?.value = 0; } } @@ -200,11 +125,3 @@ class DevToolsError { DevToolsError asRead() => DevToolsError(errorMessage, id, read: true); } - -class InspectableWidgetError extends DevToolsError { - InspectableWidgetError(super.errorMessage, super.id, {super.read}); - - @override - InspectableWidgetError asRead() => - InspectableWidgetError(errorMessage, id, read: true); -} diff --git a/packages/devtools_app/lib/src/shared/managers/notifications.dart b/packages/devtools_app/lib/src/shared/managers/notifications.dart index 8c15cebf570..ff1edf42951 100644 --- a/packages/devtools_app/lib/src/shared/managers/notifications.dart +++ b/packages/devtools_app/lib/src/shared/managers/notifications.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'dart:collection'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../globals.dart'; import '../utils/utils.dart'; diff --git a/packages/devtools_app/lib/src/shared/managers/script_manager.dart b/packages/devtools_app/lib/src/shared/managers/script_manager.dart index 02179f73d30..29d7a01d72c 100644 --- a/packages/devtools_app/lib/src/shared/managers/script_manager.dart +++ b/packages/devtools_app/lib/src/shared/managers/script_manager.dart @@ -76,17 +76,12 @@ class ScriptManager extends DisposableController Future getScript(ScriptRef scriptRef) { return _scriptCache.getScript(_service, _currentIsolate, scriptRef); } - - Script? getScriptByUri(String uri) { - return _scriptCache.getScriptByUri(uri); - } } class _ScriptCache { _ScriptCache(); final _scripts = {}; - final _uriToScript = {}; final _inProgress = >{}; /// Return a cached [Script] for the given [ScriptRef], returning null @@ -95,10 +90,6 @@ class _ScriptCache { return _scripts[scriptRef.id]; } - Script? getScriptByUri(String uri) { - return _uriToScript[uri]; - } - /// Retrieve the [Script] for the given [ScriptRef]. /// /// This caches the script lookup for future invocations. @@ -130,7 +121,6 @@ class _ScriptCache { unawaited( scriptFuture.then((script) { scripts[scriptId] = script; - _uriToScript[script.uri!] = script; }), ); diff --git a/packages/devtools_app/lib/src/shared/managers/survey.dart b/packages/devtools_app/lib/src/shared/managers/survey.dart index b275fa688f4..271bd95f9de 100644 --- a/packages/devtools_app/lib/src/shared/managers/survey.dart +++ b/packages/devtools_app/lib/src/shared/managers/survey.dart @@ -8,10 +8,12 @@ import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/widgets.dart'; import 'package:http/http.dart'; import 'package:logging/logging.dart'; +import 'package:unified_analytics/unified_analytics.dart' as ua; import '../analytics/analytics.dart' as ga; import '../development_helpers.dart'; import '../globals.dart'; +import '../primitives/query_parameters.dart'; import '../primitives/utils.dart'; import '../server/server.dart' as server; import '../utils/utils.dart'; @@ -232,7 +234,12 @@ class DevToolsSurvey { /// If [devEnvironments] is null, the survey can be shown to any platform. /// /// The possible values for this list correspond to the possible values of - /// `_ideLaunched` from [shared/analytics/_analytics_web.dart]. + /// `_ideLaunched` from [shared/analytics/_analytics_web.dart]. They can also + /// be the labels of the Dash tools that IDEs pass in the `dashTool` query + /// parameter (e.g. 'vscode-plugins', 'intellij-plugins'), which are shared by + /// every variant of an IDE (e.g. VS Code, Cursor and Antigravity). Versions + /// of DevTools that predate the `dashTool` query parameter only recognize the + /// former. final List? devEnvironments; } @@ -250,10 +257,70 @@ extension ShowSurveyExtension on DevToolsSurvey { devToolsVersion, ).isSupported(minSupportedVersion: minDevToolsVersion!); - bool get meetsEnvironmentRequirement => - devEnvironments == null || - ga.ideLaunched.isEmpty || - devEnvironments!.contains(ga.ideLaunched); + /// Whether this survey should be shown in the development environment that + /// the current DevTools session was launched from. + /// + /// See [meetsEnvironmentRequirementFor] for how [devEnvironments] are matched. + bool get meetsEnvironmentRequirement => meetsEnvironmentRequirementFor( + ideLaunched: ga.ideLaunched, + queryParams: DevToolsQueryParams.load(), + ); + + /// Whether this survey should be shown in the development environment that + /// DevTools was launched from, which is described by [ideLaunched] and + /// [queryParams]. + /// + /// A survey that lists [devEnvironments] is shown if it lists the legacy + /// [ideLaunched] value, or the `dashTool` of the IDE integration that + /// launched DevTools. The legacy and `dashTool` environment names for VS Code + /// ('VSCode' and 'vscode-plugins') and for IntelliJ/Android Studio + /// ('Android-Studio', 'IntelliJ-IDEA', 'intellij-plugins' and + /// 'android-studio-plugins') match bidirectionally, including for VS Code + /// forks (such as Cursor or Antigravity) that do not pass 'VSCode' as their + /// legacy IDE value. + @visibleForTesting + bool meetsEnvironmentRequirementFor({ + required String ideLaunched, + required DevToolsQueryParams queryParams, + }) { + final environments = devEnvironments; + if (environments == null || ideLaunched.isEmpty) return true; + + final dashTool = queryParams.dashTool; + final isVsCode = + queryParams.isVsCode || + ideLaunched == DevToolsQueryParams.legacyVsCodeIdeValue; + if (isVsCode && + (environments.contains(DevToolsQueryParams.legacyVsCodeIdeValue) || + environments.contains(ua.DashTool.vscodePlugins.label))) { + return true; + } + + final isIntelliJOrAndroidStudio = + dashTool == ua.DashTool.intellijPlugins.label || + dashTool == ua.DashTool.androidStudioPlugins.label || + ideLaunched == DevToolsQueryParams.legacyAndroidStudioIdeValue || + ideLaunched == DevToolsQueryParams.legacyIntelliJIdeValue; + if (isIntelliJOrAndroidStudio && + (environments.contains( + DevToolsQueryParams.legacyAndroidStudioIdeValue, + ) || + environments.contains(DevToolsQueryParams.legacyIntelliJIdeValue) || + environments.contains(ua.DashTool.intellijPlugins.label) || + environments.contains(ua.DashTool.androidStudioPlugins.label))) { + return true; + } + + // When an IDE integration passes `dashTool` without the legacy `ide` + // parameter, `ga.ideLaunched` defaults to 'CLI'. Ignore that fallback so + // IDE launches do not match 'CLI' surveys. + final isIdeDashTool = isVsCode || isIntelliJOrAndroidStudio; + final matchesIdeLaunched = + (!isIdeDashTool || queryParams.ide != null) && + environments.contains(ideLaunched); + return matchesIdeLaunched || + (dashTool != null && environments.contains(dashTool)); + } bool get shouldShow => meetsDateRequirement && diff --git a/packages/devtools_app/lib/src/shared/memory/class_name.dart b/packages/devtools_app/lib/src/shared/memory/class_name.dart index c1e9f7db2ce..2942d6e8c1c 100644 --- a/packages/devtools_app/lib/src/shared/memory/class_name.dart +++ b/packages/devtools_app/lib/src/shared/memory/class_name.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_shared/devtools_shared.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../primitives/simple_items.dart'; @@ -143,6 +143,7 @@ class HeapClassName with Serializable { /// Whether a class can hold a reference to an object /// without preventing garbage collection. + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9907) false positive. late final isWeak = _isWeak(className, library); /// See [isWeak]. diff --git a/packages/devtools_app/lib/src/shared/memory/heap_data.dart b/packages/devtools_app/lib/src/shared/memory/heap_data.dart index 54fc1fb5316..5c4bf6f9117 100644 --- a/packages/devtools_app/lib/src/shared/memory/heap_data.dart +++ b/packages/devtools_app/lib/src/shared/memory/heap_data.dart @@ -25,7 +25,6 @@ class HeapData { Future get calculate => _calculated.future; final _calculated = Completer(); - bool get isCalculated => _calculated.isCompleted; ClassDataList? classes; diff --git a/packages/devtools_app/lib/src/shared/memory/heap_graph_loader.dart b/packages/devtools_app/lib/src/shared/memory/heap_graph_loader.dart index 7a9bedbe6b1..e2dea14be25 100644 --- a/packages/devtools_app/lib/src/shared/memory/heap_graph_loader.dart +++ b/packages/devtools_app/lib/src/shared/memory/heap_graph_loader.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:file_selector/file_selector.dart'; +import 'package:meta/meta.dart'; import 'package:vm_service/vm_service.dart'; import '../../screens/memory/shared/primitives/memory_timeline.dart'; @@ -34,6 +35,7 @@ class HeapGraphLoaderRuntime extends HeapGraphLoader { class HeapGraphLoaderFile implements HeapGraphLoader { HeapGraphLoaderFile(this.file); + @visibleForTesting HeapGraphLoaderFile.fromPath(String path) : file = XFile(path); final XFile file; diff --git a/packages/devtools_app/lib/src/shared/memory/retaining_path.dart b/packages/devtools_app/lib/src/shared/memory/retaining_path.dart index b9e7d40e9be..9e68390a9fc 100644 --- a/packages/devtools_app/lib/src/shared/memory/retaining_path.dart +++ b/packages/devtools_app/lib/src/shared/memory/retaining_path.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:vm_service/vm_service.dart'; import '../primitives/utils.dart'; @@ -17,12 +17,15 @@ bool Function(List? list1, List? list2) _listEquality = @visibleForTesting class DebugRetainingPathUsage { /// Path is expected to be constructed for each object. + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9907) false positive. int constructed = 0; /// Only unique paths are stored. + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9907) false positive. int stored = 0; /// Only displayed paths are stringified. + // ignore: unused-code, TODO(https://github.com/flutter/devtools/issues/9907) false positive. int stringified = 0; } @@ -42,12 +45,10 @@ class PathFromRoot { PathFromRoot._( this.path, { @visibleForTesting bool debugOmitClassesInRetainingPath = false, - }) : assert(() { - debugUsage.constructed++; - return true; - }()), - hashCode = path.isEmpty ? _hashOfEmptyPath : Object.hashAll(path), - classes = debugOmitClassesInRetainingPath ? const {} : path.toSet(); + }) : hashCode = path.isEmpty ? _hashOfEmptyPath : Object.hashAll(path), + classes = debugOmitClassesInRetainingPath ? const {} : path.toSet() { + if (kDebugMode) debugUsage.constructed++; + } /// For objects directly referenced from root. const PathFromRoot._empty() @@ -100,11 +101,10 @@ class PathFromRoot { ); instances.add(newInstance); - assert(() { + if (kDebugMode) { debugUsage.stored++; assert(instances.length == debugUsage.stored); - return true; - }()); + } return newInstance; } @@ -208,10 +208,7 @@ class PathFromRoot { required String delimiter, required bool inverted, }) { - assert(() { - debugUsage.stringified++; - return true; - }()); + if (kDebugMode) debugUsage.stringified++; // Trailing separator is here to show object is referenced by root. data = data.joinWith( delimiter, diff --git a/packages/devtools_app/lib/src/shared/offline/offline_data.dart b/packages/devtools_app/lib/src/shared/offline/offline_data.dart index a7c6f48ff91..6de315f6f9a 100644 --- a/packages/devtools_app/lib/src/shared/offline/offline_data.dart +++ b/packages/devtools_app/lib/src/shared/offline/offline_data.dart @@ -10,6 +10,7 @@ import 'dart:async'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; import '../config_specific/import_export/import_export.dart'; import '../framework/routing.dart'; @@ -67,6 +68,8 @@ class OfflineDataController { } } +final _log = Logger('offline_data'); + /// Mixin that provides offline support for a DevTools screen controller. /// /// The [Screen] that is associated with this controller must have @@ -131,6 +134,7 @@ class OfflineDataController { /// ), /// } /// ``` + mixin OfflineScreenControllerMixin on DevToolsScreenController, AutoDisposeControllerMixin { final _exportController = ExportController(); @@ -168,19 +172,41 @@ mixin OfflineScreenControllerMixin required FutureOr Function(T data) loadData, }) async { if (offlineDataController.shouldLoadOfflineData(screenId)) { - // TODO(kenz): investigate this line of code. Do we need to be creating a - // second copy of the Map from offlineDataController.offlineDataJson or - // can we use it directly to save this `Map.of` call? - final json = Map.of( - (offlineDataController.offlineDataJson[screenId] as Map) - .cast(), - ); - final screenData = createData(json); + final T screenData; + try { + // TODO(kenz): investigate this line of code. Do we need to be creating a + // second copy of the Map from offlineDataController.offlineDataJson or + // can we use it directly to save this `Map.of` call? + final json = Map.of( + (offlineDataController.offlineDataJson[screenId] as Map) + .cast(), + ); + screenData = createData(json); + } catch (e, st) { + _log.shout('Error parsing offline data for $screenId', e, st); + notificationService.push( + 'Failed to load offline data for screen \'$screenId\': $e', + ); + return false; + } + if (shouldLoad(screenData)) { _loadingOfflineData.value = true; - await loadData(screenData); - _loadingOfflineData.value = false; - return true; + try { + await loadData(screenData); + return true; + } catch (e, st) { + _log.shout('Error loading offline data for $screenId', e, st); + notificationService.push( + 'Failed to load offline data for screen \'$screenId\': $e', + ); + } finally { + _loadingOfflineData.value = false; + } + } else { + notificationService.push( + 'The imported file does not contain any data for screen \'$screenId\'.', + ); } } return false; @@ -189,9 +215,22 @@ mixin OfflineScreenControllerMixin /// Exports the current screen data to a .json file and downloads the file to /// the user's Downloads directory. void exportData() { - final encodedData = _exportController.encode( - prepareOfflineScreenData().toJson(), - ); + final String encodedData; + try { + encodedData = _exportController.encode( + prepareOfflineScreenData().toJson(), + ); + } catch (e) { + // Encoding fails when the data is too large for a single JSON string (the + // maximum string length in the browser is about 2^29 characters). The + // exact error type depends on the compiler (dart2js vs. dart2wasm), so + // catch everything rather than a specific type. + notificationService.pushError( + 'Unable to export this screen\'s data. It may be too large to export: ' + '$e', + ); + return; + } _exportController.downloadFile(encodedData); } diff --git a/packages/devtools_app/lib/src/shared/offline/offline_screen.dart b/packages/devtools_app/lib/src/shared/offline/offline_screen.dart index cca7d0f6213..ae0896b068b 100644 --- a/packages/devtools_app/lib/src/shared/offline/offline_screen.dart +++ b/packages/devtools_app/lib/src/shared/offline/offline_screen.dart @@ -5,7 +5,7 @@ import 'package:collection/collection.dart' show IterableExtension; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../analytics/constants.dart' as gac; import '../framework/screen.dart'; diff --git a/packages/devtools_app/lib/src/shared/preferences/_inspector_preferences.dart b/packages/devtools_app/lib/src/shared/preferences/_inspector_preferences.dart index 440c2cc3d1b..7efdbcbfeb7 100644 --- a/packages/devtools_app/lib/src/shared/preferences/_inspector_preferences.dart +++ b/packages/devtools_app/lib/src/shared/preferences/_inspector_preferences.dart @@ -4,25 +4,10 @@ part of 'preferences.dart'; -enum InspectorDetailsViewType { - layoutExplorer(nameOverride: 'Layout Explorer'), - widgetDetailsTree(nameOverride: 'Widget Details Tree'); - - const InspectorDetailsViewType({String? nameOverride}) - : _nameOverride = nameOverride; - - final String? _nameOverride; - - String get key => _nameOverride ?? name; -} - class InspectorPreferencesController extends DisposableController with AutoDisposeControllerMixin { ValueListenable get hoverEvalModeEnabled => _hoverEvalMode; - ValueListenable get legacyInspectorEnabled => _legacyInspectorEnabled; ValueListenable get autoRefreshEnabled => _autoRefreshEnabled; - ValueListenable get defaultDetailsView => - _defaultDetailsView; ListValueNotifier get pubRootDirectories => _pubRootDirectories; ValueListenable get isRefreshingPubRootDirectories => _pubRootDirectoriesAreBusy; @@ -30,21 +15,13 @@ class InspectorPreferencesController extends DisposableController serviceConnection.inspectorService; final _hoverEvalMode = ValueNotifier(false); - final _legacyInspectorEnabled = ValueNotifier(false); final _autoRefreshEnabled = ValueNotifier(true); final _pubRootDirectories = ListValueNotifier([]); final _pubRootDirectoriesAreBusy = ValueNotifier(false); final _busyCounter = ValueNotifier(0); - final _defaultDetailsView = ValueNotifier( - InspectorDetailsViewType.layoutExplorer, - ); static const _hoverEvalModeStorageId = 'inspector.hoverEvalMode'; - static const _legacyInspectorEnabledStorageId = - 'inspector.legacyInspectorEnabled'; static const _autoRefreshEnabledStorageId = 'inspector.autoRefreshEnabled'; - static const _defaultDetailsViewStorageId = - 'inspector.defaultDetailsViewType'; static const _customPubRootDirectoriesStoragePrefix = 'inspector.customPubRootDirectories'; @@ -83,11 +60,8 @@ class InspectorPreferencesController extends DisposableController @override Future init() async { await _initHoverEvalMode(); - await _initLegacyInspectorEnabled(); await _initAutoRefreshEnabled(); - // TODO(jacobr): consider initializing this first as it is not blocking. _initPubRootDirectories(); - await _initDefaultInspectorDetailsView(); } Future _initHoverEvalMode() async { @@ -98,16 +72,6 @@ class InspectorPreferencesController extends DisposableController ); } - Future _initLegacyInspectorEnabled() async { - // TODO(https://github.com/flutter/devtools/issues/8667): Consider removing - // the old 'inspector.inspectorV2Enabled' key if it is set. - await _updateLegacyInspectorEnabled(); - _saveBooleanPreferenceChanges( - preferenceStorageId: _legacyInspectorEnabledStorageId, - preferenceNotifier: _legacyInspectorEnabled, - ); - } - Future _initAutoRefreshEnabled() async { await _updateAutoRefreshEnabled(); _saveBooleanPreferenceChanges( @@ -124,14 +88,6 @@ class InspectorPreferencesController extends DisposableController ); } - Future _updateLegacyInspectorEnabled() async { - await _updateBooleanPreference( - preferenceStorageId: _legacyInspectorEnabledStorageId, - preferenceNotifier: _legacyInspectorEnabled, - defaultValue: false, - ); - } - Future _updateAutoRefreshEnabled() async { await _updateBooleanPreference( preferenceStorageId: _autoRefreshEnabledStorageId, @@ -164,31 +120,6 @@ class InspectorPreferencesController extends DisposableController preferenceNotifier.value = preferenceValue == 'true'; } - Future _initDefaultInspectorDetailsView() async { - await _updateInspectorDetailsViewSelection(); - - addAutoDisposeListener(_defaultDetailsView, () { - safeUnawaited( - storage.setValue( - _defaultDetailsViewStorageId, - _defaultDetailsView.value.name.toString(), - ), - ); - }); - } - - Future _updateInspectorDetailsViewSelection() async { - final inspectorDetailsView = await storage.getValue( - _defaultDetailsViewStorageId, - ); - - if (inspectorDetailsView != null) { - _defaultDetailsView.value = InspectorDetailsViewType.values.firstWhere( - (e) => e.name.toString() == inspectorDetailsView, - ); - } - } - void _initPubRootDirectories() { addAutoDisposeListener( serviceConnection.serviceManager.connectedState, @@ -245,9 +176,7 @@ class InspectorPreferencesController extends DisposableController _checkedFlutterPubRoot = false; await _updateMainScriptRef(); await _updateHoverEvalMode(); - await _updateLegacyInspectorEnabled(); await loadPubRootDirectories(); - await _updateInspectorDetailsViewSelection(); } Future loadPubRootDirectories() async { @@ -473,17 +402,8 @@ class InspectorPreferencesController extends DisposableController _hoverEvalMode.value = enableHoverEvalMode; } - @visibleForTesting - void setLegacyInspectorEnabled(bool legacyInspectorEnabled) { - _legacyInspectorEnabled.value = legacyInspectorEnabled; - } - @visibleForTesting void setAutoRefreshEnabled(bool autoRefreshEnabled) { _autoRefreshEnabled.value = autoRefreshEnabled; } - - void setDefaultInspectorDetailsView(InspectorDetailsViewType value) { - _defaultDetailsView.value = value; - } } diff --git a/packages/devtools_app/lib/src/shared/preferences/_logging_preferences.dart b/packages/devtools_app/lib/src/shared/preferences/_logging_preferences.dart index 713c6ece0ed..5257ce06fa9 100644 --- a/packages/devtools_app/lib/src/shared/preferences/_logging_preferences.dart +++ b/packages/devtools_app/lib/src/shared/preferences/_logging_preferences.dart @@ -6,8 +6,6 @@ part of 'preferences.dart'; class LoggingPreferencesController extends DisposableController with AutoDisposeControllerMixin { - final retentionLimitTitle = 'Limit for the number of logs retained.'; - /// The number of logs to retain on the logging table. final retentionLimit = ValueNotifier(_defaultRetentionLimit); diff --git a/packages/devtools_app/lib/src/shared/preferences/preferences.dart b/packages/devtools_app/lib/src/shared/preferences/preferences.dart index 06ee38c5f8b..e1d3bbc6ce6 100644 --- a/packages/devtools_app/lib/src/shared/preferences/preferences.dart +++ b/packages/devtools_app/lib/src/shared/preferences/preferences.dart @@ -36,9 +36,6 @@ const _thirdPartyPathSegment = 'third_party'; /// DevTools preferences for experimental features. enum _ExperimentPreferences { - /// Deprecated, we ignore this key in favor of [wasmOptOut]. - wasm, - /// Whether a user has opted out of the dart2wasm experiment. wasmOptOut; @@ -106,8 +103,6 @@ class PreferencesController extends DisposableController ExtensionsPreferencesController get devToolsExtensions => _extensions; final _extensions = ExtensionsPreferencesController(); - // TODO(https://github.com/flutter/devtools/issues/7860): Clean-up after - // Inspector V2 has been released. InspectorPreferencesController get inspector => _inspector; final _inspector = InspectorPreferencesController(); @@ -142,13 +137,6 @@ class PreferencesController extends DisposableController setGlobal(PreferencesController, this); } - /// Enables the wasm experiment in storage. - /// - /// This is used to persist the preference across reloads. - Future enableWasmInStorage() async { - await storage.setValue(_ExperimentPreferences.wasm.storageKey, 'true'); - } - Future _initDarkMode() async { final darkModeValue = await storage.getValue( _UiPreferences.darkMode.storageKey, @@ -243,7 +231,7 @@ class PreferencesController extends DisposableController // default. This is because cross-origin-isolation is disabled by VS // Code. See https://github.com/microsoft/vscode/issues/186614. final embeddedInVsCode = - queryParams.embedMode.embedded && queryParams.ide == 'VSCode'; + queryParams.embedMode.embedded && queryParams.isVsCode; if (!embeddedInVsCode) { notificationService.push( 'Something went wrong when trying to load DevTools with WebAssembly. ' @@ -284,13 +272,17 @@ class PreferencesController extends DisposableController @override void dispose() { - cpuProfiler.dispose(); - devToolsExtensions.dispose(); - inspector.dispose(); - logging.dispose(); - memory.dispose(); - network.dispose(); - performance.dispose(); + _cpuProfiler.dispose(); + _extensions.dispose(); + _inspector.dispose(); + _logging.dispose(); + _memory.dispose(); + _network.dispose(); + _performance.dispose(); + darkModeEnabled.dispose(); + advancedDeveloperModeEnabled.dispose(); + wasmEnabled.dispose(); + verboseLoggingEnabled.dispose(); super.dispose(); } diff --git a/packages/devtools_app/lib/src/shared/primitives/ansi_utils.dart b/packages/devtools_app/lib/src/shared/primitives/ansi_utils.dart index 397c88f20aa..dba6a6ce137 100644 --- a/packages/devtools_app/lib/src/shared/primitives/ansi_utils.dart +++ b/packages/devtools_app/lib/src/shared/primitives/ansi_utils.dart @@ -292,7 +292,6 @@ class _TextPacket { _PacketKind kind; String text = ''; - String url = ''; } /// Chunk of styled text stored in a Dart friendly format. @@ -327,31 +326,4 @@ class StyledText { final List? bgColor; bool get bold => (textStyle & kBold) == kBold; - bool get dim => (textStyle & kDim) == kDim; - bool get italic => (textStyle & kItalic) == kItalic; - bool get underline => (textStyle & kUnderline) == kUnderline; - bool get strikethrough => (textStyle & kStrikethrough) == kStrikethrough; - bool get blink => (textStyle & kBlink) == kBlink; - bool get reverse => (textStyle & kReverse) == kReverse; - bool get invisible => (textStyle & kInvisible) == kInvisible; - - bool get hasStyling => textStyle != 0 || fgColor != null || bgColor != null; - - String get describeStyle { - String hex(int value) => value.toRadixString(16).padLeft(2, '0'); - String color(List rgb) => '${hex(rgb[0])}${hex(rgb[2])}${hex(rgb[2])}'; - - return [ - if (bgColor != null) 'background #${color(bgColor!)}', - if (fgColor != null) 'color #${color(fgColor!)}', - if (bold) 'bold', - if (dim) 'dim', - if (italic) 'italic', - if (underline) 'underline', - if (strikethrough) 'strikethrough', - if (blink) 'blink', - if (reverse) 'reverse', - if (invisible) 'invisible', - ].join(', '); - } } diff --git a/packages/devtools_app/lib/src/shared/primitives/byte_utils.dart b/packages/devtools_app/lib/src/shared/primitives/byte_utils.dart index c6dd14f2c63..e9e4de14503 100644 --- a/packages/devtools_app/lib/src/shared/primitives/byte_utils.dart +++ b/packages/devtools_app/lib/src/shared/primitives/byte_utils.dart @@ -162,12 +162,17 @@ class Uint8ListRingBuffer { /// Merges all the data in this ring buffer into a single [Uint8List] and /// returns it. Uint8List get merged { - final allBytes = BytesBuilder(); - debugTimeSync( - () => data.forEach(allBytes.add), - debugName: 'Uint8ListRingBuffer.mergeAllData', - ); - return allBytes.takeBytes(); + // Allocate exactly once at the final size. A [BytesBuilder] over-allocates + // up to the next power of two, which is costly for very large traces. + final merged = Uint8List(size); + debugTimeSync(() { + var offset = 0; + for (final chunk in data) { + merged.setRange(offset, offset + chunk.length, chunk); + offset += chunk.length; + } + }, debugName: 'Uint8ListRingBuffer.mergeAllData'); + return merged; } void clear() { diff --git a/packages/devtools_app/lib/src/shared/primitives/collapsible_mixin.dart b/packages/devtools_app/lib/src/shared/primitives/collapsible_mixin.dart index 1bdf7c9277f..f00216af85c 100644 --- a/packages/devtools_app/lib/src/shared/primitives/collapsible_mixin.dart +++ b/packages/devtools_app/lib/src/shared/primitives/collapsible_mixin.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Provides `animations` triggered by toggling the expanded and visible state /// of a widget. diff --git a/packages/devtools_app/lib/src/shared/primitives/custom_pointer_scroll_view.dart b/packages/devtools_app/lib/src/shared/primitives/custom_pointer_scroll_view.dart index f6cc1b52de7..caa711f1baa 100644 --- a/packages/devtools_app/lib/src/shared/primitives/custom_pointer_scroll_view.dart +++ b/packages/devtools_app/lib/src/shared/primitives/custom_pointer_scroll_view.dart @@ -3,6 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. // ignore_for_file: avoid-explicit-type-declaration, forked code from Flutter framework. +// ignore_for_file: unused-code, forked code from Flutter framework. import 'dart:async'; import 'dart:math' as math; @@ -387,7 +388,11 @@ class CustomPointerScrollableState extends State @override void dispose() { widget.controller?.detach(position!); - position!.dispose(); + _position!.dispose(); + _hold?.cancel(); + _hold = null; + _drag?.cancel(); + _drag = null; super.dispose(); } diff --git a/packages/devtools_app/lib/src/shared/primitives/diagnostics_text_styles.dart b/packages/devtools_app/lib/src/shared/primitives/diagnostics_text_styles.dart index 5773f02f3c2..44c15bbecb1 100644 --- a/packages/devtools_app/lib/src/shared/primitives/diagnostics_text_styles.dart +++ b/packages/devtools_app/lib/src/shared/primitives/diagnostics_text_styles.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// A namespace for diagnostic text styles and utilities. extension DiagnosticsTextStyles on Never { diff --git a/packages/devtools_app/lib/src/shared/primitives/encoding.dart b/packages/devtools_app/lib/src/shared/primitives/encoding.dart index a787dd9a1e1..ff22ad15e33 100644 --- a/packages/devtools_app/lib/src/shared/primitives/encoding.dart +++ b/packages/devtools_app/lib/src/shared/primitives/encoding.dart @@ -52,8 +52,9 @@ class ByteDataEncodeDecode extends EncodeDecode { @override Object toEncodable(ByteData value) { - final list = value.buffer.asUint8List(); - return base64Encode(list); + // Use a view of just this data. The underlying buffer may be larger than + // the data represented by [value]. + return base64Encode(Uint8List.sublistView(value)); } @override diff --git a/packages/devtools_app/lib/src/shared/primitives/flutter_widgets/linked_scroll_controller.dart b/packages/devtools_app/lib/src/shared/primitives/flutter_widgets/linked_scroll_controller.dart index ad1412178b0..8c64a6a7844 100644 --- a/packages/devtools_app/lib/src/shared/primitives/flutter_widgets/linked_scroll_controller.dart +++ b/packages/devtools_app/lib/src/shared/primitives/flutter_widgets/linked_scroll_controller.dart @@ -2,8 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:material_ui/material_ui.dart'; + +// ignore_for_file: unused-code, this is forked file, leave untouched. // This file was originally forked from package:flutter_widgets. Note that the // source may diverge over time. diff --git a/packages/devtools_app/lib/src/shared/primitives/list_queue_value_notifier.dart b/packages/devtools_app/lib/src/shared/primitives/list_queue_value_notifier.dart index afa0bec60aa..5888d132981 100644 --- a/packages/devtools_app/lib/src/shared/primitives/list_queue_value_notifier.dart +++ b/packages/devtools_app/lib/src/shared/primitives/list_queue_value_notifier.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, helpful notifer class that will likely be reused. + import 'dart:collection'; import 'package:flutter/foundation.dart'; diff --git a/packages/devtools_app/lib/src/shared/primitives/query_parameters.dart b/packages/devtools_app/lib/src/shared/primitives/query_parameters.dart index 7bd99f33216..dfa86b0fb81 100644 --- a/packages/devtools_app/lib/src/shared/primitives/query_parameters.dart +++ b/packages/devtools_app/lib/src/shared/primitives/query_parameters.dart @@ -6,6 +6,7 @@ import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; +import 'package:unified_analytics/unified_analytics.dart' as ua; extension type DevToolsQueryParams(Map params) { static DevToolsQueryParams empty() => DevToolsQueryParams({}); @@ -50,12 +51,58 @@ extension type DevToolsQueryParams(Map params) { String? get appSizeTestFilePath => params[AppSizeApi.testAppSizeFilePropertyName]; + // TODO(https://github.com/flutter/devtools/issues/9811): Remove this legacy + // parameter once IDE integrations have been passing dashTool and dashIdeName + // for long enough (at least two years) to allow users ample time to upgrade. /// The IDE that DevTools is embedded in or was launched from. + /// + /// This is a legacy value (for example `VSCode`, `Android-Studio` or + /// `IntelliJ-IDEA`) that IDE integrations continue to pass for the benefit of + /// versions of DevTools that predate [dashTool] and [dashIdeName]. Prefer + /// those parameters, and use [isVsCode] to check for VS Code. String? get ide => params[ideKey]; /// The feature of the IDE that DevTools was opened from. String? get ideFeature => params[ideFeatureKey]; + /// The canonical label of the Dash tool, such as an IDE plugin, that DevTools + /// is embedded in or was launched from (for example `vscode-plugins` or + /// `intellij-plugins`). + /// + /// This corresponds to the `DASH__TOOL` environment variable that IDEs set + /// for the processes they spawn. It is shared by every variant of an IDE + /// that uses the same plugin (for example VS Code, Cursor and Antigravity). + /// See [ua.DashTool] for the labels of the known tools. + String? get dashTool => params[dashToolKey]; + + /// The name of the IDE that DevTools is embedded in or was launched from (for + /// example `Visual Studio Code`, `Cursor`, `Antigravity` or `Android Studio`). + /// + /// This corresponds to the `DASH__IDE_NAME` environment variable that IDEs + /// set for the processes they spawn. The possible values are open-ended, + /// because new IDEs, and forks of existing IDEs, choose their own names. So + /// this should only be used for display and diagnostics, and not to decide + /// how DevTools behaves. Use [dashTool] or [isVsCode] for that. + String? get dashIdeName => params[dashIdeNameKey]; + + /// The best available name of the IDE that DevTools is embedded in or was + /// launched from, for display and diagnostics (for example in bug reports). + /// + /// This is [dashIdeName], which distinguishes forks of an IDE (like Cursor) + /// from the IDE itself, falling back to the legacy [ide] for IDE integrations + /// that do not pass [dashIdeName]. + String? get ideDisplayName => dashIdeName ?? ide; + + /// Whether DevTools is embedded in or was launched from VS Code, or a fork of + /// VS Code such as Cursor or Antigravity. + /// + /// This is determined by [dashTool], which is the same for every variant of + /// VS Code. The legacy [ide] is checked as well, for IDE integrations that do + /// not pass [dashTool]. + bool get isVsCode => + dashTool == ua.DashTool.vscodePlugins.label || + ide == legacyVsCodeIdeValue; + /// Keys for theming values that an IDE may pass in the embedded DevTools URI. IdeThemeQueryParams get ideThemeParams => IdeThemeQueryParams(params); @@ -81,6 +128,18 @@ extension type DevToolsQueryParams(Map params) { static const inspectorRefKey = 'inspectorRef'; static const ideKey = 'ide'; static const ideFeatureKey = 'ideFeature'; + static const dashToolKey = 'dashTool'; + static const dashIdeNameKey = 'dashIdeName'; + + /// The value of the legacy [ide] parameter that VS Code, and the forks of VS + /// Code that use the same extension, pass. + static const legacyVsCodeIdeValue = 'VSCode'; + + /// The value of the legacy [ide] parameter that Android Studio passes. + static const legacyAndroidStudioIdeValue = 'Android-Studio'; + + /// The value of the legacy [ide] parameter that IntelliJ IDEA passes. + static const legacyIntelliJIdeValue = 'IntelliJ-IDEA'; /// Query parameter key to determine whether to use dart2wasm or dart2js. /// diff --git a/packages/devtools_app/lib/src/shared/primitives/simple_items.dart b/packages/devtools_app/lib/src/shared/primitives/simple_items.dart index f6de6c579c3..582f0ea022a 100644 --- a/packages/devtools_app/lib/src/shared/primitives/simple_items.dart +++ b/packages/devtools_app/lib/src/shared/primitives/simple_items.dart @@ -30,7 +30,6 @@ const closureName = ''; const anonymousClosureName = ''; const _memoryDocUrl = 'https://docs.flutter.dev/tools/devtools/memory'; -const _consoleDocUrl = 'https://docs.flutter.dev/tools/devtools/console'; const _inspectorDocUrl = 'https://docs.flutter.dev/tools/devtools/inspector'; /// Some links to documentation. @@ -41,7 +40,6 @@ enum DocLinks { profile(_memoryDocUrl, 'profile-memory-tab'), diff(_memoryDocUrl, 'diff-snapshots-tab'), trace(_memoryDocUrl, 'trace-instances-tab'), - console(_consoleDocUrl, null), inspectorPackageDirectories(_inspectorDocUrl, 'package-directories'); const DocLinks(this.url, this.hash); diff --git a/packages/devtools_app/lib/src/shared/primitives/trace_event.dart b/packages/devtools_app/lib/src/shared/primitives/trace_event.dart index 2967fb6a722..105c093ce8d 100644 --- a/packages/devtools_app/lib/src/shared/primitives/trace_event.dart +++ b/packages/devtools_app/lib/src/shared/primitives/trace_event.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, this model class follows the Chrome Trace Event format. + /// A single trace event that follows the Chrome Trace Event Format. /// /// See Chrome Trace Event Format documentation: diff --git a/packages/devtools_app/lib/src/shared/primitives/trees.dart b/packages/devtools_app/lib/src/shared/primitives/trees.dart index a86d9720849..d41fc08f97b 100644 --- a/packages/devtools_app/lib/src/shared/primitives/trees.dart +++ b/packages/devtools_app/lib/src/shared/primitives/trees.dart @@ -2,26 +2,21 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, retain methods for completeness of TreeNode model class. + import 'dart:collection'; import 'dart:math'; -/// Non-UI specific tree code should live in this file. -/// -/// This file does not have direct dependencies on dart:html and therefore -/// allows for testing to be done on the VM. - -// TODO(kenz): look into consolidating logic between this file and -// ui/trees.dart, which houses generic tree types vs the base classes in this -// file. +// Non-UI specific tree code should live in this file. +// +// This file does not have direct dependencies on dart:html and therefore +// allows for testing to be done on the VM. abstract class TreeNode> { T? parent; final children = []; - // TODO(jacobr) should impact depth. - bool indentChildren = true; - /// Index in `parent.children`. int index = -1; diff --git a/packages/devtools_app/lib/src/shared/primitives/utils.dart b/packages/devtools_app/lib/src/shared/primitives/utils.dart index a1073e882a6..e03a8ff8116 100644 --- a/packages/devtools_app/lib/src/shared/primitives/utils.dart +++ b/packages/devtools_app/lib/src/shared/primitives/utils.dart @@ -6,17 +6,15 @@ // libraries in this package. import 'dart:async'; -import 'dart:collection'; import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; import 'package:intl/intl.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'ansi_utils.dart'; import 'byte_utils.dart'; @@ -122,20 +120,6 @@ String durationText( return '$durationStr${includeUnit ? ' ${unit.display}' : ''}'; } -T? nullSafeMin(T? a, T? b) { - if (a == null || b == null) { - return a ?? b; - } - return min(a, b); -} - -T? nullSafeMax(T? a, T? b) { - if (a == null || b == null) { - return a ?? b; - } - return max(a, b); -} - double logBase({required int x, required int base}) { return log(x) / log(base); } @@ -145,20 +129,6 @@ int log2(num x) => logBase(x: x.floor(), base: 2).floor(); int roundToNearestPow10(int x) => pow(10, logBase(x: x, base: 10).ceil()).floor(); -void executeWithDelay( - Duration delay, - void Function() callback, { - bool executeNow = false, -}) { - if (executeNow || delay.inMilliseconds <= 0) { - callback(); - } else { - Timer(delay, () { - callback(); - }); - } -} - Future delayToReleaseUiThread({int micros = 0}) async { // Even with a delay of 0 microseconds, awaiting this delay is enough to free // the UI thread to update the UI. @@ -201,39 +171,6 @@ Future timeout(Future operation, int timeoutMillis) => ), ]); -String longestFittingSubstring( - String originalText, - num maxWidth, - List asciiMeasurements, - num Function(int value) slowMeasureFallback, -) { - if (originalText.isEmpty) return originalText; - - final runes = originalText.runes.toList(); - - num currentWidth = 0; - - int i = 0; - while (i < runes.length) { - final rune = runes[i]; - final charWidth = rune < 128 - ? asciiMeasurements[rune] - : slowMeasureFallback(rune); - if (currentWidth + charWidth > maxWidth) { - break; - } - // [currentWidth] is approximate due to ignoring kerning. - currentWidth += charWidth; - i++; - } - - return originalText.substring(0, i); -} - -/// Whether a given code unit is a letter (A-Z or a-z). -bool isLetter(int codeUnit) => - (codeUnit >= 65 && codeUnit <= 90) || (codeUnit >= 97 && codeUnit <= 122); - /// Returns a simplified version of a StackFrame name. /// /// Given an input such as @@ -256,87 +193,6 @@ String getSimpleStackFrameName(String? name) { return newName.split('&').last; } -/// Return a Stream that fires events whenever any of the three given parameter -/// streams fire. -Stream combineStreams(Stream a, Stream b, Stream c) { - late StreamController controller; - - StreamSubscription? asub; - StreamSubscription? bsub; - StreamSubscription? csub; - - controller = StreamController( - onListen: () { - asub = a.listen(controller.add); - bsub = b.listen(controller.add); - csub = c.listen(controller.add); - }, - onCancel: () { - unawaited(asub?.cancel()); - unawaited(bsub?.cancel()); - unawaited(csub?.cancel()); - }, - ); - - return controller.stream; -} - -class Property { - Property(this._value); - - final _changeController = StreamController.broadcast(); - T _value; - - T get value => _value; - - set value(T newValue) { - if (newValue != _value) { - _value = newValue; - _changeController.add(newValue); - } - } - - Stream get onValueChange => _changeController.stream; -} - -/// Batch up calls to the given closure. Repeated calls to [invoke] will -/// overwrite the closure to be called. We'll delay at least [minDelay] before -/// calling the closure, but will not delay more than [maxDelay]. -class DelayedTimer { - DelayedTimer(this.minDelay, this.maxDelay); - - final Duration minDelay; - final Duration maxDelay; - - VoidCallback? _closure; - - Timer? _minTimer; - Timer? _maxTimer; - - void invoke(VoidCallback closure) { - _closure = closure; - - if (_minTimer == null) { - _minTimer = Timer(minDelay, _fire); - _maxTimer = Timer(maxDelay, _fire); - } else { - _minTimer!.cancel(); - _minTimer = Timer(minDelay, _fire); - } - } - - void _fire() { - _minTimer?.cancel(); - _minTimer = null; - - _maxTimer?.cancel(); - _maxTimer = null; - - _closure!(); - _closure = null; - } -} - /// These utilities are ported from the Flutter IntelliJ plugin. /// /// With Dart's terser JSON support, these methods don't provide much value so @@ -355,10 +211,9 @@ class JsonUtils { } } -/// Add pretty print for a JSON payload. -extension JsonMap on Map { - String prettyPrint() => const JsonEncoder.withIndent(' ').convert(this); -} +/// Returns a pretty-printed representation of a JSON payload. +String prettyPrintJson(Object? json) => + const JsonEncoder.withIndent(' ').convert(json); typedef RateLimiterCallback = Future Function(); @@ -432,7 +287,7 @@ class RateLimiter { _pendingRequest = Completer(); await callback(); } finally { - _pendingRequest!.complete(null); + _pendingRequest!.complete(); } } @@ -574,80 +429,6 @@ double safeDivide( return ifNotFinite; } -/// A change reporter that can be listened to. -/// -/// Unlike [ChangeNotifier], [Reporter] stores listeners in a set. This allows -/// O(1) addition/removal of listeners and O(N) listener dispatch. -/// -/// For small N (~ <20), [ChangeNotifier] implementations can be faster because -/// array access is more efficient than set access. Use [Reporter] instead in -/// cases where N is larger. -/// -/// When disposing, any object with a registered listener should `unregister` -/// itself. -/// -/// Only the object that created this reporter should call [notify]. -class Reporter implements Listenable { - final _listeners = {}; - - /// Adds [callback] to this reporter. - /// - /// If [callback] is already registered to this reporter, nothing will happen. - @override - void addListener(VoidCallback callback) { - _listeners.add(callback); - } - - /// Removes the listener [callback]. - @override - void removeListener(VoidCallback callback) { - _listeners.remove(callback); - } - - /// Whether or not this object has any listeners registered. - bool get hasListeners => _listeners.isNotEmpty; - - /// Notifies all listeners of a change. - /// - /// This does not do any change propagation, so if - /// a notification callback leads to a change in the listeners, - /// only the original listeners will be called. - void notify() { - for (final callback in _listeners.toList()) { - callback(); - } - } - - @override - String toString() => describeIdentity(this); -} - -/// A [Reporter] that notifies when its [value] changes. -/// -/// Similar to [ValueNotifier], but with the same performance -/// benefits as [Reporter]. -/// -/// For small N (~ <20), [ValueNotifier] implementations can be faster because -/// array access is more efficient than set access. Use [ValueReporter] instead -/// in cases where N is larger. -class ValueReporter extends Reporter implements ValueListenable { - ValueReporter(this._value); - - @override - T get value => _value; - - set value(T value) { - if (_value == value) return; - _value = value; - notify(); - } - - T _value; - - @override - String toString() => '${describeIdentity(this)}($value)'; -} - String toStringAsFixed(double num, [int fractionDigit = 1]) { return num.toStringAsFixed(fractionDigit); } @@ -664,8 +445,10 @@ extension SafeListOperations on List { } extension SafeAccess on Iterable { + // ignore: unused-code, false positive "this getter is used but never assigned a value" T? get safeFirst => isNotEmpty ? first : null; + // ignore: unused-code, false positive "this getter is used but never assigned a value" T? get safeLast => isNotEmpty ? last : null; } @@ -707,8 +490,6 @@ class LineRange { int get size => end - begin + 1; - bool contains(num target) => target >= begin && target <= end; - @override String toString() => 'LineRange($begin, $end)'; @@ -763,104 +544,72 @@ class DebugTimingLogger { } } -/// Compute a simple moving average. -/// [averagePeriod] default period is 50 units collected. -/// [ratio] default percentage is 50% range is 0..1 -class MovingAverage { - MovingAverage({ - this.averagePeriod = 50, - this.ratio = 0.5, - List? newDataSet, - }) : assert(ratio >= 0 && ratio <= 1, 'Value ratio $ratio is not 0 to 1.') { - if (newDataSet != null) { - var initialDataSet = newDataSet; - final count = newDataSet.length; - if (count > averagePeriod) { - initialDataSet = newDataSet.sublist(count - averagePeriod); - } - - dataSet.addAll(initialDataSet); - for (final value in dataSet) { - averageSum += value; - } - } - } - - final dataSet = Queue(); - - /// Total collected items in the X axis (time) used to compute moving average. - /// Default 100 periods for memory profiler 1-2 periods / seconds. - final int averagePeriod; - - /// Ratio of first item in dataSet when comparing to last - mean - /// e.g., 2 is 50% (dataSet.first ~/ ratioSpike). - final double ratio; - - /// Sum of total heap used and external heap for unitPeriod. - int averageSum = 0; - - /// Reset moving average data. - void clear() { - dataSet.clear(); - averageSum = 0; - } - - // Update the sum to get a new mean. - void add(int value) { - averageSum += value; - dataSet.add(value); - - // Update dataSet of values to not exceede the period of the moving average - // to compute the normal mean. - if (dataSet.length > averagePeriod) { - averageSum -= dataSet.removeFirst(); - } - } - - double get mean { - final periodRange = min(averagePeriod, dataSet.length); - return periodRange > 0 ? averageSum / periodRange : 0; - } - - /// If the last - mean > ratioSpike% of first value in period we're spiking. - bool hasSpike() { - final first = dataSet.safeFirst ?? 0; - final last = dataSet.safeLast ?? 0; - - return last - mean > (first * ratio); - } - - /// If the mean @ ratioSpike% > last value in period we're dipping. - bool isDipping() { - final last = dataSet.safeLast ?? 0; - return (mean * ratio) > last; - } -} - -List textSpansFromAnsi(String input, TextStyle defaultStyle) { +List textSpansFromAnsi( + String input, + TextStyle defaultStyle, { + Color? backgroundColor, +}) { final parser = AnsiParser(input); return parser.parse().map((entry) { final styled = entry.bold || entry.fgColor != null || entry.bgColor != null; return TextSpan( text: entry.text, style: styled - ? TextStyle( - color: ansiToColor(entry.fgColor), + ? defaultStyle.copyWith( + color: ansiToColor(entry.fgColor, background: backgroundColor), backgroundColor: ansiToColor(entry.bgColor), - fontWeight: entry.bold ? FontWeight.bold : FontWeight.normal, + fontWeight: entry.bold ? FontWeight.bold : null, ) : defaultStyle, ); }).toList(); } -Color? ansiToColor(List? ansiInput) { +/// Returns [input] with all ANSI escape codes removed. +String stripAnsi(String input) { + if (!input.contains('\x1B')) return input; + return AnsiParser(input).parse().map((entry) => entry.text).join(); +} + +Color? ansiToColor(List? ansiInput, {Color? background}) { if (ansiInput == null) { return null; } assert(ansiInput.length == 3, 'Ansi color list should contain 3 elements'); - return Color.fromRGBO(ansiInput[0], ansiInput[1], ansiInput[2], 1); + final color = Color.fromRGBO(ansiInput[0], ansiInput[1], ansiInput[2], 1); + if (background == null) { + return color; + } + return _adjustForContrast(color, background); +} + +/// Returns [color] adjusted so that it remains legible against [background]. +/// +/// ANSI colors are fixed terminal colors, and some of them (e.g. ansi-black, +/// ansi-white, ansi-bright-white) are effectively invisible against the +/// corresponding light or dark themed background. When the contrast between +/// [color] and [background] is too low, blend the color toward black (on a +/// light background) or white (on a dark background) to restore legibility. +Color _adjustForContrast(Color color, Color background) { + // A 3:1 contrast ratio is sufficient for terminal-style output (WCAG AA + // large-text threshold). + const minContrastRatio = 3.0; + const contrastBlendFactor = 0.6; + + final fgLuminance = color.computeLuminance(); + final bgLuminance = background.computeLuminance(); + final contrastRatio = + (max(fgLuminance, bgLuminance) + 0.05) / + (min(fgLuminance, bgLuminance) + 0.05); + if (contrastRatio >= minContrastRatio) { + return color; + } + return Color.lerp( + color, + bgLuminance > 0.5 ? Colors.black : Colors.white, + contrastBlendFactor, + )!; } /// An extension on [LogicalKeySet] to provide user-facing names for key @@ -1024,20 +773,6 @@ extension ListExtension on List { T get second => this[1]; T get third => this[2]; - - T get fourth => this[3]; - - T get fifth => this[4]; - - List allIndicesWhere(bool Function(T element) test) { - final indices = []; - for (var i = 0; i < length; i++) { - if (test(this[i])) { - indices.add(i); - } - } - return indices; - } } extension NullableListExtension on List? { diff --git a/packages/devtools_app/lib/src/shared/server/_analytics_api.dart b/packages/devtools_app/lib/src/shared/server/_analytics_api.dart index 2fdeb72472d..3b3f9dd6455 100644 --- a/packages/devtools_app/lib/src/shared/server/_analytics_api.dart +++ b/packages/devtools_app/lib/src/shared/server/_analytics_api.dart @@ -4,78 +4,6 @@ part of 'server.dart'; -// TODO(https://github.com/flutter/devtools/issues/7083): remove these server -// endpoints when the legacy analytics are fully removed. - -/// Request DevTools property value 'firstRun' (GA dialog) stored in the file -/// '~/flutter-devtools/.devtools'. -Future isFirstRun() async { - bool firstRun = false; - if (isDevToolsServerAvailable) { - final resp = await request(apiGetDevToolsFirstRun); - if (resp?.statusCode == 200) { - firstRun = json.decode(resp!.body); - } else { - logWarning(resp, apiGetDevToolsFirstRun); - } - } - return firstRun; -} - -/// Request DevTools property value 'enabled' (GA enabled) stored in the file -/// '~/.flutter-devtools/.devtools'. -Future isAnalyticsEnabled() async { - bool enabled = false; - if (isDevToolsServerAvailable) { - final resp = await request(apiGetDevToolsEnabled); - if (resp?.statusOk ?? false) { - enabled = json.decode(resp!.body); - } else { - logWarning(resp, apiGetDevToolsEnabled); - } - } - return enabled; -} - -/// Set the DevTools property 'enabled' (GA enabled) stored in the file -/// '~/.flutter-devtools/.devtools'. -Future setAnalyticsEnabled([bool value = true]) async { - if (isDevToolsServerAvailable) { - final resp = await request( - '$apiSetDevToolsEnabled' - '?$devToolsEnabledPropertyName=$value', - ); - if (resp?.statusOk ?? false) { - assert(json.decode(resp!.body) == value); - } else { - logWarning(resp, apiSetDevToolsEnabled); - } - } -} - -// TODO(terry): Move to an API scheme similar to the VM service extension where -// '/api/devToolsEnabled' returns the value (identical VM service) and -// '/api/devToolsEnabled?value=true' sets the value. - -/// Whether GA is enabled in the Flutter store file ~\.flutter. -/// -/// A return value of false implies either GA is disabled or the Flutter Tool -/// has never been run. -Future _isFlutterGAEnabled() async { - bool enabled = false; - - if (isDevToolsServerAvailable) { - final resp = await request(apiGetFlutterGAEnabled); - if (resp?.statusOk ?? false) { - enabled = json.decode(resp!.body) as bool; - } else { - logWarning(resp, apiGetFlutterGAEnabled); - } - } - - return enabled; -} - /// Requests the Flutter client id from the Flutter store file ~\.flutter. /// /// If an empty String is returned, this means that Flutter Tool has never been @@ -85,18 +13,14 @@ Future flutterGAClientID() async { String clientId = ''; if (isDevToolsServerAvailable) { - // Test if Flutter is enabled (or if Flutter Tool ever ran) if not enabled - // is false, we don't want to be the first to create a ~/.flutter file. - if (await _isFlutterGAEnabled()) { - final resp = await request(apiGetFlutterGAClientId); - if (resp?.statusOk ?? false) { - clientId = json.decode(resp!.body); - if (clientId.isEmpty) { - _log.warning('$apiGetFlutterGAClientId is empty'); - } - } else { - logWarning(resp, apiGetFlutterGAClientId); + final resp = await request(apiGetFlutterGAClientId); + if (resp?.statusOk ?? false) { + clientId = json.decode(resp!.body); + if (clientId.isEmpty) { + _log.warning('$apiGetFlutterGAClientId is empty'); } + } else { + logWarning(resp, apiGetFlutterGAClientId); } } diff --git a/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart b/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart index 4151375a8c8..6c036cf5679 100644 --- a/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart +++ b/packages/devtools_app/lib/src/shared/server/_deep_links_api.dart @@ -76,11 +76,14 @@ Future requestIosUniversalLinkSettings( }, ); final resp = await request(uri.toString()); - if (resp?.statusOk ?? false) { - return UniversalLinkSettings.fromJson(resp!.body); - } else { - logWarning(resp, DeeplinkApi.iosUniversalLinkSettings); + if (resp != null) { + if (resp.statusOk) { + return UniversalLinkSettings.fromJson(resp.body); + } else { + logWarning(resp, DeeplinkApi.iosUniversalLinkSettings); + return UniversalLinkSettings.fromErrorJson(resp.body.toString()); + } } } - return UniversalLinkSettings.empty; + return UniversalLinkSettings.error('DevTools server is not available'); } diff --git a/packages/devtools_app/lib/src/shared/server/_extensions_api.dart b/packages/devtools_app/lib/src/shared/server/_extensions_api.dart index 5933894a6e3..290abeacd49 100644 --- a/packages/devtools_app/lib/src/shared/server/_extensions_api.dart +++ b/packages/devtools_app/lib/src/shared/server/_extensions_api.dart @@ -74,11 +74,12 @@ Future> refreshAvailableExtensions( Future extensionEnabledState({ required String devtoolsOptionsFileUri, required String extensionName, + required String extensionPackage, bool? enable, }) async { _log.fine( '${enable != null ? 'setting' : 'getting'} extensionEnabledState for ' - '$extensionName in options file ($devtoolsOptionsFileUri)', + '$extensionName (package: $extensionPackage) in options file ($devtoolsOptionsFileUri)', ); if (debugDevToolsExtensions) { return debugHandleExtensionEnabledState( @@ -92,6 +93,7 @@ Future extensionEnabledState({ queryParameters: { ExtensionsApi.devtoolsOptionsUriPropertyName: devtoolsOptionsFileUri, ExtensionsApi.extensionNamePropertyName: extensionName, + ExtensionsApi.extensionPackagePropertyName: extensionPackage, if (enable != null) ExtensionsApi.enabledStatePropertyName: enable.toString(), }, diff --git a/packages/devtools_app/lib/src/shared/server/server.dart b/packages/devtools_app/lib/src/shared/server/server.dart index 24a86c4f0cc..61aaf1ad7b7 100644 --- a/packages/devtools_app/lib/src/shared/server/server.dart +++ b/packages/devtools_app/lib/src/shared/server/server.dart @@ -13,8 +13,10 @@ import 'package:http/http.dart'; import 'package:logging/logging.dart'; import 'package:path/path.dart' as path; +import '../analytics/analytics_controller.dart'; import '../development_helpers.dart'; import '../globals.dart'; +import '../primitives/query_parameters.dart'; import '../primitives/storage.dart'; import '../primitives/utils.dart'; @@ -73,7 +75,21 @@ Uri buildDevToolsServerRequestUri(String url) { // [_debugDevToolsServerFlag] environment variable declaration was not set // using `--dart-define`. const baseUri = _debugDevToolsServerEnvironmentVariable; - return Uri.parse(path.join(baseUri, url)); + final uri = Uri.parse(path.join(baseUri, url)); + + final queryParams = DevToolsQueryParams.load(); + // Forward the parent IDE name and the client-side analytics opt-out status + // to the server, so they can be propagated to any spawned subprocesses. + // Fail-safe: default to suppressing analytics if the controller is not yet + // initialized. + final newParams = { + ...uri.queryParameters, + if (queryParams.ide != null) 'ide': queryParams.ide!, + if (!isAnalyticsControllerInitialized || !isAnalyticsEnabled) + 'suppress_analytics': 'true', + }; + + return uri.replace(queryParameters: newParams); } /// Helper to catch any server request which could fail. @@ -83,7 +99,10 @@ Future request(String url) async { Response? response; try { - response = await post(buildDevToolsServerRequestUri(url)); + response = await post( + buildDevToolsServerRequestUri(url), + headers: const {devToolsRequestHeader: '1'}, + ); } catch (_) {} return response; @@ -149,8 +168,6 @@ void logWarning(Response? response, String apiType) { extension ResponseExtension on Response { bool get statusOk => statusCode == 200; - bool get statusForbidden => statusCode == 403; - bool get statusError => statusCode == 500; } class ServerConnectionStorage implements Storage { diff --git a/packages/devtools_app/lib/src/shared/server/server_api_client.dart b/packages/devtools_app/lib/src/shared/server/server_api_client.dart index 8406975ee00..9e646ab89c2 100644 --- a/packages/devtools_app/lib/src/shared/server/server_api_client.dart +++ b/packages/devtools_app/lib/src/shared/server/server_api_client.dart @@ -117,7 +117,7 @@ class DevToolsServerConnection { 'jsonrpc': '2.0', 'id': id, 'method': method, - if (params != null) 'params': params, + 'params': ?params, }); final completer = Completer(); _completers[id] = completer; diff --git a/packages/devtools_app/lib/src/shared/table/_table_row.dart b/packages/devtools_app/lib/src/shared/table/_table_row.dart index afecd702bc7..8f2bfbe4485 100644 --- a/packages/devtools_app/lib/src/shared/table/_table_row.dart +++ b/packages/devtools_app/lib/src/shared/table/_table_row.dart @@ -324,8 +324,8 @@ class _TableRowState extends State> final searchAwareBackgroundColor = isSearchMatch ? Color.alphaBlend( isActiveSearchMatch - ? activeSearchMatchColorOpaque - : searchMatchColorOpaque, + ? activeSearchMatchColorTranslucent + : searchMatchColorTranslucent, backgroundColor, ) : backgroundColor; diff --git a/packages/devtools_app/lib/src/shared/table/_tree_table.dart b/packages/devtools_app/lib/src/shared/table/_tree_table.dart index 1880f50a34a..8630bfb596b 100644 --- a/packages/devtools_app/lib/src/shared/table/_tree_table.dart +++ b/packages/devtools_app/lib/src/shared/table/_tree_table.dart @@ -121,7 +121,10 @@ class TreeTable> extends StatefulWidget { class TreeTableState> extends State> with TickerProviderStateMixin, AutoDisposeMixin { + @visibleForTesting FocusNode? get focusNode => _focusNode; + + // ignore: dispose-fields, false positive. Disposed via autoDisposeFocusNode. late FocusNode _focusNode; TreeTableController get tableController => _tableController!; @@ -165,6 +168,7 @@ class TreeTableState> extends State> @override void dispose() { + _tableController?.dispose(); _tableController = null; super.dispose(); } diff --git a/packages/devtools_app/lib/src/shared/table/table.dart b/packages/devtools_app/lib/src/shared/table/table.dart index 9e01cf2283b..c4cb624cc52 100644 --- a/packages/devtools_app/lib/src/shared/table/table.dart +++ b/packages/devtools_app/lib/src/shared/table/table.dart @@ -8,8 +8,8 @@ import 'dart:math'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' hide TableRow; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart' hide TableRow; import '../primitives/collapsible_mixin.dart'; import '../primitives/extent_delegate_list.dart'; @@ -115,9 +115,12 @@ class DevToolsTableState extends State> with AutoDisposeMixin { static const _resizingDebounceDuration = Duration(milliseconds: 200); - late ScrollController scrollController; + @visibleForTesting + late final ScrollController verticalScrollController; + + late final ScrollController _horizontalScrollbarController; + late ScrollController pinnedScrollController; - late ScrollController _horizontalScrollbarController; late List _data; @@ -142,13 +145,13 @@ class DevToolsTableState extends State> ? widget.tableController.tableUiState.scrollOffset : 0.0; widget.tableController.initScrollController(initialScrollOffset); - scrollController = widget.tableController.verticalScrollController!; + verticalScrollController = widget.tableController.verticalScrollController!; _horizontalScrollbarController = widget.tableController.horizontalScrollController!; if (widget.startScrolledAtBottom) { WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(scrollController.autoScrollToBottom(jump: true)); + unawaited(verticalScrollController.autoScrollToBottom(jump: true)); }); } @@ -208,7 +211,7 @@ class DevToolsTableState extends State> final newPos = selectedDisplayRow * defaultRowHeight; - maybeScrollToPosition(scrollController, newPos); + maybeScrollToPosition(verticalScrollController, newPos); } }); }); @@ -232,13 +235,15 @@ class DevToolsTableState extends State> final index = _data.indexOf(activeSearch); if (index == -1) return; - + final verticalScrollController = this.verticalScrollController; final y = index * defaultRowHeight; final indexInView = - y > scrollController.offset && - y < scrollController.offset + scrollController.position.extentInside; + y > verticalScrollController.offset && + y < + verticalScrollController.offset + + verticalScrollController.position.extentInside; if (!indexInView) { - await scrollController.animateTo( + await verticalScrollController.animateTo( index * defaultRowHeight, duration: defaultDuration, curve: defaultCurve, @@ -257,6 +262,7 @@ class DevToolsTableState extends State> @override void dispose() { pinnedScrollController.dispose(); + _resizingDebouncer.dispose(); super.dispose(); } @@ -388,10 +394,13 @@ class DevToolsTableState extends State> @override Widget build(BuildContext context) { + final verticalScrollController = this.verticalScrollController; + // If we're at the end already, scroll to expose the new content. if (widget.autoScrollContent) { - if (scrollController.hasClients && scrollController.atScrollBottom) { - unawaited(scrollController.autoScrollToBottom()); + if (verticalScrollController.hasClients && + verticalScrollController.atScrollBottom) { + unawaited(verticalScrollController.autoScrollToBottom()); } } @@ -405,8 +414,9 @@ class DevToolsTableState extends State> final tableUiState = widget.tableController.tableUiState; final sortColumn = widget.tableController.columns[tableUiState.sortColumnIndex]; - if (widget.preserveVerticalScrollPosition && scrollController.hasClients) { - scrollController.jumpTo(tableUiState.scrollOffset); + if (widget.preserveVerticalScrollPosition && + verticalScrollController.hasClients) { + verticalScrollController.jumpTo(tableUiState.scrollOffset); } return LayoutBuilder( @@ -478,7 +488,7 @@ class DevToolsTableState extends State> Expanded( child: Scrollbar( thumbVisibility: true, - controller: scrollController, + controller: verticalScrollController, child: GestureDetector( behavior: HitTestBehavior.translucent, onTapDown: (a) => widget.focusNode?.requestFocus(), @@ -488,13 +498,13 @@ class DevToolsTableState extends State> widget.handleKeyEvent != null ? widget.handleKeyEvent!( event, - scrollController, + verticalScrollController, constraints, ) : KeyEventResult.ignored, focusNode: widget.focusNode, child: ListView.builder( - controller: scrollController, + controller: verticalScrollController, itemCount: _dataRowCount( constraints, showColumnGroupHeader, diff --git a/packages/devtools_app/lib/src/shared/table/table_data.dart b/packages/devtools_app/lib/src/shared/table/table_data.dart index 93baa7a9b2c..be4b891f15b 100644 --- a/packages/devtools_app/lib/src/shared/table/table_data.dart +++ b/packages/devtools_app/lib/src/shared/table/table_data.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/byte_utils.dart'; import '../primitives/trees.dart'; diff --git a/packages/devtools_app/lib/src/shared/ui/colors.dart b/packages/devtools_app/lib/src/shared/ui/colors.dart index 2232fd6d282..adb1829655d 100644 --- a/packages/devtools_app/lib/src/shared/ui/colors.dart +++ b/packages/devtools_app/lib/src/shared/ui/colors.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'utils.dart'; @@ -12,19 +12,8 @@ import 'utils.dart'; const mainUiColor = Color(0xFF88B1DE); const mainRasterColor = Color(0xFF2C5DAA); -const mainUnknownColor = Color(0xFFCAB8E9); const mainAsyncColor = Color(0xFF80CBC4); -final uiColorPalette = [ - const ColorPair(background: mainUiColor, foreground: Colors.black), - const ColorPair(background: Color(0xFF6793CD), foreground: Colors.black), -]; - -final rasterColorPalette = [ - const ColorPair(background: mainRasterColor, foreground: Colors.white), - const ColorPair(background: Color(0xFF386EB6), foreground: Colors.white), -]; - // TODO(jacobr): merge this with other color scheme extensions. extension FlameChartColorScheme on ColorScheme { Color get selectedFrameBackgroundColor => @@ -34,18 +23,6 @@ extension FlameChartColorScheme on ColorScheme { isLight ? Colors.black54 : const Color.fromARGB(255, 200, 200, 200); } -// Teal 200, 400 - see https://material.io/design/color/#tools-for-picking-colors. -const asyncColorPalette = [ - ColorPair(background: mainAsyncColor, foreground: Colors.black), - ColorPair(background: Color(0xFF26A69A), foreground: Colors.black), -]; - -// Slight variation on Deep purple 100, 300 - see https://material.io/design/color/#tools-for-picking-colors. -const unknownColorPalette = [ - ColorPair(background: mainUnknownColor, foreground: Colors.black), - ColorPair(background: Color(0xFF9D84CA), foreground: Colors.black), -]; - const uiJankColor = Color(0xFFF5846B); const rasterJankColor = Color(0xFFC3595A); const shaderCompilationColor = ColorPair( @@ -111,7 +88,6 @@ extension DevToolsColorExtension on ColorScheme { Color get grey => const Color.fromARGB(255, 128, 128, 128); Color get green => isLight ? const Color(0xFF006B5F) : const Color(0xFF54DBC8); - Color get overlayShadowColor => const Color.fromRGBO(0, 0, 0, 0.5); // Deep link header is slightly darker than the default surface color. See // comment at: https://github.com/flutter/devtools/pull/7443/files#r1538361768 Color get deeplinkTableHeaderColor => surface.darken(); diff --git a/packages/devtools_app/lib/src/shared/ui/common_widgets.dart b/packages/devtools_app/lib/src/shared/ui/common_widgets.dart index dda95c8ad5d..2d34c956a7e 100644 --- a/packages/devtools_app/lib/src/shared/ui/common_widgets.dart +++ b/packages/devtools_app/lib/src/shared/ui/common_widgets.dart @@ -9,8 +9,8 @@ import 'dart:math'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../analytics/analytics.dart' as ga; @@ -21,7 +21,6 @@ import '../diagnostics/dart_object_node.dart'; import '../diagnostics/tree_builder.dart'; import '../framework/routing.dart'; import '../globals.dart'; -import '../primitives/flutter_widgets/linked_scroll_controller.dart'; import '../primitives/utils.dart'; import '../utils/utils.dart'; @@ -33,36 +32,6 @@ void setAssumedMonospaceCharacterWidth(double width) { _assumedMonospaceCharacterWidth = width; } -/// Creates a semibold version of [style]. -TextStyle semibold(TextStyle style) => - style.copyWith(fontWeight: FontWeight.w600); - -/// Creates a version of [style] that uses the primary color of [context]. -/// -/// When the app is in dark mode, it instead uses the accent color. -TextStyle primaryColor(TextStyle style, BuildContext context) { - final theme = Theme.of(context); - return style.copyWith( - color: (theme.brightness == Brightness.light) - ? theme.primaryColor - : theme.colorScheme.secondary, - fontWeight: FontWeight.w400, - ); -} - -/// Creates a version of [style] that uses the lighter primary color of -/// [context]. -/// -/// In dark mode, the light primary color still has enough contrast to be -/// visible, so we continue to use it. -TextStyle primaryColorLight(TextStyle style, BuildContext context) { - final theme = Theme.of(context); - return style.copyWith( - color: theme.primaryColorLight, - fontWeight: FontWeight.w300, - ); -} - class GaDevToolsButton extends DevToolsButton { GaDevToolsButton({ super.key, @@ -257,52 +226,6 @@ class StartStopRecordingButton extends GaDevToolsButton { final bool recording; } -/// Button to start recording data. -/// -/// * `recording`: Whether recording is in progress. -/// * `minScreenWidthForText`: The minimum width the button can be before the text is -/// omitted. -/// * `labelOverride`: Optional alternative text to use for the button. -/// * `onPressed`: The callback to be called upon pressing the button. -class RecordButton extends GaDevToolsButton { - RecordButton({ - super.key, - required bool recording, - required VoidCallback onPressed, - required super.gaScreen, - required super.gaSelection, - super.minScreenWidthForText, - super.tooltip = 'Start recording', - String? labelOverride, - }) : super( - onPressed: recording ? null : onPressed, - icon: Icons.fiber_manual_record, - label: labelOverride ?? 'Record', - ); -} - -/// Button to stop recording data. -/// -/// * `recording`: Whether recording is in progress. -/// * `minScreenWidthForText`: The minimum width the button can be before the text is -/// omitted. -/// * `onPressed`: The callback to be called upon pressing the button. -class StopRecordingButton extends GaDevToolsButton { - StopRecordingButton({ - super.key, - required bool recording, - required VoidCallback? onPressed, - required super.gaScreen, - required super.gaSelection, - super.minScreenWidthForText, - super.tooltip = 'Stop recording', - }) : super( - onPressed: !recording ? null : onPressed, - icon: Icons.stop, - label: 'Stop', - ); -} - class SettingsOutlinedButton extends GaDevToolsButton { SettingsOutlinedButton({ super.key, @@ -474,38 +397,6 @@ class DevToolsSwitch extends StatelessWidget { } } -class ProcessingInfo extends StatelessWidget { - const ProcessingInfo({ - super.key, - required this.progressValue, - required this.processedObject, - }); - - final double? progressValue; - - final String processedObject; - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'Processing $processedObject', - style: Theme.of(context).regularTextStyle, - ), - const SizedBox(height: defaultSpacing), - SizedBox( - width: 200.0, - child: LinearProgressIndicator(value: progressValue), - ), - ], - ), - ); - } -} - /// Common button for exiting offline mode. class ExitOfflineButton extends StatelessWidget { const ExitOfflineButton({required this.gaScreen, super.key}); @@ -741,33 +632,6 @@ abstract class ScaffoldAction extends StatelessWidget { } } -/// Button to open related information / documentation. -/// -/// [tooltip] specifies the hover text for the button. -/// [link] is the link that should be opened when the button is clicked. -class InformationButton extends StatelessWidget { - const InformationButton({ - super.key, - required this.tooltip, - required this.link, - }); - - final String tooltip; - - final String link; - - @override - Widget build(BuildContext context) { - return Tooltip( - message: tooltip, - child: IconButton( - icon: const Icon(Icons.help_outline), - onPressed: () async => await launchUrlWithErrorHandling(link), - ), - ); - } -} - class OutlinedRowGroup extends StatelessWidget { const OutlinedRowGroup({super.key, required this.children, this.borderColor}); @@ -812,29 +676,6 @@ class ThickDivider extends StatelessWidget { } } -BoxDecoration roundedBorderDecoration(BuildContext context) => BoxDecoration( - border: Border.all(color: Theme.of(context).focusColor), - borderRadius: defaultBorderRadius, -); - -class LeftBorder extends StatelessWidget { - const LeftBorder({super.key, this.child}); - - final Widget? child; - - @override - Widget build(BuildContext context) { - final leftBorder = Border( - left: BorderSide(color: Theme.of(context).focusColor), - ); - - return Container( - decoration: BoxDecoration(border: leftBorder), - child: child, - ); - } -} - /// The golden ratio. /// /// Makes for nice-looking rectangles. @@ -882,39 +723,6 @@ class CenteredCircularProgressIndicator extends StatelessWidget { } } -/// An extension on [LinkedScrollControllerGroup] to facilitate having the -/// scrolling widgets auto scroll to the bottom on new content. -/// -/// This extension serves the same function as the [ScrollControllerAutoScroll] -/// extension above, but we need to implement these methods again as an -/// extension on [LinkedScrollControllerGroup] because individual -/// [ScrollController]s are intentionally inaccessible from -/// [LinkedScrollControllerGroup]. -extension LinkedScrollControllerGroupExtension on LinkedScrollControllerGroup { - bool get atScrollBottom { - final pos = position; - return pos.pixels == pos.maxScrollExtent; - } - - /// Scroll the content to the bottom using the app's default animation - /// duration and curve.. - void autoScrollToBottom() async { - await animateTo( - position.maxScrollExtent, - duration: rapidDuration, - curve: defaultCurve, - ); - - // Scroll again if we've received new content in the interim. - if (hasAttachedControllers) { - final pos = position; - if (pos.pixels != pos.maxScrollExtent) { - jumpTo(pos.maxScrollExtent); - } - } - } -} - class BreadcrumbNavigator extends StatelessWidget { const BreadcrumbNavigator.builder({ super.key, @@ -1101,7 +909,6 @@ class JsonViewer extends StatefulWidget { class _JsonViewerState extends State { late Future _initializeTree; late DartObjectNode variable; - static const jsonEncoder = JsonEncoder.withIndent(' '); Future _buildAndExpand(DartObjectNode variable) async { // Build the root node @@ -1237,14 +1044,14 @@ class _JsonViewerState extends State { String copyJsonData(DartObjectNode copiedVariable) { // Check if service connection is active if (serviceConnection.serviceManager.connectedState.value.connected) { - return jsonEncoder.convert( + return prettyPrintJson( serviceConnection.serviceManager.service!.fakeServiceCache .instanceToJson(copiedVariable.value as Instance), ); } // Directly convert object to JSON if not connected - return const JsonEncoder.withIndent(' ').convert(copiedVariable.value); + return prettyPrintJson(copiedVariable.value); } } @@ -1689,117 +1496,6 @@ class CheckboxSetting extends StatelessWidget { } } -/// A widget that represents a switch setting and automatically updates for -/// value changes to [notifier]. -class SwitchSetting extends StatelessWidget { - const SwitchSetting({ - super.key, - required this.notifier, - required this.title, - this.tooltip, - this.onChanged, - this.gaScreen, - this.gaItem, - this.activeColor, - this.inactiveColor, - this.minScreenWidthForText, - }); - - final ValueNotifier notifier; - - final String title; - - final String? tooltip; - - final void Function(bool newValue)? onChanged; - - final String? gaScreen; - - final String? gaItem; - - final Color? activeColor; - - final Color? inactiveColor; - - final double? minScreenWidthForText; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return maybeWrapWithTooltip( - tooltip: tooltip, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isScreenWiderThan(context, minScreenWidthForText)) - Flexible( - child: RichText( - overflow: TextOverflow.visible, - maxLines: 3, - text: TextSpan(text: title, style: theme.regularTextStyle), - ), - ), - NotifierSwitch( - padding: const EdgeInsets.only(left: borderPadding), - activeColor: activeColor, - inactiveColor: inactiveColor, - notifier: notifier, - onChanged: (bool? value) { - final gaScreen = this.gaScreen; - final gaItem = this.gaItem; - if (gaScreen != null && gaItem != null) { - ga.select(gaScreen, '$gaItem-$value'); - } - final onChanged = this.onChanged; - if (value != null) { - onChanged?.call(value); - } - }, - ), - ], - ), - ); - } -} - -class PubWarningText extends StatelessWidget { - const PubWarningText({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isFlutterApp = - serviceConnection.serviceManager.connectedApp!.isFlutterAppNow == true; - final sdkName = isFlutterApp ? 'Flutter' : 'Dart'; - final minSdkVersion = isFlutterApp ? '2.8.0' : '2.15.0'; - return SelectionArea( - child: Text.rich( - TextSpan( - text: - 'Warning: you should no longer be launching DevTools from' - ' pub.\n\n', - style: theme.subtleTextStyle.copyWith(color: theme.colorScheme.error), - children: [ - TextSpan( - text: - 'DevTools version 2.8.0 will be the last version to ' - 'be shipped on pub. As of $sdkName\nversion >= ' - '$minSdkVersion, DevTools should be launched by running ' - 'the ', - style: theme.subtleTextStyle, - ), - TextSpan( - text: '`dart devtools`', - style: theme.subtleFixedFontStyle, - ), - TextSpan(text: '\ncommand.', style: theme.subtleTextStyle), - ], - ), - ), - ); - } -} - class BlinkingIcon extends StatefulWidget { const BlinkingIcon({ super.key, diff --git a/packages/devtools_app/lib/src/shared/ui/dialogs.dart b/packages/devtools_app/lib/src/shared/ui/dialogs.dart index d87c7ff18d2..c14ab480e65 100644 --- a/packages/devtools_app/lib/src/shared/ui/dialogs.dart +++ b/packages/devtools_app/lib/src/shared/ui/dialogs.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../config_specific/copy_to_clipboard/copy_to_clipboard.dart'; import '../globals.dart'; diff --git a/packages/devtools_app/lib/src/shared/ui/drop_down_button.dart b/packages/devtools_app/lib/src/shared/ui/drop_down_button.dart index 1fcfd0bea65..ececdc4b3b4 100644 --- a/packages/devtools_app/lib/src/shared/ui/drop_down_button.dart +++ b/packages/devtools_app/lib/src/shared/ui/drop_down_button.dart @@ -4,7 +4,7 @@ import 'package:collection/collection.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../analytics/analytics.dart' as ga; diff --git a/packages/devtools_app/lib/src/shared/ui/editable_list.dart b/packages/devtools_app/lib/src/shared/ui/editable_list.dart index b72f5866cbf..324b6ffb68a 100644 --- a/packages/devtools_app/lib/src/shared/ui/editable_list.dart +++ b/packages/devtools_app/lib/src/shared/ui/editable_list.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../config_specific/copy_to_clipboard/copy_to_clipboard.dart'; import 'common_widgets.dart'; @@ -80,6 +80,7 @@ class _EditableListState extends State { @override void dispose() { textFieldController.dispose(); + textFieldFocusNode.dispose(); super.dispose(); } diff --git a/packages/devtools_app/lib/src/shared/ui/file_import.dart b/packages/devtools_app/lib/src/shared/ui/file_import.dart index 3a425f6cc6d..a0b9135455f 100644 --- a/packages/devtools_app/lib/src/shared/ui/file_import.dart +++ b/packages/devtools_app/lib/src/shared/ui/file_import.dart @@ -6,7 +6,7 @@ import 'dart:convert'; import 'package:devtools_app_shared/ui.dart'; import 'package:file_selector/file_selector.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import '../analytics/analytics.dart' as ga; diff --git a/packages/devtools_app/lib/src/shared/ui/filter.dart b/packages/devtools_app/lib/src/shared/ui/filter.dart index f4a517fd248..8ac8d2f3a4e 100644 --- a/packages/devtools_app/lib/src/shared/ui/filter.dart +++ b/packages/devtools_app/lib/src/shared/ui/filter.dart @@ -8,7 +8,7 @@ import 'dart:convert'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/utils.dart'; import 'common_widgets.dart'; @@ -169,6 +169,7 @@ mixin FilterControllerMixin on DisposableController queryFilterArgs.forEach((key, value) => value.reset()); } + @visibleForTesting void resetFilter() { _resetToDefaultFilter(); _activeFilter.value = Filter( diff --git a/packages/devtools_app/lib/src/shared/ui/history_viewport.dart b/packages/devtools_app/lib/src/shared/ui/history_viewport.dart index aad6cf45fde..1caf1db5163 100644 --- a/packages/devtools_app/lib/src/shared/ui/history_viewport.dart +++ b/packages/devtools_app/lib/src/shared/ui/history_viewport.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../screens/debugger/common.dart'; import '../primitives/history_manager.dart'; diff --git a/packages/devtools_app/lib/src/shared/ui/hover.dart b/packages/devtools_app/lib/src/shared/ui/hover.dart index cc38795ac6f..4a3a7753591 100644 --- a/packages/devtools_app/lib/src/shared/ui/hover.dart +++ b/packages/devtools_app/lib/src/shared/ui/hover.dart @@ -8,13 +8,27 @@ import 'dart:math' as math; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'common_widgets.dart'; import 'utils.dart'; -const _maxHoverCardHeight = 250.0; +const _maxHoverCardContentHeight = 250.0; +const _hoverCardTitleHeight = 24.0; +const _hoverCardDividerHeight = 16.0; + +/// Returns the total maximum height of the [HoverCard] including content, +/// title (if present), divider, vertical padding, and borders. +double _totalMaxHoverCardHeight({ + required bool hasTitle, + double maxCardContentHeight = _maxHoverCardContentHeight, +}) { + return maxCardContentHeight + + (hasTitle ? _hoverCardTitleHeight + _hoverCardDividerHeight : 0.0) + + (denseSpacing * 2) + + (hoverCardBorderSize * 2); +} TextStyle get _hoverTitleTextStyle => fixBlurryText( const TextStyle( @@ -103,7 +117,8 @@ int _hoverIndexFor(double dx, TextSpan line) { const _hoverYOffset = 10.0; /// Minimum distance from the side of screen to show tooltip -const _hoverMargin = 16.0; +@visibleForTesting +const hoverMargin = 16.0; /// Defines how a [HoverCardTooltip] is positioned enum HoverCardPosition { @@ -142,9 +157,9 @@ class HoverCard { required Offset position, required HoverCardController hoverCardController, String? title, - double? maxCardHeight, + double? maxCardContentHeight, }) { - maxCardHeight ??= _maxHoverCardHeight; + maxCardContentHeight ??= _maxHoverCardContentHeight; final overlayState = Overlay.of(context); final theme = Theme.of(context); final colorScheme = theme.colorScheme; @@ -179,6 +194,7 @@ class HoverCard { if (title != null) ...[ SizedBox( width: width, + height: _hoverCardTitleHeight, child: Text( title, overflow: TextOverflow.ellipsis, @@ -186,11 +202,16 @@ class HoverCard { textAlign: TextAlign.center, ), ), - Divider(color: theme.focusColor), + Divider( + color: theme.focusColor, + height: _hoverCardDividerHeight, + ), ], SingleChildScrollView( child: Container( - constraints: BoxConstraints(maxHeight: maxCardHeight!), + constraints: BoxConstraints( + maxHeight: maxCardContentHeight!, + ), child: contents, ), ), @@ -215,14 +236,41 @@ class HoverCard { context: context, contents: contents, width: width, - position: Offset( - math.max(0, event.position.dx - (width / 2.0)), - event.position.dy + _hoverYOffset, + position: _calculateCardPositionFromPointerEvent( + context, + event, + width, + title: title, ), title: title, hoverCardController: hoverCardController, ); + static Offset _calculateCardPositionFromPointerEvent( + BuildContext context, + PointerHoverEvent event, + double width, { + String? title, + }) { + final overlayBox = + Overlay.of(context).context.findRenderObject() as RenderBox; + final overlaySize = overlayBox.size; + final localPosition = overlayBox.globalToLocal(event.position); + + final maxX = math.max(hoverMargin, overlaySize.width - hoverMargin - width); + final x = (localPosition.dx - (width / 2.0)).clamp(hoverMargin, maxX); + + final maxY = math.max( + hoverMargin, + overlaySize.height - + hoverMargin - + _totalMaxHoverCardHeight(hasTitle: title != null), + ); + final y = (localPosition.dy + _hoverYOffset).clamp(hoverMargin, maxY); + + return Offset(x, y); + } + late OverlayEntry _overlayEntry; bool _isRemoved = false; @@ -331,7 +379,8 @@ class HoverCardTooltip extends StatefulWidget { }) : asyncGenerateHoverCardData = null, asyncTimeout = null; - static const _hoverDelay = Duration(milliseconds: 500); + @visibleForTesting + static const hoverDelay = Duration(milliseconds: 500); static const defaultHoverWidth = 450.0; /// Whether the tooltip is currently enabled. @@ -370,7 +419,7 @@ class _HoverCardTooltipState extends State { void _onHoverExit() { _showTimer?.cancel(); - _removeTimer = Timer(HoverCardTooltip._hoverDelay, () { + _removeTimer = Timer(HoverCardTooltip.hoverDelay, () { if (_currentHoverCard != null) { _hoverCardController.maybeRemoveHoverCard(_currentHoverCard!); } @@ -398,7 +447,7 @@ class _HoverCardTooltipState extends State { final generateHoverCardData = widget.generateHoverCardData; final asyncTimeout = widget.asyncTimeout; - _showTimer = Timer(HoverCardTooltip._hoverDelay, () { + _showTimer = Timer(HoverCardTooltip.hoverDelay, () { if (asyncGenerateHoverCardData != null) { assert(generateHoverCardData == null); _showAsyncHoverCard( @@ -510,7 +559,10 @@ class _HoverCardTooltipState extends State { title: hoverCardData.title, contents: hoverCardData.contents, width: hoverCardData.width, - position: _calculateTooltipPosition(hoverCardData.width), + position: _calculateCardPosition( + hoverCardData.width, + title: hoverCardData.title, + ), hoverCardController: _hoverCardController, ), ); @@ -531,19 +583,27 @@ class _HoverCardTooltipState extends State { unawaited( hoverCardDataFuture.then( (data) => completer.complete(data), - onError: (_) => completer.complete(null), + onError: (_) => completer.complete(), ), ); return completer; } - Offset _calculateTooltipPosition(double width) { + Offset _calculateCardPosition(double width, {String? title}) { final overlayBox = Overlay.of(context).context.findRenderObject() as RenderBox; final box = context.findRenderObject() as RenderBox; - final maxX = overlayBox.size.width - _hoverMargin - width; - final maxY = overlayBox.size.height - _hoverMargin; + final maxX = math.max( + hoverMargin, + overlayBox.size.width - hoverMargin - width, + ); + final maxY = math.max( + hoverMargin, + overlayBox.size.height - + hoverMargin - + _totalMaxHoverCardHeight(hasTitle: title != null), + ); final offset = box.localToGlobal( box.size.bottomCenter(Offset.zero).translate(-width / 2, _hoverYOffset), @@ -551,8 +611,8 @@ class _HoverCardTooltipState extends State { ); return Offset( - offset.dx.clamp(_hoverMargin, maxX), - offset.dy.clamp(_hoverMargin, maxY), + offset.dx.clamp(hoverMargin, maxX), + offset.dy.clamp(hoverMargin, maxY), ); } diff --git a/packages/devtools_app/lib/src/shared/ui/icons.dart b/packages/devtools_app/lib/src/shared/ui/icons.dart index 9feaa28877e..18e58cc2ec2 100644 --- a/packages/devtools_app/lib/src/shared/ui/icons.dart +++ b/packages/devtools_app/lib/src/shared/ui/icons.dart @@ -11,45 +11,11 @@ library; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../screens/inspector/layout_explorer/ui/widgets_theme.dart'; import 'colors.dart'; -class CustomIcon extends StatelessWidget { - const CustomIcon({ - super.key, - required this.kind, - required this.text, - this.isAbstract = false, - }); - - final IconKind kind; - final String text; - final bool isAbstract; - - AssetImageIcon get baseIcon => kind.icon; - - @override - Widget build(BuildContext context) { - return SizedBox( - width: baseIcon.width, - height: baseIcon.height, - child: Stack( - alignment: AlignmentDirectional.center, - children: [ - baseIcon, - Text( - text, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 9.0, color: Color(0xFF231F20)), - ), - ], - ), - ); - } -} - /// An icon with one character class CircleIcon extends StatelessWidget { const CircleIcon({ @@ -87,23 +53,6 @@ class CircleIcon extends StatelessWidget { class CustomIconMaker { final iconCache = {}; - Widget? getCustomIcon( - String fromText, { - IconKind? kind, - bool isAbstract = false, - }) { - final theKind = kind ?? IconKind.classIcon; - if (fromText.isEmpty) { - return null; - } - - final text = fromText[0].toUpperCase(); - final mapKey = '${text}_${theKind.name}_$isAbstract'; - return iconCache.putIfAbsent(mapKey, () { - return CustomIcon(kind: theKind, text: text, isAbstract: isAbstract); - }); - } - Widget? fromWidgetName(String? name) { if (name == null) { return null; @@ -131,10 +80,6 @@ class CustomIconMaker { }); } - CustomIcon fromInfo(String name) { - return getCustomIcon(name, kind: IconKind.info) as CustomIcon; - } - bool isAlphabetic(int char) { return (char < '0'.codeUnitAt(0) || char > '9'.codeUnitAt(0)) && char != '_'.codeUnitAt(0) && @@ -142,41 +87,7 @@ class CustomIconMaker { } } -class IconKind { - const IconKind(this.name, this.icon, [AssetImageIcon? abstractIcon]) - : abstractIcon = abstractIcon ?? icon; - - static IconKind classIcon = const IconKind( - 'class', - AssetImageIcon(asset: 'icons/custom/class.png'), - AssetImageIcon(asset: 'icons/custom/class_abstract.png'), - ); - static IconKind field = const IconKind( - 'fields', - AssetImageIcon(asset: 'icons/custom/fields.png'), - ); - static IconKind interface = const IconKind( - 'interface', - AssetImageIcon(asset: 'icons/custom/interface.png'), - ); - static IconKind method = const IconKind( - 'method', - AssetImageIcon(asset: 'icons/custom/method.png'), - AssetImageIcon(asset: 'icons/custom/method_abstract.png'), - ); - static IconKind property = const IconKind( - 'property', - AssetImageIcon(asset: 'icons/custom/property.png'), - ); - static IconKind info = const IconKind( - 'info', - AssetImageIcon(asset: 'icons/custom/info.png'), - ); - - final String name; - final AssetImageIcon icon; - final AssetImageIcon abstractIcon; -} +const infoIcon = AssetImageIcon(asset: 'icons/custom/info.png'); class ColorIcon extends StatelessWidget { const ColorIcon(this.color, {super.key}); @@ -281,11 +192,4 @@ class FlutterMaterialIcons { class Octicons { static const bug = IconData(61714, fontFamily: 'Octicons'); - static const info = IconData(61778, fontFamily: 'Octicons'); - static const deviceMobile = IconData(61739, fontFamily: 'Octicons'); - static const fileZip = IconData(61757, fontFamily: 'Octicons'); - static const clippy = IconData(61724, fontFamily: 'Octicons'); - static const package = IconData(61812, fontFamily: 'Octicons'); - static const dashboard = IconData(61733, fontFamily: 'Octicons'); - static const pulse = IconData(61823, fontFamily: 'Octicons'); } diff --git a/packages/devtools_app/lib/src/shared/ui/search.dart b/packages/devtools_app/lib/src/shared/ui/search.dart index cce643767ff..7ea04674786 100644 --- a/packages/devtools_app/lib/src/shared/ui/search.dart +++ b/packages/devtools_app/lib/src/shared/ui/search.dart @@ -9,9 +9,9 @@ import 'package:async/async.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/trees.dart'; import '../primitives/utils.dart'; @@ -25,7 +25,6 @@ import 'common_widgets.dart'; /// Top 10 matches to display in auto-complete overlay. const defaultTopMatchesLimit = 10; -int topMatchesLimit = defaultTopMatchesLimit; final _log = Logger('packages/devtools_app/lib/src/shared/ui/search'); @@ -59,6 +58,7 @@ mixin SearchControllerMixin } String get search => _searchNotifier.value; + @visibleForTesting bool get isSearchInProgress => _searchInProgress.value; final _searchMatches = ValueNotifier>([]); @@ -378,12 +378,6 @@ class AutoCompleteState extends State with AutoDisposeMixin { final areaHeight = offset.dy; final maxAreaForPopup = areaHeight - tileEntryHeight; - // TODO(terry): Scrolling doesn't work so max popup height is also total - // matches to use. - topMatchesLimit = min( - defaultTopMatchesLimit, - (maxAreaForPopup / tileEntryHeight) - 1, // zero based. - ).truncate(); // Total tiles visible. final totalTiles = bottom @@ -533,7 +527,6 @@ class EditingParts { /// Parsing characters looking for valid names e.g., /// [ _ | a..z | A..Z ] [ _ | a..z | A..Z | 0..9 ]+ -const asciiSpace = 32; const ascii0 = 48; const ascii9 = 57; const asciiUnderscore = 95; diff --git a/packages/devtools_app/lib/src/shared/ui/search_highlighter.dart b/packages/devtools_app/lib/src/shared/ui/search_highlighter.dart index 6c93155edc8..23d091189da 100644 --- a/packages/devtools_app/lib/src/shared/ui/search_highlighter.dart +++ b/packages/devtools_app/lib/src/shared/ui/search_highlighter.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/utils.dart'; diff --git a/packages/devtools_app/lib/src/shared/ui/side_panel.dart b/packages/devtools_app/lib/src/shared/ui/side_panel.dart index 707c261865e..e45574e5c4c 100644 --- a/packages/devtools_app/lib/src/shared/ui/side_panel.dart +++ b/packages/devtools_app/lib/src/shared/ui/side_panel.dart @@ -8,8 +8,8 @@ import 'dart:math'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/utils.dart'; import '../utils/utils.dart'; @@ -104,7 +104,7 @@ class SidePanelViewerState extends State ); return Stack( children: [ - if (child != null) child, + ?child, SidePanel( sidePanelController: widget.controller, visibilityAnimation: visibilityAnimation, @@ -182,6 +182,17 @@ class SidePanel extends AnimatedWidget { : Expanded( child: Markdown( data: markdownData!, + styleSheet: MarkdownStyleSheet( + // [MarkdownStyleSheet.fromTheme], which supplies the + // rest of the style sheet, hard codes + // `Colors.blue.shade100` as the blockquote fill while + // taking the text color from the theme. In the dark + // theme that draws light gray text on light blue. + blockquoteDecoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: defaultBorderRadius, + ), + ), onTapLink: (text, url, title) => unawaited(launchUrlWithErrorHandling(url!)), ), diff --git a/packages/devtools_app/lib/src/shared/ui/tab.dart b/packages/devtools_app/lib/src/shared/ui/tab.dart index 6961b55d0ad..cf140cc68ef 100644 --- a/packages/devtools_app/lib/src/shared/ui/tab.dart +++ b/packages/devtools_app/lib/src/shared/ui/tab.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../analytics/analytics.dart' as ga; @@ -73,7 +73,7 @@ class DevToolsTab extends Tab { /// value. This ensures that data being refreshed, or widget tree rebuilds don't /// send spurious analytics events. class AnalyticsTabbedView extends StatefulWidget { - AnalyticsTabbedView({ + const AnalyticsTabbedView({ super.key, required this.tabs, required this.gaScreen, @@ -82,15 +82,18 @@ class AnalyticsTabbedView extends StatefulWidget { this.initialSelectedIndex, this.analyticsSessionIdentifier, this.staticSingleTab = false, - }) : trailingWidgets = List.generate( - tabs.length, - (index) => tabs[index].tab.trailing ?? const SizedBox(), - ); + this.trailingWidgets = const [], + }); final List tabs; final String gaScreen; + /// Shared trailing widgets shown on the right side of the tab bar for every + /// tab. + /// + /// Per-tab trailings from `DevToolsTab.trailing` are shown separately for the + /// currently selected tab. final List trailingWidgets; final int? initialSelectedIndex; @@ -212,7 +215,13 @@ class _AnalyticsTabbedViewState extends State tabController: _tabController, staticSingleTab: widget.staticSingleTab, ), - widget.trailingWidgets[_currentTabControllerIndex], + Row( + mainAxisSize: MainAxisSize.min, + children: [ + ?widget.tabs[_currentTabControllerIndex].tab.trailing, + ...widget.trailingWidgets, + ], + ), ], ), ), diff --git a/packages/devtools_app/lib/src/shared/ui/tree_view.dart b/packages/devtools_app/lib/src/shared/ui/tree_view.dart index 222ad382212..603ae3da3ab 100644 --- a/packages/devtools_app/lib/src/shared/ui/tree_view.dart +++ b/packages/devtools_app/lib/src/shared/ui/tree_view.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' hide Stack; +import 'package:material_ui/material_ui.dart' hide Stack; import '../primitives/collapsible_mixin.dart'; import '../primitives/trees.dart'; diff --git a/packages/devtools_app/lib/src/shared/ui/utils.dart b/packages/devtools_app/lib/src/shared/ui/utils.dart index bf82e99d997..1857d5b62bf 100644 --- a/packages/devtools_app/lib/src/shared/ui/utils.dart +++ b/packages/devtools_app/lib/src/shared/ui/utils.dart @@ -7,8 +7,8 @@ import 'dart:math'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:material_ui/material_ui.dart'; import '../primitives/utils.dart'; @@ -283,6 +283,7 @@ final class ScreenSize { ScreenSize._({required this.height, required this.width}); + // ignore: unused-code, fundamental part of this class. final MediaSize height; final MediaSize width; diff --git a/packages/devtools_app/lib/src/shared/ui/vm_flag_widgets.dart b/packages/devtools_app/lib/src/shared/ui/vm_flag_widgets.dart index 187ebff1f04..3cb9e094084 100644 --- a/packages/devtools_app/lib/src/shared/ui/vm_flag_widgets.dart +++ b/packages/devtools_app/lib/src/shared/ui/vm_flag_widgets.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../screens/profiler/cpu_profile_service.dart'; @@ -175,6 +175,12 @@ class _VMFlagsDialogState extends State with AutoDisposeMixin { }); } + @override + void dispose() { + filterController.dispose(); + super.dispose(); + } + void _updateFromController() { flags = (serviceConnection.vmFlagManager.flags.value?.flags ?? []) .map((flag) => _DialogFlag(flag)) diff --git a/packages/devtools_app/lib/src/shared/utils/profiler_utils.dart b/packages/devtools_app/lib/src/shared/utils/profiler_utils.dart index da457651dd9..329de586a51 100644 --- a/packages/devtools_app/lib/src/shared/utils/profiler_utils.dart +++ b/packages/devtools_app/lib/src/shared/utils/profiler_utils.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../screens/debugger/codeview_controller.dart'; import '../../screens/debugger/debugger_screen.dart'; diff --git a/packages/devtools_app/lib/src/shared/utils/utils.dart b/packages/devtools_app/lib/src/shared/utils/utils.dart index 5cc3e8af941..9dfbb9fc946 100644 --- a/packages/devtools_app/lib/src/shared/utils/utils.dart +++ b/packages/devtools_app/lib/src/shared/utils/utils.dart @@ -12,10 +12,11 @@ import 'dart:math'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; // ignore: avoid-importing-entrypoint-exports, required to access version. @@ -31,10 +32,7 @@ final _log = Logger('lib/src/shared/utils'); /// Logging to debug console only in debug runs. void debugLogger(String message) { - assert(() { - _log.info(message); - return true; - }()); + if (kDebugMode) _log.info(message); } /// Whether DevTools is using a dark theme. @@ -110,7 +108,7 @@ List generateDeviceDescription( title: 'Connected app type', description: connectedApp.display, ), - if (vmServiceConnection != null) vmServiceConnection, + ?vmServiceConnection, ConnectionDescription(title: 'Dart Version', description: version), if (flutterVersion != null && !flutterVersion.unknown) ...{ ConnectionDescription( @@ -129,7 +127,7 @@ List generateDeviceDescription( /// This method should be public, because it is used by g3 specific code. List issueLinkDetails() { - final ide = DevToolsQueryParams.load().ide; + final ide = DevToolsQueryParams.load().ideDisplayName; final issueDescriptionItems = [ '<-- Please describe your problem here. Be sure to include repro steps. -->', '___', // This will create a separator in the rendered markdown. @@ -288,6 +286,7 @@ Future launchUrlWithErrorHandling(String url) async { /// /// This class may be helpful when sets of work need to be done over a list, /// while avoiding blocking the UI thread. +// ignore: unused-code, this is a helpful utility, use it where we need it next. class InterruptableChunkWorker { InterruptableChunkWorker({ int chunkSize = _defaultChunkSize, diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart index 0a67722ffdd..8ac63da31ab 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/not_connected_overlay.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/globals.dart'; import '../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart index bad9c16e6aa..977798d8481 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart @@ -47,8 +47,6 @@ class PropertyEditorController extends DisposableController _editableWidgetData; final _editableWidgetData = ValueNotifier(null); - List get allProperties => - _editableWidgetData.value?.properties ?? []; String? get widgetName => _editableWidgetData.value?.name; String? get widgetDocumentation => _editableWidgetData.value?.documentation; String? get fileUri => _editableWidgetData.value?.fileUri; @@ -123,6 +121,7 @@ class PropertyEditorController extends DisposableController @override void dispose() { _requestDebouncer.dispose(); + _editableWidgetData.dispose(); super.dispose(); } diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart index 037d46088bb..7ca89f56c5f 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart @@ -6,8 +6,8 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/analytics.dart' as ga; import '../../../shared/analytics/constants.dart' as gac; @@ -206,6 +206,7 @@ class _TextInputState extends State<_TextInput> with _PropertyInputMixin<_TextInput, T>, AutoDisposeMixin { static const _paddingDiffComparedToDropdown = 1.0; + // ignore: dispose-fields, false positive. Disposed via autoDisposeFocusNode. late final FocusNode _focusNode; late final TextEditingController _controller; @@ -224,6 +225,13 @@ class _TextInputState extends State<_TextInput> // Edit property when clicking or tabbing away from input. await _editProperty(); }); + autoDisposeFocusNode(_focusNode); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); } @override diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_messages.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_messages.dart index e3cf4530149..102e7461d59 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_messages.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_messages.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/ui/colors.dart'; diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart index 65586529671..e96b008a756 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../framework/scaffold/report_feedback_button.dart'; import '../../../shared/analytics/analytics.dart' as ga; @@ -62,11 +62,9 @@ class _PropertyEditorPanelState extends State { future: _editor, builder: (context, snapshot) => switch ((snapshot.connectionState, snapshot.data)) { - (ConnectionState.done, final editor?) => - _PropertyEditorConnectedPanel( - editor, - controller: _propertyEditorController!, - ), + (ConnectionState.done, final _) => _PropertyEditorConnectedPanel( + controller: _propertyEditorController!, + ), _ => const CenteredCircularProgressIndicator(), }, ), @@ -76,9 +74,8 @@ class _PropertyEditorPanelState extends State { /// The property editor panel shown once we know an editor is available. class _PropertyEditorConnectedPanel extends StatefulWidget { - const _PropertyEditorConnectedPanel(this.editor, {required this.controller}); + const _PropertyEditorConnectedPanel({required this.controller}); - final EditorClient editor; final PropertyEditorController controller; @override @@ -143,7 +140,9 @@ class _PropertyEditorFooter extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final documentationLink = _documentationLink(); + final documentationLink = propertyEditorDocumentationLink( + DevToolsQueryParams.load(), + ); return Container( decoration: BoxDecoration( color: colorScheme.surface, @@ -168,15 +167,17 @@ class _PropertyEditorFooter extends StatelessWidget { ), ); } +} - String? _documentationLink() { - final queryParams = DevToolsQueryParams.load(); - final isEmbedded = queryParams.embedMode.embedded; - if (!isEmbedded) return null; - const uriPrefix = 'https://docs.flutter.dev/tools/'; - const uriHash = '#property-editor'; - return '$uriPrefix${queryParams.ide == 'VSCode' ? 'vs-code' : 'android-studio'}$uriHash'; - } +/// Returns the Property Editor documentation link for the IDE described by +/// [queryParams], or `null` if DevTools is not embedded in an IDE. +@visibleForTesting +String? propertyEditorDocumentationLink(DevToolsQueryParams queryParams) { + final isEmbedded = queryParams.embedMode.embedded; + if (!isEmbedded) return null; + const uriPrefix = 'https://docs.flutter.dev/tools/'; + const uriHash = '#property-editor'; + return '$uriPrefix${queryParams.isVsCode ? 'vs-code' : 'android-studio'}$uriHash'; } class _DocsLink extends StatelessWidget { diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_refactors.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_refactors.dart index 9fa2ff7a59a..0ac520283d6 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_refactors.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_refactors.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/analytics.dart' as ga; import '../../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart index ed46149c773..10e5ae6f027 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/primitives/utils.dart'; import '../../../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_desktop.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_desktop.dart deleted file mode 100644 index 0f5176205aa..00000000000 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_desktop.dart +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright 2025 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -void reloadIframe() { - // No-op for desktop platforms. -} diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_web.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_web.dart deleted file mode 100644 index cf386af6e58..00000000000 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/utils/_utils_web.dart +++ /dev/null @@ -1,9 +0,0 @@ -// Copyright 2025 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:web/web.dart'; - -void reloadIframe() { - window.location.reload(); -} diff --git a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart index 7cb0792c671..e22836779c0 100644 --- a/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart +++ b/packages/devtools_app/lib/src/standalone_ui/standalone_screen.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/service.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../shared/globals.dart'; import '../shared/ui/common_widgets.dart'; diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/debug_sessions.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/debug_sessions.dart index 033255a5789..7ec71d85125 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/debug_sessions.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/debug_sessions.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; @@ -17,7 +17,6 @@ class DebugSessions extends StatelessWidget { const DebugSessions({ required this.editor, required this.sessions, - required this.devices, super.key, }); @@ -26,9 +25,6 @@ class DebugSessions extends StatelessWidget { /// A map of debug session IDs to their debug sessions. final Map sessions; - /// A map of device IDs to their devices. - final Map devices; - @override Widget build(BuildContext context) { return Column( diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/devices.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/devices.dart index 6718b5ab4e6..645d30e7db4 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/devices.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/devices.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/analytics/constants.dart' as gac; diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/devtools_view.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/devtools_view.dart index 87a8fd58469..6b9b230097b 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/devtools_view.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/devtools_view.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/analytics.dart' as ga; import '../../../shared/analytics/constants.dart' as gac; @@ -118,7 +118,6 @@ class SidebarDevToolsScreens extends StatelessWidget { (s) => createDevToolsScreenRow( dataLeft: _buttonDataFromScreen(s), dataRight: null, - editor: editor, singleColumn: singleColumn, hasDebugSessions: debugSessions.isNotEmpty, onPressed: (data) => openDevToolsScreen( @@ -140,7 +139,6 @@ class SidebarDevToolsScreens extends StatelessWidget { createDevToolsScreenRow( dataLeft: _buttonDataFromScreen(first), dataRight: second != null ? _buttonDataFromScreen(second) : null, - editor: editor, singleColumn: singleColumn, hasDebugSessions: debugSessions.isNotEmpty, onPressed: (data) => openDevToolsScreen( @@ -174,10 +172,6 @@ class SidebarDevToolsScreens extends StatelessWidget { ScreenMetaData.home || ScreenMetaData.debugger || ScreenMetaData.vmTools || - // This screen will be removed from the first party DevTools screens soon. - // If the user depends on package:provider, the provider extension should - // show up in the DevTools extensions list instead. - ScreenMetaData.provider || ScreenMetaData.simple => false, _ => true, }; diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/extensions_view.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/extensions_view.dart index 5de8f6b83bf..27598a6287e 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/extensions_view.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/extensions_view.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../extensions/extension_screen.dart'; import '../../../shared/analytics/analytics.dart' as ga; @@ -107,7 +107,6 @@ class _SidebarDevToolsExtensionsState extends State // because we do not have control over how long the names // will be and we want to avoid ugly text wrapping. singleColumn: true, - editor: widget.editor, hasDebugSessions: widget.debugSessions.isNotEmpty, onPressed: (data) { ga.select( diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/shared.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/shared.dart index 10ec203df28..c97ec1532f5 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/shared.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/devtools/shared.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../shared/analytics/analytics.dart' as ga; import '../../../shared/analytics/constants.dart' as gac; @@ -25,7 +25,6 @@ TableRow createDevToolsScreenRow({ required DevToolsButtonData? dataRight, required bool singleColumn, required bool hasDebugSessions, - required EditorClient editor, required void Function(DevToolsButtonData data) onPressed, }) { assert( @@ -35,7 +34,6 @@ TableRow createDevToolsScreenRow({ final cellRight = dataRight != null ? _DevToolsScreenButton( data: dataRight, - editor: editor, hasDebugSessions: hasDebugSessions, onPressed: onPressed, ) @@ -44,7 +42,6 @@ TableRow createDevToolsScreenRow({ children: [ _DevToolsScreenButton( data: dataLeft, - editor: editor, hasDebugSessions: hasDebugSessions, onPressed: onPressed, ), @@ -56,13 +53,11 @@ TableRow createDevToolsScreenRow({ class _DevToolsScreenButton extends StatelessWidget { const _DevToolsScreenButton({ required this.data, - required this.editor, required this.hasDebugSessions, required this.onPressed, }); final DevToolsButtonData data; - final EditorClient editor; final bool hasDebugSessions; final void Function(DevToolsButtonData data) onPressed; diff --git a/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart b/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart index a540ca799ee..ddac8714b56 100644 --- a/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart +++ b/packages/devtools_app/lib/src/standalone_ui/vs_code/flutter_panel.dart @@ -7,7 +7,7 @@ import 'dart:async'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../../shared/analytics/analytics.dart' as ga; import '../../shared/editor/api_classes.dart'; @@ -174,11 +174,7 @@ class _EditorConnectedPanelState extends State<_EditorConnectedPanel> child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DebugSessions( - editor: widget.editor, - sessions: debugSessions, - devices: devices, - ), + DebugSessions(editor: widget.editor, sessions: debugSessions), const SizedBox(height: defaultSpacing), if (widget.editor.supportsSelectDevice) ...[ Devices( diff --git a/packages/devtools_app/pubspec.yaml b/packages/devtools_app/pubspec.yaml index c85b949dd01..c454bfeb8f2 100644 --- a/packages/devtools_app/pubspec.yaml +++ b/packages/devtools_app/pubspec.yaml @@ -7,21 +7,21 @@ publish_to: none # Note: this version should only be updated by running the 'dt update-version' # command that updates the version here and in 'devtools.dart'. -version: 2.57.0-dev.0 +version: 2.63.0-dev.0 repository: https://github.com/flutter/devtools/tree/master/packages/devtools_app environment: - sdk: ">=3.8.0-265.0.dev <4.0.0" - flutter: ">=3.32.0-1.0.pre.73" + sdk: ^3.11.0 + flutter: ^3.41.0 resolution: workspace dependencies: async: ^2.0.0 collection: ^1.15.0 + cupertino_ui: ^1.1.1 dap: ^1.1.0 - dart_service_protocol_shared: ^0.0.3 dds_service_extensions: ^2.0.2 devtools_app_shared: devtools_extensions: @@ -37,37 +37,40 @@ dependencies: sdk: flutter http: ^1.1.0 image: ^4.7.2 - intl: ^0.19.0 + intl: ^0.20.0 json_rpc_2: ^3.0.2 logging: ^1.1.1 + material_ui: ^1.4.0 meta: ^1.9.1 mime: ^2.0.0 path: ^1.8.0 + # ignore: dependencies, used to load the Perfetto UI compiled assets. perfetto_ui_compiled: path: ../../third_party/packages/perfetto_ui_compiled pointer_interceptor: ^0.10.1+1 provider: ^6.0.2 source_map_stack_trace: ^2.1.2 source_maps: ^0.10.12 + # ignore: dependencies, used in a conditional import. sse: ^4.1.2 stack_trace: ^1.12.0 string_scanner: ^1.4.0 - unified_analytics: ^7.0.0 - vm_service: ^15.0.2 + unified_analytics: ^8.4.0 + vm_service: ^15.3.0 vm_service_protos: ^2.0.0 vm_snapshot_analysis: ^0.7.6 web: ^1.0.0 - web_socket_channel: ^3.0.0 dev_dependencies: args: ^2.4.2 benchmark_harness: ^2.3.1 - build_runner: ^2.5.4 + dart_service_protocol_shared: ^0.0.3 devtools_test: path: ../devtools_test fake_async: ^1.3.1 flutter_driver: sdk: flutter + flutter_lints: ^6.0.0 flutter_test: sdk: flutter integration_test: @@ -78,7 +81,7 @@ dev_dependencies: test: ^1.21.0 web_benchmarks: ^4.1.1 webdriver: ^3.1.0 - webkit_inspection_protocol: ">=0.5.0 <2.0.0" + web_socket_channel: ^3.0.0 flutter: uses-material-design: true diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md index 6893cc7d4f4..1aadb2c50a5 100644 --- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md +++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md @@ -6,16 +6,33 @@ found in the LICENSE file or at https://developers.google.com/open-source/licens This is a draft for future release notes that are going to land on [the Flutter website](https://docs.flutter.dev/tools/devtools/release-notes). -# DevTools 2.57.0 release notes +# DevTools 2.63.0 release notes -The 2.57.0 release of the Dart and Flutter DevTools +The 2.63.0 release of the Dart and Flutter DevTools includes the following changes among other general improvements. To learn more about DevTools, check out the -[DevTools overview](https://docs.flutter.dev/tools/devtools). +[DevTools overview](/tools/devtools). ## General updates -- TODO: Remove this section if there are not any updates. +* Reused source maps across error reports instead of fetching and parsing them + repeatedly. [#9910](https://github.com/flutter/devtools/issues/9910) + +* Added guidance to run "flutter attach" when a Flutter debug app cannot + evaluate expressions and a debug-only screen is unavailable. + [#4091](https://github.com/flutter/devtools/issues/4091) + +TODO: Remove this section if there are not any updates. + +* Improved the readability of ANSI-colored text in the console and logging +screens: colors that are hard to see against the current light or dark theme, +such as black or white, are now adjusted to remain legible. +[#10060](https://github.com/flutter/devtools/pull/10060) +* Validated incoming DevTools server requests by requiring `POST` (with + `X-DevTools-Request`) or `OPTIONS` methods and validating `Host`, `Origin`, + and `Sec-Fetch-Site` headers to protect against cross-site request forgery + (CSRF), DNS rebinding, and cross-origin attacks. + [#10082](https://github.com/flutter/devtools/pull/10082) ## Inspector updates @@ -23,7 +40,14 @@ TODO: Remove this section if there are not any updates. ## Performance updates -TODO: Remove this section if there are not any updates. +* Fixed the Frame Analysis tab showing "No timeline event analysis data + available for this frame" when a frame is selected before its timeline events + have been fetched from the VM. + [#10050](https://github.com/flutter/devtools/pull/10050) +* Fixed a bug where data exported from the Performance screen could be + missing the most recent timeline events. - + [#10055](https://github.com/flutter/devtools/pull/10055), + [#9731](https://github.com/flutter/devtools/issues/9731) ## CPU profiler updates @@ -39,16 +63,28 @@ TODO: Remove this section if there are not any updates. ## Network profiler updates -- Added a filter setting to hide HTTP-profiler socket data. [#9698](https://github.com/flutter/devtools/pull/9698) +* Fixed an issue where the Network screen's toolbar controls and footer status + bar would overflow and overlap on narrow screens. On compact widths, the + video tutorial link and the embedded DevTools actions moved into the + More actions menu in the footer. + [#10004](https://github.com/flutter/devtools/issues/10004) ## Logging updates -- Added support for searching within the log details view (raw text mode). [#9712](https://github.com/flutter/devtools/pull/9712) - ![Search in log details](images/log_details_search.png "Searching within the log details view") +* Corrected time units and cumulative nature of GC events. + [#9890](https://github.com/flutter/devtools/pull/9890) +* Fixed a bug where long `dart:developer` log messages lost their structured + Details view. - + [#10011](https://github.com/flutter/devtools/pull/10011), +* Fixed ANSI-colored log messages rendering with black text and a larger font, + and removed raw ANSI escape codes from the Details view. - + [#10028](https://github.com/flutter/devtools/pull/10028) +TODO: Remove this section if there are not any updates. ## App size tool updates -- Added documentation links and improved handling for null files. [#9689](https://github.com/flutter/devtools/pull/9689) +* Fixed treemap tooltip text color across light and dark theme + [#10035](https://github.com/flutter/devtools/pull/10035) ## Deep links tool updates @@ -60,7 +96,19 @@ TODO: Remove this section if there are not any updates. ## DevTools extension updates -TODO: Remove this section if there are not any updates. +* Validated DevTools extension filesystem access by rejecting non-local + `file://` URIs (such as UNC paths), invalid `buildLocation` paths, and broken + or out-of-bounds symbolic links to prevent extensions from accessing arbitrary + files outside the package root or build directory. + [#10083](https://github.com/flutter/devtools/pull/10083) +* Fixed a race condition when running DevTools from the CLI or standalone so + the connected app's package root is registered with the Dart Tooling Daemon + before available DevTools extensions are refreshed. + [#10086](https://github.com/flutter/devtools/pull/10086) +* Confined DevTools extension options file access to currently served + extensions, rejected symlinked `devtools_options.yaml` files, and prevented + creating missing parent directories. + [#10088](https://github.com/flutter/devtools/pull/10088) ## Advanced developer mode updates @@ -69,4 +117,4 @@ TODO: Remove this section if there are not any updates. ## Full commit history To find a complete list of changes in this release, check out the -[DevTools git log](https://github.com/flutter/devtools/tree/v2.57.0). +[DevTools git log](https://github.com/flutter/devtools/tree/v2.63.0). diff --git a/packages/devtools_app/release_notes/images/log_details_search.png b/packages/devtools_app/release_notes/images/log_details_search.png deleted file mode 100644 index a99d6e35a6c..00000000000 Binary files a/packages/devtools_app/release_notes/images/log_details_search.png and /dev/null differ diff --git a/packages/devtools_app/test/extensions/extension_screen_test.dart b/packages/devtools_app/test/extensions/extension_screen_test.dart index 306a0dc16c7..036c5985fa0 100644 --- a/packages/devtools_app/test/extensions/extension_screen_test.dart +++ b/packages/devtools_app/test/extensions/extension_screen_test.dart @@ -12,8 +12,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_extensions.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { const windowSize = Size(2000.0, 2000.0); @@ -79,34 +79,76 @@ void main() { await tester.pumpWidget(wrap(Builder(builder: fooScreen.build))); expect(find.byType(ExtensionView), findsOneWidget); expect(find.byType(EmbeddedExtensionHeader), findsOneWidget); - expect(find.richTextContaining('package:foo extension'), findsOneWidget); + expect( + find.descendant( + of: find.byType(EmbeddedExtensionHeader), + matching: find.richTextContaining('package:foo extension'), + ), + findsOneWidget, + ); expect(find.richTextContaining('(v1.0.0)'), findsOneWidget); expect(find.richTextContaining('Report an issue'), findsOneWidget); expect(_extensionContextMenuFinder, findsNothing); expect(find.byType(EnableExtensionPrompt), findsOneWidget); + expect( + find.descendant( + of: find.byType(EnableExtensionPrompt), + matching: find.richTextContaining( + 'The package:foo extension has not been enabled', + ), + ), + findsOneWidget, + ); expect(find.byType(EmbeddedExtensionView), findsNothing); await tester.pumpWidget(wrap(Builder(builder: barScreen.build))); expect(find.byType(ExtensionView), findsOneWidget); expect(find.byType(EmbeddedExtensionHeader), findsOneWidget); - expect(find.richTextContaining('package:bar extension'), findsOneWidget); + expect( + find.descendant( + of: find.byType(EmbeddedExtensionHeader), + matching: find.richTextContaining('package:bar extension'), + ), + findsOneWidget, + ); expect(find.richTextContaining('(v2.0.0)'), findsOneWidget); expect(find.richTextContaining('Report an issue'), findsOneWidget); expect(_extensionContextMenuFinder, findsNothing); expect(find.byType(EnableExtensionPrompt), findsOneWidget); + expect( + find.descendant( + of: find.byType(EnableExtensionPrompt), + matching: find.richTextContaining( + 'The package:bar extension has not been enabled', + ), + ), + findsOneWidget, + ); expect(find.byType(EmbeddedExtensionView), findsNothing); await tester.pumpWidget(wrap(Builder(builder: providerScreen.build))); expect(find.byType(ExtensionView), findsOneWidget); expect(find.byType(EmbeddedExtensionHeader), findsOneWidget); expect( - find.richTextContaining('package:provider extension'), + find.descendant( + of: find.byType(EmbeddedExtensionHeader), + matching: find.richTextContaining('package:provider extension'), + ), findsOneWidget, ); expect(find.richTextContaining('(v3.0.0)'), findsOneWidget); expect(find.richTextContaining('Report an issue'), findsOneWidget); expect(_extensionContextMenuFinder, findsNothing); expect(find.byType(EnableExtensionPrompt), findsOneWidget); + expect( + find.descendant( + of: find.byType(EnableExtensionPrompt), + matching: find.richTextContaining( + 'The package:provider extension has not been enabled', + ), + ), + findsOneWidget, + ); expect(find.byType(EmbeddedExtensionView), findsNothing); }); @@ -141,11 +183,26 @@ void main() { await tester.pumpWidget(wrap(Builder(builder: fooScreen.build))); expect(find.byType(ExtensionView), findsOneWidget); expect(find.byType(EmbeddedExtensionHeader), findsOneWidget); - expect(find.richTextContaining('package:foo extension'), findsOneWidget); + expect( + find.descendant( + of: find.byType(EmbeddedExtensionHeader), + matching: find.richTextContaining('package:foo extension'), + ), + findsOneWidget, + ); expect(find.richTextContaining('(v1.0.0)'), findsOneWidget); expect(find.richTextContaining('Report an issue'), findsOneWidget); expect(_extensionContextMenuFinder, findsNothing); expect(find.byType(EnableExtensionPrompt), findsOneWidget); + expect( + find.descendant( + of: find.byType(EnableExtensionPrompt), + matching: find.richTextContaining( + 'The package:foo extension has not been enabled', + ), + ), + findsOneWidget, + ); expect(find.byType(EmbeddedExtensionView), findsNothing); }); diff --git a/packages/devtools_app/test/extensions/extension_service_helpers_test.dart b/packages/devtools_app/test/extensions/extension_service_helpers_test.dart index 52f99d9d310..fbf0129b6f4 100644 --- a/packages/devtools_app/test/extensions/extension_service_helpers_test.dart +++ b/packages/devtools_app/test/extensions/extension_service_helpers_test.dart @@ -23,6 +23,7 @@ void main() { // Returns 'b' when 'a' has parsing errors. var a = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.packageNameKey: 'bar', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: 'this-will-not-parse', DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, @@ -35,6 +36,7 @@ void main() { }); var b = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.packageNameKey: 'bar', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: '2.1.0', DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, @@ -50,6 +52,7 @@ void main() { // Returns 'a' when 'b' has parsing errors. a = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.packageNameKey: 'bar', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: '2.1.0', DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, @@ -62,6 +65,7 @@ void main() { }); b = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.packageNameKey: 'bar', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: 'this-will-not-parse', DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, @@ -76,6 +80,7 @@ void main() { // Returns 'a' when both 'a' and 'b' have parsing errors. a = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.packageNameKey: 'bar', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: 'this-will-not-parse', DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, @@ -88,6 +93,7 @@ void main() { }); b = DevToolsExtensionConfig.parse({ DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.packageNameKey: 'bar', DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', DevToolsExtensionConfig.versionKey: 'this-will-not-parse', DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, @@ -101,4 +107,94 @@ void main() { expect(takeLatestExtension(a, b), a); }); }); + + group('deduplicateExtensionsAndTakeLatest', () { + test('deduplicates matching packageName', () { + final ignored = {}; + final ext1 = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.packageNameKey: 'provider', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '1.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, + DevToolsExtensionConfig.requiresConnectionKey: 'false', + DevToolsExtensionConfig.extensionAssetsPathKey: '/path/to/provider_1', + DevToolsExtensionConfig.devtoolsOptionsUriKey: + 'file:///path/to/options', + DevToolsExtensionConfig.isPubliclyHostedKey: 'false', + DevToolsExtensionConfig.detectedFromStaticContextKey: 'true', + }); + final ext2 = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.packageNameKey: 'provider', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '2.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, + DevToolsExtensionConfig.requiresConnectionKey: 'false', + DevToolsExtensionConfig.extensionAssetsPathKey: '/path/to/provider_2', + DevToolsExtensionConfig.devtoolsOptionsUriKey: + 'file:///path/to/options', + DevToolsExtensionConfig.isPubliclyHostedKey: 'false', + DevToolsExtensionConfig.detectedFromStaticContextKey: 'true', + }); + + deduplicateExtensionsAndTakeLatest( + [ext1, ext2], + onSetIgnored: (ext, {required ignore}) { + if (ignore) { + ignored.add(ext); + } else { + ignored.remove(ext); + } + }, + ); + + expect(ignored, contains(ext1)); + expect(ignored, isNot(contains(ext2))); + }); + + test('does not deduplicate across different packageNames', () { + final ignored = {}; + final providerExt = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.packageNameKey: 'provider', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '1.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, + DevToolsExtensionConfig.requiresConnectionKey: 'false', + DevToolsExtensionConfig.extensionAssetsPathKey: '/path/to/provider', + DevToolsExtensionConfig.devtoolsOptionsUriKey: + 'file:///path/to/options', + DevToolsExtensionConfig.isPubliclyHostedKey: 'false', + DevToolsExtensionConfig.detectedFromStaticContextKey: 'true', + }); + final spoofedExt = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.packageNameKey: 'bad_pkg', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '999.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, + DevToolsExtensionConfig.requiresConnectionKey: 'false', + DevToolsExtensionConfig.extensionAssetsPathKey: '/path/to/bad_pkg', + DevToolsExtensionConfig.devtoolsOptionsUriKey: + 'file:///path/to/options', + DevToolsExtensionConfig.isPubliclyHostedKey: 'false', + DevToolsExtensionConfig.detectedFromStaticContextKey: 'true', + }); + + deduplicateExtensionsAndTakeLatest( + [providerExt, spoofedExt], + onSetIgnored: (ext, {required ignore}) { + if (ignore) { + ignored.add(ext); + } else { + ignored.remove(ext); + } + }, + ); + + // Neither should be ignored because they come from different packages. + expect(ignored, isEmpty); + }); + }); } diff --git a/packages/devtools_app/test/extensions/extension_service_test.dart b/packages/devtools_app/test/extensions/extension_service_test.dart index d8f61cb6f61..fea1cc26ace 100644 --- a/packages/devtools_app/test/extensions/extension_service_test.dart +++ b/packages/devtools_app/test/extensions/extension_service_test.dart @@ -100,6 +100,7 @@ void main() { return await server.extensionEnabledState( devtoolsOptionsFileUri: ext.devtoolsOptionsUri, extensionName: ext.name, + extensionPackage: ext.packageName, ); } diff --git a/packages/devtools_app/test/extensions/extension_settings_test.dart b/packages/devtools_app/test/extensions/extension_settings_test.dart index 9c5ac53e4dd..9e172184c88 100644 --- a/packages/devtools_app/test/extensions/extension_settings_test.dart +++ b/packages/devtools_app/test/extensions/extension_settings_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_extensions.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../test_infra/matchers/matchers.dart'; diff --git a/packages/devtools_app/test/framework/app_error_handling_test.dart b/packages/devtools_app/test/framework/app_error_handling_test.dart new file mode 100644 index 00000000000..1348a939914 --- /dev/null +++ b/packages/devtools_app/test/framework/app_error_handling_test.dart @@ -0,0 +1,37 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'package:devtools_app/src/shared/framework/app_error_handling.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:logging/logging.dart'; + +void main() { + test( + 'keeps the original stack without fetching source maps on the VM', + () async { + var requests = 0; + final client = MockClient((_) async { + requests++; + return http.Response('{}', 200); + }); + + await http.runWithClient(() async { + final logged = Logger('app_error_handling').onRecord.first; + reportError( + 'VM error', + stack: StackTrace.fromString( + '#0 original (package:devtools_app/original.dart:1:1)', + ), + ); + expect((await logged).message, contains('original.dart')); + expect(requests, 0); + }, () => client); + }, + ); +} diff --git a/packages/devtools_app/test/framework/app_error_handling_web_test.dart b/packages/devtools_app/test/framework/app_error_handling_web_test.dart new file mode 100644 index 00000000000..4ca7927f6ad --- /dev/null +++ b/packages/devtools_app/test/framework/app_error_handling_web_test.dart @@ -0,0 +1,98 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('browser') +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:devtools_app/src/shared/framework/app_error_handling.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:logging/logging.dart'; + +void main() { + test( + 'shares source maps across startup and errors, retrying failed loads', + () async { + final oldFlutterErrorHandler = FlutterError.onError; + final oldPlatformErrorHandler = PlatformDispatcher.instance.onError; + addTearDown(() { + FlutterError.onError = oldFlutterErrorHandler; + PlatformDispatcher.instance.onError = oldPlatformErrorHandler; + }); + + var requests = 0; + final successfulResponse = Completer(); + final sourceMap = jsonEncode({ + 'version': 3, + 'file': 'main.dart.${kIsWasm ? 'wasm' : 'js'}', + 'sources': ['original.dart'], + 'names': ['originalFunction'], + 'mappings': 'AAAAA', + }); + final client = MockClient((request) { + expect(request.url.path, 'main.dart.${kIsWasm ? 'wasm' : 'js'}.map'); + requests++; + switch (requests) { + case 1: + return Future.value(http.Response('Not found', 404)); + case 2: + return Future.value(http.Response('invalid source map', 200)); + case 3: + return Future.error(http.ClientException('Offline', request.url)); + default: + return successfulResponse.future; + } + }); + + Future startApp() async { + final started = Completer(); + setupErrorHandling(() async { + started.complete(); + }); + await started.future; + } + + Future report(String message) { + final logged = Logger('app_error_handling').onRecord.firstWhere( + (record) => record.message.startsWith('[source-map-test]: $message'), + ); + reportError( + message, + errorType: 'source-map-test', + stack: StackTrace.fromString( + ' at generated (main.dart.${kIsWasm ? 'wasm' : 'js'}:1:1)', + ), + ); + return logged; + } + + await http.runWithClient(() async { + await startApp(); + expect(requests, 1); + await report('invalid map'); + await report('offline map'); + expect(requests, 3); + + final first = report('first mapped error'); + final second = report('second mapped error'); + successfulResponse.complete(http.Response(sourceMap, 200)); + final records = await Future.wait([first, second]); + for (final record in records) { + expect(record.message, contains('original.dart')); + } + expect( + (await report('later mapped error')).message, + contains('original.dart'), + ); + await startApp(); + expect(requests, 4); + }, () => client); + }, + ); +} diff --git a/packages/devtools_app/test/framework/home_screen_test.dart b/packages/devtools_app/test/framework/home_screen_test.dart index 0036d28a66d..3e8ff44be9a 100644 --- a/packages/devtools_app/test/framework/home_screen_test.dart +++ b/packages/devtools_app/test/framework/home_screen_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../test_infra/flutter_test_storage.dart'; diff --git a/packages/devtools_app/test/framework/initializer_test.dart b/packages/devtools_app/test/framework/initializer_test.dart index e45135ececd..0d71e6e7f99 100644 --- a/packages/devtools_app/test/framework/initializer_test.dart +++ b/packages/devtools_app/test/framework/initializer_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/framework/notifications_test.dart b/packages/devtools_app/test/framework/notifications_test.dart index 89b684725bc..39881b5880f 100644 --- a/packages/devtools_app/test/framework/notifications_test.dart +++ b/packages/devtools_app/test/framework/notifications_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app/src/shared/globals.dart'; import 'package:devtools_app/src/shared/managers/notifications.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { group('Notifications', () { diff --git a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart index 16787447f1e..4d850847bfe 100644 --- a/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart +++ b/packages/devtools_app/test/framework/observer/disconnect_observer_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../test_infra/matchers/matchers.dart'; @@ -67,9 +67,10 @@ void main() { find.byType(ConnectToNewAppButton), showingOverlay && !isEmbedded() ? findsOneWidget : findsNothing, ); + // The Reconnect button should be shown in all modes when disconnected. expect( - find.text('Run a new debug session to reconnect.'), - showingOverlay && isEmbedded() ? findsOneWidget : findsNothing, + find.text('Reconnect'), + showingOverlay ? findsOneWidget : findsNothing, ); expect( find.text('Review recent data (offline)'), @@ -170,5 +171,58 @@ void main() { // TODO(kenz): test navigation that occurs by clicking on buttons. This will // require either modifying the test wrappers to take a set of routes or // writing an integration test for this user journey. + + group('reconnect button', () { + testWidgets('shows Reconnect button in embedded mode', ( + WidgetTester tester, + ) async { + setGlobal(IdeTheme, IdeTheme(embedMode: EmbedMode.embedOne)); + await pumpDisconnectObserver(tester); + verifyObserverState(tester, connected: true, showingOverlay: false); + + // Trigger a disconnect. + fakeServiceConnectionManager.serviceManager.setConnectedState(false); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: false, showingOverlay: true); + + // Verify the Reconnect button is present in embedded mode. + expect(find.text('Reconnect'), findsOneWidget); + // ConnectToNewAppButton should NOT be shown in embedded mode. + expect(find.byType(ConnectToNewAppButton), findsNothing); + }); + + testWidgets('shows Reconnect button in non-embedded mode', ( + WidgetTester tester, + ) async { + await pumpDisconnectObserver(tester); + verifyObserverState(tester, connected: true, showingOverlay: false); + + // Trigger a disconnect. + fakeServiceConnectionManager.serviceManager.setConnectedState(false); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: false, showingOverlay: true); + + // Both Reconnect and ConnectToNewAppButton should be shown. + expect(find.text('Reconnect'), findsOneWidget); + expect(find.byType(ConnectToNewAppButton), findsOneWidget); + }); + + testWidgets('hides overlay when reconnection succeeds', ( + WidgetTester tester, + ) async { + await pumpDisconnectObserver(tester); + verifyObserverState(tester, connected: true, showingOverlay: false); + + // Trigger a disconnect. + fakeServiceConnectionManager.serviceManager.setConnectedState(false); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: false, showingOverlay: true); + + // Simulate a successful reconnection by setting connected state. + fakeServiceConnectionManager.serviceManager.setConnectedState(true); + await tester.pumpAndSettle(); + verifyObserverState(tester, connected: true, showingOverlay: false); + }); + }); }); } diff --git a/packages/devtools_app/test/framework/scaffold/scaffold_ai_assistant_test.dart b/packages/devtools_app/test/framework/scaffold/scaffold_ai_assistant_test.dart deleted file mode 100644 index 8b3d04f0cb4..00000000000 --- a/packages/devtools_app/test/framework/scaffold/scaffold_ai_assistant_test.dart +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright 2025 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app/devtools_app.dart'; -import 'package:devtools_app/src/framework/scaffold/scaffold.dart'; -import 'package:devtools_app/src/shared/ai_assistant/widgets/ai_assistant_pane.dart'; -import 'package:devtools_app/src/shared/feature_flags.dart'; -import 'package:devtools_app/src/shared/framework/framework_controller.dart'; -import 'package:devtools_app/src/shared/managers/survey.dart'; -import 'package:devtools_app_shared/service.dart'; -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/devtools_test.dart'; -import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/mockito.dart'; - -void main() { - late MockServiceConnectionManager mockServiceConnection; - late MockServiceManager mockServiceManager; - - setUp(() { - mockServiceConnection = createMockServiceConnectionWithDefaults(); - mockServiceManager = - mockServiceConnection.serviceManager as MockServiceManager; - when( - mockServiceManager.connectedState, - ).thenReturn(ValueNotifier(const ConnectedState(false))); - final mockErrorBadgeManager = MockErrorBadgeManager(); - when( - mockServiceConnection.errorBadgeManager, - ).thenReturn(mockErrorBadgeManager); - when( - mockErrorBadgeManager.errorCountNotifier(any), - ).thenReturn(ValueNotifier(0)); - - setGlobal(ServiceConnectionManager, mockServiceConnection); - setGlobal(FrameworkController, FrameworkController()); - setGlobal(SurveyService, SurveyService()); - setGlobal(IdeTheme, IdeTheme()); - setGlobal(NotificationService, NotificationService()); - setGlobal(BannerMessagesController, BannerMessagesController()); - }); - - Future pumpScaffold( - WidgetTester tester, { - required Screen screen, - bool withConnectedApp = true, - bool withOfflineData = false, - }) async { - if (withOfflineData) { - final offlineController = MockOfflineDataController(); - offlineController.showingOfflineData.value = true; - setGlobal(OfflineDataController, offlineController); - } - - MockConnectedApp? connectedApp; - if (withConnectedApp) { - connectedApp = MockConnectedApp(); - mockConnectedApp(connectedApp); - } - when( - mockServiceManager.connectedAppInitialized, - ).thenReturn(withConnectedApp); - when(mockServiceManager.connectedApp).thenReturn(connectedApp); - - await tester.pumpWidget( - wrapWithControllers( - DevToolsScaffold(screens: [screen]), - analytics: AnalyticsController( - enabled: false, - shouldShowConsentMessage: false, - consentMessage: 'fake message', - ), - ), - ); - } - - group('AI Assistant pane', () { - testWidgets('is visible for supported screens', ( - WidgetTester tester, - ) async { - FeatureFlags.aiAssistant.setEnabledForTests(true); - - await pumpScaffold(tester, screen: const _TestScreenWithAi()); - - expect(find.byType(AiAssistantPane), findsOneWidget); - }); - - testWidgets('is not visible for unsupported screens', ( - WidgetTester tester, - ) async { - FeatureFlags.aiAssistant.setEnabledForTests(true); - - await pumpScaffold(tester, screen: const _TestScreenWithoutAi()); - - expect(find.byType(AiAssistantPane), findsNothing); - }); - - testWidgets('is not visible when app is not connected', ( - WidgetTester tester, - ) async { - FeatureFlags.aiAssistant.setEnabledForTests(true); - - await pumpScaffold( - tester, - screen: const _TestScreenWithAi(), - withConnectedApp: false, - ); - - expect(find.byType(AiAssistantPane), findsNothing); - }); - - testWidgets('is not visible when feature flag is disabled', ( - WidgetTester tester, - ) async { - FeatureFlags.aiAssistant.setEnabledForTests(false); - - await pumpScaffold(tester, screen: const _TestScreenWithAi()); - - expect(find.byType(AiAssistantPane), findsNothing); - }); - - testWidgets('is not visible when in offline mode', ( - WidgetTester tester, - ) async { - FeatureFlags.aiAssistant.setEnabledForTests(true); - - await pumpScaffold( - tester, - screen: const _TestScreenWithAi(), - withOfflineData: true, - ); - - expect(find.byType(AiAssistantPane), findsNothing); - }); - }); -} - -class _TestScreenWithAi extends Screen { - const _TestScreenWithAi() - : super('test_screen_with_ai', showFloatingDebuggerControls: false); - - @override - bool showAiAssistant() => true; - - @override - Widget buildScreenBody(BuildContext context) => const SizedBox(); -} - -class _TestScreenWithoutAi extends Screen { - const _TestScreenWithoutAi() - : super('test_screen_without_ai', showFloatingDebuggerControls: false); - - @override - Widget buildScreenBody(BuildContext context) => const SizedBox(); -} diff --git a/packages/devtools_app/test/framework/scaffold/scaffold_debugger_test.dart b/packages/devtools_app/test/framework/scaffold/scaffold_debugger_test.dart index 381e3abac6c..55aa4ce94f7 100644 --- a/packages/devtools_app/test/framework/scaffold/scaffold_debugger_test.dart +++ b/packages/devtools_app/test/framework/scaffold/scaffold_debugger_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { @@ -110,15 +110,9 @@ class _TestScreen extends Screen { const _TestScreen( this.name, this.key, { - bool showFloatingDebuggerControls = true, - Key? tabKey, - }) : super( - name, - title: name, - icon: Icons.computer, - tabKey: tabKey, - showFloatingDebuggerControls: showFloatingDebuggerControls, - ); + super.showFloatingDebuggerControls = true, + super.tabKey, + }) : super(name, title: name, icon: Icons.computer); final String name; final Key key; diff --git a/packages/devtools_app/test/framework/scaffold/scaffold_debugging_controls_test.dart b/packages/devtools_app/test/framework/scaffold/scaffold_debugging_controls_test.dart index 09692bdf952..e6d3f5fa4d0 100644 --- a/packages/devtools_app/test/framework/scaffold/scaffold_debugging_controls_test.dart +++ b/packages/devtools_app/test/framework/scaffold/scaffold_debugging_controls_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { @@ -89,18 +89,8 @@ void main() { } class _TestScreen extends Screen { - const _TestScreen( - this.name, - this.key, { - bool showFloatingDebuggerControls = true, - Key? tabKey, - }) : super( - name, - title: name, - icon: Icons.computer, - tabKey: tabKey, - showFloatingDebuggerControls: showFloatingDebuggerControls, - ); + const _TestScreen(this.name, this.key, {super.tabKey}) + : super(name, title: name, icon: Icons.computer); final String name; final Key key; diff --git a/packages/devtools_app/test/framework/scaffold/scaffold_no_app_test.dart b/packages/devtools_app/test/framework/scaffold/scaffold_no_app_test.dart index aff4cfa7905..4e0c0b243c5 100644 --- a/packages/devtools_app/test/framework/scaffold/scaffold_no_app_test.dart +++ b/packages/devtools_app/test/framework/scaffold/scaffold_no_app_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { @@ -77,18 +77,8 @@ void main() { } class _TestScreen extends Screen { - const _TestScreen( - this.name, - this.key, { - bool showFloatingDebuggerControls = true, - Key? tabKey, - }) : super( - name, - title: name, - icon: Icons.computer, - tabKey: tabKey, - showFloatingDebuggerControls: showFloatingDebuggerControls, - ); + const _TestScreen(this.name, this.key, {super.tabKey}) + : super(name, title: name, icon: Icons.computer); final String name; final Key key; diff --git a/packages/devtools_app/test/framework/scaffold/scaffold_profile_test.dart b/packages/devtools_app/test/framework/scaffold/scaffold_profile_test.dart index ea4fbc45e41..f5a1a20e0a0 100644 --- a/packages/devtools_app/test/framework/scaffold/scaffold_profile_test.dart +++ b/packages/devtools_app/test/framework/scaffold/scaffold_profile_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { @@ -84,18 +84,8 @@ void main() { } class _TestScreen extends Screen { - const _TestScreen( - this.name, - this.key, { - bool showFloatingDebuggerControls = true, - Key? tabKey, - }) : super( - name, - title: name, - icon: Icons.computer, - tabKey: tabKey, - showFloatingDebuggerControls: showFloatingDebuggerControls, - ); + const _TestScreen(this.name, this.key, {super.tabKey}) + : super(name, title: name, icon: Icons.computer); final String name; final Key key; diff --git a/packages/devtools_app/test/framework/scaffold/scaffold_test.dart b/packages/devtools_app/test/framework/scaffold/scaffold_test.dart index a888a352685..34dc608baa0 100644 --- a/packages/devtools_app/test/framework/scaffold/scaffold_test.dart +++ b/packages/devtools_app/test/framework/scaffold/scaffold_test.dart @@ -3,7 +3,10 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/extensions/extension_screen.dart'; +import 'package:devtools_app/src/extensions/extension_settings.dart'; import 'package:devtools_app/src/framework/scaffold/scaffold.dart'; +import 'package:devtools_app/src/shared/development_helpers.dart'; import 'package:devtools_app/src/shared/framework/framework_controller.dart'; import 'package:devtools_app/src/shared/managers/survey.dart'; import 'package:devtools_app/src/shared/primitives/query_parameters.dart'; @@ -13,8 +16,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { @@ -288,21 +291,74 @@ void main() { ); expect(scaffold.actions, isEmpty); }); + + test( + 'defaultActions includes ExtensionSettingsAction based on EmbedMode and screen type', + () { + setGlobal(IdeTheme, IdeTheme()); + expect( + DevToolsScaffold.defaultActions().any( + (w) => w is ExtensionSettingsAction, + ), + isTrue, + ); + // In embedMany without extension query params or screen, it is hidden: + setGlobal(IdeTheme, IdeTheme(embedMode: EmbedMode.embedMany)); + expect( + DevToolsScaffold.defaultActions().any( + (w) => w is ExtensionSettingsAction, + ), + isFalse, + ); + // In embedMany with hideAllExceptExtensions, it is visible: + expect( + DevToolsScaffold.defaultActions( + queryParams: DevToolsQueryParams({'hide': 'all-except-extensions'}), + ).any((w) => w is ExtensionSettingsAction), + isTrue, + ); + // In embedOne mode with standard screen, it is hidden: + setGlobal(IdeTheme, IdeTheme(embedMode: EmbedMode.embedOne)); + expect( + DevToolsScaffold.defaultActions( + currentScreen: _screen1, + ).any((w) => w is ExtensionSettingsAction), + isFalse, + ); + // In embedOne mode with ExtensionScreen, it is visible: + final extensionScreen = ExtensionScreen( + StubDevToolsExtensions.fooExtension, + ); + expect( + DevToolsScaffold.defaultActions( + currentScreen: extensionScreen, + ).any((w) => w is ExtensionSettingsAction), + isTrue, + ); + }, + ); + + testWidgets( + 'hides ExtensionSettingsAction in StatusLine for EmbedMode.embedOne', + (WidgetTester tester) async { + setGlobal(IdeTheme, IdeTheme(embedMode: EmbedMode.embedOne)); + await tester.pumpWidget( + wrapScaffold( + DevToolsScaffold( + screens: const [_screen1], + page: _screen1.screenId, + embedMode: EmbedMode.embedOne, + ), + ), + ); + expect(find.byType(ExtensionSettingsAction), findsNothing); + }, + ); } class _TestScreen extends Screen { - const _TestScreen( - this.name, - this.key, { - bool showFloatingDebuggerControls = true, - Key? tabKey, - }) : super( - name, - title: name, - icon: Icons.computer, - tabKey: tabKey, - showFloatingDebuggerControls: showFloatingDebuggerControls, - ); + const _TestScreen(this.name, this.key, {super.tabKey}) + : super(name, title: name, icon: Icons.computer); final String name; final Key key; diff --git a/packages/devtools_app/test/framework/scaffold/status_line_test.dart b/packages/devtools_app/test/framework/scaffold/status_line_test.dart new file mode 100644 index 00000000000..8483ceacc26 --- /dev/null +++ b/packages/devtools_app/test/framework/scaffold/status_line_test.dart @@ -0,0 +1,593 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/framework/scaffold/about_dialog.dart'; +import 'package:devtools_app/src/framework/scaffold/settings_dialog.dart'; +import 'package:devtools_app_shared/shared.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:provider/provider.dart'; + +import '../../test_infra/responsive_layout_utils.dart'; + +/// The height of the status line. It is fixed, so the overflow menu trigger is +/// clamped to it. The trigger is [actionWidgetSize] wide and this tall. +const _footerHeight = statusLineHeight + 2 * densePadding; + +/// The width, in logical pixels, at and above which the video tutorial link and +/// the embedded DevTools actions are shown inline. Below it they are in the +/// overflow menu. +const _inlineSecondaryItemsMinWidth = 900.0; + +/// The compact width used for menu interaction tests. The Settings dialog's +/// content overflows below this width, so Settings is tested only here; see +/// [_expectDialogOpens]. +const _compactInteractionWidth = 600.0; + +const _noClientConnection = 'No client connection'; + +/// The URL of the video tutorial for [screen], as the footer link opens it. +String _tutorialUrl(ScreenMetaData screen) { + return 'https://youtu.be/_EYk-E29edo${screen.tutorialVideoTimestamp}'; +} + +Finder _inStatusLine(Finder finder) { + return find.descendant(of: find.byType(StatusLine), matching: finder); +} + +/// The footer's connection status. It is text when there is room for it, and +/// otherwise an icon with a tooltip. +Finder _connectionStatus({required bool connected}) { + if (connected) return _inStatusLine(find.byTooltip('Connected device')); + final icon = _inStatusLine(find.byTooltip(_noClientConnection)); + return icon.evaluate().isNotEmpty + ? icon + : _inStatusLine(find.text(_noClientConnection)); +} + +Finder _trigger() => _inStatusLine(find.byTooltip('More actions')); + +/// Gives keyboard focus to the overflow menu trigger by requesting focus on the +/// focus node that the trigger's button owns. It does not press Tab, so Tab +/// traversal to the trigger is not covered by these tests. +Future _focusTrigger(WidgetTester tester) async { + final icon = find.descendant(of: _trigger(), matching: find.byType(Icon)); + final triggerFocus = Focus.of(tester.element(icon)); + triggerFocus.requestFocus(); + await tester.pump(); + expect( + FocusManager.instance.primaryFocus, + triggerFocus, + reason: 'The overflow menu trigger should take keyboard focus.', + ); +} + +/// Opens the overflow menu from the keyboard, as a keyboard user would. +Future _openMenuWithKeyboard(WidgetTester tester) async { + await _focusTrigger(tester); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.byType(PopupMenuItem), + findsWidgets, + reason: 'Enter on the focused trigger should open the menu.', + ); +} + +/// The label of the menu item that has keyboard focus, if any. +String? _focusedMenuItemLabel() { + final focus = FocusManager.instance.primaryFocus?.context; + final item = focus + ?.findAncestorWidgetOfExactType>(); + final child = item?.child; + return child is Text ? child.data : null; +} + +/// Activates the menu item labeled [label] from the keyboard: arrow keys move +/// focus through the open menu, and Enter activates the focused item. +Future _activateMenuItem(WidgetTester tester, String label) async { + for (var i = 0; i < 10 && _focusedMenuItemLabel() != label; i++) { + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + } + expect( + _focusedMenuItemLabel(), + label, + reason: '"$label" should be reachable with the arrow keys.', + ); + await tester.sendKeyEvent(LogicalKeyboardKey.enter); + await tester.pump(const Duration(milliseconds: 500)); +} + +/// Asserts that [dialog] is shown, then closes it with its Close button. +/// +/// The Settings dialog's checkbox rows overflow below 600 pixels (224 pixels at +/// 328 pixels), in shared dialog code that is reported separately from this +/// change. The About dialog has no overflow at 328 pixels. +Future _expectDialogOpens(WidgetTester tester, Finder dialog) async { + expect(dialog, findsOneWidget); + await tester.tap(find.text('CLOSE')); + await tester.pump(const Duration(milliseconds: 500)); + expect(dialog, findsNothing); +} + +void main() { + setUpAll(loadFonts); + + late FakeServiceConnectionManager connection; + + setUp(() { + connection = createLayoutServiceConnection(); + setUpLayoutGlobals(connection); + }); + + /// Pumps an isolated [StatusLine] for [screen] and returns any reported + /// [FlutterError]s. + /// + /// The status line is a stateless widget, so it is tested in isolation + /// rather than through `pumpDevToolsScaffold`. The tests use + /// [_FakeStatusScreen], whose page status needs no screen controller, so no + /// controller is initialized and no polling timers need cleanup. + Future> pumpStatusLine( + WidgetTester tester, { + required Screen screen, + required bool embedded, + required bool connected, + }) { + final embedMode = embedded ? EmbedMode.embedOne : EmbedMode.none; + setGlobal(IdeTheme, IdeTheme(embedMode: embedMode)); + connection.serviceManager.setConnectedState(connected); + return collectFlutterErrors(() async { + await tester.pumpWidget( + // The providers sit above the app so that dialogs opened from the + // footer can read them, as they do in the app. + MultiProvider( + providers: [ + Provider.value( + value: AnalyticsController( + enabled: false, + shouldShowConsentMessage: false, + consentMessage: 'fake message', + ), + ), + Provider.value( + value: ReleaseNotesController(), + ), + ], + child: wrapWithControllers( + Scaffold( + bottomNavigationBar: StatusLine( + currentScreen: screen, + isEmbedded: embedded, + isConnected: connected, + ), + ), + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); + }); + } + + group('footer layout', () { + for (final width in responsiveLayoutWidths) { + testWidgetsWithWindowSize( + 'Network footer (standalone, connected) has no overlap or clipping ' + 'at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: false, + connected: true, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectFooterGeometry(tester, width: width, connected: true); + }, + ); + } + + // The embedded branch only adds the secondary actions, which the overflow + // menu tests below cover; one narrow embedded smoke test exercises it. + testWidgetsWithWindowSize( + 'Network footer (embedded, connected) has no overlap or clipping at 328', + const Size(328, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: true, + connected: true, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectFooterGeometry(tester, width: 328, connected: true); + }, + ); + + // The disconnected connection status is an icon with a tooltip at + // [MediaSize.xs] and text above it, so two representative widths cover the + // icon-vs-text behavior. + for (final (width, showsIcon) in [(328.0, true), (1400.0, false)]) { + testWidgetsWithWindowSize( + 'Network footer (standalone, disconnected) has no overlap or clipping ' + 'at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpStatusLine( + tester, + // The real screens show no page status while disconnected. + screen: const _FakeStatusScreen(statusText: null), + embedded: false, + connected: false, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectFooterGeometry(tester, width: width, connected: false); + expect( + _inStatusLine(find.byTooltip(_noClientConnection)), + showsIcon ? findsOneWidget : findsNothing, + reason: 'Expected ${showsIcon ? 'icon' : 'text'} at $width', + ); + expect( + _inStatusLine(find.text(_noClientConnection)), + showsIcon ? findsNothing : findsOneWidget, + reason: 'Expected ${showsIcon ? 'no text' : 'text'} at $width', + ); + }, + ); + } + + testWidgetsWithWindowSize( + 'footer for a screen without a page status lays out cleanly', + const Size(328, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(statusText: null), + embedded: false, + connected: true, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + expect(find.byType(StatusLine), findsOneWidget); + final statusLine = tester.getRect(find.byType(StatusLine)); + final connection = tester.getRect(_connectionStatus(connected: true)); + expectWithinBounds({'connection': connection}, statusLine); + }, + ); + + // The page status is wrapped in a Flexible, not an Expanded, so that it + // keeps its natural width and the spaceBetween alignment keeps spacing the + // footer items evenly on wide screens, as it did before this change. + for (final embedded in [false, true]) { + testWidgetsWithWindowSize( + 'page status keeps its natural width on a wide screen ' + '(${embedded ? 'embedded' : 'standalone'})', + const Size(1400, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: embedded, + connected: true, + ); + expect(errors, isEmpty, reason: errors.join('\n')); + + final statusText = _inStatusLine(find.textContaining(' of ')); + expect(statusText, findsOneWidget); + final textRect = tester.getRect(statusText); + // The nearest ancestor Row is the page status box, which also + // holds the recording spinner. + final boxRect = tester.getRect( + find.ancestor(of: statusText, matching: find.byType(Row)).first, + ); + expect( + boxRect.width, + lessThanOrEqualTo( + textRect.width + denseSpacing + smallProgressSize + 8.0, + ), + reason: + 'The page status should hug its content on a wide screen; ' + 'an Expanded wrapper would stretch it to fill the slack.', + ); + + if (!embedded) { + // With no trailing actions, the connection status is the last + // item and stays pinned to the right edge. + final connection = tester.getRect( + _connectionStatus(connected: true), + ); + final statusLine = tester.getRect(find.byType(StatusLine)); + expect( + statusLine.right - connection.right, + lessThanOrEqualTo(defaultSpacing + geometryTolerance), + ); + } + }, + ); + } + }); + + group('overflow menu', () { + testWidgetsWithWindowSize( + 'trigger is a footer-height button with an accessible name', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + final handle = tester.ensureSemantics(); + await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: true, + connected: true, + ); + final trigger = _trigger(); + expect(trigger, findsOneWidget); + expect(tester.getSize(trigger).width, actionWidgetSize); + expect( + tester.getSize(trigger).height, + lessThanOrEqualTo(_footerHeight), + ); + expect( + tester.getSemantics(trigger), + matchesSemantics( + tooltip: 'More actions', + isButton: true, + isEnabled: true, + isFocusable: true, + hasEnabledState: true, + hasTapAction: true, + hasFocusAction: true, + hasExpandedState: true, + ), + ); + handle.dispose(); + }, + ); + + testWidgetsWithWindowSize( + 'opens with Enter, lists every action, and closes with Escape', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + for (final label in [ + 'Watch tutorial', + 'Settings', + 'Report feedback', + 'About DevTools', + ]) { + expect( + find.widgetWithText(PopupMenuItem, label), + findsOneWidget, + ); + } + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.byType(PopupMenuItem), findsNothing); + }, + ); + + testWidgetsWithWindowSize( + 'Settings from the menu opens the Settings dialog', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + final errors = await collectFlutterErrors( + () => _activateMenuItem(tester, 'Settings'), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + await _expectDialogOpens(tester, find.byType(SettingsDialog)); + }, + ); + + testWidgetsWithWindowSize( + 'About from the menu opens the About dialog', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + final errors = await collectFlutterErrors( + () => _activateMenuItem(tester, 'About DevTools'), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + await _expectDialogOpens(tester, find.byType(DevToolsAboutDialog)); + }, + ); + + testWidgetsWithWindowSize( + 'Watch tutorial from the menu opens the same video and records the same ' + 'analytics event as the inline link', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + final analytics = AnalyticsRecorder()..install(); + addTearDown(() { + urls.dispose(); + analytics.dispose(); + }); + await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + await _activateMenuItem(tester, 'Watch tutorial'); + await tester.pump(const Duration(milliseconds: 200)); + + expect(urls.launched, [_tutorialUrl(ScreenMetaData.network)]); + expect( + analytics.recordedSelection( + screenId: ScreenMetaData.network.id, + item: AnalyticsRecorder.tutorialItem(ScreenMetaData.network.id), + ), + isTrue, + ); + }, + ); + + testWidgetsWithWindowSize( + 'Report feedback from the menu opens the issue tracker and records the ' + 'analytics event', + const Size(_compactInteractionWidth, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + final analytics = AnalyticsRecorder()..install(); + addTearDown(() { + urls.dispose(); + analytics.dispose(); + }); + await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: true, + connected: true, + ); + await _openMenuWithKeyboard(tester); + await _activateMenuItem(tester, 'Report feedback'); + await tester.pump(const Duration(milliseconds: 200)); + + expect(urls.launched, hasLength(1)); + expect( + urls.launched.single, + startsWith('https://github.com/flutter/devtools/issues/new'), + ); + expect( + analytics.recordedSelection(screenId: 'main', item: 'feedbackButton'), + isTrue, + ); + }, + ); + + testWidgetsWithWindowSize( + 'inline tutorial link at a wide width opens the same video', + const Size(1400, responsiveLayoutHeight), + (WidgetTester tester) async { + final urls = UrlLaunchRecorder()..install(); + addTearDown(urls.dispose); + await pumpStatusLine( + tester, + screen: const _FakeStatusScreen(), + embedded: true, + connected: true, + ); + expect(_trigger(), findsNothing); + await tester.tap(_inStatusLine(find.text('Watch tutorial'))); + await tester.pump(const Duration(milliseconds: 200)); + expect(urls.launched, [_tutorialUrl(ScreenMetaData.network)]); + }, + ); + }); +} + +/// Asserts the geometry of the footer for the given configuration. +/// +/// Every footer element must lie inside the status line, must not intersect +/// another element, and must not be truncated. Each primary control must be +/// hit-testable. +void _expectFooterGeometry( + WidgetTester tester, { + required double width, + required bool connected, +}) { + final statusLine = tester.getRect(find.byType(StatusLine)); + final docs = _inStatusLine(find.byType(DocumentationLink)); + final connection = _connectionStatus(connected: connected); + final tutorial = _inStatusLine(find.text('Watch tutorial')); + final trigger = _trigger(); + + final elements = { + 'docs': tester.getRect(docs), + 'connection': tester.getRect(connection), + }; + if (trigger.evaluate().isNotEmpty) { + elements['trigger'] = tester.getRect(trigger); + } + if (tutorial.evaluate().isNotEmpty) { + elements['tutorial'] = tester.getRect(tutorial); + } + expectNoIntersections(elements); + expectWithinBounds(elements, statusLine); + + expectTextNotTruncated( + tester, + find.descendant(of: docs, matching: find.byType(Text)), + ); + final connectionText = connected + ? find.descendant(of: connection, matching: find.byType(Text)) + : _inStatusLine(find.text(_noClientConnection)); + if (connectionText.evaluate().isNotEmpty) { + expectTextNotTruncated(tester, connectionText); + } + + final inlineSecondary = width >= _inlineSecondaryItemsMinWidth; + expect(trigger, inlineSecondary ? findsNothing : findsOneWidget); + expect(tutorial, inlineSecondary ? findsWidgets : findsNothing); + expectInteractable(docs); + if (trigger.evaluate().isNotEmpty) { + expect(tester.getSize(trigger).width, actionWidgetSize); + expectInteractable(trigger); + } +} + +/// A lightweight [Screen] for testing [StatusLine] in isolation. +/// +/// Its page status needs no screen controller, so no controller is ever +/// initialized and no polling timers need cleanup (unlike a real screen, +/// whose `buildStatus` looks up a controller and would start one). +/// [statusText] is the page status content; `null` means no page status. +class _FakeStatusScreen extends Screen { + const _FakeStatusScreen({this.statusText = '1 of 10 requests'}) + : super(_screenId); + + static const _screenId = 'network'; + + final String? statusText; + + @override + String? get docPageId => 'network'; + + @override + Widget buildScreenBody(BuildContext context) => const SizedBox(); + + @override + Widget? buildStatus(BuildContext context) { + final text = statusText; + if (text == null) return null; + // Mimics the real page status: a spinner-sized element followed by text, + // so the Flexible wrapper's natural-width behavior is exercised. + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: smallProgressSize, height: smallProgressSize), + const SizedBox(width: denseSpacing), + Text(text), + ], + ); + } +} diff --git a/packages/devtools_app/test/http/curl_command_test.dart b/packages/devtools_app/test/http/curl_command_test.dart index 81cda7cb8fc..9cee579842e 100644 --- a/packages/devtools_app/test/http/curl_command_test.dart +++ b/packages/devtools_app/test/http/curl_command_test.dart @@ -2,10 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:convert'; import 'dart:typed_data'; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/shared/http/curl_command.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:vm_service/vm_service.dart'; @@ -205,9 +208,262 @@ void main() { "curl --location --request POST 'https://jsonplaceholder.typicode.com/posts' \\\n--data-raw '{\n \"title\": \"foo\", \"body\": \"bar\", \"userId\": 1\n}\n '", ); }); + + test('includes headers and body when response never completes', () { + final data = DartIOHttpRequestData( + HttpProfileRequest.parse({ + 'id': '7', + 'isolateId': 'isolates/0', + 'method': 'POST', + 'uri': 'https://example.com/api/login', + 'events': [], + 'startTime': 0, + 'endTime': 1000, + 'request': { + 'headers': { + 'content-type': ['application/json'], + 'accept': ['application/json'], + 'locale': ['en'], + }, + 'contentLength': 42, + 'cookies': [], + 'followRedirects': true, + 'maxRedirects': 5, + 'persistentConnection': false, + }, + 'response': null, + 'requestBody': utf8.encode( + '{"email":"user@example.com","password":"secret"}', + ), + })!, + requestFullDataFromVmService: false, + ); + + expect( + data.requestBody, + '{"email":"user@example.com","password":"secret"}', + ); + expect( + CurlCommand.from(data).toString(), + "curl --location --request POST 'https://example.com/api/login' \\\n--header 'content-type: application/json' \\\n--header 'accept: application/json' \\\n--header 'locale: en' \\\n--data-raw '{\"email\":\"user@example.com\",\"password\":\"secret\"}'", + ); + }); + + test('includes body when request has an error', () { + final data = DartIOHttpRequestData( + HttpProfileRequest.parse({ + 'id': '8', + 'isolateId': 'isolates/0', + 'method': 'POST', + 'uri': 'https://example.com/api/login', + 'events': [], + 'startTime': 0, + 'endTime': 1000, + 'request': { + 'error': 'Connection timed out', + 'contentLength': 2, + 'cookies': [], + 'followRedirects': true, + 'maxRedirects': 5, + 'persistentConnection': false, + }, + 'response': null, + 'requestBody': utf8.encode('{}'), + })!, + requestFullDataFromVmService: false, + ); + + expect(data.requestBody, '{}'); + expect(CurlCommand.from(data).toString(), contains("--data-raw '{}'")); + }); + }); + + // Regression coverage for the request body across the lifecycle of a + // request in the Network tab. The body is known once the request has been + // sent, so it must be available for Copy as cURL whether the request is + // still awaiting its response, completed, or failed, and it must survive the + // profile refreshes that replace the underlying request while it is pending. + // See https://github.com/flutter/devtools/pull/9963. + group('NetworkCurlCommand request body lifecycle', () { + const body = '{"email":"user@example.com"}'; + const curlWithHeaderAndBody = + "curl --location --request POST 'https://example.com/api/login' " + "\\\n--header 'content-type: application/json' " + "\\\n--data-raw '$body'"; + + test('includes body for a pending request awaiting its response', () { + final data = DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + response: null, + requestBody: utf8.encode(body), + ), + requestFullDataFromVmService: false, + ); + + expect(data.inProgress, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + }); + + test('includes body for a completed request', () { + final data = DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + response: _completedResponseJson, + requestBody: utf8.encode(body), + ), + requestFullDataFromVmService: false, + ); + + expect(data.inProgress, isFalse); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + }); + + test('includes body for a request that failed without a response', () { + final data = DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + requestError: 'Connection timed out', + response: null, + requestBody: utf8.encode(body), + ), + requestFullDataFromVmService: false, + ); + + expect(data.didFail, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), contains("--data-raw '$body'")); + }); + + // These tests exercise `getFullRequestData`, which fetches the body from + // the VM service when a request is selected, followed by `merge` calls + // that simulate `getHttpProfile` polling. + group('with VM service', () { + tearDown(() => removeGlobal(ServiceConnectionManager)); + + test( + 'retains body fetched while pending across profile refreshes', + () async { + _serveFullRequestFromVmService( + _parseProfileRequest( + requestSent: true, + response: null, + requestBody: utf8.encode(body), + ), + ); + + // Entries from `getHttpProfile` polling never carry bodies. + final data = DartIOHttpRequestData( + _parseProfileRequest(requestSent: true, response: null), + requestFullDataFromVmService: false, + ); + + // Selecting the request in the Network tab fetches its full data. + await data.getFullRequestData(); + expect(data.inProgress, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + + // The next poll replaces the profile entry while still pending. + data.merge( + DartIOHttpRequestData( + _parseProfileRequest(requestSent: true, response: null), + requestFullDataFromVmService: false, + ), + ); + expect(data.inProgress, isTrue); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + + // The response eventually completes. + data.merge( + DartIOHttpRequestData( + _parseProfileRequest( + requestSent: true, + response: _completedResponseJson, + ), + requestFullDataFromVmService: false, + ), + ); + expect(data.inProgress, isFalse); + expect(data.requestBody, body); + expect(CurlCommand.from(data).toString(), curlWithHeaderAndBody); + }, + ); + }); }); } +/// Sets up a fake VM service whose `getHttpProfileRequest` returns [request]. +void _serveFullRequestFromVmService(HttpProfileRequest request) { + setGlobal( + ServiceConnectionManager, + FakeServiceConnectionManager( + service: FakeServiceManager.createFakeService( + httpProfile: HttpProfile( + requests: [request], + timestamp: DateTime.fromMicrosecondsSinceEpoch(0), + ), + ), + ), + ); +} + +/// Parses an [HttpProfileRequest] shaped like the dart:io HTTP profiler JSON. +/// +/// dart:io only reports `endTime` and `request` once the request has been +/// fully sent ([requestSent]), and `response` once a response starts. +HttpProfileRequest _parseProfileRequest({ + required bool requestSent, + required Map? response, + String? requestError, + List? requestBody, +}) { + return HttpProfileRequest.parse({ + 'id': '1', + 'isolateId': 'isolates/0', + 'method': 'POST', + 'uri': 'https://example.com/api/login', + 'events': [], + 'startTime': 0, + if (requestSent) ...{ + 'endTime': 1000, + 'request': requestError != null + ? {'error': requestError} + : { + 'headers': { + 'content-type': ['application/json'], + }, + 'connectionInfo': {}, + 'contentLength': requestBody?.length ?? 0, + 'cookies': [], + 'followRedirects': true, + 'maxRedirects': 5, + 'persistentConnection': true, + }, + }, + 'response': ?response, + 'requestBody': ?requestBody, + })!; +} + +const _completedResponseJson = { + 'startTime': 2000, + 'endTime': 3000, + 'headers': {}, + 'compressionState': 'notCompressed', + 'connectionInfo': {}, + 'contentLength': 0, + 'cookies': [], + 'isRedirect': false, + 'persistentConnection': true, + 'reasonPhrase': 'OK', + 'redirects': [], + 'statusCode': 200, +}; + class _TestDartIOHttpRequestData extends DartIOHttpRequestData { _TestDartIOHttpRequestData(this._request) : super(_request); diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart new file mode 100644 index 00000000000..2575cb09fd3 --- /dev/null +++ b/packages/devtools_app/test/screens/accessibility/accessibility_controller_test.dart @@ -0,0 +1,566 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'dart:ui' show Rect, SemanticsFlag; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/service/service_registrations.dart' + as registrations; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:vm_service/vm_service.dart'; + +void main() { + group('AccessibilityController', () { + late AccessibilityController controller; + + setUp(() { + final fakeServiceConnection = FakeServiceConnectionManager(); + when( + fakeServiceConnection.serviceManager.connectedApp!.isFlutterWebAppNow, + ).thenReturn(false); + when( + fakeServiceConnection.serviceManager.connectedApp!.isProfileBuildNow, + ).thenReturn(false); + + fakeServiceConnection + .serviceManager + .serviceExtensionResponses[registrations.enableSemantics] = + Response.parse({})!; + fakeServiceConnection + .serviceManager + .serviceExtensionResponses[registrations + .getIssuesAndSemanticsTree] = Response.parse({ + 'data': { + '0': {'id': '0', 'label': 'Root'}, + }, + })!; + + setGlobal(NotificationService, NotificationService()); + setGlobal( + DevToolsEnvironmentParameters, + ExternalDevToolsEnvironmentParameters(), + ); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + + controller = AccessibilityController()..init(); + }); + + test('initial state', () { + final uninitializedController = AccessibilityController(); + expect( + uninitializedController.brightness.value, + BrightnessOverride.system, + ); + expect(uninitializedController.textScale.value, 1.0); + expect(uninitializedController.boldText.value, isFalse); + expect(uninitializedController.screenReader.value, isFalse); + expect(uninitializedController.highContrast.value, isFalse); + expect(uninitializedController.semanticsRoots.value, isEmpty); + expect(uninitializedController.semanticsTreeLoading.value, isFalse); + }); + + test( + 'service extension state change updates controller brightness state', + () { + final fakeServiceExtensionManager = + serviceConnection.serviceManager.serviceExtensionManager + as FakeServiceExtensionManager; + + expect(controller.brightness.value, BrightnessOverride.system); + + // Simulate service extension state change from device to dark mode + fakeServiceExtensionManager.fakeServiceExtensionStateChanged( + brightnessMode.extension, + 'Brightness.dark', + ); + expect(controller.brightness.value, BrightnessOverride.dark); + + // Simulate service extension state change from device to light mode + fakeServiceExtensionManager.fakeServiceExtensionStateChanged( + brightnessMode.extension, + 'Brightness.light', + ); + expect(controller.brightness.value, BrightnessOverride.light); + + // Simulate service extension state change from device to system + fakeServiceExtensionManager.fakeServiceExtensionStateChanged( + brightnessMode.extension, + 'system', + ); + expect(controller.brightness.value, BrightnessOverride.system); + }, + ); + + test( + 'setting controller brightness updates service extension state', + () async { + final fakeServiceExtensionManager = + serviceConnection.serviceManager.serviceExtensionManager + as FakeServiceExtensionManager; + + // Initial state + expect(controller.brightness.value, BrightnessOverride.system); + + // Set to dark mode + controller.brightness.value = BrightnessOverride.dark; + + // Wait for async operations to complete + await Future.delayed(Duration.zero); + + final darkState = fakeServiceExtensionManager + .getServiceExtensionState(brightnessMode.extension) + .value; + expect(darkState.value, equals('Brightness.dark')); + expect(darkState.enabled, isTrue); + + // Set to light mode + controller.brightness.value = BrightnessOverride.light; + await Future.delayed(Duration.zero); + + final lightState = fakeServiceExtensionManager + .getServiceExtensionState(brightnessMode.extension) + .value; + expect(lightState.value, equals('Brightness.light')); + expect(lightState.enabled, isTrue); + + // Set to system + controller.brightness.value = BrightnessOverride.system; + await Future.delayed(Duration.zero); + + final systemState = fakeServiceExtensionManager + .getServiceExtensionState(brightnessMode.extension) + .value; + expect(systemState.value, equals('system')); + expect(systemState.enabled, isFalse); + }, + ); + + test( + 'SemanticsNodeModel properties, parseRect, rectDisplay, and shallowCopy', + () { + const expectedRect = Rect.fromLTWH(0, 0, 763, 32); + const expectedIssues = [ + AccessibilityIssue( + rule: 'labeledTapTarget', + description: 'Interactive element has no label for screen readers.', + ), + ]; + final child = SemanticsNodeModel( + id: '1', + label: 'Child Node', + value: 'Child Value', + hint: 'Child Hint', + rect: expectedRect, + flags: {SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}, + issues: expectedIssues, + widgetName: 'ElevatedButton', + ); + final parent = SemanticsNodeModel( + id: '0', + label: 'Parent Node', + flags: {SemanticsFlag.isHeader}, + widgetName: 'Column', + )..addChild(child); + + expect(parent.children, hasLength(1)); + expect(parent.children.first.id, equals('1')); + expect(parent.rectDisplay, isNull); + expect(child.rectDisplay, equals('rect: Rect.fromLTWH(0, 0, 763, 32)')); + + final copy = child.shallowCopy(); + expect(copy.id, equals('1')); + expect(copy.label, equals('Child Node')); + expect(copy.value, equals('Child Value')); + expect(copy.hint, equals('Child Hint')); + expect(copy.rect, equals(expectedRect)); + expect( + copy.flags, + equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}), + ); + expect(copy.issues, equals(expectedIssues)); + expect(copy.widgetName, equals('ElevatedButton')); + expect(copy.children, isEmpty); + + expect( + SemanticsNodeModel.parseRect({ + 'left': 10, + 'top': 20, + 'width': 100, + 'height': 50, + }), + equals(const Rect.fromLTWH(10, 20, 100, 50)), + ); + expect( + SemanticsNodeModel.parseRect({ + 'left': 10, + 'top': 20, + 'right': 110, + 'bottom': 70, + }), + equals(const Rect.fromLTRB(10, 20, 110, 70)), + ); + expect(SemanticsNodeModel.parseRect(null), isNull); + expect(SemanticsNodeModel.parseRect('invalid'), isNull); + }, + ); + + test( + 'loadSemanticsTree sets error state when no main isolate connected', + () async { + final fakeServiceConnection = _NullIsolateServiceConnectionManager(); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + + final testController = AccessibilityController(); + expect(testController.semanticsTreeError.value, isNull); + await testController.loadSemanticsTree(); + expect( + testController.semanticsTreeError.value, + equals('Failed to load semantics tree: no connected application.'), + ); + expect(testController.semanticsTreeLoading.value, isFalse); + expect(testController.semanticsRoots.value, isEmpty); + expect(testController.selectedSemanticsNode.value, isNull); + }, + ); + + test( + 'loadSemanticsTree sets error state when service extension returns error', + () async { + final fakeServiceManager = + serviceConnection.serviceManager as FakeServiceManager; + fakeServiceManager.serviceExtensionResponses[registrations + .enableSemantics] = Response.parse( + {}, + )!; + fakeServiceManager.serviceExtensionResponses[registrations + .getIssuesAndSemanticsTree] = Response.parse({ + 'error': 'Semantics not enabled.', + })!; + + final testController = AccessibilityController(); + await testController.loadSemanticsTree(); + + expect( + testController.semanticsTreeError.value, + equals( + 'Failed to load semantics tree: Exception: Semantics not enabled.', + ), + ); + expect(testController.semanticsRoots.value, isEmpty); + expect(testController.selectedSemanticsNode.value, isNull); + }, + ); + + test( + 'loadSemanticsTree parses full SemanticsNode.toJson format with multiple nodes and selects root', + () async { + final fakeServiceManager = + serviceConnection.serviceManager as FakeServiceManager; + fakeServiceManager.serviceExtensionResponses[registrations + .enableSemantics] = Response.parse( + {}, + )!; + fakeServiceManager.serviceExtensionResponses[registrations + .getIssuesAndSemanticsTree] = Response.parse({ + 'data': { + '0': { + 'id': 0, + 'label': 'Root View', + 'value': 'Main Screen', + 'hint': '', + 'tooltip': '', + 'increasedValue': '', + 'decreasedValue': '', + 'flags': ['hasEnabledState', 'isEnabled'], + 'actions': [], + 'rect': { + 'left': 0.0, + 'top': 0.0, + 'width': 390.0, + 'height': 844.0, + }, + 'transform': [ + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ], + 'childrenInTraversalOrder': [1, 2], + 'childrenInHitTestOrder': [2, 1], + }, + '1': { + 'id': 1, + 'label': 'Settings Header', + 'flags': ['isHeader'], + 'actions': [], + 'rect': { + 'left': 16.0, + 'top': 40.0, + 'width': 358.0, + 'height': 32.0, + }, + }, + '2': { + 'id': 2, + 'label': 'Search Input', + 'value': 'Flutter', + 'hint': 'Enter search query', + 'tooltip': 'Search field', + 'flags': ['isTextField'], + 'actions': ['tap', 'setSelection'], + 'rect': { + 'left': 16.0, + 'top': 88.0, + 'width': 358.0, + 'height': 48.0, + }, + 'childrenInTraversalOrder': [3], + 'childrenInHitTestOrder': [3], + }, + '3': { + 'id': 3, + 'label': 'Clear Text', + 'tooltip': 'Clear input content', + 'flags': ['isButton', 'hasCheckedState'], + 'actions': ['tap'], + 'rect': { + 'left': 330.0, + 'top': 96.0, + 'width': 32.0, + 'height': 32.0, + }, + }, + }, + })!; + + final testController = AccessibilityController(); + await testController.loadSemanticsTree(); + + expect(testController.semanticsRoots.value, hasLength(1)); + final root = testController.semanticsRoots.value.first; + expect(root.id, equals('0')); + expect(root.label, equals('Root View')); + expect(root.value, equals('Main Screen')); + expect(root.hint, isEmpty); + expect(root.rect, equals(const Rect.fromLTWH(0, 0, 390, 844))); + expect( + root.flags, + equals({SemanticsFlag.hasEnabledState, SemanticsFlag.isEnabled}), + ); + expect(root.children, hasLength(2)); + + // Root node is automatically selected on load + expect(testController.selectedSemanticsNode.value, equals(root)); + expect(root.isSelected, isTrue); + + // Node 1: Settings Header + final headerNode = root.children[0]; + expect(headerNode.id, equals('1')); + expect(headerNode.label, equals('Settings Header')); + expect(headerNode.rect, equals(const Rect.fromLTWH(16, 40, 358, 32))); + expect(headerNode.flags, equals({SemanticsFlag.isHeader})); + expect(headerNode.children, isEmpty); + + // Node 2: Search Input + final searchNode = root.children[1]; + expect(searchNode.id, equals('2')); + expect(searchNode.label, equals('Search Input')); + expect(searchNode.value, equals('Flutter')); + expect(searchNode.hint, equals('Enter search query')); + expect(searchNode.rect, equals(const Rect.fromLTWH(16, 88, 358, 48))); + expect(searchNode.flags, equals({SemanticsFlag.isTextField})); + expect(searchNode.children, hasLength(1)); + + // Node 3: Clear Text Button (child of Node 2) + final clearButtonNode = searchNode.children.first; + expect(clearButtonNode.id, equals('3')); + expect(clearButtonNode.label, equals('Clear Text')); + expect( + clearButtonNode.rect, + equals(const Rect.fromLTWH(330, 96, 32, 32)), + ); + expect( + clearButtonNode.flags, + equals({SemanticsFlag.isButton, SemanticsFlag.hasCheckedState}), + ); + expect(clearButtonNode.children, isEmpty); + + // Selecting another node updates selectedSemanticsNode and isSelected state + testController.selectSemanticsNode(clearButtonNode); + expect( + testController.selectedSemanticsNode.value, + equals(clearButtonNode), + ); + expect(clearButtonNode.isSelected, isTrue); + expect(root.isSelected, isFalse); + }, + ); + + test( + 'loadSemanticsTree parses flat nodes map and builds child hierarchy', + () async { + final fakeServiceManager = + serviceConnection.serviceManager as FakeServiceManager; + fakeServiceManager.serviceExtensionResponses[registrations + .enableSemantics] = Response.parse( + {}, + )!; + fakeServiceManager.serviceExtensionResponses[registrations + .getIssuesAndSemanticsTree] = Response.parse({ + 'data': { + '0': { + 'id': 0, + 'label': 'Root', + 'childrenInTraversalOrder': [1], + }, + '1': { + 'id': 1, + 'label': 'Child full', + 'flags': ['isButton'], + }, + }, + })!; + + final testController = AccessibilityController(); + await testController.loadSemanticsTree(); + + expect(testController.semanticsRoots.value, hasLength(1)); + final root = testController.semanticsRoots.value.first; + expect(root.id, equals('0')); + expect(root.label, equals('Root')); + expect(root.children, hasLength(1)); + expect(root.children.first.id, equals('1')); + expect(root.children.first.label, equals('Child full')); + expect(root.children.first.flags, equals({SemanticsFlag.isButton})); + }, + ); + + test( + 'loadSemanticsTree parses accessibility issues from getIssuesAndSemanticsTree', + () async { + final fakeServiceManager = + serviceConnection.serviceManager as FakeServiceManager; + fakeServiceManager.serviceExtensionResponses[registrations + .enableSemantics] = Response.parse( + {}, + )!; + fakeServiceManager.serviceExtensionResponses[registrations + .getIssuesAndSemanticsTree] = Response.parse({ + 'data': { + '0': { + 'id': 0, + 'label': 'Root', + 'childrenInTraversalOrder': [13], + }, + '13': { + 'id': 13, + 'label': '', + 'flags': ['isButton'], + }, + }, + 'issues': [ + { + 'nodeId': 13, + 'rule': 'labeledTapTarget', + 'description': + 'Interactive element has no label for screen readers.', + }, + ], + })!; + + final testController = AccessibilityController(); + await testController.loadSemanticsTree(); + + expect(testController.semanticsRoots.value, hasLength(1)); + final root = testController.semanticsRoots.value.first; + expect(root.issues, isEmpty); + expect(root.children, hasLength(1)); + + final child = root.children.first; + expect(child.id, equals('13')); + expect(child.issues, hasLength(1)); + expect(child.issues.first.rule, equals('labeledTapTarget')); + expect( + child.issues.first.description, + equals('Interactive element has no label for screen readers.'), + ); + }, + ); + + test('dispose calls disposeSemantics', () async { + final recordingServiceConnection = _RecordingServiceConnectionManager(); + setGlobal(ServiceConnectionManager, recordingServiceConnection); + + final testController = AccessibilityController(); + testController.dispose(); + await Future.delayed(Duration.zero); + + final calls = + (recordingServiceConnection.serviceManager + as _RecordingServiceManager) + .recordedCalls; + expect( + calls.any((call) => call.$1 == registrations.disposeSemantics), + isTrue, + ); + }); + }); +} + +class _RecordingServiceConnectionManager extends FakeServiceConnectionManager { + @override + late final serviceManager = _RecordingServiceManager(); +} + +// ignore: subtype_of_sealed_class, fake for testing. +class _RecordingServiceManager extends FakeServiceManager { + final recordedCalls = <(String, Map?)>[]; + + @override + Future callServiceExtensionOnMainIsolate( + String method, { + Map? args, + }) async { + recordedCalls.add((method, args)); + return serviceExtensionResponses[method] ?? Response.parse({})!; + } +} + +class _NullIsolateServiceConnectionManager + extends FakeServiceConnectionManager { + @override + late final serviceManager = _NullIsolateServiceManager(); +} + +// ignore: subtype_of_sealed_class, fake for testing. +class _NullIsolateServiceManager extends FakeServiceManager { + @override + late final isolateManager = _NullIsolateManager(); +} + +base class _NullIsolateManager extends FakeIsolateManager { + @override + ValueListenable get mainIsolate => + ValueNotifier(null); +} diff --git a/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart new file mode 100644 index 00000000000..e1c14e9ce40 --- /dev/null +++ b/packages/devtools_app/test/screens/accessibility/accessibility_screen_test.dart @@ -0,0 +1,595 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'dart:ui' show SemanticsFlag; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + late AccessibilityScreen screen; + late AccessibilityController controller; + const windowSize = Size(1000.0, 1000.0); + + group('Accessibility Screen', () { + Future pumpAccessibilityScreen(WidgetTester tester) async { + await tester.pumpWidget( + wrapWithControllers( + const AccessibilityScreenBody(), + accessibility: controller, + ), + ); + } + + setUp(() { + final fakeServiceConnection = FakeServiceConnectionManager(); + when( + fakeServiceConnection.serviceManager.connectedApp!.isFlutterWebAppNow, + ).thenReturn(false); + when( + fakeServiceConnection.serviceManager.connectedApp!.isProfileBuildNow, + ).thenReturn(false); + when( + fakeServiceConnection.errorBadgeManager.errorCountNotifier( + 'accessibility', + ), + ).thenReturn(ValueNotifier(0)); + + setGlobal(NotificationService, NotificationService()); + setGlobal( + DevToolsEnvironmentParameters, + ExternalDevToolsEnvironmentParameters(), + ); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + setGlobal(IdeTheme, IdeTheme()); + + controller = AccessibilityController(); + screen = AccessibilityScreen(); + }); + + testWidgets('builds its tab', (WidgetTester tester) async { + await tester.pumpWidget(wrap(Builder(builder: screen.buildTab))); + expect(find.text('Accessibility'), findsOneWidget); + }); + + testWidgetsWithWindowSize('builds split view with panes', windowSize, ( + WidgetTester tester, + ) async { + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + expect(find.byType(AccessibilityScreenBody), findsOneWidget); + expect(find.byType(SplitPane), findsAtLeastNWidgets(1)); + + // Semantics Tree pane should be visible + expect(find.byType(AccessibilitySemanticsTreePane), findsOneWidget); + + // Semantics Node Details pane should be visible + expect(find.byType(SemanticsNodeDetailsPane), findsOneWidget); + + // Overrides pane should be visible + expect(find.byType(AccessibilityOverridesPane), findsOneWidget); + }); + + testWidgetsWithWindowSize( + 'renders all override controls in AccessibilityOverridesPane', + windowSize, + (WidgetTester tester) async { + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + expect(find.text('Accessibility Overrides'), findsOneWidget); + expect( + find.text( + 'Simulate and test accessibility settings on the connected device in real-time.', + ), + findsOneWidget, + ); + + // Brightness controls + expect(find.text('Brightness'), findsOneWidget); + expect( + find.text('Override the color scheme mode of the app.'), + findsOneWidget, + ); + expect( + find.byType(RoundedDropDownButton), + findsOneWidget, + ); + + // Text Scale controls + expect(find.text('Text Scale'), findsOneWidget); + expect(find.text('Scale the system font size.'), findsOneWidget); + expect(find.text('1.00x'), findsOneWidget); + expect(find.byType(Slider), findsOneWidget); + + // Switches + expect(find.text('Bold Text'), findsOneWidget); + expect( + find.text('Forces all text in the application to be bold.'), + findsOneWidget, + ); + expect(find.text('Screen Reader Debugger'), findsOneWidget); + expect( + find.text('Debug and test screen reader layouts.'), + findsOneWidget, + ); + expect(find.text('High Contrast'), findsOneWidget); + expect( + find.text('Increases the contrast of text and icons.'), + findsOneWidget, + ); + expect(find.byType(Switch), findsNWidgets(3)); + }, + ); + + testWidgetsWithWindowSize( + 'interacting with override controls updates controller state', + windowSize, + (WidgetTester tester) async { + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + Finder findSwitchFor(String label) { + return find.descendant( + of: find.ancestor(of: find.text(label), matching: find.byType(Row)), + matching: find.byType(Switch), + ); + } + + // 1. Test Brightness Dropdown + expect(controller.brightness.value, BrightnessOverride.system); + await tester.tap( + find.byType(RoundedDropDownButton), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Light Mode').last); + await tester.pumpAndSettle(); + expect(controller.brightness.value, BrightnessOverride.light); + + // 2. Test Bold Text Switch + expect(controller.boldText.value, isFalse); + final boldTextSwitch = findSwitchFor('Bold Text'); + await tester.ensureVisible(boldTextSwitch); + await tester.pumpAndSettle(); + await tester.tap(boldTextSwitch); + await tester.pumpAndSettle(); + expect(controller.boldText.value, isTrue); + + // 3. Test Screen Reader Debugger Switch + expect(controller.screenReader.value, isFalse); + final screenReaderSwitch = findSwitchFor('Screen Reader Debugger'); + await tester.ensureVisible(screenReaderSwitch); + await tester.pumpAndSettle(); + await tester.tap(screenReaderSwitch); + await tester.pumpAndSettle(); + expect(controller.screenReader.value, isTrue); + + // 4. Test High Contrast Switch + expect(controller.highContrast.value, isFalse); + final highContrastSwitch = findSwitchFor('High Contrast'); + await tester.ensureVisible(highContrastSwitch); + await tester.pumpAndSettle(); + await tester.tap(highContrastSwitch); + await tester.pumpAndSettle(); + expect(controller.highContrast.value, isTrue); + }, + ); + + testWidgetsWithWindowSize( + 'renders semantics tree when nodes are loaded', + windowSize, + (WidgetTester tester) async { + final rootNode = SemanticsNodeModel( + id: '0', + label: 'Root Node', + flags: {SemanticsFlag.isHeader}, + widgetName: 'HeaderWidget', + ); + controller.semanticsRoots.value = [rootNode]; + + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + expect(find.text('SemanticsNode #0'), findsAtLeastNWidgets(1)); + expect(find.text('"Root Node"'), findsAtLeastNWidgets(1)); + expect(find.text('HeaderWidget'), findsOneWidget); + }, + ); + + testWidgetsWithWindowSize( + 'renders semantics tree error state when error occurs', + windowSize, + (WidgetTester tester) async { + controller.semanticsTreeError.value = + 'Failed to load semantics tree: network error.'; + + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + expect( + find.text('Failed to load semantics tree: network error.'), + findsOneWidget, + ); + expect(find.text('Try Again'), findsOneWidget); + }, + ); + + testWidgetsWithWindowSize( + 'renders parent and child nodes in semantics tree', + windowSize, + (WidgetTester tester) async { + final childNode = SemanticsNodeModel( + id: '1', + label: 'Child Node', + flags: {SemanticsFlag.isButton}, + widgetName: 'ElevatedButton', + ); + final rootNode = SemanticsNodeModel( + id: '0', + label: 'Root Node', + widgetName: 'Column', + )..addChild(childNode); + + rootNode.expandCascading(); + controller.semanticsRoots.value = [rootNode]; + + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + expect( + find.descendant( + of: find.byType(AccessibilitySemanticsTreePane), + matching: find.text('SemanticsNode #0'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(AccessibilitySemanticsTreePane), + matching: find.text('SemanticsNode #1'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(AccessibilitySemanticsTreePane), + matching: find.text('"Child Node"'), + ), + findsOneWidget, + ); + expect(find.text('ElevatedButton'), findsOneWidget); + }, + ); + + testWidgetsWithWindowSize( + 'renders empty state with Load Semantics Tree button when no roots', + windowSize, + (WidgetTester tester) async { + controller.semanticsRoots.value = []; + await pumpAccessibilityScreen(tester); + controller.semanticsTreeError.value = null; + await tester.pumpAndSettle(); + + expect( + find.text( + 'No semantics tree loaded. Inspect the accessibility hierarchy of the connected app.', + ), + findsOneWidget, + ); + expect( + find.widgetWithText(DevToolsButton, 'Load Semantics Tree'), + findsOneWidget, + ); + expect(find.byType(RefreshButton), findsNothing); + }, + ); + + testWidgetsWithWindowSize( + 'renders refresh button when roots are loaded', + windowSize, + (WidgetTester tester) async { + final rootNode = SemanticsNodeModel(id: '0', label: 'Root Node'); + controller.semanticsRoots.value = [rootNode]; + + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + expect(find.byType(RefreshButton), findsOneWidget); + }, + ); + + testWidgetsWithWindowSize( + 'renders appropriate icon for node with hasCheckedState flag', + windowSize, + (WidgetTester tester) async { + final node = SemanticsNodeModel( + id: '0', + label: 'Checkbox Node', + flags: {SemanticsFlag.hasCheckedState}, + ); + controller.semanticsRoots.value = [node]; + + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.check_box_outlined), findsOneWidget); + }, + ); + + testWidgetsWithWindowSize( + 'renders node details placeholder when no node is selected', + windowSize, + (WidgetTester tester) async { + controller.semanticsRoots.value = []; + + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + expect(find.text('Semantics Node Details'), findsOneWidget); + expect( + find.text('Select a node in the semantics tree to view its details.'), + findsOneWidget, + ); + }, + ); + + testWidgetsWithWindowSize( + 'renders root node details automatically and updates when a child node is selected', + windowSize, + (WidgetTester tester) async { + final childNode = SemanticsNodeModel( + id: '26', + label: 'CloseButton', + rect: const Rect.fromLTWH(0, 0, 763, 32), + flags: { + SemanticsFlag.isButton, + SemanticsFlag.hasEnabledState, + SemanticsFlag.isEnabled, + SemanticsFlag.isFocusable, + }, + widgetName: 'CloseButton', + ); + final rootNode = SemanticsNodeModel( + id: '0', + label: 'Root Node', + value: 'Initial Value', + hint: 'Tap to interact', + rect: const Rect.fromLTWH(0, 0, 1000, 1000), + flags: {SemanticsFlag.isHeader}, + )..addChild(childNode); + + rootNode.expandCascading(); + controller.semanticsRoots.value = [rootNode]; + + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + final detailsPane = find.byType(SemanticsNodeDetailsPane); + + // Initially, root node (#0) is automatically selected + expect( + find.descendant( + of: detailsPane, + matching: find.text('SemanticsNode #0'), + ), + findsOneWidget, + ); + expect( + find.descendant(of: detailsPane, matching: find.text('"Root Node"')), + findsOneWidget, + ); + expect( + find.descendant( + of: detailsPane, + matching: find.text('"Initial Value"'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: detailsPane, + matching: find.text('"Tap to interact"'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: detailsPane, + matching: find.text('rect: Rect.fromLTWH(0, 0, 1000, 1000)'), + ), + findsOneWidget, + ); + expect( + find.descendant(of: detailsPane, matching: find.text('isHeader')), + findsOneWidget, + ); + + // Tap child node (#26) in the tree pane + await tester.tap( + find.descendant( + of: find.byType(AccessibilitySemanticsTreePane), + matching: find.text('SemanticsNode #26'), + ), + ); + await tester.pumpAndSettle(); + + // Details pane should now display SemanticsNode #26 details + expect( + find.descendant( + of: detailsPane, + matching: find.text('SemanticsNode #26'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: detailsPane, + matching: find.text('"CloseButton"'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: detailsPane, + matching: find.text('rect: Rect.fromLTWH(0, 0, 763, 32)'), + ), + findsOneWidget, + ); + // Empty value and hint should render as (empty) + expect( + find.descendant(of: detailsPane, matching: find.text('(empty)')), + findsNWidgets(2), + ); + // Flags chips + expect( + find.descendant(of: detailsPane, matching: find.text('isButton')), + findsOneWidget, + ); + expect( + find.descendant( + of: detailsPane, + matching: find.text('hasEnabledState'), + ), + findsOneWidget, + ); + expect( + find.descendant(of: detailsPane, matching: find.text('isEnabled')), + findsOneWidget, + ); + expect( + find.descendant(of: detailsPane, matching: find.text('isFocusable')), + findsOneWidget, + ); + + // Tap parent node (#0) in the tree pane to select it; its child (#26) + // should remain expanded and visible in the tree. + await tester.tap( + find.descendant( + of: find.byType(AccessibilitySemanticsTreePane), + matching: find.text('SemanticsNode #0'), + ), + ); + await tester.pumpAndSettle(); + + expect(controller.selectedSemanticsNode.value, equals(rootNode)); + expect(rootNode.isExpanded, isTrue); + expect( + find.descendant( + of: find.byType(AccessibilitySemanticsTreePane), + matching: find.text('SemanticsNode #26'), + ), + findsOneWidget, + ); + + // Select child (#26) again, then tap the expand/collapse arrow on + // parent (#0); it should collapse without changing the selected node. + controller.selectSemanticsNode(childNode); + await tester.pumpAndSettle(); + expect(controller.selectedSemanticsNode.value, equals(childNode)); + + await tester.tap( + find.descendant( + of: find.byType(AccessibilitySemanticsTreePane), + matching: find.byIcon(Icons.keyboard_arrow_down), + ), + ); + await tester.pumpAndSettle(); + + expect(rootNode.isExpanded, isFalse); + expect(controller.selectedSemanticsNode.value, equals(childNode)); + }, + ); + + testWidgetsWithWindowSize( + 'renders accessibility issues in tree pane and node details pane', + windowSize, + (WidgetTester tester) async { + final nodeWithIssue = SemanticsNodeModel( + id: '13', + issues: const [ + AccessibilityIssue( + rule: 'labeledTapTarget', + description: + 'Interactive element has no label for screen readers.', + ), + ], + ); + final rootNode = SemanticsNodeModel(id: '0', label: 'Root Node') + ..addChild(nodeWithIssue); + + rootNode.expandCascading(); + controller.semanticsRoots.value = [rootNode]; + + await pumpAccessibilityScreen(tester); + await tester.pumpAndSettle(); + + final treePane = find.byType(AccessibilitySemanticsTreePane); + final detailsPane = find.byType(SemanticsNodeDetailsPane); + + // Tree pane should display a warning icon for node #13. + expect( + find.descendant( + of: treePane, + matching: find.byIcon(Icons.warning_amber_rounded), + ), + findsOneWidget, + ); + + // Initially root node (#0) has no issues, so details pane has no issues section. + expect( + find.descendant( + of: detailsPane, + matching: find.textContaining('Accessibility Issues'), + ), + findsNothing, + ); + + // Select node #13 in the tree pane. + await tester.tap( + find.descendant( + of: treePane, + matching: find.text('SemanticsNode #13'), + ), + ); + await tester.pumpAndSettle(); + + // Details pane should now show the Accessibility Issues section and card. + expect( + find.descendant( + of: detailsPane, + matching: find.text('Accessibility Issues (1)'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: detailsPane, + matching: find.text('labeledTapTarget'), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: detailsPane, + matching: find.text( + 'Interactive element has no label for screen readers.', + ), + ), + findsOneWidget, + ); + }, + ); + }); +} diff --git a/packages/devtools_app/test/screens/app_size/app_size_screen_test.dart b/packages/devtools_app/test/screens/app_size/app_size_screen_test.dart index 135127828e7..c2af7812031 100644 --- a/packages/devtools_app/test/screens/app_size/app_size_screen_test.dart +++ b/packages/devtools_app/test/screens/app_size/app_size_screen_test.dart @@ -12,8 +12,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_test_utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/test_data/app_size/deferred_app.dart'; diff --git a/packages/devtools_app/test/screens/app_size/code_size_attribution_test.dart b/packages/devtools_app/test/screens/app_size/code_size_attribution_test.dart index 575251f3cbf..e0875386fae 100644 --- a/packages/devtools_app/test/screens/app_size/code_size_attribution_test.dart +++ b/packages/devtools_app/test/screens/app_size/code_size_attribution_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_snapshot_analysis/precompiler_trace.dart'; import 'package:vm_snapshot_analysis/program_info.dart'; diff --git a/packages/devtools_app/test/screens/cpu_profiler/cpu_profile_benchmark_test.dart b/packages/devtools_app/test/screens/cpu_profiler/cpu_profile_benchmark_test.dart index 98452901e33..b4e7d430269 100644 --- a/packages/devtools_app/test/screens/cpu_profiler/cpu_profile_benchmark_test.dart +++ b/packages/devtools_app/test/screens/cpu_profiler/cpu_profile_benchmark_test.dart @@ -35,7 +35,7 @@ void main() { final score = await benchmark.measure(); expect( score, - lessThan(100000), + lessThan(120000), reason: 'Exceeded benchmark for run $i: $score', ); } diff --git a/packages/devtools_app/test/screens/cpu_profiler/cpu_profiler_test.dart b/packages/devtools_app/test/screens/cpu_profiler/cpu_profiler_test.dart index 65ef96b5b65..6778b3e2615 100644 --- a/packages/devtools_app/test/screens/cpu_profiler/cpu_profiler_test.dart +++ b/packages/devtools_app/test/screens/cpu_profiler/cpu_profiler_test.dart @@ -18,8 +18,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/cpu_profiler/method_table/method_table_test.dart b/packages/devtools_app/test/screens/cpu_profiler/method_table/method_table_test.dart index d347b7698e6..f2ab3d6741c 100644 --- a/packages/devtools_app/test/screens/cpu_profiler/method_table/method_table_test.dart +++ b/packages/devtools_app/test/screens/cpu_profiler/method_table/method_table_test.dart @@ -12,8 +12,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/cpu_profiler/profiler_screen_test.dart b/packages/devtools_app/test/screens/cpu_profiler/profiler_screen_test.dart index 6a7d1ed95be..f37e6e75bd4 100644 --- a/packages/devtools_app/test/screens/cpu_profiler/profiler_screen_test.dart +++ b/packages/devtools_app/test/screens/cpu_profiler/profiler_screen_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app/src/shared/ui/file_import.dart'; import 'package:devtools_app/src/shared/ui/vm_flag_widgets.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../test_infra/scenes/cpu_profiler/default.dart'; import '../../test_infra/scenes/scene_test_extensions.dart'; @@ -125,8 +125,7 @@ void main() { expect(find.byType(CpuProfilerDisabled), findsOneWidget); expect(find.byType(ProfileRecordingInstructions), findsNothing); - expect(find.byType(RecordButton), findsNothing); - expect(find.byType(StopRecordingButton), findsNothing); + expect(find.byType(StartStopRecordingButton), findsNothing); expect(find.byType(ClearButton), findsNothing); expect(find.byType(CpuSamplingRateDropdown), findsNothing); expect(find.byType(OpenSaveButtonGroup), findsNothing); diff --git a/packages/devtools_app/test/screens/debugger/debugger_codeview_statistics_test.dart b/packages/devtools_app/test/screens/debugger/debugger_codeview_statistics_test.dart index 56e199fb031..188e171d047 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_codeview_statistics_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_codeview_statistics_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/screens/debugger/debugger_codeview_test.dart b/packages/devtools_app/test/screens/debugger/debugger_codeview_test.dart index db88c2f5eb4..b516b42c5b4 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_codeview_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_codeview_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/debugger/debugger_console_test.dart b/packages/devtools_app/test/screens/debugger/debugger_console_test.dart index 392bc0db2e4..4e9b1873074 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_console_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_console_test.dart @@ -9,9 +9,9 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/utils/ansi.dart'; diff --git a/packages/devtools_app/test/screens/debugger/debugger_controls_overflow_test.dart b/packages/devtools_app/test/screens/debugger/debugger_controls_overflow_test.dart new file mode 100644 index 00000000000..8692075584d --- /dev/null +++ b/packages/devtools_app/test/screens/debugger/debugger_controls_overflow_test.dart @@ -0,0 +1,95 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/screens/debugger/controls.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + /// Widths the debugging controls are expected to lay out at without + /// overflowing. + /// + /// The controls stop showing button labels below + /// [DebuggingControls.minWidth], but the remaining icon-only content still + /// did not fit below roughly 630px, which is a realistic width for DevTools + /// embedded in an IDE side panel. See + /// https://github.com/flutter/devtools/issues/4917. + const windowWidths = [1200.0, 800.0, 600.0, 500.0, 400.0]; + + const windowHeight = 800.0; + + late FakeServiceConnectionManager fakeServiceConnection; + late MockScriptManager scriptManager; + late MockDebuggerController debuggerController; + + setUp(() { + fakeServiceConnection = FakeServiceConnectionManager(); + scriptManager = MockScriptManager(); + mockConnectedApp(fakeServiceConnection.serviceManager.connectedApp!); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + setGlobal(IdeTheme, IdeTheme()); + setGlobal(ScriptManager, scriptManager); + setGlobal(NotificationService, NotificationService()); + setGlobal(BreakpointManager, BreakpointManager()); + setGlobal( + DevToolsEnvironmentParameters, + ExternalDevToolsEnvironmentParameters(), + ); + setGlobal(PreferencesController, PreferencesController()); + fakeServiceConnection.consoleService.ensureServiceInitialized(); + when( + fakeServiceConnection.errorBadgeManager.errorCountNotifier('debugger'), + ).thenReturn(ValueNotifier(0)); + debuggerController = createMockDebuggerControllerWithDefaults(); + }); + + Future pumpControls(WidgetTester tester) async { + await tester.pumpWidget( + wrapWithControllers( + const DebuggingControls(), + debugger: debuggerController, + ), + ); + await tester.pump(); + } + + group('DebuggingControls', () { + for (final width in windowWidths) { + testWidgetsWithWindowSize( + 'does not overflow at ${width.toInt()}px', + Size(width, windowHeight), + (WidgetTester tester) async { + await pumpControls(tester); + + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgetsWithWindowSize( + 'keeps the file explorer button pinned to the right edge', + const Size(1200.0, windowHeight), + (WidgetTester tester) async { + await pumpControls(tester); + + final controlsRight = tester + .getRect(find.byType(DebuggingControls)) + .right; + final fileExplorerButtonRight = tester + .getRect( + find.widgetWithIcon(GaDevToolsButton, Icons.folder_outlined), + ) + .right; + + expect(fileExplorerButtonRight, equals(controlsRight)); + }, + ); + }); +} diff --git a/packages/devtools_app/test/screens/debugger/debugger_evaluation_test.dart b/packages/devtools_app/test/screens/debugger/debugger_evaluation_test.dart index ef0c1f1d96f..af1fba7e631 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_evaluation_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_evaluation_test.dart @@ -275,6 +275,8 @@ void main() { 'bitLength', 'toString', 'remainder', + 'trailingZeroBitCount', + 'oneBitCount', 'abs', 'sign', 'isEven', diff --git a/packages/devtools_app/test/screens/debugger/debugger_floating_test.dart b/packages/devtools_app/test/screens/debugger/debugger_floating_test.dart index 06185005851..226e08add6a 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_floating_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_floating_test.dart @@ -7,8 +7,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/debugger/debugger_screen_breakpoints_test.dart b/packages/devtools_app/test/screens/debugger/debugger_screen_breakpoints_test.dart index 7470497d0f9..98e7f6fb3c3 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_screen_breakpoints_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_screen_breakpoints_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; @@ -56,9 +56,6 @@ void main() { ), ]; final codeViewController = debuggerController.codeViewController; - when( - mockBreakpointManager.breakpoints, - ).thenReturn(ValueNotifier(breakpoints)); when( mockBreakpointManager.breakpointsWithLocation, ).thenReturn(ValueNotifier(breakpointsWithLocation)); diff --git a/packages/devtools_app/test/screens/debugger/debugger_screen_call_stack_test.dart b/packages/devtools_app/test/screens/debugger/debugger_screen_call_stack_test.dart index 9d697fed40b..1b26cac4a02 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_screen_call_stack_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_screen_call_stack_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/debugger/debugger_screen_dap_variables_test.dart b/packages/devtools_app/test/screens/debugger/debugger_screen_dap_variables_test.dart index 4bd607df63f..18aff353c5e 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_screen_dap_variables_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_screen_dap_variables_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { @@ -126,4 +126,39 @@ void main() { // String is now visible. expect(stringFinder, findsOneWidget); }); + testWidgetsWithWindowSize( + 'Truncates long variables to a single line with tooltip', + windowSize, + (WidgetTester tester) async { + final longString = 'a' * 1000; + final node = DapObjectNode( + service: vmService, + variable: dap.Variable( + name: 'longVar', + value: longString, + variablesReference: 0, + ), + ); + + fakeServiceConnection.appState.setDapVariables([node]); + await pumpDebuggerScreen(tester, debuggerController); + + final tooltipFinder = find.byWidgetPredicate( + (widget) => widget is DevToolsTooltip && widget.message == longString, + ); + expect(tooltipFinder, findsOneWidget); + + final finder = find.byType(RichText); + bool foundTruncated = false; + for (final widget in tester.widgetList(finder)) { + if (widget.text.toPlainText().contains('longVar: $longString')) { + expect(widget.maxLines, 1); + expect(widget.softWrap, false); + expect(widget.overflow, TextOverflow.ellipsis); + foundTruncated = true; + } + } + expect(foundTruncated, isTrue); + }, + ); } diff --git a/packages/devtools_app/test/screens/debugger/debugger_screen_explorer_visibility_test.dart b/packages/devtools_app/test/screens/debugger/debugger_screen_explorer_visibility_test.dart index 29d92c2e030..2432b554d54 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_screen_explorer_visibility_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_screen_explorer_visibility_test.dart @@ -7,8 +7,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/debugger/debugger_screen_paused_test.dart b/packages/devtools_app/test/screens/debugger/debugger_screen_paused_test.dart index 8507c46016d..56201113654 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_screen_paused_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_screen_paused_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/debugger/debugger_screen_test.dart b/packages/devtools_app/test/screens/debugger/debugger_screen_test.dart index daa2f649724..72ee8237d77 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_screen_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_screen_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/debugger/debugger_screen_variables_test.dart b/packages/devtools_app/test/screens/debugger/debugger_screen_variables_test.dart index 21464355d0d..4c3688fce1a 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_screen_variables_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_screen_variables_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/utils/variable_utils.dart'; diff --git a/packages/devtools_app/test/screens/debugger/debugger_scripts_test.dart b/packages/devtools_app/test/screens/debugger/debugger_scripts_test.dart index 213c80f87c4..67d12388969 100644 --- a/packages/devtools_app/test/screens/debugger/debugger_scripts_test.dart +++ b/packages/devtools_app/test/screens/debugger/debugger_scripts_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/debugger/eval_field_test.dart b/packages/devtools_app/test/screens/debugger/eval_field_test.dart index 959cb92a901..221a2ab46dc 100644 --- a/packages/devtools_app/test/screens/debugger/eval_field_test.dart +++ b/packages/devtools_app/test/screens/debugger/eval_field_test.dart @@ -10,9 +10,9 @@ import 'package:devtools_app/src/shared/ui/search.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { group('ExpressionEvalField', () { diff --git a/packages/devtools_app/test/screens/debugger/file_search_test.dart b/packages/devtools_app/test/screens/debugger/file_search_test.dart index 7693fde1d81..ac06a62793f 100644 --- a/packages/devtools_app/test/screens/debugger/file_search_test.dart +++ b/packages/devtools_app/test/screens/debugger/file_search_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/screens/debugger/program_explorer_test.dart b/packages/devtools_app/test/screens/debugger/program_explorer_test.dart index 189a7f561df..afde87f6f6c 100644 --- a/packages/devtools_app/test/screens/debugger/program_explorer_test.dart +++ b/packages/devtools_app/test/screens/debugger/program_explorer_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/debugger/span_parser_test.dart b/packages/devtools_app/test/screens/debugger/span_parser_test.dart index 42f64641373..f542740a55a 100644 --- a/packages/devtools_app/test/screens/debugger/span_parser_test.dart +++ b/packages/devtools_app/test/screens/debugger/span_parser_test.dart @@ -108,7 +108,7 @@ void main() { // These goldens are updated using the usual Flutter --update-goldens // flag: // - // flutter test test/shared/span_parser_test.dart --update-goldens + // flutter test test/screens/debugger/span_parser_test.dart --update-goldens final testFiles = testDataDirectory.listSync().whereType().where( (file) => path.extension(file.path) == '.dart', ); diff --git a/packages/devtools_app/test/screens/debugger/syntax_highlighter_test.dart b/packages/devtools_app/test/screens/debugger/syntax_highlighter_test.dart index b8b8fc1464c..2588d53b356 100644 --- a/packages/devtools_app/test/screens/debugger/syntax_highlighter_test.dart +++ b/packages/devtools_app/test/screens/debugger/syntax_highlighter_test.dart @@ -8,6 +8,7 @@ library; import 'dart:convert'; import 'dart:io'; +import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:devtools_app/src/screens/debugger/span_parser.dart'; import 'package:devtools_app/src/screens/debugger/syntax_highlighter.dart'; import 'package:devtools_app/src/shared/framework/routing.dart'; @@ -15,9 +16,8 @@ import 'package:devtools_app/src/shared/ui/colors.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; const modifierSpans = [ // Start multi-capture spans diff --git a/packages/devtools_app/test/screens/debugger/typed_data_variable_test.dart b/packages/devtools_app/test/screens/debugger/typed_data_variable_test.dart index dc3c4d34fd3..b0f0325e643 100644 --- a/packages/devtools_app/test/screens/debugger/typed_data_variable_test.dart +++ b/packages/devtools_app/test/screens/debugger/typed_data_variable_test.dart @@ -372,13 +372,6 @@ void main() { expect( variable.children.first.displayValue, '[ffffffff, 00000000, ffffffff, 00000000]', - skip: kIsWeb, - ); - // Formatting is different on the web. - expect( - variable.children.first.displayValue, - '[-1, 0, -1, 0]', - skip: !kIsWeb, ); }); @@ -408,12 +401,6 @@ void main() { expect( variable.children.first.displayValue, '[0.000000, -232.199997, 2.330000, 9.000000]', - skip: kIsWeb, - ); - expect( - variable.children.first.displayValue, - '[0, -232.1999969482422, 2.3299999237060547, 9]', - skip: !kIsWeb, ); }); @@ -438,16 +425,7 @@ void main() { await buildVariablesTree(variable); - expect( - variable.children.first.displayValue, - '[0.000000, -1232.222000]', - skip: kIsWeb, - ); - expect( - variable.children.first.displayValue, - '[0, -1232.222]', - skip: !kIsWeb, - ); + expect(variable.children.first.displayValue, '[0.000000, -1232.222000]'); }); test( diff --git a/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart b/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart index e94daac714d..35e69c9f918 100644 --- a/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart +++ b/packages/devtools_app/test/screens/deep_link_validation/deep_links_screen_test.dart @@ -17,8 +17,8 @@ import 'package:devtools_shared/devtools_deeplink.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/test_data/deep_link/fake_responses.dart'; @@ -655,5 +655,29 @@ void main() { expect(find.text('/ios-path1'), findsOneWidget); expect(find.text('NOT /ios-path2'), findsOneWidget); }); + + testWidgetsWithWindowSize( + 'surfaces iOS universal link settings error to UI', + windowSize, + (WidgetTester tester) async { + final deepLinksController = TestDeepLinksController(); + const iosErrorMessage = + 'Unknown iOS build configuration (Debug) or target (Runner).'; + + deepLinksController + ..selectedProject.value = FlutterProject( + path: '/abc', + androidVariants: ['debug', 'release'], + iosBuildOptions: xcodeBuildOptions, + ) + ..fakeAndroidDeepLinks = [defaultAndroidDeeplink] + ..fakeIosError = iosErrorMessage; + + await pumpDeepLinkScreen(tester, controller: deepLinksController); + + expect(deepLinksController.pagePhase.value, PagePhase.analyzeErrorPage); + expect(find.text(iosErrorMessage), findsOneWidget); + }, + ); }); } diff --git a/packages/devtools_app/test/screens/deep_link_validation/select_project_view_test.dart b/packages/devtools_app/test/screens/deep_link_validation/select_project_view_test.dart index 7602c18c4fb..4a847b004d4 100644 --- a/packages/devtools_app/test/screens/deep_link_validation/select_project_view_test.dart +++ b/packages/devtools_app/test/screens/deep_link_validation/select_project_view_test.dart @@ -12,8 +12,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/utils/deep_links_utils.dart'; diff --git a/packages/devtools_app/test/screens/dtd/dtd_tools_screen_test.dart b/packages/devtools_app/test/screens/dtd/dtd_tools_screen_test.dart index b7053212220..407f8822fcc 100644 --- a/packages/devtools_app/test/screens/dtd/dtd_tools_screen_test.dart +++ b/packages/devtools_app/test/screens/dtd/dtd_tools_screen_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/screens/dtd/events_test.dart b/packages/devtools_app/test/screens/dtd/events_test.dart index 1bae605fcbe..afc43f2695c 100644 --- a/packages/devtools_app/test/screens/dtd/events_test.dart +++ b/packages/devtools_app/test/screens/dtd/events_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/screens/dtd/services_test.dart b/packages/devtools_app/test/screens/dtd/services_test.dart index ab30f562503..c1768bd5cf8 100644 --- a/packages/devtools_app/test/screens/dtd/services_test.dart +++ b/packages/devtools_app/test/screens/dtd/services_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/screens/inspector/diagnostics_test.dart b/packages/devtools_app/test/screens/inspector/diagnostics_test.dart index 1e1bb3e2457..3bff21c152e 100644 --- a/packages/devtools_app/test/screens/inspector/diagnostics_test.dart +++ b/packages/devtools_app/test/screens/inspector/diagnostics_test.dart @@ -5,13 +5,14 @@ import 'dart:convert'; import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/shared/feature_flags.dart'; import 'package:devtools_app/src/shared/ui/utils.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { group('DiagnosticsNodeDescription', () { @@ -46,6 +47,7 @@ void main() { } '''); setUp(() { + setEnableExperiments(); setGlobal( DevToolsEnvironmentParameters, ExternalDevToolsEnvironmentParameters(), diff --git a/packages/devtools_app/test/screens/inspector/inspector_error_navigator_test.dart b/packages/devtools_app/test/screens/inspector/inspector_error_navigator_test.dart index b546faae833..00cb66c6fff 100644 --- a/packages/devtools_app/test/screens/inspector/inspector_error_navigator_test.dart +++ b/packages/devtools_app/test/screens/inspector/inspector_error_navigator_test.dart @@ -5,15 +5,17 @@ import 'dart:collection'; import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/shared/feature_flags.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { setUp(() { + setEnableExperiments(); setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); setGlobal(IdeTheme, IdeTheme()); }); diff --git a/packages/devtools_app/test/screens/inspector/inspector_integration_test.dart b/packages/devtools_app/test/screens/inspector/inspector_integration_test.dart index 659959add0c..e29a6f2d03f 100644 --- a/packages/devtools_app/test/screens/inspector/inspector_integration_test.dart +++ b/packages/devtools_app/test/screens/inspector/inspector_integration_test.dart @@ -2,92 +2,134 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:collection/collection.dart'; import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/screens/inspector/layout_explorer/ui/utils.dart'; +import 'package:devtools_app/src/screens/inspector/widget_properties/properties_view.dart'; +import 'package:devtools_app/src/shared/ui/tab.dart'; +import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:path/path.dart' as p; import '../../test_infra/flutter_test_driver.dart' show FlutterRunConfiguration; import '../../test_infra/flutter_test_environment.dart'; import '../../test_infra/matchers/matchers.dart'; +// Note: This test uses packages/devtools_app/test/test_infra/fixtures/flutter_app +// running on the flutter-tester device. + // This is a bit conservative to ensure we do not get flakes due to // slow interactions with the VM Service. This delay could likely be // reduced to under 1 second without introducing flakes. const inspectorChangeSettleTime = Duration(seconds: 2); void main() { - const windowSize = Size(2600.0, 1200.0); // We need to use real async in this test so we need to use this binding. initializeLiveTestWidgetsFlutterBindingWithAssets(); + const windowSize = Size(2600.0, 1200.0); - late FlutterTestEnvironment env; - - Future resetInspectorSelection() async { + // The auto-refresh tests modify lib/main.dart in-place. + // We use `useTempDirectory: true` to copy the fixture app to a temporary + // directory. This prevents flaky test failures in other tests (like + // inspector_service_test.dart) that run in parallel. + final env = FlutterTestEnvironment( + const FlutterRunConfiguration(withDebugger: true), + testAppDirectory: 'test/test_infra/fixtures/inspector_app', + useTempDirectory: true, + ); + + env.afterEverySetup = () async { final service = serviceConnection.inspectorService; + await _resetPubRootDirectories(service as InspectorService); if (env.reuseTestEnvironment) { // Ensure the previous test did not set the selection on the device. // TODO(jacobr): add a proper method to WidgetInspectorService that does // this. setSelection currently ignores null selection requests which is // a misfeature. - await service!.inspectorLibrary.eval( + await service.inspectorLibrary.eval( 'WidgetInspectorService.instance.selection.clear()', isAlive: null, ); } - } + }; setUp(() async { await env.setupEnvironment(); setGlobal(BannerMessagesController, BannerMessagesController()); - // Ensure the legacy inspector is enabled: - preferences.inspector.setLegacyInspectorEnabled(true); }); - group('screenshot tests', () { - setUpAll(() { - env = FlutterTestEnvironment( - const FlutterRunConfiguration(withDebugger: true), - ); - env.afterEverySetup = resetInspectorSelection; - }); + tearDown(() async { + await env.tearDownEnvironment(force: true); + }); - tearDownAll(() async { - await env.tearDownEnvironment(force: true); - }); + tearDownAll(() { + env.finalTeardown(); + }); - testWidgetsWithWindowSize('navigation', windowSize, ( + group('screenshot tests', () { + testWidgetsWithWindowSize('initial load', windowSize, ( WidgetTester tester, ) async { - await env.setupEnvironment(); expect(serviceConnection.serviceManager.service, equals(env.service)); expect(serviceConnection.serviceManager.isolateManager, isNotNull); - final screen = InspectorScreen(); - await tester.pumpWidget( - wrapWithInspectorControllers(Builder(builder: screen.build)), - ); - await tester.pump(const Duration(seconds: 1)); - final InspectorScreenBodyState state = tester.state( - find.byType(InspectorScreenBody), - ); - final controller = state.controller; - while (!controller.flutterAppFrameReady) { - await controller.maybeLoadUI(); - await tester.pumpAndSettle(); - } - // Give time for the initial animation to complete. - await tester.pumpAndSettle(inspectorChangeSettleTime); + await _loadInspectorUI(tester); + await expectLater( find.byType(InspectorScreenBody), matchesDevToolsGolden( '../../test_infra/goldens/integration_inspector_initial_load.png', ), ); + }); - // Click on the Center widget (row index #5) - await tester.tap(find.richText('Center')); + testWidgetsWithWindowSize( + 'loads after a hot-restart', + windowSize, + (WidgetTester tester) async { + // Load the inspector panel. + await _loadInspectorUI(tester); + + // Expect the Center widget to be visible in the widget tree. + final centerWidgetFinder = find.richText('CustomCenter'); + expect(centerWidgetFinder, findsOneWidget); + + // Trigger a hot-restart and wait for the first Flutter frame. + await env.flutter!.hotRestart(); + await _waitForFlutterFrame(tester, isInitialLoad: false); + + // Wait for the Center widget to be visible again. + final centerWidgetFinderWithRetries = await retryUntilFound( + centerWidgetFinder, + tester: tester, + ); + expect(centerWidgetFinderWithRetries, findsOneWidget); + + await expectLater( + find.byType(InspectorScreenBody), + matchesDevToolsGolden( + '../../test_infra/goldens/integration_inspector_after_hot_restart.png', + ), + ); + }, + skip: true, // https://github.com/flutter/devtools/issues/8179 + ); + + testWidgetsWithWindowSize('widget selection', windowSize, ( + WidgetTester tester, + ) async { + await _loadInspectorUI(tester); + + // Select the CustomCenter widget (row index #4) + await tester.tap(find.richText('CustomCenter')); await tester.pumpAndSettle(inspectorChangeSettleTime); await expectLater( find.byType(InspectorScreenBody), @@ -96,349 +138,383 @@ void main() { ), ); - // Select the details tree. - await tester.tap( - find.text(InspectorDetailsViewType.widgetDetailsTree.key), + // Verify the properties are displayed: + verifyPropertyIsVisible( + name: 'alignment', + value: 'Alignment.center', + tester: tester, ); - await tester.pumpAndSettle(inspectorChangeSettleTime); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_select_center_details_tree.png', - ), - // Implementation widgets from Flutter framework are not guaranteed to - // be stable. - skip: 'https://github.com/flutter/flutter/issues/172037', + verifyPropertyIsVisible( + name: 'dependencies', + value: '[Directionality]', + tester: tester, ); + }); - // Select the RichText row. - await tester.tap(find.richText('RichText')); - await tester.pumpAndSettle(inspectorChangeSettleTime); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_richtext_selected.png', - ), - // Implementation widgets from Flutter framework are not guaranteed to - // be stable. - skip: 'https://github.com/flutter/flutter/issues/172037', - ); + testWidgetsWithWindowSize( + 'expand and collapse implementation widgets', + windowSize, + (WidgetTester tester) async { + await _loadInspectorUI(tester); + + // Toggle implementation widgets on. + await _toggleImplementationWidgets(tester); + + // Before hidden widgets are expanded, confirm the implementing + // Container of CustomContainer is hidden: + final hideableNodeFinder = findNodeMatching('Container'); + expect(hideableNodeFinder, findsNothing); + + // Expand the hidden group that contains the Container: + final moreWidgetsRow = findChildRowOf('CustomContainer'); + final expandButton = findExpandCollapseButtonForRow( + rowFinder: moreWidgetsRow, + isExpand: true, + ); + await tester.tap(expandButton); + await tester.pumpAndSettle(inspectorChangeSettleTime); + await expectLater( + find.byType(InspectorScreenBody), + matchesDevToolsGolden( + '../../test_infra/goldens/integration_inspector_implementation_widgets_expanded.png', + ), + // Implementation widgets from Flutter framework are not guaranteed to + // be stable. + skip: 'https://github.com/flutter/flutter/issues/172037', + ); + + // Confirm the Container is visible, and select it: + expect(hideableNodeFinder, findsOneWidget); + await tester.tap(hideableNodeFinder); + await tester.pumpAndSettle(inspectorChangeSettleTime); + await expectLater( + find.byType(InspectorScreenBody), + matchesDevToolsGolden( + '../../test_infra/goldens/integration_inspector_hideable_widget_selected.png', + ), + // Implementation widgets from Flutter framework are not guaranteed to + // be stable. + skip: 'https://github.com/flutter/flutter/issues/172037', + ); + + // Collapse the hidden group that contains the Container: + final collapsibleRow = findChildRowOf('CustomContainer'); + final collapseButton = findExpandCollapseButtonForRow( + rowFinder: collapsibleRow, + isExpand: false, + ); + await tester.tap(collapseButton); + await tester.pumpAndSettle(inspectorChangeSettleTime); + await expectLater( + find.byType(InspectorScreenBody), + matchesDevToolsGolden( + '../../test_infra/goldens/integration_inspector_implementation_widgets_collapsed.png', + ), + ); + }, + ); + + testWidgetsWithWindowSize('search for implementation widgets', windowSize, ( + WidgetTester tester, + ) async { + await _loadInspectorUI(tester); - // Test hovering over the icon shown when a property has its default - // value. - // TODO(jacobr): support tooltips in the Flutter version of the inspector. - // https://github.com/flutter/devtools/issues/2570. - // For example, verify that the tooltip hovering over the default value - // icons is "Default value". - // Test selecting a widget. - - // Two 'Scaffold's: a breadcrumb and an actual tree item - expect(find.richText('Scaffold'), findsNWidgets(2)); - // select Scaffold widget in summary tree. - await tester.tap(find.richText('Scaffold').last); - await tester.pumpAndSettle(inspectorChangeSettleTime); - // This tree is huge. If there is a change to package:flutter it may - // change. If this happens don't panic and rebaseline the golden. - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_scaffold_selected.png', - ), - // Implementation widgets from Flutter framework are not guaranteed to - // be stable. - skip: 'https://github.com/flutter/flutter/issues/172037', - ); + // Toggle implementation widgets on. + await _toggleImplementationWidgets(tester); - // The important thing about this is that the details tree should scroll - // instead of re-rooting as the selected row is already visible in the - // details tree. - await tester.tap(find.richText('AnimatedPhysicalModel')); + // Before searching, confirm the implementing DefaultTextStyle of + // CustomApp is hidden: + final hideableNodeFinder = findNodeMatching('DefaultTextStyle'); + expect(hideableNodeFinder, findsNothing); + + // Search for the DefaultTextStyle: + final searchButtonFinder = find.ancestor( + of: find.byIcon(Icons.search), + matching: find.byType(ToolbarAction), + ); + await tester.tap(searchButtonFinder); + await tester.pumpAndSettle(inspectorChangeSettleTime); + await tester.enterText(find.byType(TextField), 'DefaultTextStyle'); await tester.pumpAndSettle(inspectorChangeSettleTime); + await tester.tap(find.byIcon(Icons.close)); + await tester.pumpAndSettle(inspectorChangeSettleTime); + + // Confirm the DefaultTextStyle is visible and selected: + expect(hideableNodeFinder, findsOneWidget); await expectLater( find.byType(InspectorScreenBody), matchesDevToolsGolden( - '../../test_infra/goldens/integration_animated_physical_model_selected.png', + '../../test_infra/goldens/integration_inspector_hideable_widget_selected_from_search.png', ), // Implementation widgets from Flutter framework are not guaranteed to // be stable. skip: 'https://github.com/flutter/flutter/issues/172037', ); - - await env.tearDownEnvironment(); }); + }); - // TODO(jacobr): convert these tests to screenshot tests like the initial - // state test. - /* - - - // Intentionally trigger multiple quick navigate action to ensure that - // multiple quick navigation commands in a row do not trigger race - // conditions getting out of order updates from the server. - tree.navigateDown(); - tree.navigateDown(); - tree.navigateDown(); - await detailsTree.nextUiFrame; - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center\n' - ' │ [/icons/inspector/textArea.png]Text\n' - ' └─▼[A]AppBar <-- selected\n' - ' [/icons/inspector/textArea.png]Text\n', - ), - ); - // Make sure we don't go off the bottom of the tree. - tree.navigateDown(); - tree.navigateDown(); - tree.navigateDown(); - tree.navigateDown(); - tree.navigateDown(); - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center\n' - ' │ [/icons/inspector/textArea.png]Text\n' - ' └─▼[A]AppBar\n' - ' [/icons/inspector/textArea.png]Text <-- selected\n', - ), - ); - tree.navigateUp(); - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center\n' - ' │ [/icons/inspector/textArea.png]Text\n' - ' └─▼[A]AppBar <-- selected\n' - ' [/icons/inspector/textArea.png]Text\n', - ), - ); - tree.navigateLeft(); - await detailsTree.nextUiFrame; - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center\n' - ' │ [/icons/inspector/textArea.png]Text\n' - ' └─▶[A]AppBar <-- selected\n', - ), - ); - tree.navigateLeft(); - // First navigate left goes to the parent. - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold <-- selected\n' - ' ├───▼[C]Center\n' - ' │ [/icons/inspector/textArea.png]Text\n' - ' └─▶[A]AppBar\n', - ), - ); - tree.navigateLeft(); - // Next navigate left closes the parent. - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▶[S]Scaffold <-- selected\n', - ), - ); + testWidgetsWithWindowSize('hide all implementation widgets', windowSize, ( + WidgetTester tester, + ) async { + await _loadInspectorUI(tester); + + // Toggle implementation widgets on. + await _toggleImplementationWidgets(tester); + + // Confirm the hidden widgets are visible behind affordances like "X more + // widgets". + expect(find.richTextContaining('more widgets...'), findsWidgets); + + // Toggle implementation widgets off. + await _toggleImplementationWidgets(tester); + + // Confirm that the hidden widgets are no longer visible. + expect(find.richTextContaining('more widgets...'), findsNothing); + await expectLater( + find.byType(InspectorScreenBody), + matchesDevToolsGolden( + '../../test_infra/goldens/integration_inspector_implementation_widgets_hidden.png', + ), + ); + + // Refresh the tree. + final refreshTreeButton = find.descendant( + of: find.byType(ToolbarAction), + matching: find.byIcon(Icons.refresh), + ); + + await tester.tap(refreshTreeButton); + await tester.pumpAndSettle(inspectorChangeSettleTime); + + // Confirm that the hidden widgets are still not visible. + expect(find.richTextContaining('more widgets...'), findsNothing); + }); - tree.navigateRight(); - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold <-- selected\n' - ' ├───▼[C]Center\n' - ' │ [/icons/inspector/textArea.png]Text\n' - ' └─▶[A]AppBar\n', - ), - ); + // TODO(elliette): Expand into test group for cases when: + // - selected widget is implementation widget and implementation widgets are hidden (this test case) + // - selected widget is implementation widget and implementation widgets are visible + // - selected widget is not implementation widget and implementation widgets are hidden + // - selected widget is not implementation widget and implementation widgets are visible + testWidgetsWithWindowSize('selecting implementation widget', windowSize, ( + WidgetTester tester, + ) async { + // Load the Inspector. + await _loadInspectorUI(tester); + + // Toggle implementation widgets on. + await _toggleImplementationWidgets(tester); + + await tester.pumpAndSettle(inspectorChangeSettleTime); + final state = + tester.state(find.byType(InspectorScreenBody)) + as InspectorScreenBodyState; + + // Find the CustomText diagnostic node. + final diagnostics = state.controller.inspectorTree.rowsInTree.value.map( + (row) => row!.node.diagnostic, + ); + final customTextDiagnostic = diagnostics.firstWhere( + (d) => d?.description == 'CustomText', + )!; + expect(customTextDiagnostic.isCreatedByLocalProject, isTrue); + + // Toggle implementation widgets off. + await _toggleImplementationWidgets(tester); + + // Verify the CustomText diagnostic node is still in the tree. + final diagnosticsNow = state.controller.inspectorTree.rowsInTree.value.map( + (row) => row!.node.diagnostic, + ); + expect( + diagnosticsNow.any((d) => d?.valueRef == customTextDiagnostic.valueRef), + isTrue, + ); + + // Get the implementing Text child of the CustomText diagnostic node. + final service = serviceConnection.inspectorService as InspectorService; + final group = service.createObjectGroup('test-group'); + final customTextSubtree = await group.getDetailsSubtree( + customTextDiagnostic, + ); + final textDiagnostic = (await customTextSubtree!.children)!.firstWhere( + (child) => child.description == 'Text', + ); + + // Verify the Text child is an implementation node that is not in the tree. + expect(textDiagnostic.isCreatedByLocalProject, isFalse); + expect( + diagnosticsNow.any((d) => d?.valueRef == textDiagnostic.valueRef), + isFalse, + ); + + // Mimic selecting the Text diagnostic node with the on-device inspector. + await group.setSelectionInspector(textDiagnostic.valueRef, false); + await tester.pumpAndSettle(inspectorChangeSettleTime); + + // Verify the CustomText node is now selected. + final selectedNode = state.controller.selectedNode.value; + expect( + selectedNode!.diagnostic!.valueRef, + equals(customTextDiagnostic.valueRef), + ); + + // Verify the notification about selecting an implementation widget is displayed. + expect( + find.text('Selected an implementation widget of CustomText: Text.'), + findsOneWidget, + ); + }); - // Node is already expanded so this is equivalent to navigate down. - tree.navigateRight(); - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center <-- selected\n' - ' │ [/icons/inspector/textArea.png]Text\n' - ' └─▶[A]AppBar\n', - ), - ); + testWidgetsWithWindowSize( + 'tree nodes contain only essential information', + windowSize, + (WidgetTester tester) async { + const requiredDetailsForTreeNode = [ + 'description', + 'shouldIndent', + 'valueId', + 'widgetRuntimeType', + ]; + const possibleDetailsForTreeNode = [ + 'textPreview', + 'children', + 'createdByLocalProject', + ]; + const extraneousDetailsForTreeNode = [ + 'creationLocation', + 'type', + 'style', + 'hasChildren', + 'stateful', + ]; + + await _loadInspectorUI(tester); + final state = + tester.state(find.byType(InspectorScreenBody)) + as InspectorScreenBodyState; + final rowsInTree = state.controller.inspectorTree.rowsInTree.value; + + for (final row in rowsInTree) { + final detailKeys = row?.node.diagnostic?.json.keys ?? const []; + expect( + requiredDetailsForTreeNode.every( + (detail) => detailKeys.contains(detail), + ), + isTrue, + ); + expect( + detailKeys.every( + (detail) => + requiredDetailsForTreeNode.contains(detail) || + possibleDetailsForTreeNode.contains(detail), + ), + isTrue, + ); + expect( + detailKeys.none( + (detail) => extraneousDetailsForTreeNode.contains(detail), + ), + isTrue, + ); + } + }, + ); - await detailsTree.nextUiFrame; + group('auto-refresh after code edits', () { + final flutterAppMainPath = p.join(env.testAppDirectory, 'lib', 'main.dart'); + String flutterMainContents = ''; - // Make sure the details and main trees have not gotten out of sync. - expect( - detailsTree.toStringDeep(hidePropertyLines: true), - equalsIgnoringHashCodes('▼[C]Center <-- selected\n' - '└─▼[/icons/inspector/textArea.png]Text\n' - ' └─▼[/icons/inspector/textArea.png]RichText\n'), - ); + setUp(() { + // Save contents of main.dart file. + flutterMainContents = File(flutterAppMainPath).readAsStringSync(); - await env.tearDownEnvironment(); + // Enable auto-refresh. + preferences.inspector.setAutoRefreshEnabled(true); }); - */ - - // TODO(jacobr): uncomment hotReload test once the hot reload test is not - // flaky. https://github.com/flutter/devtools/issues/642 - /* - test('hotReload', () async { - if (flutterVersion == '1.2.1') { - // This test can be flaky in Flutter 1.2.1 because of - // https://github.com/dart-lang/sdk/issues/33838 - // so we just skip it. This block of code can be removed after the next - // stable flutter release. - // TODO(dantup): Remove this. - return; - } - await env.setupEnvironment(); - - await serviceManager.performHotReload(); - // Ensure the inspector does not fall over and die after a hot reload. - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center\n' - ' │ [/icons/inspector/textArea.png]Text <-- selected\n' - ' └─▼[A]AppBar\n' - ' [/icons/inspector/textArea.png]Text\n', - ), - ); - // TODO(jacobr): would be nice to have some tests that trigger a hot - // reload that actually changes app state in a meaningful way. + tearDown(() { + // Re-set contents of main.dart. + File( + flutterAppMainPath, + ).writeAsStringSync(flutterMainContents, flush: true); - await env.tearDownEnvironment(); + // Re-set changes to auto refresh. + preferences.inspector.setAutoRefreshEnabled(true); }); - */ - // TODO(jacobr): uncomment out the hotRestart tests once - // https://github.com/flutter/devtools/issues/337 is fixed. - /* - test('hotRestart', () async { - await env.setupEnvironment(); - - // The important thing about this is that the details tree should scroll - // instead of re-rooting as the selected row is already visible in the - // details tree. - simulateRowClick(tree, rowIndex: 4); - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R]root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center <-- selected\n' - ' │ ▼[/icons/inspector/textArea.png]Text\n' - ' └─▼[A]AppBar\n' - ' ▼[/icons/inspector/textArea.png]Text\n', - ), - ); - /// After the hot restart some existing calls to the vm service may - /// timeout and that is ok. - serviceManager.manager.service.doNotWaitForPendingFuturesBeforeExit(); - - await serviceManager.performHotRestart(); - // The isolate starts out paused on a hot restart so we have to resume - // it manually to make the test pass. - - await serviceManager.manager.service - .resume(serviceManager.isolateManager.selectedIsolate.id); - - // First UI transition is to an empty tree. - await detailsTree.nextUiFrame; - expect(tree.toStringDeep(), equalsIgnoringHashCodes('\n')); - - // Notice that the selection has been lost due to the hot restart. - await detailsTree.nextUiFrame; - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center\n' - ' │ ▼[/icons/inspector/textArea.png]Text\n' - ' └─▼[A]AppBar\n' - ' ▼[/icons/inspector/textArea.png]Text\n', - ), + void makeEditToFlutterMain({ + required String toReplace, + required String replaceWith, + }) { + final file = File(flutterAppMainPath); + final fileContents = file.readAsStringSync(); + file.writeAsStringSync( + fileContents.replaceAll(toReplace, replaceWith), + flush: true, ); + } - // Verify that the selection can actually be changed after a restart. - simulateRowClick(tree, rowIndex: 4); - expect( - tree.toStringDeep(), - equalsIgnoringHashCodes( - '▼[R][root]\n' - ' ▼[M]MyApp\n' - ' ▼[M]MaterialApp\n' - ' ▼[S]Scaffold\n' - ' ├───▼[C]Center <-- selected\n' - ' │ ▼[/icons/inspector/textArea.png]Text\n' - ' └─▼[A]AppBar\n' - ' ▼[/icons/inspector/textArea.png]Text\n', - ), - ); - await env.tearDownEnvironment(); - }); -*/ + testWidgetsWithWindowSize( + 'changing parent widget of selected', + windowSize, + (WidgetTester tester) async { + await _loadInspectorUI(tester); + + // Toggle implementation widgets on. + await _toggleImplementationWidgets(tester); + + // Give time for the initial animation to complete. + await tester.pumpAndSettle(inspectorChangeSettleTime); + + // Verify the CustomButton widget is after the CustomCenter widget. + expect( + _treeRowsAreInOrder( + treeRowDescriptions: ['CustomCenter', 'CustomButton'], + startingAtIndex: 7, + ), + isTrue, + ); + + // Verify the CustomButton widget is not visible in the properties view. + expect(_findWidgetLabelMatching('CustomButton'), findsNothing); + + // Select the CustomButton widget. + await tester.tap(_findTreeRowMatching('CustomButton')); + await tester.pumpAndSettle(inspectorChangeSettleTime); + + // Verify the CustomButton widget is now visible in the properties view. + expect(_findWidgetLabelMatching('CustomButton'), findsOneWidget); + + // Make edit to main.dart to replace CustomCenter with an Align. + makeEditToFlutterMain(toReplace: 'CustomCenter', replaceWith: 'Align'); + await env.flutter!.hotReload(); + await tester.pumpAndSettle(inspectorChangeSettleTime); + + // Verify the Align is now in the widget tree instead of Center. + expect( + _treeRowsAreInOrder( + treeRowDescriptions: ['Align', 'CustomButton'], + startingAtIndex: 7, + ), + isTrue, + ); + + // Verify the CustomButton widget is still selected. + expect(_findWidgetLabelMatching('CustomButton'), findsOneWidget); + }, + ); }); group('widget errors', () { - setUpAll(() async { - env = FlutterTestEnvironment( - testAppDirectory: 'test/test_infra/fixtures/inspector_app', - const FlutterRunConfiguration(withDebugger: true), - ); + testWidgetsWithWindowSize('show navigator and error labels', windowSize, ( + WidgetTester tester, + ) async { await env.setupEnvironment( config: const FlutterRunConfiguration( withDebugger: true, entryScript: 'lib/overflow_errors.dart', ), ); - env.afterEverySetup = resetInspectorSelection; - // Enable the legacy inspector. - preferences.inspector.setLegacyInspectorEnabled(true); - }); - - testWidgetsWithWindowSize('show navigator and error labels', windowSize, ( - WidgetTester tester, - ) async { expect(serviceConnection.serviceManager.service, equals(env.service)); expect(serviceConnection.serviceManager.isolateManager, isNotNull); @@ -447,14 +523,8 @@ void main() { wrapWithInspectorControllers(Builder(builder: screen.build)), ); await tester.pumpAndSettle(const Duration(seconds: 1)); - final InspectorScreenBodyState state = tester.state( - find.byType(InspectorScreenBody), - ); - final controller = state.controller; - while (!controller.flutterAppFrameReady) { - await controller.maybeLoadUI(); - await tester.pumpAndSettle(); - } + await _waitForFlutterFrame(tester); + await env.flutter!.hotReload(); // Give time for the initial animation to complete. await tester.pumpAndSettle(inspectorChangeSettleTime); @@ -470,14 +540,171 @@ void main() { await tester.tap(find.byIcon(Icons.keyboard_arrow_down)); await tester.pumpAndSettle(inspectorChangeSettleTime); } + + final flexExplorerTab = find.descendant( + of: find.byType(DevToolsTab), + matching: find.text('Flex explorer'), + ); + await tester.tap(flexExplorerTab); + await tester.pumpAndSettle(inspectorChangeSettleTime); + await expectLater( find.byType(InspectorScreenBody), matchesDevToolsGolden( '../../test_infra/goldens/integration_inspector_errors_2_error_selected.png', ), ); - - await env.tearDownEnvironment(); }); }); } + +Future _toggleImplementationWidgets(WidgetTester tester) async { + // Tap the "Show Implementation Widgets" button (selected by default). + final showImplementationWidgetsButton = find.descendant( + of: find.byType(DevToolsToggleButton), + matching: find.text('Show Implementation Widgets'), + ); + expect(showImplementationWidgetsButton, findsOneWidget); + await tester.tap(showImplementationWidgetsButton); + await tester.pumpAndSettle(inspectorChangeSettleTime); +} + +Future _loadInspectorUI(WidgetTester tester) async { + final screen = InspectorScreen(); + await tester.pumpWidget( + wrapWithInspectorControllers(Builder(builder: screen.build)), + ); + await tester.pump(const Duration(seconds: 1)); + await _waitForFlutterFrame(tester); + + await tester.pumpAndSettle(inspectorChangeSettleTime); +} + +Future _waitForFlutterFrame( + WidgetTester tester, { + bool isInitialLoad = true, +}) async { + final state = + tester.state(find.byType(InspectorScreenBody)) + as InspectorScreenBodyState; + final controller = state.controller; + while (!controller.flutterAppFrameReady) { + // On the initial load, we might have instantiated the controller after the + // first Flutter frame was sent. In which case, calling `maybeLoadUI` is + // necessary to ensure we detect that the widget tree is ready. + if (isInitialLoad) { + await controller.maybeLoadUI(); + } + await tester.pump(safePumpDuration); + } +} + +Finder findNodeMatching(String text) => find.ancestor( + of: find.richText(text), + matching: find.byType(DescriptionDisplay), +); + +Finder findChildRowOf(String description) { + final parentRowFinder = _findTreeRowMatching(description); + final parentWidget = _getWidgetFromFinder( + parentRowFinder, + ); + final parentIndex = parentWidget.row.index; + + return find.byType(InspectorRowContent).at(parentIndex + 1); +} + +Finder findExpandCollapseButtonForRow({ + required Finder rowFinder, + required bool isExpand, +}) { + final expandCollapseButtonFinder = find.descendant( + of: rowFinder, + matching: find.byType(TextButton), + ); + expect(expandCollapseButtonFinder, findsOneWidget); + + final expandCollapseButtonTextFinder = find.descendant( + of: expandCollapseButtonFinder, + matching: find.text(isExpand ? '(expand)' : '(collapse)'), + ); + expect(expandCollapseButtonTextFinder, findsOneWidget); + + return expandCollapseButtonFinder; +} + +void verifyPropertyIsVisible({ + required String name, + required String value, + required WidgetTester tester, +}) { + // Verify the property name is visible: + final propertyNameFinder = find.descendant( + of: find.byType(PropertyName), + matching: find.text(name), + ); + expect(propertyNameFinder, findsOneWidget); + + // Verify the property value is visible: + final propertyValueFinder = find.descendant( + of: find.byType(PropertyValue), + matching: find.richText(value), + ); + expect(propertyValueFinder, findsOneWidget); + + // Verify the property name and value are aligned: + final propertyNameCenter = tester.getCenter(propertyNameFinder); + final propertyValueCenter = tester.getCenter(propertyValueFinder); + expect(propertyNameCenter.dy, equals(propertyValueCenter.dy)); +} + +bool _treeRowsAreInOrder({ + required List treeRowDescriptions, + required int startingAtIndex, +}) { + final treeRowIndices = []; + + for (final description in treeRowDescriptions) { + final treeRow = _getWidgetFromFinder( + _findTreeRowMatching(description), + ); + treeRowIndices.add(treeRow.row.index); + } + + int indexToCheck = startingAtIndex; + for (final index in treeRowIndices) { + if (index == indexToCheck) { + indexToCheck++; + } else { + return false; + } + } + return true; +} + +Finder _findTreeRowMatching(String description) => find.ancestor( + of: find.richText(description), + matching: find.byType(InspectorRowContent), +); + +Finder _findWidgetLabelMatching(String description) => find.ancestor( + of: find.richText(description), + matching: find.byType(WidgetLabel), +); + +T _getWidgetFromFinder(Finder finder) => + finder.first.evaluate().first.widget as T; + +Future _resetPubRootDirectories(InspectorService inspectorService) async { + final currentPubRootDirectories = await inspectorService + .getPubRootDirectories(); + if (currentPubRootDirectories != null) { + await inspectorService.removePubRootDirectories(currentPubRootDirectories); + } + + final rootLibrary = await serviceConnection.serviceManager + .mainIsolateRootLibraryUriAsString(); + if (rootLibrary != null) { + await inspectorService.addPubRootDirectories([rootLibrary]); + } +} diff --git a/packages/devtools_app/test/screens/inspector/inspector_screen_controller_test.dart b/packages/devtools_app/test/screens/inspector/inspector_screen_controller_test.dart new file mode 100644 index 00000000000..02fb69dda21 --- /dev/null +++ b/packages/devtools_app/test/screens/inspector/inspector_screen_controller_test.dart @@ -0,0 +1,75 @@ +// Copyright 2025 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late ServiceConnectionManager serviceConnectionManager; + late InspectorScreenController controller; + + setUp(() { + serviceConnectionManager = ServiceConnectionManager(); + setGlobal(ServiceConnectionManager, serviceConnectionManager); + controller = InspectorScreenController(); + // Mirror the badge-ownership registration performed in [init] without + // constructing the full Inspector tree controllers. + serviceConnection.errorBadgeManager.manageErrorCount(InspectorScreen.id); + }); + + int unreadBadgeCount() => serviceConnection.errorBadgeManager + .errorCountNotifier(InspectorScreen.id) + .value; + + test('appendError stores the error and increments the badge', () { + controller.appendError(InspectableWidgetError('Overflow', 'ref-1')); + + expect(controller.inspectorErrors.value.length, equals(1)); + expect(controller.inspectorErrors.value['ref-1']!.errorMessage, 'Overflow'); + expect(unreadBadgeCount(), equals(1)); + }); + + test('appendError does not double-count the same error id', () { + controller.appendError(InspectableWidgetError('Overflow', 'ref-1')); + controller.appendError(InspectableWidgetError('Overflow again', 'ref-1')); + + expect(controller.inspectorErrors.value.length, equals(1)); + expect(unreadBadgeCount(), equals(1)); + }); + + test('markErrorAsRead decrements the badge', () { + final error = InspectableWidgetError('Overflow', 'ref-1'); + controller.appendError(error); + controller.markErrorAsRead(error); + + expect(controller.inspectorErrors.value['ref-1']!.read, isTrue); + expect(unreadBadgeCount(), equals(0)); + }); + + test('clearErrors resets errors and badge', () { + controller.appendError(InspectableWidgetError('Overflow', 'ref-1')); + controller.appendError(InspectableWidgetError('Null check', 'ref-2')); + expect(unreadBadgeCount(), equals(2)); + + controller.clearErrors(); + + expect(controller.inspectorErrors.value, isEmpty); + expect(unreadBadgeCount(), equals(0)); + }); + + test( + 'scaffold-style clearErrorCount does not drop inspector unread badge', + () { + controller.appendError(InspectableWidgetError('Overflow', 'ref-1')); + expect(unreadBadgeCount(), equals(1)); + + // Simulates DevToolsScaffold clearing the badge on tab navigation. + serviceConnection.errorBadgeManager.clearErrorCount(InspectorScreen.id); + + expect(unreadBadgeCount(), equals(1)); + expect(controller.inspectorErrors.value.length, equals(1)); + }, + ); +} diff --git a/packages/devtools_app/test/screens/inspector/inspector_screen_test.dart b/packages/devtools_app/test/screens/inspector/inspector_screen_test.dart index 0a09554f33e..a33a076cd8d 100644 --- a/packages/devtools_app/test/screens/inspector/inspector_screen_test.dart +++ b/packages/devtools_app/test/screens/inspector/inspector_screen_test.dart @@ -8,16 +8,18 @@ import 'dart:convert'; import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/screens/inspector/inspector_settings_dialog.dart'; import 'package:devtools_app/src/screens/inspector/layout_explorer/flex/flex.dart'; -import 'package:devtools_app/src/screens/inspector/layout_explorer/layout_explorer.dart'; -import 'package:devtools_app/src/screens/inspector_shared/inspector_settings_dialog.dart'; +import 'package:devtools_app/src/screens/inspector/widget_details.dart'; import 'package:devtools_app/src/service/service_extensions.dart' as extensions; +import 'package:devtools_app/src/shared/feature_flags.dart'; +import 'package:devtools_app/src/shared/ui/tab.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart' hide Fake; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/flutter_test_storage.dart'; @@ -40,6 +42,7 @@ void main() { } setUp(() { + setEnableExperiments(); fakeServiceConnection = FakeServiceConnectionManager(); fakeExtensionManager = fakeServiceConnection.serviceManager.serviceExtensionManager; @@ -57,10 +60,7 @@ void main() { setGlobal(PreferencesController, PreferencesController()); setGlobal(Storage, FlutterTestStorage()); setGlobal(NotificationService, NotificationService()); - setGlobal(BannerMessagesController, BannerMessagesController()); fakeServiceConnection.consoleService.ensureServiceInitialized(); - // Enable the legacy inspector: - preferences.inspector.setLegacyInspectorEnabled(true); }); Future mockExtensions() async { @@ -100,7 +100,6 @@ void main() { WidgetTester tester, ) async { await tester.pumpWidget(buildInspectorScreen()); - await tester.pumpAndSettle(); expect(find.byType(InspectorScreenBody), findsOneWidget); }); @@ -304,12 +303,19 @@ void main() { 'should render StoryOfYourFlexWidget', windowSize, (WidgetTester tester) async { - final controller = TestInspectorController()..setSelectedNode(treeNode); + final controller = TestInspectorController() + ..setSelectedNode(treeNode) + ..setSelectedDiagnostic(diagnostic); await tester.pumpWidget( MaterialApp( - home: Scaffold(body: LayoutExplorerTab(controller: controller)), + home: Scaffold(body: WidgetDetails(controller: controller)), ), ); + + // Navigate to the flex explorer tab. + await tester.tap(_findFlexExplorerTab()); + await tester.pumpAndSettle(); + expect(find.byType(FlexLayoutExplorerWidget), findsOneWidget); }, ); @@ -321,11 +327,23 @@ void main() { final controller = TestInspectorController(); await tester.pumpWidget( MaterialApp( - home: Scaffold(body: LayoutExplorerTab(controller: controller)), + home: Scaffold(body: WidgetDetails(controller: controller)), ), ); - expect(find.byType(FlexLayoutExplorerWidget), findsNothing); - controller.setSelectedNode(treeNode); + + // Flex explorer is not available for selected wiget. + expect(_findFlexExplorerTab(), findsNothing); + + // Select a flex widget. + controller + ..setSelectedNode(treeNode) + ..setSelectedDiagnostic(diagnostic); + await tester.pumpAndSettle(); + + // Flex explorer is available for the flex widget. + final flexExplorerTab = _findFlexExplorerTab(); + expect(flexExplorerTab, findsOneWidget); + await tester.tap(flexExplorerTab); await tester.pumpAndSettle(); expect(find.byType(FlexLayoutExplorerWidget), findsOneWidget); }, @@ -346,8 +364,12 @@ void main() { await tester.pumpWidget(buildInspectorScreen()); await tester.tap(find.byType(SettingsOutlinedButton)); - await tester.pumpAndSettle(); - expect(find.byType(FlutterInspectorSettingsDialog), findsOneWidget); + + final settingsDialogFinder = await retryUntilFound( + find.byType(FlutterInspectorSettingsDialog), + tester: tester, + ); + expect(settingsDialogFinder, findsOneWidget); final hoverCheckBoxSetting = find.ancestor( of: find.richTextContaining('Enable hover inspection'), @@ -358,7 +380,7 @@ void main() { matching: find.byType(NotifierCheckbox), ); await tester.tap(hoverModeCheckBox); - await tester.pumpAndSettle(); + await tester.pump(safePumpDuration); expect( preferences.inspector.hoverEvalModeEnabled.value, !startingHoverEvalModeValue, @@ -366,9 +388,13 @@ void main() { }, ); }); - // TODO(jacobr): add screenshot tests that connect to a test application // in the same way the inspector_controller test does today and take golden // images. Alternately: support an offline inspector mode and add tests of // that mode which would enable faster tests that run as unittests. } + +Finder _findFlexExplorerTab() => find.descendant( + of: find.byType(DevToolsTab), + matching: find.text('Flex explorer'), +); diff --git a/packages/devtools_app/test/screens/inspector/inspector_tree_test.dart b/packages/devtools_app/test/screens/inspector/inspector_tree_test.dart index 17d1b1238a3..e067c5a1036 100644 --- a/packages/devtools_app/test/screens/inspector/inspector_tree_test.dart +++ b/packages/devtools_app/test/screens/inspector/inspector_tree_test.dart @@ -3,13 +3,13 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app/devtools_app.dart'; -import 'package:devtools_app/src/screens/inspector/inspector_breadcrumbs.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart' hide Fake; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'utils/inspector_tree.dart'; @@ -37,7 +37,6 @@ void main() { inspectorController = InspectorController( inspectorTree: InspectorTreeController(), - detailsTree: InspectorTreeController(), treeType: FlutterTreeType.widget, )..firstInspectorTreeLoadCompleted = true; }); @@ -45,24 +44,35 @@ void main() { Future pumpInspectorTree( WidgetTester tester, { required InspectorTreeController treeController, - bool isSummaryTree = false, }) async { final debuggerController = DebuggerController(); - final summaryTreeController = isSummaryTree - ? null - : InspectorTreeController(); await tester.pumpWidget( wrapWithControllers( debugger: debuggerController, InspectorTree( controller: inspectorController, treeController: treeController, - summaryTreeController: summaryTreeController, - isSummaryTree: isSummaryTree, ), ), ); - await tester.pumpAndSettle(); + } + + InspectorTreeController buildTreeController({ + required void Function({bool notifyFlutterInspector}) onSelectionChange, + }) { + return InspectorTreeController() + ..config = InspectorTreeConfig( + onNodeAdded: (_, _) {}, + onClientActiveChange: (_) {}, + onSelectionChange: onSelectionChange, + ) + ..root = (InspectorTreeNode() + ..appendChild(InspectorTreeNode()) + ..appendChild(InspectorTreeNode())); + } + + List visibleNodes(InspectorTreeController controller) { + return controller.rowsInTree.value.map((row) => row!.node).toList(); } group('InspectorTreeController', () { @@ -76,21 +86,21 @@ void main() { ); await pumpInspectorTree(tester, treeController: treeController); - expect(treeController.getRow(const Offset(0, -100.0)), isNull); - expect(treeController.getRowOffset(-1), equals(0)); + expect(treeController.rowForOffset(const Offset(0, -100.0)), isNull); + expect(treeController.rowOffset(-1), equals(0)); - expect(treeController.getRow(const Offset(0, 0.0)), isNull); - expect(treeController.getRowOffset(0), equals(0)); + expect(treeController.rowForOffset(const Offset(0, 0.0)), isNull); + expect(treeController.rowOffset(0), equals(0)); treeController.root = InspectorTreeNode() ..appendChild(InspectorTreeNode()); await pumpInspectorTree(tester, treeController: treeController); - expect(treeController.getRow(const Offset(0, -20))!.index, 0); - expect(treeController.getRowOffset(-1), equals(0)); - expect(treeController.getRow(const Offset(0, 0.0)), isNotNull); - expect(treeController.getRowOffset(0), equals(0)); + expect(treeController.rowForOffset(const Offset(0, -20))!.index, 0); + expect(treeController.rowOffset(-1), equals(0)); + expect(treeController.rowForOffset(const Offset(0, 0.0)), isNotNull); + expect(treeController.rowOffset(0), equals(0)); // This operation would previously throw an exception in debug builds // and infinite loop in release builds. @@ -143,33 +153,427 @@ void main() { expect(find.richText('Text: "Multiline text content"'), findsOneWidget); }); + }); - testWidgets('Shows breadcrumbs in Widget detail tree', (tester) async { - final diagnosticNode = await widgetToInspectorTreeDiagnosticsNode( - widget: const Text('Hello'), - tester: tester, + group('InspectorTreeController keyboard navigation', () { + testWidgets( + 'navigateDown triggers onSelectionChange with notifyFlutterInspector true', + (WidgetTester tester) async { + bool? capturedNotify; + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) { + capturedNotify = notifyFlutterInspector; + }, + ); + + await pumpInspectorTree(tester, treeController: treeController); + + treeController.navigateDown(); + await tester.pump(); + + expect(capturedNotify, isTrue); + }, + ); + + testWidgets( + 'navigateUp triggers onSelectionChange with notifyFlutterInspector true', + (WidgetTester tester) async { + bool? capturedNotify; + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) { + capturedNotify = notifyFlutterInspector; + }, + ); + + await pumpInspectorTree(tester, treeController: treeController); + + // Move selection to the second row so navigateUp has somewhere to go. + treeController.navigateDown(); + await tester.pump(); + treeController.navigateDown(); + await tester.pump(); + + capturedNotify = null; + treeController.navigateUp(); + await tester.pump(); + + expect(capturedNotify, isTrue); + }, + ); + + testWidgets( + 'navigateRight triggers onSelectionChange with notifyFlutterInspector true', + (WidgetTester tester) async { + bool? capturedNotify; + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) { + capturedNotify = notifyFlutterInspector; + }, + ); + + await pumpInspectorTree(tester, treeController: treeController); + + final root = treeController.root!; + final firstChild = root.children.first; + root.isExpanded = false; + treeController.setSelectedNode(root); + + // First right-arrow navigation expands the selected node. + capturedNotify = null; + treeController.navigateRight(); + await tester.pump(); + + expect(root.isExpanded, isTrue); + expect(treeController.selection, root); + expect(capturedNotify, isNull); + + // Once expanded, right-arrow navigation selects the next visible row. + treeController.navigateRight(); + await tester.pump(); + + expect(treeController.selection, firstChild); + expect(capturedNotify, isTrue); + }, + ); + + testWidgets( + 'navigateLeft triggers onSelectionChange with notifyFlutterInspector true', + (WidgetTester tester) async { + bool? capturedNotify; + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) { + capturedNotify = notifyFlutterInspector; + }, + ); + + await pumpInspectorTree(tester, treeController: treeController); + + final root = treeController.root!; + final firstChild = root.children.first..isExpanded = false; + treeController.setSelectedNode(firstChild); + + capturedNotify = null; + treeController.navigateLeft(); + await tester.pump(); + + expect(treeController.selection, root); + expect(capturedNotify, isTrue); + }, + ); + }); + + group('InspectorTree keyboard events', () { + testWidgets( + 'arrowDown key triggers onSelectionChange with notifyFlutterInspector true', + (WidgetTester tester) async { + bool? capturedNotify; + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) { + capturedNotify = notifyFlutterInspector; + }, + ); + + await pumpInspectorTree(tester, treeController: treeController); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + expect(capturedNotify, isTrue); + }, + ); + + testWidgets( + 'arrowUp key triggers onSelectionChange with notifyFlutterInspector true', + (WidgetTester tester) async { + bool? capturedNotify; + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) { + capturedNotify = notifyFlutterInspector; + }, + ); + + await pumpInspectorTree(tester, treeController: treeController); + + // Move selection to the second row first. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + capturedNotify = null; + await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp); + await tester.pump(); + + expect(capturedNotify, isTrue); + }, + ); + + testWidgets( + 'arrowRight key triggers onSelectionChange with notifyFlutterInspector true', + (WidgetTester tester) async { + bool? capturedNotify; + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) { + capturedNotify = notifyFlutterInspector; + }, + ); + + await pumpInspectorTree(tester, treeController: treeController); + + final root = treeController.root!; + final firstChild = root.children.first; + root.isExpanded = false; + treeController.setSelectedNode(root); + + // First arrowRight expands the selected node. + capturedNotify = null; + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(root.isExpanded, isTrue); + expect(treeController.selection, root); + expect(capturedNotify, isNull); + + // Once expanded, arrowRight selects the next visible row. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + + expect(treeController.selection, firstChild); + expect(capturedNotify, isTrue); + }, + ); + + testWidgets( + 'arrowLeft key triggers onSelectionChange with notifyFlutterInspector true', + (WidgetTester tester) async { + bool? capturedNotify; + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) { + capturedNotify = notifyFlutterInspector; + }, + ); + + await pumpInspectorTree(tester, treeController: treeController); + + final root = treeController.root!; + final firstChild = root.children.first..isExpanded = false; + treeController.setSelectedNode(firstChild); + + capturedNotify = null; + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(treeController.selection, root); + expect(capturedNotify, isTrue); + }, + ); + + testWidgets( + 'arrowLeft key collapses selected node without removing previous rows', + (WidgetTester tester) async { + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) {}, + ); + + final root = treeController.root!; + final previousSibling = root.children.first; + final selectedSibling = root.children.last + ..appendChild(InspectorTreeNode()) + ..appendChild(InspectorTreeNode()); + final selectedSiblingFirstChild = selectedSibling.children.first; + final selectedSiblingSecondChild = selectedSibling.children.last; + treeController.root = root; + treeController.setSelectedNode(selectedSibling); + + await pumpInspectorTree(tester, treeController: treeController); + + expect(visibleNodes(treeController), [ + root, + previousSibling, + selectedSibling, + selectedSiblingFirstChild, + selectedSiblingSecondChild, + ]); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(selectedSibling.isExpanded, isFalse); + expect(treeController.selection, selectedSibling); + expect(visibleNodes(treeController), [ + root, + previousSibling, + selectedSibling, + ]); + }, + ); + + testWidgets( + 'arrowLeft key on collapsed child selects parent without changing rows', + (WidgetTester tester) async { + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) {}, + ); + + final root = treeController.root!; + final previousSibling = root.children.first; + final parent = root.children.last + ..appendChild(InspectorTreeNode()) + ..appendChild(InspectorTreeNode()); + final child = parent.children.first..isExpanded = false; + final nextSibling = parent.children.last; + treeController.root = root; + treeController.setSelectedNode(child); + + await pumpInspectorTree(tester, treeController: treeController); + + final rowsBeforeArrowLeft = visibleNodes(treeController); + expect(rowsBeforeArrowLeft, [ + root, + previousSibling, + parent, + child, + nextSibling, + ]); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + expect(treeController.selection, parent); + expect(visibleNodes(treeController), rowsBeforeArrowLeft); + }, + ); + + testWidgets('arrowLeft key does not put the tree into the loading state', ( + WidgetTester tester, + ) async { + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) {}, ); - final treeController = inspectorTreeControllerFromNode(diagnosticNode); await pumpInspectorTree(tester, treeController: treeController); - expect(find.byType(InspectorBreadcrumbNavigator), findsOneWidget); + final root = treeController.root!; + treeController.setSelectedNode(root); + await tester.pump(); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + + // After collapsing the root, only the root row remains visible. The + // tree must still render that row instead of a loading indicator. + expect(find.byType(CenteredCircularProgressIndicator), findsNothing); + expect(visibleNodes(treeController), [root]); }); - testWidgets('Shows no breadcrumbs widget in summary tree', (tester) async { - final diagnosticNode = await widgetToInspectorTreeDiagnosticsNode( - widget: const Text('Hello'), - tester: tester, - ); + testWidgets( + 'onSelectNode does not re-expand a node the user just collapsed via ' + 'the arrow-left key', + (WidgetTester tester) async { + // Regression test: clicking a still-visible row used to call + // expandPath on the clicked node, which re-expanded the clicked node + // itself and undid any subtree collapse the user had just performed + // via the arrow-left key. + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) {}, + ); - final treeController = inspectorTreeControllerFromNode(diagnosticNode); - await pumpInspectorTree( - tester, - treeController: treeController, - isSummaryTree: true, - ); + final root = treeController.root!; + final firstChild = root.children.first + ..appendChild(InspectorTreeNode()) + ..appendChild(InspectorTreeNode()); + final secondChild = root.children.last; + treeController.root = root; + treeController.setSelectedNode(firstChild); - expect(find.byType(InspectorBreadcrumbNavigator), findsNothing); - }); + await pumpInspectorTree(tester, treeController: treeController); + + // Collapse [firstChild] so its grandchildren are hidden. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); + await tester.pump(); + expect(firstChild.isExpanded, isFalse); + expect(visibleNodes(treeController), [root, firstChild, secondChild]); + + // Re-selecting the just-collapsed row must not re-expand it. + treeController.onSelectNode(firstChild); + await tester.pump(); + + expect(firstChild.isExpanded, isFalse); + expect(visibleNodes(treeController), [root, firstChild, secondChild]); + }, + ); + + testWidgets( + 'onSelectNode does not re-expand a node the user just collapsed by ' + 'clicking it', + (WidgetTester tester) async { + // Regression test: clicking a row used to call expandPath on the + // clicked node itself, so a user could not select a node in its + // collapsed state. + final treeController = buildTreeController( + onSelectionChange: ({bool notifyFlutterInspector = false}) {}, + ); + + final root = treeController.root!; + final firstChild = root.children.first + ..appendChild(InspectorTreeNode()) + ..isExpanded = false; + treeController.root = root; + + await pumpInspectorTree(tester, treeController: treeController); + + treeController.onSelectNode(firstChild); + await tester.pump(); + + expect(treeController.selection, firstChild); + expect(firstChild.isExpanded, isFalse); + }, + ); + }); + + group('InspectorTree loading indicator', () { + testWidgets( + 'shows a loading indicator while the initial tree load is in progress', + (WidgetTester tester) async { + // Before the first inspector tree load completes, a tree with at most + // a single row represents the "still loading" state and should render + // a progress indicator instead of the bare row. + inspectorController.firstInspectorTreeLoadCompleted = false; + + final treeController = InspectorTreeController() + ..config = InspectorTreeConfig( + onNodeAdded: (_, _) {}, + onClientActiveChange: (_) {}, + ) + ..root = InspectorTreeNode(); + + await pumpInspectorTree(tester, treeController: treeController); + + expect(find.byType(CenteredCircularProgressIndicator), findsOneWidget); + }, + ); + + testWidgets( + 'renders a single-row tree (no spinner) after the initial load has ' + 'completed', + (WidgetTester tester) async { + // Regression test: collapsing the root via the arrow-left key shrinks + // the visible rows down to a single row. Before the fix, the + // [InspectorTree] widget treated a one-row tree as "loading" and + // showed a spinner, hiding the user's [root] row. + inspectorController.firstInspectorTreeLoadCompleted = true; + + final treeController = InspectorTreeController() + ..config = InspectorTreeConfig( + onNodeAdded: (_, _) {}, + onClientActiveChange: (_) {}, + ) + ..root = InspectorTreeNode(); + + await pumpInspectorTree(tester, treeController: treeController); + + expect(find.byType(CenteredCircularProgressIndicator), findsNothing); + }, + ); }); } diff --git a/packages/devtools_app/test/screens/inspector/layout_explorer/flex/arrow_test.dart b/packages/devtools_app/test/screens/inspector/layout_explorer/flex/arrow_test.dart index 2b5bf8cf887..05e7ba8bcbf 100644 --- a/packages/devtools_app/test/screens/inspector/layout_explorer/flex/arrow_test.dart +++ b/packages/devtools_app/test/screens/inspector/layout_explorer/flex/arrow_test.dart @@ -4,8 +4,8 @@ import 'package:devtools_app/src/screens/inspector/layout_explorer/ui/arrow.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../test_infra/matchers/matchers.dart'; diff --git a/packages/devtools_app/test/screens/inspector/layout_explorer/flex/flex_test.dart b/packages/devtools_app/test/screens/inspector/layout_explorer/flex/flex_test.dart index a8b5264ff9c..f912a319244 100644 --- a/packages/devtools_app/test/screens/inspector/layout_explorer/flex/flex_test.dart +++ b/packages/devtools_app/test/screens/inspector/layout_explorer/flex/flex_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app/src/shared/console/eval/inspector_tree.dart'; import 'package:devtools_app/src/shared/diagnostics/diagnostics_node.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../test_infra/matchers/matchers.dart'; diff --git a/packages/devtools_app/test/screens/inspector/layout_explorer/inspector_data_models_test.dart b/packages/devtools_app/test/screens/inspector/layout_explorer/inspector_data_models_test.dart index a8e0cd0d86a..42645ddb6a3 100644 --- a/packages/devtools_app/test/screens/inspector/layout_explorer/inspector_data_models_test.dart +++ b/packages/devtools_app/test/screens/inspector/layout_explorer/inspector_data_models_test.dart @@ -7,9 +7,9 @@ import 'package:devtools_app/src/screens/inspector/layout_explorer/ui/theme.dart import 'package:devtools_app/src/shared/primitives/math_utils.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'layout_explorer_test_utils.dart'; diff --git a/packages/devtools_app/test/screens/inspector/layout_explorer/layout_explorer_test_utils.dart b/packages/devtools_app/test/screens/inspector/layout_explorer/layout_explorer_test_utils.dart index c2b1f449af6..8ab72032aaf 100644 --- a/packages/devtools_app/test/screens/inspector/layout_explorer/layout_explorer_test_utils.dart +++ b/packages/devtools_app/test/screens/inspector/layout_explorer/layout_explorer_test_utils.dart @@ -4,8 +4,8 @@ import 'package:devtools_app/src/shared/diagnostics/diagnostics_node.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'layout_explorer_serialization_delegate.dart'; diff --git a/packages/devtools_app/test/screens/inspector/utils/inspector_tree.dart b/packages/devtools_app/test/screens/inspector/utils/inspector_tree.dart index 88963611a34..9546ab0e4b1 100644 --- a/packages/devtools_app/test/screens/inspector/utils/inspector_tree.dart +++ b/packages/devtools_app/test/screens/inspector/utils/inspector_tree.dart @@ -4,9 +4,9 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; /// Create an `InspectorTreeControllerFlutter` from a single `RemoteDiagnosticsNode` InspectorTreeController inspectorTreeControllerFromNode( diff --git a/packages/devtools_app/test/screens/inspector/widget_creation_location_trailing_test.dart b/packages/devtools_app/test/screens/inspector/widget_creation_location_trailing_test.dart new file mode 100644 index 00000000000..694cf72a97e --- /dev/null +++ b/packages/devtools_app/test/screens/inspector/widget_creation_location_trailing_test.dart @@ -0,0 +1,122 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/src/screens/inspector/inspector_controller.dart'; +import 'package:devtools_app/src/screens/inspector/widget_properties/properties_view.dart'; +import 'package:devtools_app/src/shared/diagnostics/primitives/source_location.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart' hide Fake; +import 'package:material_ui/material_ui.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + setUp(() { + setGlobal(IdeTheme, IdeTheme()); + }); + + Widget wrapTrailing(Widget child) { + return MaterialApp( + theme: themeFor( + isDarkTheme: false, + ideTheme: IdeTheme(), + theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme), + ), + home: Scaffold(body: child), + ); + } + + testWidgets('shows file name with line and column', ( + WidgetTester tester, + ) async { + final controller = _TestInspectorController(); + addTearDown(controller.dispose); + + final location = InspectorSourceLocation({ + 'file': 'file:///Users/prismo/flutter_app/lib/main.dart', + 'line': 109, + 'column': 23, + }, null); + controller.setProperties(( + widgetProperties: const [], + renderProperties: const [], + layoutProperties: null, + creationLocation: location, + )); + + await tester.pumpWidget( + wrapTrailing(WidgetCreationLocationTrailing(controller: controller)), + ); + + expect(find.byType(RichText), findsOneWidget); + final richText = tester.widget(find.byType(RichText)); + expect(richText.text.toPlainText(), 'main.dart:109:23'); + }); + + testWidgets('tooltip includes the full file URI', ( + WidgetTester tester, + ) async { + final controller = _TestInspectorController(); + addTearDown(controller.dispose); + + final location = InspectorSourceLocation({ + 'file': 'file:///Users/prismo/flutter_app/lib/main.dart', + 'line': 109, + 'column': 23, + }, null); + controller.setProperties(( + widgetProperties: const [], + renderProperties: const [], + layoutProperties: null, + creationLocation: location, + )); + + await tester.pumpWidget( + wrapTrailing(WidgetCreationLocationTrailing(controller: controller)), + ); + + final tooltip = + tester.widget(find.byType(DevToolsTooltip)) as DevToolsTooltip; + expect( + tooltip.message, + 'file:///Users/prismo/flutter_app/lib/main.dart:109:23', + ); + }); + + testWidgets('hides when creation location is unavailable', ( + WidgetTester tester, + ) async { + final controller = _TestInspectorController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + wrapTrailing(WidgetCreationLocationTrailing(controller: controller)), + ); + + expect(find.byType(RichText), findsNothing); + }); +} + +class _TestInspectorController extends Fake implements InspectorController { + final _selectedNodeProperties = ValueNotifier(( + widgetProperties: const [], + renderProperties: const [], + layoutProperties: null, + creationLocation: null, + )); + + @override + ValueListenable get selectedNodeProperties => + _selectedNodeProperties; + + void setProperties(WidgetTreeNodeProperties properties) { + _selectedNodeProperties.value = properties; + } + + @override + void dispose() { + _selectedNodeProperties.dispose(); + } +} diff --git a/packages/devtools_app/test/screens/inspector_v2/diagnostics_test.dart b/packages/devtools_app/test/screens/inspector_v2/diagnostics_test.dart deleted file mode 100644 index 97f24b660f7..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/diagnostics_test.dart +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright 2022 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:convert'; - -import 'package:devtools_app/devtools_app.dart'; -import 'package:devtools_app/src/shared/feature_flags.dart'; -import 'package:devtools_app/src/shared/ui/utils.dart'; -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/devtools_test.dart'; -import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('DiagnosticsNodeDescription', () { - final renderObjectJson = jsonDecode(''' - { - "properties": [ - { - "description": "horizontal", - "name": "direction" - }, - { - "description": "start", - "name": "mainAxisAlignment" - }, - { - "description": "max", - "name": "mainAxisSize" - }, - { - "description": "center", - "name": "crossAxisAlignment" - }, - { - "description": "ltr", - "name": "textDirection" - }, - { - "description": "down", - "name": "verticalDirection" - } - ] - } - '''); - setUp(() { - setEnableExperiments(); - setGlobal( - DevToolsEnvironmentParameters, - ExternalDevToolsEnvironmentParameters(), - ); - setGlobal(PreferencesController, PreferencesController()); - setGlobal(IdeTheme, IdeTheme()); - setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); - }); - - group('hover eval', () { - final nodeJson = { - 'widgetRuntimeType': 'Row', - 'renderObject': renderObjectJson, - 'hasChildren': false, - 'children': [], - }; - final inspectorService = MockInspectorObjectGroupBase(); - final diagnostic = RemoteDiagnosticsNode( - nodeJson, - inspectorService, - false, - null, - ); - late DiagnosticsNodeDescription diagnosticsNodeDescription; - - setUp(() { - preferences.inspector.setHoverEvalMode(true); - diagnosticsNodeDescription = DiagnosticsNodeDescription(diagnostic); - }); - - testWidgets('can be enabled from preferences', ( - WidgetTester tester, - ) async { - await tester.pumpWidget(wrap(diagnosticsNodeDescription)); - - final hoverCardTooltip = - tester.widget(find.byType(HoverCardTooltip)) as HoverCardTooltip; - expect(hoverCardTooltip.enabled(), true); - }); - - testWidgets('can be disabled from preferences', ( - WidgetTester tester, - ) async { - preferences.inspector.setHoverEvalMode(false); - - await tester.pumpWidget(wrap(diagnosticsNodeDescription)); - - final hoverCardTooltip = - tester.widget(find.byType(HoverCardTooltip)) as HoverCardTooltip; - expect(hoverCardTooltip.enabled(), false); - }); - - testWidgets('disabled when inspector service not set', ( - WidgetTester tester, - ) async { - final diagnosticWithoutService = RemoteDiagnosticsNode( - nodeJson, - null, - false, - null, - ); - diagnosticsNodeDescription = DiagnosticsNodeDescription( - diagnosticWithoutService, - ); - - await tester.pumpWidget(wrap(diagnosticsNodeDescription)); - - final hoverCardTooltip = - tester.widget(find.byType(HoverCardTooltip)) as HoverCardTooltip; - expect(hoverCardTooltip.enabled(), false); - }); - }); - - group('approximateNodeWidth', () { - const epsilon = 7.0; - testWidgets('property diagnostics node with name and description', ( - WidgetTester tester, - ) async { - final nodeJson = { - 'widgetRuntimeType': 'Row', - 'renderObject': renderObjectJson, - 'hasChildren': false, - 'children': [], - 'description': - 'this is a showname description, which will show up after the name', - 'showName': true, - 'name': 'THE NAME to be shown', - }; - final diagnosticWithoutService = RemoteDiagnosticsNode( - nodeJson, - null, - true, - null, - ); - final diagnosticsNodeDescription = DiagnosticsNodeDescription( - diagnosticWithoutService, - ); - - await tester.pumpWidget(wrap(diagnosticsNodeDescription)); - - final approximatedWidth = - DiagnosticsNodeDescription.approximateNodeWidth( - diagnosticWithoutService, - ); - - final diagnosticsNodeFind = find.byType(DiagnosticsNodeDescription); - // There are many rich texts, containg the name, and description. - final allRichTexts = find - .descendant( - of: diagnosticsNodeFind, - matching: find.byType(RichText), - ) - .evaluate() - .map((e) => e.widget as RichText); - final measuredWidthOfAllRichTexts = allRichTexts.fold( - 0, - (previousValue, richText) => - previousValue + calculateTextSpanWidth(richText.text as TextSpan), - ); - expect( - approximatedWidth, - moreOrLessEquals(measuredWidthOfAllRichTexts, epsilon: epsilon), - ); - }); - - testWidgets('diagnostics node with icon and description', ( - WidgetTester tester, - ) async { - final nodeJson = { - 'widgetRuntimeType': 'Row', - 'renderObject': renderObjectJson, - 'hasChildren': false, - 'description': 'This is the description', - 'children': [], - 'showName': false, - }; - final diagnosticWithoutService = RemoteDiagnosticsNode( - nodeJson, - null, - false, - null, - ); - final diagnosticsNodeDescription = DiagnosticsNodeDescription( - diagnosticWithoutService, - ); - - await tester.pumpWidget(wrap(diagnosticsNodeDescription)); - - final approximatedTextWidth = - DiagnosticsNodeDescription.approximateNodeWidth( - diagnosticWithoutService, - ); - - final diagnosticsNodeFind = find.byType(DiagnosticsNodeDescription); - // The icon is part of the clickable width, so we include it. - final measuredIconWidth = tester - .getSize( - find.descendant( - of: diagnosticsNodeFind, - matching: find.byType(AssetImageIcon), - ), - ) - .width; - - // There is only one rich text widget, containing the description. - final richTextWidget = - find - .descendant( - of: diagnosticsNodeFind, - matching: find.byType(RichText), - ) - .first - .evaluate() - .first - .widget - as RichText; - final measuredTextWidth = calculateTextSpanWidth( - richTextWidget.text as TextSpan, - ); - - expect( - approximatedTextWidth, - moreOrLessEquals( - measuredTextWidth + measuredIconWidth, - epsilon: epsilon, - ), - ); - }); - - testWidgets('error node with different fontSize', ( - WidgetTester tester, - ) async { - // Nodes with normal levels default to using the default fontSize, so - // using an error level node allows us to test different font sizes. - final nodeJson = { - 'widgetRuntimeType': 'Row', - 'renderObject': renderObjectJson, - 'hasChildren': false, - 'children': [], - 'description': - 'this is a showname description, which will show up after the name', - 'showName': true, - 'name': 'THE NAME to be shown', - 'level': 'error', - }; - final diagnosticWithoutService = RemoteDiagnosticsNode( - nodeJson, - null, - false, - null, - ); - - //Use a textStyle that is much larger than the normal style - const textStyle = TextStyle(fontSize: 24.0, fontFamily: 'Roboto'); - final diagnosticsNodeDescription = DiagnosticsNodeDescription( - diagnosticWithoutService, - style: textStyle, - ); - - await tester.pumpWidget(wrap(diagnosticsNodeDescription)); - - final approximatedWidth = - DiagnosticsNodeDescription.approximateNodeWidth( - diagnosticWithoutService, - ); - - final diagnosticsNodeFind = find.byType(DiagnosticsNodeDescription); - // There are many rich texts, containg the name, and description. - final allRichTexts = find - .descendant( - of: diagnosticsNodeFind, - matching: find.byType(RichText), - ) - .evaluate() - .map((e) => e.widget as RichText); - - final measuredWidthOfAllRichTexts = allRichTexts.fold(0, ( - previousValue, - richText, - ) { - final originalTextSpan = richText.text as TextSpan; - - return previousValue + calculateTextSpanWidth(originalTextSpan); - }); - - expect( - approximatedWidth, - moreOrLessEquals(measuredWidthOfAllRichTexts, epsilon: epsilon), - ); - }); - }); - }); -} diff --git a/packages/devtools_app/test/screens/inspector_v2/inspector_error_navigator_test.dart b/packages/devtools_app/test/screens/inspector_v2/inspector_error_navigator_test.dart deleted file mode 100644 index 6db56eb0c62..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/inspector_error_navigator_test.dart +++ /dev/null @@ -1,133 +0,0 @@ -// Copyright 2021 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:collection'; - -import 'package:devtools_app/devtools_app.dart'; -import 'package:devtools_app/src/shared/feature_flags.dart'; -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/devtools_test.dart'; -import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - setUp(() { - setEnableExperiments(); - setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); - setGlobal(IdeTheme, IdeTheme()); - }); - - group('Inspector Error Navigator', () { - Future testNavigate( - WidgetTester tester, { - required IconData tapIcon, - required int errorCount, - int? startIndex, - int? expectedIndex, - }) async { - var index = startIndex; - final navigator = ErrorNavigator( - errorIndex: index, - errors: _generateErrors(errorCount), - onSelectError: (newIndex) => index = newIndex, - ); - - await tester.pumpWidget(wrap(navigator)); - await tester.tap(find.byIcon(tapIcon)); - - expect(index, equals(expectedIndex)); - } - - testWidgets('shows count when no selection', (WidgetTester tester) async { - await tester.pumpWidget( - wrap( - ErrorNavigator( - errorIndex: null, - errors: _generateErrors(10), - onSelectError: (_) {}, - ), - ), - ); - expect(find.text('Errors: 10'), findsOneWidget); - }); - - testWidgets('shows x/y when selected error', (WidgetTester tester) async { - await tester.pumpWidget( - wrap( - ErrorNavigator( - errorIndex: 0, - errors: _generateErrors(10), - onSelectError: (_) {}, - ), - ), - ); - expect(find.text('Error 1/10'), findsOneWidget); - }); - - testWidgets( - 'can navigate forwards', - // Intentionally unawaited. - // ignore: discarded_futures - (WidgetTester tester) => testNavigate( - tester, - tapIcon: Icons.keyboard_arrow_down, - errorCount: 10, - startIndex: 5, - expectedIndex: 6, - ), - ); - - testWidgets( - 'can navigate backwards', - // Intentionally unawaited. - // ignore: discarded_futures - (WidgetTester tester) => testNavigate( - tester, - tapIcon: Icons.keyboard_arrow_up, - errorCount: 10, - startIndex: 5, - expectedIndex: 4, - ), - ); - - testWidgets( - 'wraps forwards', - // Intentionally unawaited. - // ignore: discarded_futures - (WidgetTester tester) => testNavigate( - tester, - tapIcon: Icons.keyboard_arrow_down, - errorCount: 10, - startIndex: 9, - expectedIndex: 0, - ), - ); - - testWidgets( - 'wraps backwards', - // Intentionally unawaited. - // ignore: discarded_futures - (WidgetTester tester) => testNavigate( - tester, - tapIcon: Icons.keyboard_arrow_up, - errorCount: 10, - startIndex: 0, - expectedIndex: 9, - ), - ); - }); -} - -LinkedHashMap _generateErrors(int count) => - LinkedHashMap.fromEntries( - List.generate( - count, - (index) => MapEntry( - 'error-$index', - InspectableWidgetError('Error $index', 'error-$index'), - ), - ), - ); diff --git a/packages/devtools_app/test/screens/inspector_v2/inspector_integration_test.dart b/packages/devtools_app/test/screens/inspector_v2/inspector_integration_test.dart deleted file mode 100644 index c9f902fdb60..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/inspector_integration_test.dart +++ /dev/null @@ -1,803 +0,0 @@ -// Copyright 2020 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -@TestOn('vm') -library; - -import 'dart:io'; - -import 'package:collection/collection.dart'; -import 'package:devtools_app/devtools_app.dart' - hide InspectorScreenBodyState, InspectorScreenBody, InspectorRowContent; -import 'package:devtools_app/src/screens/inspector/inspector_screen_body.dart' - as legacy; -import 'package:devtools_app/src/screens/inspector_shared/inspector_controls.dart'; -import 'package:devtools_app/src/screens/inspector_v2/inspector_screen_body.dart'; -import 'package:devtools_app/src/screens/inspector_v2/inspector_tree_controller.dart'; -import 'package:devtools_app/src/screens/inspector_v2/layout_explorer/ui/utils.dart'; -import 'package:devtools_app/src/screens/inspector_v2/widget_properties/properties_view.dart'; -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -import '../../test_infra/flutter_test_driver.dart' show FlutterRunConfiguration; -import '../../test_infra/flutter_test_environment.dart'; -import '../../test_infra/matchers/matchers.dart'; - -// Note: This test uses packages/devtools_app/test/test_infra/fixtures/flutter_app -// running on the flutter-tester device. - -// This is a bit conservative to ensure we do not get flakes due to -// slow interactions with the VM Service. This delay could likely be -// reduced to under 1 second without introducing flakes. -const inspectorChangeSettleTime = Duration(seconds: 2); - -void main() { - // We need to use real async in this test so we need to use this binding. - initializeLiveTestWidgetsFlutterBindingWithAssets(); - const windowSize = Size(2600.0, 1200.0); - - final env = FlutterTestEnvironment( - const FlutterRunConfiguration(withDebugger: true), - testAppDirectory: 'test/test_infra/fixtures/inspector_app', - ); - - env.afterEverySetup = () async { - final service = serviceConnection.inspectorService; - await _resetPubRootDirectories(service as InspectorService); - if (env.reuseTestEnvironment) { - // Ensure the previous test did not set the selection on the device. - // TODO(jacobr): add a proper method to WidgetInspectorService that does - // this. setSelection currently ignores null selection requests which is - // a misfeature. - await service.inspectorLibrary.eval( - 'WidgetInspectorService.instance.selection.clear()', - isAlive: null, - ); - } - }; - - setUp(() async { - await env.setupEnvironment(); - // Enable the V2 inspector: - preferences.inspector.setLegacyInspectorEnabled(false); - setGlobal(BannerMessagesController, BannerMessagesController()); - }); - - tearDown(() async { - await env.tearDownEnvironment(force: true); - // Re-set changes to preferences: - preferences.inspector.setLegacyInspectorEnabled(true); - }); - - tearDownAll(() { - env.finalTeardown(); - }); - - group('screenshot tests', () { - testWidgetsWithWindowSize('initial load', windowSize, ( - WidgetTester tester, - ) async { - expect(serviceConnection.serviceManager.service, equals(env.service)); - expect(serviceConnection.serviceManager.isolateManager, isNotNull); - - await _loadInspectorUI(tester); - - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_initial_load.png', - ), - ); - }); - - testWidgetsWithWindowSize( - 'loads after a hot-restart', - windowSize, - (WidgetTester tester) async { - // Load the inspector panel. - await _loadInspectorUI(tester); - - // Expect the Center widget to be visible in the widget tree. - final centerWidgetFinder = find.richText('CustomCenter'); - expect(centerWidgetFinder, findsOneWidget); - - // Trigger a hot-restart and wait for the first Flutter frame. - await env.flutter!.hotRestart(); - await _waitForFlutterFrame(tester, isInitialLoad: false); - - // Wait for the Center widget to be visible again. - final centerWidgetFinderWithRetries = await retryUntilFound( - centerWidgetFinder, - tester: tester, - ); - expect(centerWidgetFinderWithRetries, findsOneWidget); - - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_after_hot_restart.png', - ), - ); - }, - skip: true, // https://github.com/flutter/devtools/issues/8179 - ); - - testWidgetsWithWindowSize('widget selection', windowSize, ( - WidgetTester tester, - ) async { - await _loadInspectorUI(tester); - - // Select the CustomCenter widget (row index #4) - await tester.tap(find.richText('CustomCenter')); - await tester.pumpAndSettle(inspectorChangeSettleTime); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_select_center.png', - ), - ); - - // Verify the properties are displayed: - verifyPropertyIsVisible( - name: 'alignment', - value: 'Alignment.center', - tester: tester, - ); - verifyPropertyIsVisible( - name: 'dependencies', - value: '[Directionality]', - tester: tester, - ); - }); - - testWidgetsWithWindowSize( - 'expand and collapse implementation widgets', - windowSize, - (WidgetTester tester) async { - await _loadInspectorUI(tester); - - // Toggle implementation widgets on. - await _toggleImplementationWidgets(tester); - - // Before hidden widgets are expanded, confirm the implementing - // Container of CustomContainer is hidden: - final hideableNodeFinder = findNodeMatching('Container'); - expect(hideableNodeFinder, findsNothing); - - // Expand the hidden group that contains the Container: - final moreWidgetsRow = findChildRowOf('CustomContainer'); - final expandButton = findExpandCollapseButtonForRow( - rowFinder: moreWidgetsRow, - isExpand: true, - ); - await tester.tap(expandButton); - await tester.pumpAndSettle(inspectorChangeSettleTime); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_implementation_widgets_expanded.png', - ), - // Implementation widgets from Flutter framework are not guaranteed to - // be stable. - skip: 'https://github.com/flutter/flutter/issues/172037', - ); - - // Confirm the Container is visible, and select it: - expect(hideableNodeFinder, findsOneWidget); - await tester.tap(hideableNodeFinder); - await tester.pumpAndSettle(inspectorChangeSettleTime); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_hideable_widget_selected.png', - ), - // Implementation widgets from Flutter framework are not guaranteed to - // be stable. - skip: 'https://github.com/flutter/flutter/issues/172037', - ); - - // Collapse the hidden group that contains the Container: - final collapsibleRow = findChildRowOf('CustomContainer'); - final collapseButton = findExpandCollapseButtonForRow( - rowFinder: collapsibleRow, - isExpand: false, - ); - await tester.tap(collapseButton); - await tester.pumpAndSettle(inspectorChangeSettleTime); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_implementation_widgets_collapsed.png', - ), - ); - }, - ); - - testWidgetsWithWindowSize('search for implementation widgets', windowSize, ( - WidgetTester tester, - ) async { - await _loadInspectorUI(tester); - - // Toggle implementation widgets on. - await _toggleImplementationWidgets(tester); - - // Before searching, confirm the implementing DefaultTextStyle of - // CustomApp is hidden: - final hideableNodeFinder = findNodeMatching('DefaultTextStyle'); - expect(hideableNodeFinder, findsNothing); - - // Search for the DefaultTextStyle: - final searchButtonFinder = find.ancestor( - of: find.byIcon(Icons.search), - matching: find.byType(ToolbarAction), - ); - await tester.tap(searchButtonFinder); - await tester.pumpAndSettle(inspectorChangeSettleTime); - await tester.enterText(find.byType(TextField), 'DefaultTextStyle'); - await tester.pumpAndSettle(inspectorChangeSettleTime); - await tester.tap(find.byIcon(Icons.close)); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Confirm the DefaultTextStyle is visible and selected: - expect(hideableNodeFinder, findsOneWidget); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_hideable_widget_selected_from_search.png', - ), - // Implementation widgets from Flutter framework are not guaranteed to - // be stable. - skip: 'https://github.com/flutter/flutter/issues/172037', - ); - }); - }); - - testWidgetsWithWindowSize('hide all implementation widgets', windowSize, ( - WidgetTester tester, - ) async { - await _loadInspectorUI(tester); - - // Toggle implementation widgets on. - await _toggleImplementationWidgets(tester); - - // Confirm the hidden widgets are visible behind affordances like "X more - // widgets". - expect(find.richTextContaining('more widgets...'), findsWidgets); - - // Toggle implementation widgets off. - await _toggleImplementationWidgets(tester); - - // Confirm that the hidden widgets are no longer visible. - expect(find.richTextContaining('more widgets...'), findsNothing); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_implementation_widgets_hidden.png', - ), - ); - - // Refresh the tree. - final refreshTreeButton = find.descendant( - of: find.byType(ToolbarAction), - matching: find.byIcon(Icons.refresh), - ); - - await tester.tap(refreshTreeButton); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Confirm that the hidden widgets are still not visible. - expect(find.richTextContaining('more widgets...'), findsNothing); - }); - - // TODO(elliette): Expand into test group for cases when: - // - selected widget is implementation widget and implementation widgets are hidden (this test case) - // - selected widget is implementation widget and implementation widgets are visible - // - selected widget is not implementation widget and implementation widgets are hidden - // - selected widget is not implementation widget and implementation widgets are visible - testWidgetsWithWindowSize('selecting implementation widget', windowSize, ( - WidgetTester tester, - ) async { - // Load the Inspector. - await _loadInspectorUI(tester); - - // Toggle implementation widgets on. - await _toggleImplementationWidgets(tester); - - await tester.pumpAndSettle(inspectorChangeSettleTime); - final state = - tester.state(find.byType(InspectorScreenBody)) - as InspectorScreenBodyState; - - // Find the CustomText diagnostic node. - final diagnostics = state.controller.inspectorTree.rowsInTree.value.map( - (row) => row!.node.diagnostic, - ); - final customTextDiagnostic = diagnostics.firstWhere( - (d) => d?.description == 'CustomText', - )!; - expect(customTextDiagnostic.isCreatedByLocalProject, isTrue); - - // Toggle implementation widgets off. - await _toggleImplementationWidgets(tester); - - // Verify the CustomText diagnostic node is still in the tree. - final diagnosticsNow = state.controller.inspectorTree.rowsInTree.value.map( - (row) => row!.node.diagnostic, - ); - expect( - diagnosticsNow.any((d) => d?.valueRef == customTextDiagnostic.valueRef), - isTrue, - ); - - // Get the implementing Text child of the CustomText diagnostic node. - final service = serviceConnection.inspectorService as InspectorService; - final group = service.createObjectGroup('test-group'); - final customTextSubtree = await group.getDetailsSubtree( - customTextDiagnostic, - ); - final textDiagnostic = (await customTextSubtree!.children)!.firstWhere( - (child) => child.description == 'Text', - ); - - // Verify the Text child is an implementation node that is not in the tree. - expect(textDiagnostic.isCreatedByLocalProject, isFalse); - expect( - diagnosticsNow.any((d) => d?.valueRef == textDiagnostic.valueRef), - isFalse, - ); - - // Mimic selecting the Text diagnostic node with the on-device inspector. - await group.setSelectionInspector(textDiagnostic.valueRef, false); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Verify the CustomText node is now selected. - final selectedNode = state.controller.selectedNode.value; - expect( - selectedNode!.diagnostic!.valueRef, - equals(customTextDiagnostic.valueRef), - ); - - // Verify the notification about selecting an implementation widget is displayed. - expect( - find.text('Selected an implementation widget of CustomText: Text.'), - findsOneWidget, - ); - }); - - testWidgetsWithWindowSize('can revert to legacy inspector', windowSize, ( - WidgetTester tester, - ) async { - await _loadInspectorUI(tester); - - // Select the CustomCenter widget (row index #4) - await tester.tap(find.richText('CustomCenter')); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Disable Inspector V2: - await toggleLegacyInspector(tester); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Verify the legacy inspector is visible: - await expectLater( - find.byType(legacy.InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_revert_to_legacy.png', - ), - ); - }); - - // Test to verify https://github.com/flutter/devtools/issues/8487 is fixed. - testWidgetsWithWindowSize( - 'revert to legacy inspector, hot-restart, and back to new inspector', - windowSize, - (WidgetTester tester) async { - await _loadInspectorUI(tester); - - // Disable Inspector V2. - await toggleLegacyInspector(tester); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Verify the legacy inspector is visible. - expect(find.richTextContaining('Widget Details Tree'), findsOneWidget); - - // Trigger a hot restart. - await env.flutter!.hotRestart(); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Enable Inspector V2. - await toggleLegacyInspector(tester); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Verify the legacy inspector is not visible. - expect(find.richTextContaining('Widget Details Tree'), findsNothing); - - // Wait for the widget tree to load. - final centerWidgetFinder = find.richText('Center'); - final centerWidgetFinderWithRetries = await retryUntilFound( - centerWidgetFinder, - tester: tester, - retries: 10, - ); - expect(centerWidgetFinderWithRetries, findsOneWidget); - }, - skip: true, // https://github.com/flutter/devtools/issues/8490 - ); - - testWidgetsWithWindowSize( - 'tree nodes contain only essential information', - windowSize, - (WidgetTester tester) async { - const requiredDetailsForTreeNode = [ - 'description', - 'shouldIndent', - 'valueId', - 'widgetRuntimeType', - ]; - const possibleDetailsForTreeNode = [ - 'textPreview', - 'children', - 'createdByLocalProject', - ]; - const extraneousDetailsForTreeNode = [ - 'creationLocation', - 'type', - 'style', - 'hasChildren', - 'stateful', - ]; - - await _loadInspectorUI(tester); - final state = - tester.state(find.byType(InspectorScreenBody)) - as InspectorScreenBodyState; - final rowsInTree = state.controller.inspectorTree.rowsInTree.value; - - for (final row in rowsInTree) { - final detailKeys = row?.node.diagnostic?.json.keys ?? const []; - expect( - requiredDetailsForTreeNode.every( - (detail) => detailKeys.contains(detail), - ), - isTrue, - ); - expect( - detailKeys.every( - (detail) => - requiredDetailsForTreeNode.contains(detail) || - possibleDetailsForTreeNode.contains(detail), - ), - isTrue, - ); - expect( - detailKeys.none( - (detail) => extraneousDetailsForTreeNode.contains(detail), - ), - isTrue, - ); - } - }, - ); - - group('auto-refresh after code edits', () { - final flutterAppMainPath = p.join(env.testAppDirectory, 'lib', 'main.dart'); - String flutterMainContents = ''; - - setUp(() { - // Save contents of main.dart file. - flutterMainContents = File(flutterAppMainPath).readAsStringSync(); - - // Enable auto-refresh. - preferences.inspector.setAutoRefreshEnabled(true); - }); - - tearDown(() { - // Re-set contents of main.dart. - File( - flutterAppMainPath, - ).writeAsStringSync(flutterMainContents, flush: true); - - // Re-set changes to auto refresh. - preferences.inspector.setAutoRefreshEnabled(true); - }); - - void makeEditToFlutterMain({ - required String toReplace, - required String replaceWith, - }) { - final file = File(flutterAppMainPath); - final fileContents = file.readAsStringSync(); - file.writeAsStringSync( - fileContents.replaceAll(toReplace, replaceWith), - flush: true, - ); - } - - testWidgetsWithWindowSize( - 'changing parent widget of selected', - windowSize, - (WidgetTester tester) async { - await _loadInspectorUI(tester); - - // Toggle implementation widgets on. - await _toggleImplementationWidgets(tester); - - // Give time for the initial animation to complete. - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Verify the CustomButton widget is after the CustomCenter widget. - expect( - _treeRowsAreInOrder( - treeRowDescriptions: ['CustomCenter', 'CustomButton'], - startingAtIndex: 7, - ), - isTrue, - ); - - // Verify the CustomButton widget is not visible in the properties view. - expect(_findWidgetLabelMatching('CustomButton'), findsNothing); - - // Select the CustomButton widget. - await tester.tap(_findTreeRowMatching('CustomButton')); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Verify the CustomButton widget is now visible in the properties view. - expect(_findWidgetLabelMatching('CustomButton'), findsOneWidget); - - // Make edit to main.dart to replace CustomCenter with an Align. - makeEditToFlutterMain(toReplace: 'CustomCenter', replaceWith: 'Align'); - await env.flutter!.hotReload(); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Verify the Align is now in the widget tree instead of Center. - expect( - _treeRowsAreInOrder( - treeRowDescriptions: ['Align', 'CustomButton'], - startingAtIndex: 7, - ), - isTrue, - ); - - // Verify the CustomButton widget is still selected. - expect(_findWidgetLabelMatching('CustomButton'), findsOneWidget); - }, - ); - }); - - group('widget errors', () { - testWidgetsWithWindowSize('show navigator and error labels', windowSize, ( - WidgetTester tester, - ) async { - await env.setupEnvironment( - config: const FlutterRunConfiguration( - withDebugger: true, - entryScript: 'lib/overflow_errors.dart', - ), - ); - expect(serviceConnection.serviceManager.service, equals(env.service)); - expect(serviceConnection.serviceManager.isolateManager, isNotNull); - - final screen = InspectorScreen(); - await tester.pumpWidget( - wrapWithInspectorControllers(Builder(builder: screen.build)), - ); - await tester.pumpAndSettle(const Duration(seconds: 1)); - await _waitForFlutterFrame(tester); - - await env.flutter!.hotReload(); - // Give time for the initial animation to complete. - await tester.pumpAndSettle(inspectorChangeSettleTime); - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_errors_1_initial_load.png', - ), - ); - - // Navigate so one of the errors is selected. - for (var i = 0; i < 2; i++) { - await tester.tap(find.byIcon(Icons.keyboard_arrow_down)); - await tester.pumpAndSettle(inspectorChangeSettleTime); - } - await expectLater( - find.byType(InspectorScreenBody), - matchesDevToolsGolden( - '../../test_infra/goldens/integration_inspector_v2_errors_2_error_selected.png', - ), - ); - }); - }); -} - -Future _toggleImplementationWidgets(WidgetTester tester) async { - // Tap the "Show Implementation Widgets" button (selected by default). - final showImplementationWidgetsButton = find.descendant( - of: find.byType(DevToolsToggleButton), - matching: find.text('Show Implementation Widgets'), - ); - expect(showImplementationWidgetsButton, findsOneWidget); - await tester.tap(showImplementationWidgetsButton); - await tester.pumpAndSettle(inspectorChangeSettleTime); -} - -Future _loadInspectorUI(WidgetTester tester) async { - final screen = InspectorScreen(); - await tester.pumpWidget( - wrapWithInspectorControllers(Builder(builder: screen.build)), - ); - await tester.pump(const Duration(seconds: 1)); - await _waitForFlutterFrame(tester); - - await tester.pumpAndSettle(inspectorChangeSettleTime); -} - -Future _waitForFlutterFrame( - WidgetTester tester, { - bool isInitialLoad = true, -}) async { - final state = - tester.state(find.byType(InspectorScreenBody)) - as InspectorScreenBodyState; - final controller = state.controller; - while (!controller.flutterAppFrameReady) { - // On the initial load, we might have instantiated the controller after the - // first Flutter frame was sent. In which case, calling `maybeLoadUI` is - // necessary to ensure we detect that the widget tree is ready. - if (isInitialLoad) { - await controller.maybeLoadUI(); - } - await tester.pump(safePumpDuration); - } -} - -Finder findNodeMatching(String text) => find.ancestor( - of: find.richText(text), - matching: find.byType(DescriptionDisplay), -); - -Finder findChildRowOf(String description) { - final parentRowFinder = _findTreeRowMatching(description); - final parentWidget = _getWidgetFromFinder( - parentRowFinder, - ); - final parentIndex = parentWidget.row.index; - - return find.byType(InspectorRowContent).at(parentIndex + 1); -} - -Finder findExpandCollapseButtonForRow({ - required Finder rowFinder, - required bool isExpand, -}) { - final expandCollapseButtonFinder = find.descendant( - of: rowFinder, - matching: find.byType(TextButton), - ); - expect(expandCollapseButtonFinder, findsOneWidget); - - final expandCollapseButtonTextFinder = find.descendant( - of: expandCollapseButtonFinder, - matching: find.text(isExpand ? '(expand)' : '(collapse)'), - ); - expect(expandCollapseButtonTextFinder, findsOneWidget); - - return expandCollapseButtonFinder; -} - -Future toggleLegacyInspector(WidgetTester tester) async { - // Open settings dialog. - final inspectorSettingsDialogButton = find.descendant( - of: find.byType(InspectorServiceExtensionButtonGroup), - matching: find.byType(SettingsOutlinedButton), - ); - await tester.tap(inspectorSettingsDialogButton); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Toggle the "legacy Inspector" checkbox. - final settingsRow = find.ancestor( - of: find.richTextContaining('Use legacy inspector'), - matching: find.byType(Row), - ); - final inspectorCheckbox = find.descendant( - of: settingsRow, - matching: find.byType(NotifierCheckbox), - ); - await tester.tap(inspectorCheckbox); - await tester.pumpAndSettle(inspectorChangeSettleTime); - - // Close the settings dialog. - final closeButton = find.byType(DialogCloseButton); - await tester.tap(closeButton); - await tester.pumpAndSettle(inspectorChangeSettleTime); -} - -void verifyPropertyIsVisible({ - required String name, - required String value, - required WidgetTester tester, -}) { - // Verify the property name is visible: - final propertyNameFinder = find.descendant( - of: find.byType(PropertyName), - matching: find.text(name), - ); - expect(propertyNameFinder, findsOneWidget); - - // Verify the property value is visible: - final propertyValueFinder = find.descendant( - of: find.byType(PropertyValue), - matching: find.richText(value), - ); - expect(propertyValueFinder, findsOneWidget); - - // Verify the property name and value are aligned: - final propertyNameCenter = tester.getCenter(propertyNameFinder); - final propertyValueCenter = tester.getCenter(propertyValueFinder); - expect(propertyNameCenter.dy, equals(propertyValueCenter.dy)); -} - -bool areHorizontallyAligned( - Finder widgetAFinder, - Finder widgetBFinder, { - required WidgetTester tester, -}) { - final widgetACenter = tester.getCenter(widgetAFinder); - final widgetBCenter = tester.getCenter(widgetBFinder); - - return widgetACenter.dy == widgetBCenter.dy; -} - -bool _treeRowsAreInOrder({ - required List treeRowDescriptions, - required int startingAtIndex, -}) { - final treeRowIndices = []; - - for (final description in treeRowDescriptions) { - final treeRow = _getWidgetFromFinder( - _findTreeRowMatching(description), - ); - treeRowIndices.add(treeRow.row.index); - } - - int indexToCheck = startingAtIndex; - for (final index in treeRowIndices) { - if (index == indexToCheck) { - indexToCheck++; - } else { - return false; - } - } - return true; -} - -Finder _findTreeRowMatching(String description) => find.ancestor( - of: find.richText(description), - matching: find.byType(InspectorRowContent), -); - -Finder _findWidgetLabelMatching(String description) => find.ancestor( - of: find.richText(description), - matching: find.byType(WidgetLabel), -); - -T _getWidgetFromFinder(Finder finder) => - finder.first.evaluate().first.widget as T; - -Future _resetPubRootDirectories(InspectorService inspectorService) async { - final currentPubRootDirectories = await inspectorService - .getPubRootDirectories(); - if (currentPubRootDirectories != null) { - await inspectorService.removePubRootDirectories(currentPubRootDirectories); - } - - final rootLibrary = await serviceConnection.serviceManager - .mainIsolateRootLibraryUriAsString(); - if (rootLibrary != null) { - await inspectorService.addPubRootDirectories([rootLibrary]); - } -} diff --git a/packages/devtools_app/test/screens/inspector_v2/inspector_screen_test.dart b/packages/devtools_app/test/screens/inspector_v2/inspector_screen_test.dart deleted file mode 100644 index 813cc1eb0b9..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/inspector_screen_test.dart +++ /dev/null @@ -1,408 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -// Fake construction requires number of unawaited calls. -// ignore_for_file: discarded_futures - -import 'dart:convert'; - -import 'package:devtools_app/devtools_app.dart' - hide - InspectorController, - InspectorTreeController, - InspectorScreenBody, - ErrorNavigator, - InspectorTreeNode; -import 'package:devtools_app/src/screens/inspector_shared/inspector_settings_dialog.dart'; -import 'package:devtools_app/src/screens/inspector_v2/inspector_screen_body.dart'; -import 'package:devtools_app/src/screens/inspector_v2/layout_explorer/flex/flex.dart'; -import 'package:devtools_app/src/screens/inspector_v2/widget_details.dart'; -import 'package:devtools_app/src/service/service_extensions.dart' as extensions; -import 'package:devtools_app/src/shared/console/eval/inspector_tree_v2.dart'; -import 'package:devtools_app/src/shared/feature_flags.dart'; -import 'package:devtools_app/src/shared/ui/tab.dart'; -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/devtools_test.dart'; -import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart' hide Fake; -import 'package:mockito/mockito.dart'; - -import '../../test_infra/flutter_test_storage.dart'; - -void main() { - final screen = InspectorScreen(); - - late FakeServiceConnectionManager fakeServiceConnection; - late FakeServiceExtensionManager fakeExtensionManager; - const windowSize = Size(2600.0, 1200.0); - - final debuggerController = createMockDebuggerControllerWithDefaults(); - - Widget buildInspectorScreen() { - return wrapWithControllers( - Builder(builder: screen.build), - debugger: debuggerController, - inspector: InspectorScreenController(), - ); - } - - setUp(() { - setEnableExperiments(); - fakeServiceConnection = FakeServiceConnectionManager(); - fakeExtensionManager = - fakeServiceConnection.serviceManager.serviceExtensionManager; - mockConnectedApp(fakeServiceConnection.serviceManager.connectedApp!); - when( - fakeServiceConnection.errorBadgeManager.errorCountNotifier('inspector'), - ).thenReturn(ValueNotifier(0)); - - setGlobal( - DevToolsEnvironmentParameters, - ExternalDevToolsEnvironmentParameters(), - ); - setGlobal(ServiceConnectionManager, fakeServiceConnection); - setGlobal(IdeTheme, IdeTheme()); - setGlobal(PreferencesController, PreferencesController()); - setGlobal(Storage, FlutterTestStorage()); - setGlobal(NotificationService, NotificationService()); - fakeServiceConnection.consoleService.ensureServiceInitialized(); - }); - - Future mockExtensions() async { - fakeExtensionManager.extensionValueOnDevice = { - extensions.toggleSelectWidgetMode.extension: true, - extensions.enableOnDeviceInspector.extension: true, - extensions.toggleOnDeviceWidgetInspector.extension: true, - extensions.debugPaint.extension: false, - }; - await fakeExtensionManager.fakeAddServiceExtension( - extensions.toggleOnDeviceWidgetInspector.extension, - ); - await fakeExtensionManager.fakeAddServiceExtension( - extensions.toggleSelectWidgetMode.extension, - ); - await fakeExtensionManager.fakeAddServiceExtension( - extensions.enableOnDeviceInspector.extension, - ); - await fakeExtensionManager.fakeAddServiceExtension( - extensions.debugPaint.extension, - ); - await fakeExtensionManager.fakeFrame(); - } - - void mockNoExtensionsAvailable() { - fakeExtensionManager.extensionValueOnDevice = { - extensions.toggleOnDeviceWidgetInspector.extension: true, - extensions.toggleSelectWidgetMode.extension: false, - extensions.debugPaint.extension: false, - }; - // Don't actually send any events to the client indicating that service - // extensions are avaiable. - fakeExtensionManager.fakeFrame(); - } - - testWidgetsWithWindowSize('builds its tab', windowSize, ( - WidgetTester tester, - ) async { - await tester.pumpWidget(buildInspectorScreen()); - expect(find.byType(InspectorScreenBody), findsOneWidget); - }); - - group('Widget Errors', () { - // Display of error navigator/indicators is tested by a golden in - // inspector_integration_test.dart - - testWidgetsWithWindowSize( - 'does not render error navigator if no errors', - windowSize, - (WidgetTester tester) async { - await tester.pumpWidget(buildInspectorScreen()); - expect(find.byType(ErrorNavigator), findsNothing); - }, - ); - }); - - testWidgetsWithWindowSize('builds with no data', windowSize, ( - WidgetTester tester, - ) async { - // Make sure the window is wide enough to display description text. - - await tester.pumpWidget(buildInspectorScreen()); - expect(find.byType(InspectorScreenBody), findsOneWidget); - expect(find.byTooltip('Refresh Tree'), findsOneWidget); - expect(find.text(extensions.debugPaint.title), findsOneWidget); - // Make sure there is not an overflow if the window is narrow. - // TODO(jacobr): determine why there are overflows in the test environment - // but not on the actual device for this cae. - // await setWindowSize(const Size(1000.0, 1200.0)); - // Verify that description text is no-longer shown. - // expect(find.text(extensions.debugPaint.description), findsOneWidget); - }); - - testWidgetsWithWindowSize( - 'Test toggling service extension buttons', - windowSize, - (WidgetTester tester) async { - await mockExtensions(); - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .debugPaint - .extension], - isFalse, - ); - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .toggleOnDeviceWidgetInspector - .extension], - isTrue, - ); - - await tester.pumpWidget(buildInspectorScreen()); - - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .toggleSelectWidgetMode - .extension], - isTrue, - ); - - // We need a frame to find out that the service extension state has changed. - expect(find.byType(InspectorScreenBody), findsOneWidget); - expect( - find.text(extensions.toggleSelectWidgetMode.title), - findsOneWidget, - ); - expect(find.text(extensions.debugPaint.title), findsOneWidget); - await tester.pump(); - await tester.tap(find.text(extensions.toggleSelectWidgetMode.title)); - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .toggleSelectWidgetMode - .extension], - isFalse, - ); - // Verify the other service extension's state hasn't changed. - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .debugPaint - .extension], - isFalse, - ); - - await tester.tap(find.text(extensions.toggleSelectWidgetMode.title)); - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .toggleSelectWidgetMode - .extension], - isTrue, - ); - - await tester.tap(find.text(extensions.debugPaint.title)); - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .debugPaint - .extension], - isTrue, - ); - }, - ); - - testWidgetsWithWindowSize( - 'Test toggling service extension buttons with no extensions available', - windowSize, - (WidgetTester tester) async { - mockNoExtensionsAvailable(); - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .debugPaint - .extension], - isFalse, - ); - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .toggleOnDeviceWidgetInspector - .extension], - isTrue, - ); - - await tester.pumpWidget(buildInspectorScreen()); - await tester.pump(); - expect(find.byType(InspectorScreenBody), findsOneWidget); - expect( - find.text(extensions.toggleOnDeviceWidgetInspector.title), - findsOneWidget, - ); - expect(find.text(extensions.debugPaint.title), findsOneWidget); - await tester.pump(); - - await tester.tap( - find.text(extensions.toggleOnDeviceWidgetInspector.title), - ); - // Verify the service extension state has not changed. - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .toggleOnDeviceWidgetInspector - .extension], - isTrue, - ); - await tester.tap( - find.text(extensions.toggleOnDeviceWidgetInspector.title), - ); - // Verify the service extension state has not changed. - expect( - fakeExtensionManager.extensionValueOnDevice[extensions - .toggleOnDeviceWidgetInspector - .extension], - isTrue, - ); - - // TODO(jacobr): also verify that the service extension buttons look - // visually disabled. - }, - ); - - group('LayoutDetailsTab', () { - final renderObjectJson = jsonDecode(''' - { - "properties": [ - { - "description": "horizontal", - "name": "direction" - }, - { - "description": "start", - "name": "mainAxisAlignment" - }, - { - "description": "max", - "name": "mainAxisSize" - }, - { - "description": "center", - "name": "crossAxisAlignment" - }, - { - "description": "ltr", - "name": "textDirection" - }, - { - "description": "down", - "name": "verticalDirection" - } - ] - } - '''); - final diagnostic = RemoteDiagnosticsNode( - { - 'widgetRuntimeType': 'Row', - 'renderObject': renderObjectJson, - 'hasChildren': false, - 'children': [], - }, - null, - false, - null, - ); - final treeNode = InspectorTreeNode()..diagnostic = diagnostic; - testWidgetsWithWindowSize( - 'should render StoryOfYourFlexWidget', - windowSize, - (WidgetTester tester) async { - final controller = TestInspectorV2Controller() - ..setSelectedNode(treeNode) - ..setSelectedDiagnostic(diagnostic); - await tester.pumpWidget( - MaterialApp( - home: Scaffold(body: WidgetDetails(controller: controller)), - ), - ); - - // Navigate to the flex explorer tab. - await tester.tap(_findFlexExplorerTab()); - await tester.pumpAndSettle(); - - expect(find.byType(FlexLayoutExplorerWidget), findsOneWidget); - }, - ); - - testWidgetsWithWindowSize( - 'should listen to controller selection event', - windowSize, - (WidgetTester tester) async { - final controller = TestInspectorV2Controller(); - await tester.pumpWidget( - MaterialApp( - home: Scaffold(body: WidgetDetails(controller: controller)), - ), - ); - - // Flex explorer is not available for selected wiget. - expect(_findFlexExplorerTab(), findsNothing); - - // Select a flex widget. - controller - ..setSelectedNode(treeNode) - ..setSelectedDiagnostic(diagnostic); - await tester.pumpAndSettle(); - - // Flex explorer is available for the flex widget. - final flexExplorerTab = _findFlexExplorerTab(); - expect(flexExplorerTab, findsOneWidget); - await tester.tap(flexExplorerTab); - await tester.pumpAndSettle(); - expect(find.byType(FlexLayoutExplorerWidget), findsOneWidget); - }, - ); - }); - - group('FlutterInspectorSettingsDialog', () { - const startingHoverEvalModeValue = false; - - setUp(() { - preferences.inspector.setHoverEvalMode(startingHoverEvalModeValue); - }); - - testWidgetsWithWindowSize( - 'can update hover inspection setting', - windowSize, - (WidgetTester tester) async { - await tester.pumpWidget(buildInspectorScreen()); - - await tester.tap(find.byType(SettingsOutlinedButton)); - - final settingsDialogFinder = await retryUntilFound( - find.byType(FlutterInspectorSettingsDialog), - tester: tester, - ); - expect(settingsDialogFinder, findsOneWidget); - - final hoverCheckBoxSetting = find.ancestor( - of: find.richTextContaining('Enable hover inspection'), - matching: find.byType(CheckboxSetting), - ); - final hoverModeCheckBox = find.descendant( - of: hoverCheckBoxSetting, - matching: find.byType(NotifierCheckbox), - ); - await tester.tap(hoverModeCheckBox); - await tester.pump(safePumpDuration); - expect( - preferences.inspector.hoverEvalModeEnabled.value, - !startingHoverEvalModeValue, - ); - }, - ); - }); - // TODO(jacobr): add screenshot tests that connect to a test application - // in the same way the inspector_controller test does today and take golden - // images. Alternately: support an offline inspector mode and add tests of - // that mode which would enable faster tests that run as unittests. -} - -Finder _findFlexExplorerTab() => find.descendant( - of: find.byType(DevToolsTab), - matching: find.text('Flex explorer'), -); diff --git a/packages/devtools_app/test/screens/inspector_v2/inspector_tree_test.dart b/packages/devtools_app/test/screens/inspector_v2/inspector_tree_test.dart deleted file mode 100644 index 5a7c8966850..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/inspector_tree_test.dart +++ /dev/null @@ -1,147 +0,0 @@ -// Copyright 2021 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app/devtools_app.dart' - hide - InspectorController, - InspectorTreeController, - InspectorTree, - InspectorTreeConfig, - InspectorTreeNode; -import 'package:devtools_app/src/screens/inspector_v2/inspector_controller.dart'; -import 'package:devtools_app/src/screens/inspector_v2/inspector_tree_controller.dart'; -import 'package:devtools_app/src/shared/console/eval/inspector_tree_v2.dart'; -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/devtools_test.dart'; -import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart' hide Fake; -import 'package:mockito/mockito.dart'; - -import 'utils/inspector_tree.dart'; - -void main() { - late FakeServiceConnectionManager fakeServiceConnection; - late InspectorController inspectorController; - - setUp(() { - fakeServiceConnection = FakeServiceConnectionManager(); - final app = fakeServiceConnection.serviceManager.connectedApp!; - when(app.isFlutterAppNow).thenReturn(true); - when(app.isProfileBuildNow).thenReturn(false); - - setGlobal( - DevToolsEnvironmentParameters, - ExternalDevToolsEnvironmentParameters(), - ); - setGlobal(ServiceConnectionManager, fakeServiceConnection); - setGlobal(IdeTheme, IdeTheme()); - setGlobal(PreferencesController, PreferencesController()); - setGlobal(NotificationService, NotificationService()); - setGlobal(BreakpointManager, BreakpointManager()); - mockConnectedApp(fakeServiceConnection.serviceManager.connectedApp!); - - inspectorController = InspectorController( - inspectorTree: InspectorTreeController(), - treeType: FlutterTreeType.widget, - )..firstInspectorTreeLoadCompleted = true; - }); - - Future pumpInspectorTree( - WidgetTester tester, { - required InspectorTreeController treeController, - }) async { - final debuggerController = DebuggerController(); - await tester.pumpWidget( - wrapWithControllers( - debugger: debuggerController, - InspectorTree( - controller: inspectorController, - treeController: treeController, - ), - ), - ); - } - - group('InspectorTreeController', () { - testWidgets('Row with negative index regression test', ( - WidgetTester tester, - ) async { - final treeController = InspectorTreeController() - ..config = InspectorTreeConfig( - onNodeAdded: (_, _) {}, - onClientActiveChange: (_) {}, - ); - await pumpInspectorTree(tester, treeController: treeController); - - expect(treeController.rowForOffset(const Offset(0, -100.0)), isNull); - expect(treeController.rowOffset(-1), equals(0)); - - expect(treeController.rowForOffset(const Offset(0, 0.0)), isNull); - expect(treeController.rowOffset(0), equals(0)); - - treeController.root = InspectorTreeNode() - ..appendChild(InspectorTreeNode()); - - await pumpInspectorTree(tester, treeController: treeController); - - expect(treeController.rowForOffset(const Offset(0, -20))!.index, 0); - expect(treeController.rowOffset(-1), equals(0)); - expect(treeController.rowForOffset(const Offset(0, 0.0)), isNotNull); - expect(treeController.rowOffset(0), equals(0)); - - // This operation would previously throw an exception in debug builds - // and infinite loop in release builds. - treeController.scrollToRect(const Rect.fromLTWH(0, -20, 100, 100)); - }); - }); - - group('Inspector tree content preview', () { - testWidgets('Shows simple text preview', (WidgetTester tester) async { - final diagnosticNode = await widgetToInspectorTreeDiagnosticsNode( - widget: const Text('Content'), - tester: tester, - ); - - final treeController = inspectorTreeControllerFromNode(diagnosticNode); - await pumpInspectorTree(tester, treeController: treeController); - - expect(find.richText('Text: "Content"'), findsOneWidget); - }); - - testWidgets('Shows preview from Text.rich', (WidgetTester tester) async { - final diagnosticNode = await widgetToInspectorTreeDiagnosticsNode( - widget: const Text.rich( - TextSpan( - children: [ - TextSpan(text: 'Rich '), - TextSpan(text: 'text'), - ], - ), - ), - tester: tester, - ); - - final treeController = inspectorTreeControllerFromNode(diagnosticNode); - await pumpInspectorTree(tester, treeController: treeController); - - expect(find.richText('Text: "Rich text"'), findsOneWidget); - }); - - testWidgets('Strips new lines from text preview', ( - WidgetTester tester, - ) async { - final diagnosticNode = await widgetToInspectorTreeDiagnosticsNode( - widget: const Text('Multiline\ntext\n\ncontent'), - tester: tester, - ); - - final treeController = inspectorTreeControllerFromNode(diagnosticNode); - await pumpInspectorTree(tester, treeController: treeController); - - expect(find.richText('Text: "Multiline text content"'), findsOneWidget); - }); - }); -} diff --git a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/flex/arrow_test.dart b/packages/devtools_app/test/screens/inspector_v2/layout_explorer/flex/arrow_test.dart deleted file mode 100644 index 587958b1752..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/flex/arrow_test.dart +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app/src/screens/inspector_v2/layout_explorer/ui/arrow.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../../../test_infra/matchers/matchers.dart'; - -void main() { - const relativeGoldenPath = - '../../../../test_infra/goldens/inspector_v2/layout_explorer/flex'; - - group('Arrow Golden Tests', () { - group('Unidirectional', () { - Widget buildUnidirectionalArrowWrapper(ArrowType type) => Directionality( - textDirection: TextDirection.ltr, - child: SizedBox( - width: 100, - height: 100, - child: ArrowWrapper.unidirectional( - type: type, - arrowColor: Colors.black, - arrowHeadSize: 8.0, - child: Container(width: 10, height: 10, color: Colors.red), - ), - ), - ); - testWidgets('left', (WidgetTester tester) async { - final widget = buildUnidirectionalArrowWrapper(ArrowType.left); - await tester.pumpWidget(widget); - await expectLater( - find.byWidget(widget), - matchesDevToolsGolden( - '$relativeGoldenPath/arrow_unidirectional_left.png', - ), - ); - }, skip: kIsWeb); - testWidgets('up', (WidgetTester tester) async { - final widget = buildUnidirectionalArrowWrapper(ArrowType.up); - await tester.pumpWidget(widget); - await expectLater( - find.byWidget(widget), - matchesDevToolsGolden( - '$relativeGoldenPath/arrow_unidirectional_up.png', - ), - ); - }, skip: kIsWeb); - testWidgets('right', (WidgetTester tester) async { - final widget = buildUnidirectionalArrowWrapper(ArrowType.right); - await tester.pumpWidget(widget); - await expectLater( - find.byWidget(widget), - matchesDevToolsGolden( - '$relativeGoldenPath/arrow_unidirectional_right.png', - ), - ); - }, skip: kIsWeb); - testWidgets('down', (WidgetTester tester) async { - final widget = buildUnidirectionalArrowWrapper(ArrowType.down); - await tester.pumpWidget(widget); - await expectLater( - find.byWidget(widget), - matchesDevToolsGolden( - '$relativeGoldenPath/arrow_unidirectional_down.png', - ), - ); - }, skip: kIsWeb); - }); - - group('Bidirectional', () { - Widget buildBidirectionalArrowWrapper(Axis direction) => Directionality( - textDirection: TextDirection.ltr, - child: SizedBox( - width: 100, - height: 100, - child: ArrowWrapper.bidirectional( - direction: direction, - arrowColor: Colors.black, - arrowHeadSize: 8.0, - child: Container(width: 10, height: 10, color: Colors.red), - ), - ), - ); - testWidgets('horizontal', (WidgetTester tester) async { - final widget = buildBidirectionalArrowWrapper(Axis.horizontal); - await tester.pumpWidget(widget); - await expectLater( - find.byWidget(widget), - matchesDevToolsGolden( - '$relativeGoldenPath/arrow_bidirectional_horizontal.png', - ), - ); - }, skip: kIsWeb); - testWidgets('vertical', (WidgetTester tester) async { - final widget = buildBidirectionalArrowWrapper(Axis.vertical); - await tester.pumpWidget(widget); - await expectLater( - find.byWidget(widget), - matchesDevToolsGolden( - '$relativeGoldenPath/arrow_bidirectional_vertical.png', - ), - ); - }, skip: kIsWeb); - }); - }); -} diff --git a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/flex/flex_test.dart b/packages/devtools_app/test/screens/inspector_v2/layout_explorer/flex/flex_test.dart deleted file mode 100644 index 5f4ea4a2fbf..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/flex/flex_test.dart +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'dart:convert'; - -import 'package:devtools_app/src/screens/inspector_v2/layout_explorer/flex/flex.dart'; -import 'package:devtools_app/src/shared/console/eval/inspector_tree_v2.dart'; -import 'package:devtools_app/src/shared/diagnostics/diagnostics_node.dart'; -import 'package:devtools_test/devtools_test.dart'; -import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../../../test_infra/matchers/matchers.dart'; - -// TODO(albertusangga): Re-enable tests in this files -// https://github.com/flutter/devtools/issues/1403 -void main() { - const windowSize = Size(1750, 1750); - const relativeGoldenPath = - '../../../../test_infra/goldens/inspector_v2/layout_explorer/flex'; - - Map buildDiagnosticsNodeJson(Axis axis) => jsonDecode(''' - { - "description": "${axis == Axis.horizontal ? 'Row' : 'Column'}", - "type": "_ElementDiagnosticableTreeNode", - "style": "dense", - "hasChildren": true, - "allowWrap": false, - "objectId": "inspector-267513", - "valueId": "inspector-251", - "summaryTree": true, - "constraints": { - "type": "BoxConstraints", - "description": "BoxConstraints(w=300.0, h=60.0)", - "minWidth": "300.0", - "minHeight": "60.0", - "maxHeight": "60.0", - "maxWidth": "300.0" - }, - "size": { - "width": "300.0", - "height": "60.0" - }, - "isFlex": true, - "children": [ - { - "description": "Container", - "type": "_ElementDiagnosticableTreeNode", - "style": "dense", - "hasChildren": true, - "allowWrap": false, - "objectId": "inspector-267524", - "valueId": "inspector-269", - "summaryTree": true, - "constraints": { - "type": "BoxConstraints", - "description": "BoxConstraints(0.0<=w<=Infinity, 0.0<=h<=56.0)", - "minWidth": "0.0", - "minHeight": "0.0", - "maxHeight": "56.0", - "maxWidth": "Infinity" - }, - "size": { - "width": "56.0", - "height": "25.0" - }, - "flexFactor": null, - "createdByLocalProject": true, - "children": [], - "widgetRuntimeType": "Container", - "stateful": false - }, - { - "description": "Expanded", - "type": "_ElementDiagnosticableTreeNode", - "style": "dense", - "hasChildren": true, - "allowWrap": false, - "objectId": "inspector-267563", - "valueId": "inspector-332", - "summaryTree": true, - "constraints": { - "type": "BoxConstraints", - "description": "BoxConstraints(w=40.0, 0.0<=h<=56.0)", - "minWidth": "40.0", - "minHeight": "0.0", - "maxHeight": "56.0", - "maxWidth": "40.0" - }, - "size": { - "width": "40.0", - "height": "31.0" - }, - "flexFactor": 1, - "createdByLocalProject": true, - "children": [], - "widgetRuntimeType": "Expanded" - } - ], - "widgetRuntimeType": "${axis == Axis.horizontal ? 'Row' : 'Column'}", - "renderObject": { - "description": "RenderFlex#6cfb1 relayoutBoundary=up5", - "type": "DiagnosticableTreeNode", - "hasChildren": true, - "allowWrap": false, - "objectId": "inspector-3758", - "valueId": "inspector-118", - "summaryTree": true, - "properties": [ - { - "description": " (can use size)", - "type": "DiagnosticsProperty", - "name": "parentData", - "style": "singleLine", - "allowNameWrap": true, - "objectId": "inspector-3759", - "valueId": "inspector-120", - "summaryTree": true, - "properties": [], - "ifNull": "MISSING", - "tooltip": "can use size", - "missingIfNull": true, - "propertyType": "ParentData", - "defaultLevel": "info" - }, - { - "description": "${axis.name}", - "type": "EnumProperty", - "name": "direction", - "style": "singleLine", - "allowNameWrap": true, - "objectId": "inspector-3762", - "valueId": "inspector-126", - "summaryTree": true, - "properties": [], - "missingIfNull": false, - "propertyType": "Axis", - "defaultLevel": "info" - }, - { - "description": "start", - "type": "EnumProperty", - "name": "mainAxisAlignment", - "style": "singleLine", - "allowNameWrap": true, - "objectId": "inspector-3763", - "valueId": "inspector-128", - "summaryTree": true, - "properties": [], - "missingIfNull": false, - "propertyType": "MainAxisAlignment", - "defaultLevel": "info" - }, - { - "description": "max", - "type": "EnumProperty", - "name": "mainAxisSize", - "style": "singleLine", - "allowNameWrap": true, - "objectId": "inspector-3764", - "valueId": "inspector-130", - "summaryTree": true, - "properties": [], - "missingIfNull": false, - "propertyType": "MainAxisSize", - "defaultLevel": "info" - }, - { - "description": "center", - "type": "EnumProperty", - "name": "crossAxisAlignment", - "style": "singleLine", - "allowNameWrap": true, - "objectId": "inspector-3765", - "valueId": "inspector-132", - "summaryTree": true, - "properties": [], - "missingIfNull": false, - "propertyType": "CrossAxisAlignment", - "defaultLevel": "info" - }, - { - "description": "ltr", - "type": "EnumProperty", - "name": "textDirection", - "style": "singleLine", - "allowNameWrap": true, - "objectId": "inspector-3766", - "valueId": "inspector-83", - "summaryTree": true, - "properties": [], - "defaultValue": "null", - "missingIfNull": false, - "propertyType": "TextDirection", - "defaultLevel": "info" - }, - { - "description": "down", - "type": "EnumProperty", - "name": "verticalDirection", - "style": "singleLine", - "allowNameWrap": true, - "objectId": "inspector-3767", - "valueId": "inspector-135", - "summaryTree": true, - "properties": [], - "defaultValue": "null", - "missingIfNull": false, - "propertyType": "VerticalDirection", - "defaultLevel": "info" - }, - { - "description": "alphabetic", - "type": "EnumProperty", - "name": "textBaseline", - "style": "singleLine", - "allowNameWrap": true, - "objectId": "inspector-3767", - "valueId": "inspector-135", - "summaryTree": true, - "properties": [], - "defaultValue": "null", - "missingIfNull": false, - "propertyType": "TextBaseline", - "defaultLevel": "info" - } - ] - } - } - '''); - - Widget wrap(Widget widget) { - return MaterialApp(home: Scaffold(body: widget)); - } - - /// current workaround for flaky image asset testing. - /// https://github.com/flutter/flutter/issues/38997 - Future pump(WidgetTester tester, Widget w) async { - await tester.runAsync(() async { - await tester.pumpWidget(w); - for (final element in find.byType(Image).evaluate()) { - final widget = element.widget as Image; - final image = widget.image; - await precacheImage(image, element); - await tester.pumpAndSettle(); - } - }); - } - - testWidgetsWithWindowSize('Row golden test', windowSize, ( - WidgetTester tester, - ) async { - final rowWidgetJsonNode = buildDiagnosticsNodeJson(Axis.horizontal); - final diagnostic = RemoteDiagnosticsNode( - rowWidgetJsonNode, - null, - false, - null, - ); - final treeNode = InspectorTreeNode()..diagnostic = diagnostic; - final controller = TestInspectorV2Controller()..setSelectedNode(treeNode); - final widget = wrap(FlexLayoutExplorerWidget(controller)); - await pump(tester, widget); - await tester.pumpAndSettle(); - await expectLater( - find.byWidget(widget), - matchesDevToolsGolden('$relativeGoldenPath/story_of_row_layout.png'), - ); - }, skip: true); - - testWidgetsWithWindowSize('Column golden test', windowSize, ( - WidgetTester tester, - ) async { - final columnWidgetJsonNode = buildDiagnosticsNodeJson(Axis.vertical); - final diagnostic = RemoteDiagnosticsNode( - columnWidgetJsonNode, - null, - false, - null, - ); - final treeNode = InspectorTreeNode()..diagnostic = diagnostic; - final controller = TestInspectorV2Controller()..setSelectedNode(treeNode); - final widget = wrap(FlexLayoutExplorerWidget(controller)); - await pump(tester, widget); - await expectLater( - find.byWidget(widget), - matchesDevToolsGolden('$relativeGoldenPath/story_of_column_layout.png'), - ); - }, skip: true); -} diff --git a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/inspector_data_models_test.dart b/packages/devtools_app/test/screens/inspector_v2/layout_explorer/inspector_data_models_test.dart deleted file mode 100644 index 6c8302d73df..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/inspector_data_models_test.dart +++ /dev/null @@ -1,442 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app/src/screens/inspector_v2/inspector_data_models.dart'; -import 'package:devtools_app/src/screens/inspector_v2/layout_explorer/ui/theme.dart'; -import 'package:devtools_app/src/shared/primitives/math_utils.dart'; -import 'package:devtools_app_shared/ui.dart'; -import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'layout_explorer_test_utils.dart'; - -void main() { - setGlobal(IdeTheme, IdeTheme()); - - group('FlexLayoutProperties tests', () { - Future toFlexLayoutProperties( - Flex flex, { - required WidgetTester tester, - int subtreeDepth = 2, - double? width, - double? height, - }) async { - final wrappedWidget = SizedBox(width: width, height: height, child: flex); - final rootNodeDiagnostics = - await widgetToLayoutExplorerRemoteDiagnosticsNode( - widget: wrappedWidget, - tester: tester, - subtreeDepth: subtreeDepth, - ); - final flexDiagnostics = rootNodeDiagnostics.childrenNow.first; - return FlexLayoutProperties.fromDiagnostics(flexDiagnostics); - } - - testWidgets( - 'FlexLayoutProperties.fromJson creates correct value from enum', - (tester) async { - const widget = Row( - textDirection: TextDirection.ltr, - children: [SizedBox()], - ); - final flexProperties = await toFlexLayoutProperties( - widget, - tester: tester, - ); - expect(flexProperties.direction, Axis.horizontal); - expect(flexProperties.mainAxisAlignment, MainAxisAlignment.start); - expect(flexProperties.mainAxisSize, MainAxisSize.max); - expect(flexProperties.crossAxisAlignment, CrossAxisAlignment.center); - expect(flexProperties.textDirection, TextDirection.ltr); - expect(flexProperties.verticalDirection, VerticalDirection.down); - expect(flexProperties.textBaseline, null); - }, - ); - - testWidgets('startIsTopLeft should return false', (tester) async { - const columnWidget = Column( - verticalDirection: VerticalDirection.up, - children: [SizedBox()], - ); - final columnProperties = await toFlexLayoutProperties( - columnWidget, - tester: tester, - ); - expect(columnProperties.startIsTopLeft, false); - - const rowWidget = Row( - textDirection: TextDirection.rtl, - children: [SizedBox()], - ); - final rowProperties = await toFlexLayoutProperties( - rowWidget, - tester: tester, - ); - expect(rowProperties.startIsTopLeft, false); - }); - - testWidgets( - 'displayChildren is the same as children when start is top left', - (tester) async { - final widget = Column(children: [const SizedBox(), Container()]); - final properties = await toFlexLayoutProperties(widget, tester: tester); - expect(properties.startIsTopLeft, true); - expect(properties.displayChildren[0].description, 'SizedBox'); - expect(properties.displayChildren[1].description, 'Container'); - }, - ); - - testWidgets( - 'displayChildren is a reversed children when start is not top left', - (tester) async { - final widget = Column( - verticalDirection: VerticalDirection.up, - children: [const SizedBox(), Container()], - ); - final properties = await toFlexLayoutProperties(widget, tester: tester); - expect(properties.startIsTopLeft, false); - expect(properties.displayChildren[0].description, 'Container'); - expect(properties.displayChildren[1].description, 'SizedBox'); - }, - ); - - group('childrenRenderProperties tests', () { - const maxMainAxisDimension = 500.0; - - double maxSizeAvailable(Axis _) => maxMainAxisDimension; - - List childrenRenderProperties( - FlexLayoutProperties properties, - ) => properties.childrenRenderProperties( - smallestRenderWidth: minRenderWidth, - largestRenderWidth: defaultMaxRenderWidth, - smallestRenderHeight: minRenderHeight, - largestRenderHeight: defaultMaxRenderHeight, - maxSizeAvailable: maxSizeAvailable, - ); - - final childrenWidgets = [ - const SizedBox(width: 50.0), - const SizedBox(width: 75.0, height: 25.0), - ]; - - testWidgets( - 'returns correct RenderProperties with main axis not flipped when start is top left', - (tester) async { - final widget = Row(children: childrenWidgets); - final properties = await toFlexLayoutProperties( - widget, - width: maxMainAxisDimension, - tester: tester, - subtreeDepth: 3, - ); - final renderProps = properties.childrenRenderProperties( - smallestRenderWidth: minRenderWidth, - largestRenderWidth: defaultMaxRenderWidth, - smallestRenderHeight: minRenderHeight, - largestRenderHeight: defaultMaxRenderHeight, - maxSizeAvailable: maxSizeAvailable, - ); - expect(renderProps.length, 3); - expect(renderProps, [ - RenderProperties( - axis: Axis.horizontal, - size: const Size(250, 250), - realSize: const Size(50.0, 0.0), - offset: const Offset(0.0, 125.0), - layoutProperties: properties, - ), - RenderProperties( - axis: Axis.horizontal, - size: const Size(261.5, 500), - realSize: const Size(75.0, 25.0), - offset: const Offset(250.0, 0.0), - layoutProperties: properties, - ), - RenderProperties( - axis: Axis.horizontal, - size: const Size(400, 500), - realSize: const Size(375.0, 25.0), - offset: const Offset(511.5, 0.0), - isFreeSpace: true, - layoutProperties: properties, - ), - ]); - }, - ); - - testWidgets( - 'returns correct RenderProperties with main axis flipped when start is not top left', - (tester) async { - final widget = Row( - textDirection: TextDirection.rtl, - children: childrenWidgets, - ); - final properties = await toFlexLayoutProperties( - widget, - tester: tester, - width: maxMainAxisDimension, - subtreeDepth: 3, - ); - final renderProps = properties.childrenRenderProperties( - smallestRenderWidth: minRenderWidth, - largestRenderWidth: defaultMaxRenderWidth, - smallestRenderHeight: minRenderHeight, - largestRenderHeight: defaultMaxRenderHeight, - maxSizeAvailable: maxSizeAvailable, - ); - expect(renderProps.length, 3); - expect(renderProps, [ - RenderProperties( - axis: Axis.horizontal, - size: const Size(261.5, 500.0), - realSize: const Size(75.0, 25.0), - offset: const Offset(400.0, 0.0), - layoutProperties: properties, - ), - RenderProperties( - axis: Axis.horizontal, - size: const Size(250.0, 250.0), - realSize: const Size(50.0, 0.0), - offset: const Offset(661.5, 125.0), - layoutProperties: properties, - ), - RenderProperties( - axis: Axis.horizontal, - size: const Size(400, 500), - realSize: const Size(375.0, 25.0), - offset: const Offset(0.0, 0.0), - isFreeSpace: true, - layoutProperties: properties, - ), - ]); - }, - ); - - testWidgets( - 'when the start is not top left, render properties should be equals to its mirrored version', - (tester) async { - Row buildWidget({ - required bool flipMainAxis, - required MainAxisAlignment mainAxisAlignment, - }) => Row( - textDirection: flipMainAxis ? TextDirection.rtl : TextDirection.ltr, - mainAxisAlignment: flipMainAxis - ? mainAxisAlignment.reversed - : mainAxisAlignment, - children: flipMainAxis - ? childrenWidgets.reversed.toList() - : childrenWidgets, - ); - for (final mainAxisAlignment in MainAxisAlignment.values) { - final originalWidgetRenderProperties = childrenRenderProperties( - await toFlexLayoutProperties( - buildWidget( - flipMainAxis: false, - mainAxisAlignment: mainAxisAlignment, - ), - tester: tester, - ), - ); - final mirroredWidgetRenderProperties = childrenRenderProperties( - await toFlexLayoutProperties( - buildWidget( - flipMainAxis: true, - mainAxisAlignment: mainAxisAlignment, - ), - tester: tester, - ), - ); - expect( - originalWidgetRenderProperties, - mirroredWidgetRenderProperties, - ); - } - }, - ); - }); - }); - - group('LayoutProperties tests', () { - testWidgets('deserializes RemoteDiagnosticsNode correctly', (tester) async { - const constraints = BoxConstraints( - minWidth: 432.0, - maxWidth: 432.0, - minHeight: 56.0, - maxHeight: 56.0, - ); - const size = Size(432.0, 56.0); - final widget = Container( - width: size.width, - height: size.height, - constraints: constraints, - child: const Row(children: [SizedBox()]), - ); - final diagnosticsNode = await widgetToLayoutExplorerRemoteDiagnosticsNode( - widget: widget, - tester: tester, - subtreeDepth: 2, - ); - final rowDiagnosticsNode = diagnosticsNode.childrenNow.first; - final layoutProperties = LayoutProperties(rowDiagnosticsNode); - - expect(layoutProperties.size, size); - expect(layoutProperties.constraints, constraints); - expect(layoutProperties.totalChildren, 1); - }); - - group('describeWidthConstraints and describeHeightConstraints', () { - testWidgets('single value', (tester) async { - const width = 25.0; - const height = 56.0; - const constraints = BoxConstraints.tightFor( - width: width, - height: height, - ); - final widget = ConstrainedBox( - constraints: constraints, - child: const SizedBox(), - ); - final constrainedBoxDiagnosticsNode = - await widgetToLayoutExplorerRemoteDiagnosticsNode( - widget: widget, - tester: tester, - ); - final sizedBoxDiagnosticsNode = - constrainedBoxDiagnosticsNode.childrenNow.first; - final layoutProperties = LayoutProperties(sizedBoxDiagnosticsNode); - expect(layoutProperties.describeHeightConstraints(), 'h=$height'); - expect(layoutProperties.describeWidthConstraints(), 'w=$width'); - }); - - testWidgets('range value', (tester) async { - const minWidth = 25.0, maxWidth = 50.0; - const minHeight = 75.0, maxHeight = 100.0; - const constraints = BoxConstraints( - minWidth: minWidth, - maxWidth: maxWidth, - minHeight: minHeight, - maxHeight: maxHeight, - ); - final widget = ConstrainedBox( - constraints: constraints, - child: const SizedBox(), - ); - final constrainedBoxDiagnosticsNode = - await widgetToLayoutExplorerRemoteDiagnosticsNode( - widget: widget, - tester: tester, - ); - final sizedBoxDiagnosticsNode = - constrainedBoxDiagnosticsNode.childrenNow.first; - final layoutProperties = LayoutProperties(sizedBoxDiagnosticsNode); - expect( - layoutProperties.describeHeightConstraints(), - '$minHeight<=h<=$maxHeight', - ); - expect( - layoutProperties.describeWidthConstraints(), - '$minWidth<=w<=$maxWidth', - ); - }); - - testWidgets('unconstrained width', (tester) async { - final widget = Row(children: [Container()]); - final rowDiagnosticsNode = - await widgetToLayoutExplorerRemoteDiagnosticsNode( - widget: widget, - tester: tester, - ); - final containerDiagnosticsNode = rowDiagnosticsNode.childrenNow.first; - final layoutProperties = LayoutProperties(containerDiagnosticsNode); - expect( - layoutProperties.describeWidthConstraints(), - 'width is unconstrained', - ); - }); - - testWidgets('unconstrained height', (tester) async { - final widget = Column(children: [Container()]); - final columnDiagnosticsNode = - await widgetToLayoutExplorerRemoteDiagnosticsNode( - widget: widget, - tester: tester, - ); - final containerDiagnosticsNode = - columnDiagnosticsNode.childrenNow.first; - final layoutProperties = LayoutProperties(containerDiagnosticsNode); - expect( - layoutProperties.describeHeightConstraints(), - 'height is unconstrained', - ); - }); - }); - - testWidgets('describeWidth and describeHeight', (tester) async { - const width = 432.5, height = 56.0; - final widget = SizedBox(width: width, height: height, child: Container()); - final sizedBoxNode = await widgetToLayoutExplorerRemoteDiagnosticsNode( - widget: widget, - tester: tester, - ); - final containerNode = sizedBoxNode.childrenNow.first; - final layoutProperties = LayoutProperties(containerNode); - expect(layoutProperties.describeHeight(), 'h=$height'); - expect(layoutProperties.describeWidth(), 'w=$width'); - }); - }); - - group('computeRenderSizes', () { - test( - 'scale sizes so the largestSize maps to largestRenderSize with forceToOccupyMaxSize=false', - () { - final renderSizes = computeRenderSizes( - sizes: [100.0, 200.0, 300.0], - smallestSize: 100.0, - largestSize: 300.0, - smallestRenderSize: 200.0, - largestRenderSize: 600.0, - maxSizeAvailable: 2000, - useMaxSizeAvailable: false, - ); - expect(renderSizes, [200.0, 400.0, 600.0]); - expect(sum(renderSizes), lessThan(2000)); - }, - ); - - test( - 'scale sizes so the items fit maxSizeAvailable with forceToOccupyMaxSize=true', - () { - final renderSizes = computeRenderSizes( - sizes: [100.0, 200.0, 300.0], - smallestSize: 100.0, - largestSize: 300.0, - smallestRenderSize: 200.0, - largestRenderSize: 600.0, - maxSizeAvailable: 2000, - ); - expect(renderSizes, [200.0, 666.6666666666667, 1133.3333333333335]); - expect(sum(renderSizes) - 2000.0, lessThan(0.01)); - }, - ); - - test( - 'scale sizes when the items exceeds maxSizeAvailable with forceToOccupyMaxSize=true should not change any behavior', - () { - final renderSizes = computeRenderSizes( - sizes: [100.0, 200.0, 300.0], - smallestSize: 100.0, - largestSize: 300.0, - smallestRenderSize: 300.0, - largestRenderSize: 900.0, - maxSizeAvailable: 250.0, - ); - expect(renderSizes, [300.0, 600.0, 900.0]); - expect(sum(renderSizes), greaterThan(250.0)); - }, - ); - }); -} diff --git a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/layout_explorer_serialization_delegate.dart b/packages/devtools_app/test/screens/inspector_v2/layout_explorer/layout_explorer_serialization_delegate.dart deleted file mode 100644 index 3f1b6091ea3..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/layout_explorer_serialization_delegate.dart +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright 2020 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -/// This class is merely a duplicate of the InspectorSerializationDelegate inside the `assets/scripts/inspector_polyfill_script.dart`. -/// This delegate is used for testing Widget Diagnostics Node so that we don't have to create manual JSON each time we want to create new test cases. -/// TODO(adalberht): Ask Jacob on what's the better solution on code reuse between the Layout Explorer polyfillscripts and the code inside test package? -class LayoutExplorerSerializationDelegate - extends InspectorSerializationDelegate { - LayoutExplorerSerializationDelegate({ - String super.groupName = '', - super.subtreeDepth, - required super.service, - }) : super( - summaryTree: true, - addAdditionalPropertiesCallback: (node, delegate) { - final additionalJson = {}; - final value = node.value; - if (value is Element) { - final renderObject = value.renderObject!; - additionalJson['renderObject'] = renderObject - .toDiagnosticsNode() - .toJsonMap( - delegate.copyWith(subtreeDepth: 0, includeProperties: true), - ); - // Required for test. - // ignore: invalid_use_of_protected_member - final constraints = renderObject.constraints; - - final constraintsProperty = { - 'type': constraints.runtimeType.toString(), - 'description': constraints.toString(), - }; - if (constraints is BoxConstraints) { - constraintsProperty.addAll({ - 'minWidth': constraints.minWidth.toString(), - 'minHeight': constraints.minHeight.toString(), - 'maxWidth': constraints.maxWidth.toString(), - 'maxHeight': constraints.maxHeight.toString(), - }); - } - additionalJson['constraints'] = constraintsProperty; - - if (renderObject is RenderBox) { - additionalJson['size'] = { - 'width': renderObject.size.width.toString(), - 'height': renderObject.size.height.toString(), - }; - - final parentData = renderObject.parentData; - if (parentData is FlexParentData) { - additionalJson['flexFactor'] = parentData.flex ?? 0; - additionalJson['flexFit'] = - (parentData.fit ?? FlexFit.tight).name; - } - } - } - return additionalJson; - }, - ); -} diff --git a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/layout_explorer_test_utils.dart b/packages/devtools_app/test/screens/inspector_v2/layout_explorer/layout_explorer_test_utils.dart deleted file mode 100644 index c2b1f449af6..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/layout_explorer_test_utils.dart +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright 2020 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app/src/shared/diagnostics/diagnostics_node.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'layout_explorer_serialization_delegate.dart'; - -Future widgetToLayoutExplorerRemoteDiagnosticsNode({ - required Widget widget, - required WidgetTester tester, - int subtreeDepth = 1, -}) async { - await tester.pumpWidget(MaterialApp(home: Scaffold(body: widget))); - final element = find.byWidget(widget).evaluate().first; - final nodeJson = element - .toDiagnosticsNode(style: DiagnosticsTreeStyle.dense) - .toJsonMap( - LayoutExplorerSerializationDelegate( - subtreeDepth: subtreeDepth, - service: WidgetInspectorService.instance, - ), - ); - return RemoteDiagnosticsNode(nodeJson, null, false, null); -} diff --git a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/widget_theme_test.dart b/packages/devtools_app/test/screens/inspector_v2/layout_explorer/widget_theme_test.dart deleted file mode 100644 index 7e3bd1a671f..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/layout_explorer/widget_theme_test.dart +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2021 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app/src/screens/inspector_v2/layout_explorer/ui/widgets_theme.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('Test WidgetTheme', () { - test('Correct asset from widget with a type', () { - const widgetName = 'AnimatedBuilder'; - final theme = WidgetTheme.fromName(widgetName); - expect(theme.iconAsset, 'icons/inspector/widget_icons/animated.png'); - }); - - test('Has default theme for custom widget', () { - const widgetName = 'CustomWidget'; - final theme = WidgetTheme.fromName(widgetName); - expect(theme.color, WidgetTheme.otherWidgetColor); - }); - }); -} diff --git a/packages/devtools_app/test/screens/inspector_v2/utils/inspector_tree.dart b/packages/devtools_app/test/screens/inspector_v2/utils/inspector_tree.dart deleted file mode 100644 index d2aac2767d3..00000000000 --- a/packages/devtools_app/test/screens/inspector_v2/utils/inspector_tree.dart +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2021 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app/devtools_app.dart' - hide InspectorTreeController, InspectorTreeConfig, InspectorTreeNode; -import 'package:devtools_app/src/screens/inspector_v2/inspector_tree_controller.dart'; -import 'package:devtools_app/src/shared/console/eval/inspector_tree_v2.dart'; -import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Create an `InspectorTreeControllerFlutter` from a single `RemoteDiagnosticsNode` -InspectorTreeController inspectorTreeControllerFromNode( - RemoteDiagnosticsNode node, -) { - final controller = InspectorTreeController() - ..config = InspectorTreeConfig( - onNodeAdded: (_, _) {}, - onClientActiveChange: (_) {}, - ); - - controller.root = InspectorTreeNode() - ..appendChild(InspectorTreeNode()..diagnostic = node); - - return controller; -} - -/// Replicates the functionality of `getRootWidgetSummaryTreeWithPreviews` from -/// inspector_polyfill_script.dart -Future widgetToInspectorTreeDiagnosticsNode({ - required Widget widget, - required WidgetTester tester, -}) async { - await tester.pumpWidget(wrap(widget)); - final element = find.byWidget(widget).evaluate().first; - final nodeJson = element - .toDiagnosticsNode(style: DiagnosticsTreeStyle.dense) - .toJsonMap( - InspectorSerializationDelegate( - service: WidgetInspectorService.instance, - subtreeDepth: 1000000, - summaryTree: true, - addAdditionalPropertiesCallback: (node, delegate) { - final additionalJson = {}; - - final value = node.value; - if (value is Element) { - final renderObject = value.renderObject; - if (renderObject is RenderParagraph) { - additionalJson['textPreview'] = renderObject.text.toPlainText(); - } - } - - return additionalJson; - }, - ), - ); - - return RemoteDiagnosticsNode(nodeJson, null, false, null); -} diff --git a/packages/devtools_app/test/screens/logging/logging_controller_test.dart b/packages/devtools_app/test/screens/logging/logging_controller_test.dart index 4362b58423c..8d746dcfba4 100644 --- a/packages/devtools_app/test/screens/logging/logging_controller_test.dart +++ b/packages/devtools_app/test/screens/logging/logging_controller_test.dart @@ -5,6 +5,7 @@ @TestOn('vm') library; +import 'dart:async'; import 'dart:convert'; import 'package:devtools_app/devtools_app.dart'; @@ -19,6 +20,42 @@ import 'package:vm_service/vm_service.dart'; void main() { var timestampCounter = 0; + Event gcEvent({ + required double newTime, + required double oldTime, + required String isolateId, + }) => Event.parse({ + 'kind': EventKind.kGC, + 'timestamp': ++timestampCounter, + 'isolate': { + 'type': '@Isolate', + 'id': isolateId, + 'name': 'main', + 'number': '1', + }, + 'reason': 'idle', + 'new': { + 'type': 'HeapSpace', + 'name': 'new', + 'collections': 1, + 'avgCollectionPeriodMillis': 100.0, + 'used': 1024, + 'capacity': 2048, + 'external': 0, + 'time': newTime, + }, + 'old': { + 'type': 'HeapSpace', + 'name': 'old', + 'collections': 1, + 'avgCollectionPeriodMillis': 100.0, + 'used': 4096, + 'capacity': 8192, + 'external': 0, + 'time': oldTime, + }, + })!; + group('LoggingController', () { late LoggingController controller; @@ -194,6 +231,94 @@ void main() { expect(controller.filteredData.value, isEmpty); }); + test('GC events track duration delta', () async { + final fakeService = + serviceConnection.serviceManager.service as FakeVmServiceWrapper; + + expect(controller.data, isEmpty); + + // First GC event: baseline established, no duration printed. + fakeService.emitGCEvent( + event: gcEvent(newTime: 0.1, oldTime: 0.2, isolateId: 'isolates/123'), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(1)); + expect( + controller.data.first.summary, + 'main • idle collection • 0.0 MB used of 0.0 MB', + ); + + // Second GC event: delta is + // (0.15 + 0.25) - (0.1 + 0.2) = 0.4 - 0.3 = 0.1s = 100ms. + fakeService.emitGCEvent( + event: gcEvent(newTime: 0.15, oldTime: 0.25, isolateId: 'isolates/123'), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(2)); + expect( + controller.data[1].summary, + 'main • idle collection in 100.0 ms • 0.0 MB used of 0.0 MB', + ); + + // Third GC event on a different isolate: no baseline yet, so no duration + // printed. + fakeService.emitGCEvent( + event: gcEvent(newTime: 0.05, oldTime: 0.05, isolateId: 'isolates/456'), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(3)); + expect( + controller.data[2].summary, + 'main • idle collection • 0.0 MB used of 0.0 MB', + ); + + // Fourth GC event: clock reset on isolates/123 (cumulative goes from 0.4s to 0.1s). + // Since newCumulativeTime (0.1s) < previousGcTime (0.4s), duration delta is skipped, + // and baseline is updated to 0.1s. + fakeService.emitGCEvent( + event: gcEvent(newTime: 0.05, oldTime: 0.05, isolateId: 'isolates/123'), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(4)); + expect( + controller.data[3].summary, + 'main • idle collection • 0.0 MB used of 0.0 MB', + ); + + // Fifth GC event: delta since reset baseline (0.1s) is (0.08 + 0.12) - 0.1 = 0.2 - 0.1 = 0.1s = 100ms. + fakeService.emitGCEvent( + event: gcEvent(newTime: 0.08, oldTime: 0.12, isolateId: 'isolates/123'), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(5)); + expect( + controller.data[4].summary, + 'main • idle collection in 100.0 ms • 0.0 MB used of 0.0 MB', + ); + + // Clear logs: resets baselines. + controller.clear(); + expect(controller.data, isEmpty); + + // Sixth GC event (same isolate as first): baseline was cleared, so no + // duration printed. + fakeService.emitGCEvent( + event: gcEvent(newTime: 0.2, oldTime: 0.3, isolateId: 'isolates/123'), + ); + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(1)); + expect( + controller.data.first.summary, + 'main • idle collection • 0.0 MB used of 0.0 MB', + ); + }); + test('matchesForSearch - default filters', () { prepareTestLogs(); @@ -380,5 +505,184 @@ void main() { expect(nullDetails.prettyPrinted(), null); }, ); + + test('prettyPrinted removes ANSI escape codes', () { + final log = LogData( + 'stdout', + 'flutter: \x1B[45mComponentChanged\x1B[0m updated', + 0, + ); + expect( + log.prettyPrinted(), + 'flutter: ComponentChanged updated', + ); + }); + + test('prettyPrinted is not cached before details are computed', () async { + final completer = Completer(); + final log = LogData( + 'log', + 'preview', + 0, + detailsComputer: () => completer.future, + ); + expect(log.prettyPrinted(), 'preview'); + + completer.complete('\x1B[32mfull details\x1B[0m'); + await log.detailsComputed.future; + expect(log.prettyPrinted(), 'full details'); + }); + + test('prettyPrinted removes ANSI escape codes from json details', () { + final log = LogData( + 'log', + jsonEncode({'message': '\x1B[45mComponentChanged\x1B[0m'}), + 0, + ); + expect( + log.prettyPrinted(), + '{\n' + ' "message": "ComponentChanged"\n' + '}', + ); + }); + + test('computeDeveloperLogDetailsJson keeps structured details', () async { + const truncatedPreview = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'; + const fullMessage = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.' + 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.' + 'signature-padding-to-make-this-longer-than-vm-preview'; + + final eventJson = { + 'type': 'Event', + 'kind': 'Logging', + 'logRecord': { + 'type': 'LogRecord', + 'level': 800, + 'message': { + 'type': '@Instance', + 'kind': 'String', + 'valueAsString': truncatedPreview, + 'valueAsStringIsTruncated': true, + 'length': fullMessage.length, + }, + 'error': {'type': '@Instance', 'kind': 'Null'}, + 'stackTrace': {'type': '@Instance', 'kind': 'Null'}, + }, + }; + + final details = computeDeveloperLogDetailsJson( + eventJson, + fullMessage: fullMessage, + ); + final log = LogData('log', details, 1); + await log.compute(); + + final decoded = jsonDecode(log.details!) as Map; + expect(decoded['kind'], 'Logging'); + expect(decoded['logRecord'], isA()); + + final message = + (decoded['logRecord'] as Map)['message'] as Map; + expect(message['valueAsString'], fullMessage); + expect(message['valueAsStringIsTruncated'], false); + expect(message['length'], fullMessage.length); + expect(jsonDecode(log.encodedDetails), isA()); + }); + + test('computeDeveloperLogDetailsJson updates error and stackTrace', () { + final eventJson = { + 'type': 'Event', + 'kind': 'Logging', + 'logRecord': { + 'message': { + 'valueAsString': 'short', + 'valueAsStringIsTruncated': false, + 'length': 5, + }, + 'error': { + 'valueAsString': 'err...', + 'valueAsStringIsTruncated': true, + 'length': 20, + }, + 'stackTrace': { + 'valueAsString': '#0 foo', + 'valueAsStringIsTruncated': false, + 'length': 6, + }, + }, + }; + + final details = computeDeveloperLogDetailsJson( + eventJson, + fullMessage: 'short', + fullError: 'error-full-message', + fullStackTrace: '#0 foo\n#1 bar', + ); + final decoded = jsonDecode(details) as Map; + final logRecord = decoded['logRecord'] as Map; + + expect( + (logRecord['error'] as Map)['valueAsString'], + 'error-full-message', + ); + expect((logRecord['error'] as Map)['valueAsStringIsTruncated'], false); + expect( + (logRecord['stackTrace'] as Map)['valueAsString'], + '#0 foo\n#1 bar', + ); + }); + }); + + group('StdoutEventHandler', () { + late LoggingController controller; + late StdoutEventHandler stdoutHandler; + + Event stdoutEvent(String message, {int? timestamp}) { + return Event( + bytes: base64Encode(utf8.encode(message)), + timestamp: timestamp ?? ++timestampCounter, + ); + } + + setUp(() { + setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); + setGlobal(MessageBus, MessageBus()); + setGlobal(PreferencesController, PreferencesController()); + + controller = LoggingController()..init(); + stdoutHandler = StdoutEventHandler(controller, 'stdout'); + }); + + test('combines newline-terminated continuation into one log', () async { + stdoutHandler.handle(stdoutEvent('line1\n')); + stdoutHandler.handle(stdoutEvent('line2')); + + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(1)); + expect(controller.data.single.kind, 'stdout'); + expect(controller.data.single.details, 'line1\nline2'); + }); + + test('still combines message followed by lone newline', () { + stdoutHandler.handle(stdoutEvent('line1')); + stdoutHandler.handle(stdoutEvent('\n')); + + expect(controller.data, hasLength(1)); + expect(controller.data.single.details, 'line1\n'); + }); + + test('keeps separate entries for distinct non-newline chunks', () async { + stdoutHandler.handle(stdoutEvent('line1')); + stdoutHandler.handle(stdoutEvent('line2')); + + await Future.delayed(const Duration(milliseconds: 10)); + + expect(controller.data, hasLength(2)); + expect(controller.data[0].details, 'line1'); + expect(controller.data[1].details, 'line2'); + }); }); } diff --git a/packages/devtools_app/test/screens/logging/logging_screen_data_test.dart b/packages/devtools_app/test/screens/logging/logging_screen_data_test.dart index ab38883724f..d90889c6278 100644 --- a/packages/devtools_app/test/screens/logging/logging_screen_data_test.dart +++ b/packages/devtools_app/test/screens/logging/logging_screen_data_test.dart @@ -12,8 +12,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/utils/ansi.dart'; diff --git a/packages/devtools_app/test/screens/logging/logging_screen_test.dart b/packages/devtools_app/test/screens/logging/logging_screen_test.dart index 26eb88b9c6c..f63dde498f8 100644 --- a/packages/devtools_app/test/screens/logging/logging_screen_test.dart +++ b/packages/devtools_app/test/screens/logging/logging_screen_test.dart @@ -16,8 +16,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/utils/test_utils.dart'; diff --git a/packages/devtools_app/test/screens/logging/metadata_test.dart b/packages/devtools_app/test/screens/logging/metadata_test.dart index 7844d0a40d4..2a5c093c615 100644 --- a/packages/devtools_app/test/screens/logging/metadata_test.dart +++ b/packages/devtools_app/test/screens/logging/metadata_test.dart @@ -7,9 +7,9 @@ import 'package:devtools_app/src/screens/logging/metadata.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../test_infra/matchers/matchers.dart'; diff --git a/packages/devtools_app/test/screens/memory/chart/chart_test.dart b/packages/devtools_app/test/screens/memory/chart/chart_test.dart index 620180e67fd..219dcc2e945 100644 --- a/packages/devtools_app/test/screens/memory/chart/chart_test.dart +++ b/packages/devtools_app/test/screens/memory/chart/chart_test.dart @@ -13,8 +13,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../test_infra/matchers/matchers.dart'; import '../../../test_infra/test_data/memory.dart'; diff --git a/packages/devtools_app/test/screens/memory/control/settings_dialog_test.dart b/packages/devtools_app/test/screens/memory/control/settings_dialog_test.dart index c287d9f260d..6a2b7684b9e 100644 --- a/packages/devtools_app/test/screens/memory/control/settings_dialog_test.dart +++ b/packages/devtools_app/test/screens/memory/control/settings_dialog_test.dart @@ -7,8 +7,8 @@ import 'package:devtools_app/src/shared/globals.dart'; import 'package:devtools_app/src/shared/ui/common_widgets.dart'; import 'package:devtools_app_shared/src/ui/dialogs.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../test_infra/matchers/matchers.dart'; import '../../../test_infra/scenes/memory/default.dart'; diff --git a/packages/devtools_app/test/screens/memory/diff/controller/diff_pane_controller_test.dart b/packages/devtools_app/test/screens/memory/diff/controller/diff_pane_controller_test.dart index 8531dde587a..1087e266dbc 100644 --- a/packages/devtools_app/test/screens/memory/diff/controller/diff_pane_controller_test.dart +++ b/packages/devtools_app/test/screens/memory/diff/controller/diff_pane_controller_test.dart @@ -3,14 +3,14 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app/src/screens/memory/framework/memory_tabs.dart'; -import 'package:devtools_app/src/screens/memory/panes/diff/controller/diff_pane_controller.dart'; import 'package:devtools_app/src/screens/memory/panes/diff/controller/diff_pane_controller.dart' as diff_pane_controller show Json; +import 'package:devtools_app/src/screens/memory/panes/diff/controller/diff_pane_controller.dart'; import 'package:devtools_app/src/screens/memory/panes/diff/controller/snapshot_item.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../test_infra/scenes/memory/default.dart'; diff --git a/packages/devtools_app/test/screens/memory/diff/widgets/class_filter_test.dart b/packages/devtools_app/test/screens/memory/diff/widgets/class_filter_test.dart index 72d18a519d3..751fde727d2 100644 --- a/packages/devtools_app/test/screens/memory/diff/widgets/class_filter_test.dart +++ b/packages/devtools_app/test/screens/memory/diff/widgets/class_filter_test.dart @@ -7,8 +7,8 @@ import 'package:devtools_app/src/screens/memory/panes/diff/diff_pane.dart'; import 'package:devtools_app/src/screens/memory/shared/heap/class_filter.dart'; import 'package:devtools_app/src/screens/memory/shared/widgets/class_filter.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../test_infra/matchers/matchers.dart'; import '../../../../test_infra/scenes/memory/diff_snapshot.dart'; diff --git a/packages/devtools_app/test/screens/memory/diff/widgets/diff_pane_test.dart b/packages/devtools_app/test/screens/memory/diff/widgets/diff_pane_test.dart index ce1153778ff..7c8a7df91f8 100644 --- a/packages/devtools_app/test/screens/memory/diff/widgets/diff_pane_test.dart +++ b/packages/devtools_app/test/screens/memory/diff/widgets/diff_pane_test.dart @@ -7,8 +7,8 @@ import 'package:devtools_app/src/screens/memory/framework/memory_tabs.dart'; import 'package:devtools_app/src/screens/memory/panes/diff/diff_pane.dart'; import 'package:devtools_app/src/screens/memory/panes/diff/widgets/snapshot_list.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../../test_infra/matchers/matchers.dart'; import '../../../../test_infra/scenes/memory/default.dart'; diff --git a/packages/devtools_app/test/screens/memory/framework/memory_controller_test.dart b/packages/devtools_app/test/screens/memory/framework/memory_controller_test.dart index 01e83020ef7..528cbdb5ae2 100644 --- a/packages/devtools_app/test/screens/memory/framework/memory_controller_test.dart +++ b/packages/devtools_app/test/screens/memory/framework/memory_controller_test.dart @@ -7,11 +7,12 @@ import 'dart:io'; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/memory/shared/heap/class_filter.dart'; +import 'package:devtools_app_shared/service.dart' show RootInfo; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; @@ -177,4 +178,26 @@ void main() { expect(scene.controller.diff.core.snapshots.value.length, 1); }); }); + + group('root package', () { + for (final (rootLibrary, expectedRootPackage) in const [ + ('package:my_app/main.dart', 'package:my_app'), + // The root library of a Dart CLI app started with `dart run` is a `file:` + // URI, not a `package:` URI. + ('file:///app/bin/main.dart', 'file:'), + ]) { + testWidgets( + 'is $expectedRootPackage when the root library is $rootLibrary', + (WidgetTester tester) async { + scene = MemoryDefaultScene(); + await scene.setUp( + classList: classList, + rootInfo: RootInfo(rootLibrary), + ); + + expect(scene.controller.trace!.rootPackage, expectedRootPackage); + }, + ); + } + }); } diff --git a/packages/devtools_app/test/screens/memory/framework/memory_screen_test.dart b/packages/devtools_app/test/screens/memory/framework/memory_screen_test.dart index 79befb2bf5c..7d11df4bd7e 100644 --- a/packages/devtools_app/test/screens/memory/framework/memory_screen_test.dart +++ b/packages/devtools_app/test/screens/memory/framework/memory_screen_test.dart @@ -15,8 +15,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../../test_infra/matchers/matchers.dart'; diff --git a/packages/devtools_app/test/screens/memory/framework/memory_service_test.dart b/packages/devtools_app/test/screens/memory/framework/memory_service_test.dart deleted file mode 100644 index 650f13afef1..00000000000 --- a/packages/devtools_app/test/screens/memory/framework/memory_service_test.dart +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -import 'package:devtools_app/src/screens/memory/framework/memory_controller.dart'; -import 'package:devtools_app/src/screens/memory/shared/primitives/memory_timeline.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../../../test_infra/flutter_test_driver.dart' - show FlutterRunConfiguration; -import '../../../test_infra/flutter_test_environment.dart'; - -late MemoryController memoryController; - -// Track number of onMemory events received. -int memoryTrackersReceived = 0; - -int previousTimestamp = 0; - -bool firstSample = true; - -void main() { - // TODO(https://github.com/flutter/devtools/issues/2053): rewrite. - // ignore: dead_code - if (false) { - final env = FlutterTestEnvironment( - const FlutterRunConfiguration(withDebugger: true), - ); - - env.afterNewSetup = () { - memoryController = MemoryController(); - }; - } -} - -void validateHeapInfo(MemoryTimeline timeline) { - for (final sample in timeline.data) { - expect(sample.timestamp, greaterThan(0)); - expect(sample.timestamp, greaterThan(previousTimestamp)); - - expect(sample.used, greaterThan(0)); - expect(sample.used, lessThan(sample.capacity)); - - expect(sample.external, greaterThan(0)); - expect(sample.external, lessThan(sample.capacity)); - - // TODO(terry): Bug - VM's first HeapSample returns a null for the rss value. - // Subsequent samples the rss values are valid integers. This is - // a VM regression https://github.com/dart-lang/sdk/issues/40766. - // When fixed, remove below test rss != null and firstSample global. - if (firstSample) { - expect(sample.rss, greaterThan(0)); - expect(sample.rss, greaterThan(sample.capacity)); - firstSample = false; - } - - expect(sample.capacity, greaterThan(0)); - expect(sample.capacity, greaterThan(sample.used + sample.external)); - - previousTimestamp = sample.timestamp; - } - - timeline.data.clear(); - - memoryTrackersReceived++; -} diff --git a/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart b/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart index c3824f0e1ae..098a05f97d3 100644 --- a/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart +++ b/packages/devtools_app/test/screens/memory/profile/allocation_profile_table_view_test.dart @@ -5,6 +5,7 @@ import 'package:devtools_app/src/screens/memory/framework/memory_tabs.dart'; import 'package:devtools_app/src/screens/memory/panes/profile/model.dart'; import 'package:devtools_app/src/screens/memory/panes/profile/profile_pane_controller.dart'; +import 'package:devtools_app/src/screens/memory/panes/profile/profile_view.dart'; import 'package:devtools_app/src/shared/globals.dart'; import 'package:devtools_app/src/shared/memory/gc_stats.dart'; import 'package:devtools_app/src/shared/primitives/byte_utils.dart'; @@ -12,8 +13,9 @@ import 'package:devtools_app/src/shared/primitives/utils.dart'; import 'package:devtools_app/src/shared/table/table.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../test_infra/scenes/memory/default.dart'; @@ -319,5 +321,57 @@ void main() { lastValue = internalSize; } }); + + testWidgetsWithWindowSize('pins class to top of table', windowSize, ( + WidgetTester tester, + ) async { + await scene.pump(tester); + + final allocationProfileController = scene.controller.profile!; + await navigateToAllocationProfile(tester, allocationProfileController); + + final table = find.byType(FlatTable); + expect(table, findsOneWidget); + + final state = tester.state>(table.first); + + // "All Classes" is always pinned via [ProfileRecord.isTotal]. + expect(state.tableController.pinnedData, isNotEmpty); + expect( + state.tableController.pinnedData.every((record) => !record.userPinned), + isTrue, + ); + + // Pin icons are hidden until the cell is hovered. + final pinTargets = find.byKey(allocationProfilePinButtonKey); + expect(pinTargets, findsWidgets); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await tester.pump(); + + await gesture.moveTo(tester.getCenter(pinTargets.first)); + await tester.pumpAndSettle(); + + final pinButtons = find.byIcon(Icons.push_pin_outlined); + expect(pinButtons, findsWidgets); + + await tester.tap(pinButtons.first); + await tester.pumpAndSettle(); + + expect(find.byIcon(Icons.push_pin), findsWidgets); + expect( + state.tableController.pinnedData.any((record) => record.userPinned), + isTrue, + ); + + // Pinned class stays at the top after sorting by class name. + await tester.tap(find.text('Class')); + await tester.pumpAndSettle(); + + final pinnedData = state.tableController.pinnedData; + expect(pinnedData.any((record) => record.userPinned), isTrue); + }); }); } diff --git a/packages/devtools_app/test/screens/memory/shared/primitives/memory_utils_test.dart b/packages/devtools_app/test/screens/memory/shared/primitives/memory_utils_test.dart new file mode 100644 index 00000000000..b6dd9f03ee1 --- /dev/null +++ b/packages/devtools_app/test/screens/memory/shared/primitives/memory_utils_test.dart @@ -0,0 +1,58 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/src/screens/memory/shared/primitives/memory_utils.dart'; +import 'package:devtools_app/src/shared/memory/class_name.dart'; +import 'package:devtools_app_shared/service.dart' show RootInfo; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RootInfoMemoryExtension.rootPackagePrefix', () { + for (final (rootLibrary, expectedPrefix) in const [ + ('package:my_app/main.dart', 'package:my_app'), + ('package:my_app/src/nested/file.dart', 'package:my_app'), + ('package:my_app', 'package:my_app'), + // The root library of a Dart CLI app started with `dart run`. + ('file:///app/bin/main.dart', 'file:'), + ('org-dartlang-app:///lib/main.dart', 'org-dartlang-app:'), + (null, null), + ]) { + test('is $expectedPrefix for $rootLibrary', () { + expect(RootInfo(rootLibrary).rootPackagePrefix, expectedPrefix); + }); + } + + test('classifies classes of a file: root library as project classes', () { + const rootLibrary = 'file:///app/bin/main.dart'; + final heapClass = HeapClassName( + library: rootLibrary, + className: 'MyClass', + ); + + expect( + heapClass.classType(RootInfo(rootLibrary).rootPackagePrefix), + ClassType.rootPackage, + ); + }); + + test('keeps classes of other libraries out of the project', () { + final rootPrefix = RootInfo('package:my_app/main.dart').rootPackagePrefix; + + expect( + HeapClassName( + library: 'package:my_app/src/model.dart', + className: 'Model', + ).classType(rootPrefix), + ClassType.rootPackage, + ); + expect( + HeapClassName( + library: 'package:other_package/other.dart', + className: 'Other', + ).classType(rootPrefix), + ClassType.dependency, + ); + }); + }); +} diff --git a/packages/devtools_app/test/screens/memory/tracing/tracing_view_test.dart b/packages/devtools_app/test/screens/memory/tracing/tracing_view_test.dart index d7e8d8e79ac..487460c4b3b 100644 --- a/packages/devtools_app/test/screens/memory/tracing/tracing_view_test.dart +++ b/packages/devtools_app/test/screens/memory/tracing/tracing_view_test.dart @@ -13,8 +13,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/network/har_network_test.dart b/packages/devtools_app/test/screens/network/har_network_test.dart index 98a8a7883a7..a1c735f095e 100644 --- a/packages/devtools_app/test/screens/network/har_network_test.dart +++ b/packages/devtools_app/test/screens/network/har_network_test.dart @@ -8,7 +8,6 @@ import 'dart:io'; import 'package:devtools_app/src/screens/network/constants.dart'; import 'package:devtools_app/src/screens/network/har_data_entry.dart'; import 'package:devtools_app/src/screens/network/har_network_data.dart'; - import 'package:flutter_test/flutter_test.dart'; void main() { @@ -58,10 +57,25 @@ void main() { }); group('HarDataEntry', () { + final entryJson = + ((jsonData['log'] as Map)['entries'] as List).first + as Map; + + Map copyWithStatus(Object? status) { + final copy = jsonDecode(jsonEncode(entryJson)) as Map; + final response = copy['response'] as Map; + response['status'] = status; + return copy; + } + + Map copyWithRequestError(String error) { + final copy = jsonDecode(jsonEncode(entryJson)) as Map; + final request = copy['request'] as Map; + request['error'] = error; + return copy; + } + test('fromJson parses correctly', () { - final entryJson = - ((jsonData['log'] as Map)['entries'] as List).first - as Map; final harDataEntry = HarDataEntry.fromJson(entryJson); expect( @@ -71,12 +85,10 @@ void main() { expect(harDataEntry.request.method, 'GET'); expect(harDataEntry.request.requestHeaders, isNotEmpty); expect(harDataEntry.request.requestCookies, isEmpty); + expect(harDataEntry.request.status, '200'); }); test('toJson serializes correctly', () { - final entryJson = - ((jsonData['log'] as Map)['entries'] as List).first - as Map; final harDataEntry = HarDataEntry.fromJson(entryJson); final json = HarDataEntry.toJson(harDataEntry.request); @@ -89,6 +101,11 @@ void main() { expect(request?['cookies'], isEmpty); expect(json['cache'], isEmpty); + final response = json['response'] as Map?; + expect(response, isNotNull); + expect(response?['status'], 200); + expect(response?['statusText'], ''); + final timings = json['timings'] as Map?; expect(timings?['blocked'], NetworkEventDefaults.blocked); expect(timings?['dns'], NetworkEventDefaults.dns); @@ -98,5 +115,68 @@ void main() { expect(timings?['ssl'], NetworkEventDefaults.ssl); expect(json['comment'], ''); }); + + test('handles "Error" status in JSON', () { + final errorJson = copyWithStatus('Error'); + final harDataEntry = HarDataEntry.fromJson(errorJson); + expect(harDataEntry.request.status, '-1'); + + final serialized = HarDataEntry.toJson(harDataEntry.request); + expect((serialized['response'] as Map)['status'], -1); + }); + + test('handles "Cancelled" status in JSON', () { + final cancelledJson = copyWithStatus('Cancelled'); + final harDataEntry = HarDataEntry.fromJson(cancelledJson); + expect(harDataEntry.request.status, '-1'); + + final serialized = HarDataEntry.toJson(harDataEntry.request); + expect((serialized['response'] as Map)['status'], -1); + }); + + test('handles -1 integer status in JSON', () { + final minusOneJson = copyWithStatus(-1); + final harDataEntry = HarDataEntry.fromJson(minusOneJson); + expect(harDataEntry.request.status, '-1'); + + final serialized = HarDataEntry.toJson(harDataEntry.request); + expect((serialized['response'] as Map)['status'], -1); + }); + + test('handles invalid string status in JSON', () { + final invalidJson = copyWithStatus('foo'); + final harDataEntry = HarDataEntry.fromJson(invalidJson); + expect(harDataEntry.request.status, '-1'); + + final serialized = HarDataEntry.toJson(harDataEntry.request); + expect((serialized['response'] as Map)['status'], -1); + }); + + test('handles null status in JSON', () { + final nullJson = copyWithStatus(null); + final harDataEntry = HarDataEntry.fromJson(nullJson); + expect(harDataEntry.request.status, '-1'); + + final serialized = HarDataEntry.toJson(harDataEntry.request); + expect((serialized['response'] as Map)['status'], -1); + }); + + test('handles request connection error', () { + final errorJson = copyWithRequestError('connection failed'); + final harDataEntry = HarDataEntry.fromJson(errorJson); + expect(harDataEntry.request.status, 'Error'); + + final serialized = HarDataEntry.toJson(harDataEntry.request); + expect((serialized['response'] as Map)['status'], -1); + }); + + test('handles request cancellation error', () { + final cancelledJson = copyWithRequestError('cancelled'); + final harDataEntry = HarDataEntry.fromJson(cancelledJson); + expect(harDataEntry.request.status, 'Cancelled'); + + final serialized = HarDataEntry.toJson(harDataEntry.request); + expect((serialized['response'] as Map)['status'], -1); + }); }); } diff --git a/packages/devtools_app/test/screens/network/network_clear_test.dart b/packages/devtools_app/test/screens/network/network_clear_test.dart new file mode 100644 index 00000000000..b53140c7a97 --- /dev/null +++ b/packages/devtools_app/test/screens/network/network_clear_test.dart @@ -0,0 +1,313 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/hot_restart_network_vm_service.dart'; +import 'utils/network_lifecycle_test_utils.dart'; +import 'utils/network_test_utils.dart'; + +void main() { + group('Network View clear button', () { + late HotRestartNetworkVmService vmService; + late FakeServiceConnectionManager fakeServiceConnection; + + setUp(() { + vmService = HotRestartNetworkVmService(); + fakeServiceConnection = FakeServiceConnectionManager(service: vmService); + }); + + tearDown(disposeNetworkLifecycleControllers); + + group('request visibility after clear', () { + test('displays new HTTP requests after clear while recording', () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'before-clear', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, hasLength(1)); + + await controller.clear(); + expect(controller.requests.value, isEmpty); + + vmService.appendHttpRequest( + isolateId, + createTestHttpRequest(id: 'after-clear', method: 'POST'), + ); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('POST'), + reason: + 'Network View should display new requests after Clear while ' + 'recording is active.', + ); + }); + + test('polling remains active after clear', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'polling-test', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + await controller.clear(); + + expect(controller.isPolling, isTrue); + expect(controller.recordingNotifier.value, isTrue); + }); + + test('keeps HTTP logging enabled after clear', () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'logging-test', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + await controller.clear(); + + expect(vmService.isHttpLoggingEnabled(isolateId), isTrue); + }); + + test('keeps socket profiling enabled after clear', () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'socket-test', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + await controller.clear(); + + expect(vmService.isSocketProfilingEnabled(isolateId), isTrue); + }); + }); + + group('clear refresh timestamp tracking', () { + test( + 'resets HTTP refresh timestamps to the VM timeline on clear', + () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + await controller.networkService.refreshNetworkData(); + + controller + .networkService + .lastHttpDataRefreshTimePerIsolate[isolateId] = + 500_000; + + await controller.clear(); + + final timelineMicros = + (await vmService.getVMTimelineMicros()).timestamp!; + expect( + controller + .networkService + .lastHttpDataRefreshTimePerIsolate[isolateId], + timelineMicros, + ); + expect(controller.lastSocketDataRefreshMicros, timelineMicros); + }, + ); + + test('does not show stale requests after clear', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest( + id: 'stale-request', + method: 'GET', + startTime: 1_500_000, + ), + ], + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, hasLength(1)); + + await controller.clear(); + await controller.networkService.refreshNetworkData(); + + expect(controller.requests.value, isEmpty); + }); + }); + + group('clear combined with hot restart', () { + test('clear then hot restart then new requests', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'pre-clear', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + await controller.clear(); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + + vmService.appendHttpRequest( + postRestartIsolateId, + createTestHttpRequest( + id: 'after-clear-and-restart', + method: 'PUT', + startTime: 9_000_000, + ), + ); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('PUT'), + ); + expect(vmService.isHttpLoggingEnabled(postRestartIsolateId), isTrue); + }); + + test('hot restart then clear then new requests', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'pre-restart', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + vmService.appendHttpRequest( + postRestartIsolateId, + createTestHttpRequest( + id: 'after-restart', + method: 'POST', + startTime: 8_000_000, + ), + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, isNotEmpty); + + await controller.clear(); + expect(controller.requests.value, isEmpty); + + vmService.appendHttpRequest( + postRestartIsolateId, + createTestHttpRequest( + id: 'after-restart-and-clear', + method: 'DELETE', + startTime: 10_000_000, + ), + ); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('DELETE'), + ); + }); + }); + + group('state after clear', () { + test('clears selected request', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'selected', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + controller.selectedRequest.value = controller.requests.value.first; + + await controller.clear(); + + expect(controller.selectedRequest.value, isNull); + }); + + test('preserves active search text', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest( + id: 'searchable', + method: 'GET', + uri: 'https://example.com/api', + ), + ], + ); + await controller.networkService.refreshNetworkData(); + controller.search = 'example'; + + await controller.clear(); + + expect(controller.search, 'example'); + }); + + test('supports multiple consecutive clears', () async { + final isolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'multi-clear-1', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + + await controller.clear(); + await controller.clear(); + + vmService.appendHttpRequest( + isolateId, + createTestHttpRequest( + id: 'after-multi-clear', + method: 'PATCH', + startTime: 3_000_000, + ), + ); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('PATCH'), + ); + }); + }); + }); +} diff --git a/packages/devtools_app/test/screens/network/network_controller_test.dart b/packages/devtools_app/test/screens/network/network_controller_test.dart index 1cc1d6d69a4..2da78f04eaa 100644 --- a/packages/devtools_app/test/screens/network/network_controller_test.dart +++ b/packages/devtools_app/test/screens/network/network_controller_test.dart @@ -109,10 +109,17 @@ void main() { expect(requests.length, numRequests); final httpRequests = requests.whereType().toList(); for (final request in httpRequests) { - expect(request.duration, request.inProgress ? isNull : isNotNull); + expect( + request.duration, + request.inProgress || request.endTimestamp == null + ? isNull + : isNotNull, + ); expect(request.general.length, greaterThan(0)); expect(httpMethods.contains(request.method), true); - expect(request.status, request.inProgress ? isNull : isNotNull); + if (request.inProgress) { + expect(request.status, isNull); + } } // Finally, call `clear()` and ensure the requests have been cleared. @@ -205,15 +212,31 @@ void main() { controller.setActiveFilter(query: 'status:Error'); expect(profile, hasLength(numRequests)); - expect(controller.filteredData.value, hasLength(1)); - - controller.setActiveFilter(query: 's:101'); + final errorCount = profile + .whereType() + .where((request) => request.status == 'Error') + .length; + expect(controller.filteredData.value, hasLength(errorCount)); + + final firstStatus = profile + .whereType() + .map((request) => request.status) + .whereType() + .first; + final firstStatusCount = profile + .whereType() + .where((request) => request.status == firstStatus) + .length; + controller.setActiveFilter(query: 's:$firstStatus'); expect(profile, hasLength(numRequests)); - expect(controller.filteredData.value, hasLength(1)); + expect(controller.filteredData.value, hasLength(firstStatusCount)); controller.setActiveFilter(query: '-s:Error'); expect(profile, hasLength(numRequests)); - expect(controller.filteredData.value, hasLength(8)); + expect( + controller.filteredData.value, + hasLength(numRequests - errorCount), + ); controller.setActiveFilter(query: 'type:json'); expect(profile, hasLength(numRequests)); @@ -253,11 +276,28 @@ void main() { controller.setActiveFilter(query: '-status:error method:get'); expect(profile, hasLength(numRequests)); - expect(controller.filteredData.value, hasLength(3)); + final nonErrorGetCount = profile + .whereType() + .where( + (request) => + request.method.toLowerCase() == 'get' && + request.status?.toLowerCase() != 'error', + ) + .length; + expect(controller.filteredData.value, hasLength(nonErrorGetCount)); controller.setActiveFilter(query: '-status:error method:get t:http'); expect(profile, hasLength(numRequests)); - expect(controller.filteredData.value, hasLength(2)); + final nonErrorGetHttpCount = profile + .whereType() + .where( + (request) => + request.method.toLowerCase() == 'get' && + request.status?.toLowerCase() != 'error' && + request.type.toLowerCase() == 'http', + ) + .length; + expect(controller.filteredData.value, hasLength(nonErrorGetHttpCount)); }); test('filterData hides tcp sockets via setting filter', () async { @@ -341,6 +381,21 @@ void main() { 'statusCode': 200, }, })!; + final request1CancelledWithStatusCode = HttpProfileRequest.parse({ + ...httpBaseObject, + 'events': [ + { + 'timestamp': startTime + 100, + 'event': 'Request cancelled by client', + }, + ], + 'response': { + 'startTime': startTime, + 'endTime': null, + 'redirects': [], + 'statusCode': 200, + }, + })!; final request2Pending = HttpProfileRequest.parse({ ...httpBaseObject, 'id': '102', @@ -365,6 +420,7 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: reqs, sockets: sockets, + webSockets: const [], timelineMicrosOffset: 0, ); expect(notifyCount, 1); @@ -384,6 +440,7 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: [request1Done], sockets: [socketStats1Done], + webSockets: const [], timelineMicrosOffset: 0, ); expect(notifyCount, 2); @@ -403,12 +460,39 @@ void main() { }, ); + test('latest request update wins over stale status for same id', () { + currentNetworkRequests.updateOrAddAll( + requests: [request1Done], + sockets: const [], + webSockets: const [], + timelineMicrosOffset: 0, + ); + + final initialRequest = + currentNetworkRequests.getRequest('101')! as DartIOHttpRequestData; + expect(initialRequest.status, '200'); + expect(initialRequest.status, isNot('Cancelled')); + + currentNetworkRequests.updateOrAddAll( + requests: [request1CancelledWithStatusCode], + sockets: const [], + webSockets: const [], + timelineMicrosOffset: 0, + ); + + final updatedRequest = + currentNetworkRequests.getRequest('101')! as DartIOHttpRequestData; + expect(updatedRequest.status, 'Cancelled'); + expect(updatedRequest.inProgress, false); + }); + test('clear', () { final reqs = [request1Pending, request2Pending]; final sockets = [socketStats1Pending, socketStats2Pending]; currentNetworkRequests.updateOrAddAll( requests: reqs, sockets: sockets, + webSockets: const [], timelineMicrosOffset: 0, ); @@ -434,6 +518,7 @@ void main() { currentNetworkRequests.updateOrAddAll( requests: reqs, sockets: sockets, + webSockets: const [], timelineMicrosOffset: 0, ); @@ -460,5 +545,207 @@ void main() { ); }); }); + group('websocket', () { + final connectTime = DateTime(2026, 8, 18, 10).toUtc(); + final openTime = connectTime.add(const Duration(seconds: 1)); + final lastUpdated = openTime.add(const Duration(seconds: 2)); + + final events = [ + WebSocketEvent(event: 'WebSocket.Connect', timestamp: connectTime), + WebSocketEvent( + event: 'WebSocket.Send', + timestamp: openTime, + frameNumber: 1, + direction: 'out', + opcode: 'text', + payloadSize: 5, + ), + WebSocketEvent( + event: 'WebSocket.Receive', + timestamp: lastUpdated, + frameNumber: 2, + direction: 'in', + opcode: 'text', + payloadSize: 7, + ), + ]; + + WebSocketConnection createConnection({ + String id = '42', + String state = 'open', + int bytesSent = 5, + int bytesReceived = 7, + int framesSent = 1, + int framesReceived = 1, + List? connectionEvents, + }) { + return WebSocketConnection( + isolateId: 'isolate-1', + id: id, + uri: Uri.parse('wss://example.com/socket'), + state: state, + protocol: 'chat', + connectTimestamp: connectTime, + openTimestamp: openTime, + bytesSent: bytesSent, + bytesReceived: bytesReceived, + framesSent: framesSent, + framesReceived: framesReceived, + pingCount: 2, + pongCount: 2, + lastUpdated: lastUpdated, + events: connectionEvents ?? events, + ); + } + + test('adds WebSocket connection', () { + final connection = createConnection(); + + currentNetworkRequests.updateOrAddAll( + requests: const [], + sockets: const [], + webSockets: [connection], + timelineMicrosOffset: 0, + ); + + expect(currentNetworkRequests.value, hasLength(1)); + + final request = currentNetworkRequests.getRequest( + 'websocket:isolate-1:42', + ); + + expect(request, isA()); + + final webSocket = request! as WebSocket; + expect(webSocket.connectionId, '42'); + expect(webSocket.uri, 'wss://example.com/socket'); + expect(webSocket.protocol, 'chat'); + expect(webSocket.state, 'open'); + expect(webSocket.bytesSent, 5); + expect(webSocket.bytesReceived, 7); + expect(webSocket.framesSent, 1); + expect(webSocket.framesReceived, 1); + expect(webSocket.pingCount, 2); + expect(webSocket.pongCount, 2); + expect(webSocket.events, hasLength(3)); + }); + + test('updates existing WebSocket instead of adding duplicate', () { + final initialConnection = createConnection(); + + currentNetworkRequests.updateOrAddAll( + requests: const [], + sockets: const [], + webSockets: [initialConnection], + timelineMicrosOffset: 0, + ); + + final updatedConnection = createConnection( + bytesSent: 15, + bytesReceived: 27, + framesSent: 3, + framesReceived: 4, + connectionEvents: [ + ...events, + WebSocketEvent( + event: 'WebSocket.Send', + timestamp: lastUpdated, + frameNumber: 3, + direction: 'out', + opcode: 'binary', + payloadSize: 10, + ), + ], + ); + + currentNetworkRequests.updateOrAddAll( + requests: const [], + sockets: const [], + webSockets: [updatedConnection], + timelineMicrosOffset: 0, + ); + + expect(currentNetworkRequests.value, hasLength(1)); + + final request = currentNetworkRequests.getRequest( + 'websocket:isolate-1:42', + ); + + expect(request, isA()); + + final webSocket = request! as WebSocket; + expect(webSocket.bytesSent, 15); + expect(webSocket.bytesReceived, 27); + expect(webSocket.framesSent, 3); + expect(webSocket.framesReceived, 4); + expect(webSocket.events, hasLength(4)); + }); + + test('notifies listeners once for multiple WebSocket connections', () { + final connection1 = createConnection(); + + final connection2 = createConnection(id: '43'); + + currentNetworkRequests.updateOrAddAll( + requests: const [], + sockets: const [], + webSockets: [connection1, connection2], + timelineMicrosOffset: 0, + ); + + expect(currentNetworkRequests.value, hasLength(2)); + expect(notifyCount, 1); + }); + + test('maps closed WebSocket state and duration', () { + final closeTime = openTime.add(const Duration(seconds: 5)); + + final connection = WebSocketConnection( + isolateId: 'isolate-1', + id: '44', + uri: Uri.parse('wss://example.com/socket'), + state: 'closed', + protocol: 'chat', + connectTimestamp: connectTime, + openTimestamp: openTime, + closeTimestamp: closeTime, + closeCode: 1000, + closeReason: 'Normal closure', + lastUpdated: closeTime, + events: const [], + ); + + final webSocket = WebSocket(connection); + + expect(webSocket.state, 'closed'); + expect(webSocket.inProgress, false); + expect(webSocket.didFail, false); + expect(webSocket.endTimestamp, closeTime); + expect(webSocket.duration, closeTime.difference(connectTime)); + expect(webSocket.closeCode, 1000); + expect(webSocket.closeReason, 'Normal closure'); + }); + + test('maps WebSocket error state', () { + final connection = WebSocketConnection( + isolateId: 'isolate-1', + id: '45', + uri: Uri.parse('wss://example.com/socket'), + state: 'error', + connectTimestamp: connectTime, + closeTimestamp: lastUpdated, + error: 'Connection failed', + lastUpdated: lastUpdated, + events: const [], + ); + + final webSocket = WebSocket(connection); + + expect(webSocket.state, 'error'); + expect(webSocket.didFail, true); + expect(webSocket.inProgress, false); + expect(webSocket.error, 'Connection failed'); + }); + }); }); } diff --git a/packages/devtools_app/test/screens/network/network_hot_restart_test.dart b/packages/devtools_app/test/screens/network/network_hot_restart_test.dart new file mode 100644 index 00000000000..3e9cff107c3 --- /dev/null +++ b/packages/devtools_app/test/screens/network/network_hot_restart_test.dart @@ -0,0 +1,476 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/hot_restart_network_vm_service.dart'; +import 'utils/network_lifecycle_test_utils.dart'; +import 'utils/network_test_utils.dart'; + +void main() { + group('Network View hot restart', () { + late HotRestartNetworkVmService vmService; + late FakeServiceConnectionManager fakeServiceConnection; + + setUp(() { + vmService = HotRestartNetworkVmService(); + fakeServiceConnection = FakeServiceConnectionManager(service: vmService); + }); + + tearDown(disposeNetworkLifecycleControllers); + + group('baseline behavior before hot restart', () { + test('displays HTTP requests after recording starts', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'pre-restart-1', method: 'GET'), + ], + ); + + expect( + vmService.isHttpLoggingEnabled(vmService.currentIsolateId), + isTrue, + ); + await controller.networkService.refreshNetworkData(); + + expect(controller.requests.value, hasLength(1)); + expect( + controller.requests.value.single, + isA().having( + (request) => request.method, + 'method', + 'GET', + ), + ); + }); + + test('continues polling while connected', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + expect(controller.isPolling, isTrue); + expect(controller.recordingNotifier.value, isTrue); + }); + }); + + group('hot restart request visibility', () { + test( + 'continues displaying new HTTP requests after hot restart', + () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'pre-restart', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, hasLength(1)); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + vmService.setHttpProfile(postRestartIsolateId, [ + createTestHttpRequest( + id: 'post-restart', + method: 'POST', + startTime: 8_000_000, + ), + ]); + + await controller.networkService.refreshNetworkData(); + + final methods = controller.requests.value + .whereType() + .map((request) => request.method) + .toSet(); + expect( + methods, + contains('POST'), + reason: + 'Network View should display requests made on the new isolate ' + 'after a hot restart.', + ); + }, + ); + + test('polling remains active after hot restart', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + expect(controller.isPolling, isTrue); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + + expect(controller.isPolling, isTrue); + expect(controller.recordingNotifier.value, isTrue); + }); + }); + + group('profiling re-registration after hot restart', () { + test( + 're-enables HTTP timeline logging on the new isolate after hot restart', + () async { + final preRestartIsolateId = vmService.currentIsolateId; + await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + expect(vmService.isHttpLoggingEnabled(preRestartIsolateId), isTrue); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + + expect( + vmService.isHttpLoggingEnabled(postRestartIsolateId), + isTrue, + reason: + 'HTTP timeline logging must be re-enabled on the new main ' + 'isolate after a hot restart.', + ); + }, + ); + + test( + 're-enables socket profiling on the new isolate after hot restart', + () async { + final preRestartIsolateId = vmService.currentIsolateId; + await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + expect( + vmService.isSocketProfilingEnabled(preRestartIsolateId), + isTrue, + ); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + await pumpEventQueue(); + + expect( + vmService.isSocketProfilingEnabled(postRestartIsolateId), + isTrue, + reason: + 'Socket profiling must be re-enabled on the new main isolate ' + 'after a hot restart.', + ); + }, + ); + }); + + group('isolate refresh timestamp tracking', () { + test( + 'fetches HTTP profile data for a new isolate after profiling is re-enabled', + () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + await controller.networkService.refreshNetworkData(); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + vmService.setHttpProfile(postRestartIsolateId, [ + createTestHttpRequest( + id: 'new-isolate-request', + method: 'PUT', + startTime: 9_000_000, + ), + ]); + + await pumpEventQueue(); + await controller.networkService.refreshNetworkData(); + + expect( + controller.networkService.lastHttpDataRefreshTimePerIsolate + .containsKey(postRestartIsolateId), + isTrue, + reason: + 'NetworkService should track refresh timestamps for the new ' + 'isolate after a hot restart.', + ); + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('PUT'), + ); + }, + ); + + test( + 'stale isolate IDs do not prevent fetching from the new isolate', + () async { + final preRestartIsolateId = vmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + ); + await controller.networkService.refreshNetworkData(); + + controller + .networkService + .lastHttpDataRefreshTimePerIsolate[preRestartIsolateId] = + 12_000_000; + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + vmService.setHttpProfile(postRestartIsolateId, [ + createTestHttpRequest( + id: 'after-stale-isolate', + method: 'DELETE', + startTime: 10_000_000, + ), + ]); + await pumpEventQueue(); + await controller.networkService.refreshNetworkData(); + + expect( + controller.requests.value.whereType().map( + (request) => request.method, + ), + contains('DELETE'), + ); + }, + ); + }); + + group('state restoration across hot restart', () { + test('retains pre-restart requests when not cleared', () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'kept-request', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + expect(controller.requests.value, hasLength(1)); + + final postRestartIsolateId = vmService.simulateHotRestart(); + notifyMainIsolateChanged(fakeServiceConnection, postRestartIsolateId); + vmService.setHttpProfile(postRestartIsolateId, [ + createTestHttpRequest( + id: 'new-request', + method: 'POST', + startTime: 11_000_000, + ), + ]); + await pumpEventQueue(); + await controller.networkService.refreshNetworkData(); + + final methods = controller.requests.value + .whereType() + .map((request) => request.method) + .toList(); + expect(methods, contains('GET')); + expect(methods, contains('POST')); + }); + + test( + 'clears selected request when it is no longer in filtered data', + () async { + final controller = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: fakeServiceConnection, + initialProfile: [ + createTestHttpRequest(id: 'selected-request', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + controller.selectedRequest.value = controller.requests.value.first; + + await controller.clear(); + expect(controller.selectedRequest.value, isNull); + }, + ); + }); + }); + + group('Network View service lifecycle', () { + tearDown(disposeNetworkLifecycleControllers); + + test( + 'starts recording when controller initializes while connected', + () async { + final reconnectionService = HotRestartNetworkVmService(); + final connection = FakeServiceConnectionManager( + service: reconnectionService, + ); + final reconnectedController = await initNetworkLifecycleController( + vmService: reconnectionService, + fakeServiceConnection: connection, + ); + + expect(reconnectedController.isPolling, isTrue); + expect( + reconnectionService.isHttpLoggingEnabled( + reconnectionService.currentIsolateId, + ), + isTrue, + ); + }, + ); + + test( + 're-initializes recording after controller is disposed and recreated', + () async { + final vmService = HotRestartNetworkVmService(); + final connection = FakeServiceConnectionManager(service: vmService); + final isolateId = vmService.currentIsolateId; + await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: connection, + ); + + screenControllers.disposeConnectedControllers(); + + final recreatedController = await initNetworkLifecycleController( + vmService: vmService, + fakeServiceConnection: connection, + ); + + expect(recreatedController.isPolling, isTrue); + expect(vmService.isHttpLoggingEnabled(isolateId), isTrue); + }, + ); + }); + + group('NetworkService hot restart edge cases', () { + tearDown(disposeNetworkLifecycleControllers); + + test( + 'refreshNetworkData is a no-op when the VM service is unavailable', + () async { + final localVmService = HotRestartNetworkVmService(); + final localConnection = FakeServiceConnectionManager( + service: localVmService, + ); + final localController = await initNetworkLifecycleController( + vmService: localVmService, + fakeServiceConnection: localConnection, + ); + + localConnection.serviceManager.service = null; + await expectLater( + localController.networkService.refreshNetworkData(), + completes, + ); + expect(localController.requests.value, isEmpty); + }, + ); + + test( + 'refreshNetworkData swallows service disappeared error during isolate iteration', + () async { + final localVmService = HotRestartNetworkVmService(); + final localConnection = FakeServiceConnectionManager( + service: localVmService, + ); + final localController = await initNetworkLifecycleController( + vmService: localVmService, + fakeServiceConnection: localConnection, + ); + + localVmService.simulateServiceDisappeared = true; + await expectLater( + localController.networkService.refreshNetworkData(), + completes, + ); + }, + ); + + test( + 'updateLastHttpDataRefreshTime does not add entries for new isolates', + () async { + final localVmService = HotRestartNetworkVmService(); + final localConnection = FakeServiceConnectionManager( + service: localVmService, + ); + final localController = await initNetworkLifecycleController( + vmService: localVmService, + fakeServiceConnection: localConnection, + ); + + final postRestartIsolateId = localVmService.simulateHotRestart(); + await localController.networkService.updateLastHttpDataRefreshTime(); + + expect( + localController.networkService.lastHttpDataRefreshTimePerIsolate + .containsKey(postRestartIsolateId), + isFalse, + reason: + 'updateLastHttpDataRefreshTime only updates existing isolate ' + 'entries; new isolates are registered on the first profile fetch.', + ); + }, + ); + + test( + 'pause then resume uses VM timeline for HTTP refresh timestamps', + () async { + final localVmService = HotRestartNetworkVmService(); + final localConnection = FakeServiceConnectionManager( + service: localVmService, + ); + final isolateId = localVmService.currentIsolateId; + final controller = await initNetworkLifecycleController( + vmService: localVmService, + fakeServiceConnection: localConnection, + initialProfile: [ + createTestHttpRequest(id: 'before-pause', method: 'GET'), + ], + ); + await controller.networkService.refreshNetworkData(); + expect( + controller.networkService.lastHttpDataRefreshTimePerIsolate + .containsKey(isolateId), + isTrue, + ); + + await controller.togglePolling(false); + await controller.togglePolling(true); + + final timelineMicros = + (await localVmService.getVMTimelineMicros()).timestamp!; + expect( + controller + .networkService + .lastHttpDataRefreshTimePerIsolate[isolateId], + timelineMicros, + reason: + 'Resuming recording must stamp HTTP refresh times with the VM ' + 'timeline clock, not wall-clock time.', + ); + expect(controller.lastSocketDataRefreshMicros, timelineMicros); + + localVmService.appendHttpRequest( + isolateId, + createTestHttpRequest( + id: 'after-resume', + method: 'POST', + startTime: timelineMicros + 1_000, + ), + ); + await controller.networkService.refreshNetworkData(); + expect( + controller.requests.value.map((r) => r.id), + contains('after-resume'), + ); + }, + ); + }); +} diff --git a/packages/devtools_app/test/screens/network/network_model_test.dart b/packages/devtools_app/test/screens/network/network_model_test.dart index 8b1459e7a80..f958186f5a6 100644 --- a/packages/devtools_app/test/screens/network/network_model_test.dart +++ b/packages/devtools_app/test/screens/network/network_model_test.dart @@ -10,6 +10,7 @@ import 'package:devtools_test/devtools_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:vm_service/vm_service.dart'; +import '../../test_infra/test_data/http_get_cancelled_json.dart'; import '../../test_infra/test_data/network.dart'; import 'utils/network_test_utils.dart'; @@ -67,6 +68,221 @@ void main() { }); }); + group('WebSocketNetworkRequest', () { + final connectTimestamp = DateTime.utc(2026, 8, 18, 10); + final openTimestamp = connectTimestamp.add(const Duration(seconds: 1)); + final lastUpdated = openTimestamp.add(const Duration(seconds: 2)); + + WebSocketConnection createConnection({ + String id = '42', + String state = 'open', + int bytesSent = 100, + int bytesReceived = 200, + int framesSent = 10, + int framesReceived = 20, + int pingCount = 3, + int pongCount = 3, + DateTime? closeTimestamp, + int? closeCode, + String? closeReason, + String? error, + List? events, + }) { + return WebSocketConnection( + isolateId: 'isolate-1', + id: id, + uri: Uri.parse('wss://example.com/socket'), + state: state, + protocol: 'chat', + connectTimestamp: connectTimestamp, + openTimestamp: openTimestamp, + closeTimestamp: closeTimestamp, + bytesSent: bytesSent, + bytesReceived: bytesReceived, + framesSent: framesSent, + framesReceived: framesReceived, + pingCount: pingCount, + pongCount: pongCount, + closeCode: closeCode, + closeReason: closeReason, + error: error, + lastUpdated: lastUpdated, + events: events ?? const [], + ); + } + + test('maps connection properties correctly', () { + final connection = createConnection(); + + final webSocket = WebSocket(connection); + + expect(webSocket.connectionId, '42'); + expect(webSocket.uri, 'wss://example.com/socket'); + expect(webSocket.protocol, 'chat'); + expect(webSocket.state, 'open'); + + expect(webSocket.bytesSent, 100); + expect(webSocket.bytesReceived, 200); + expect(webSocket.framesSent, 10); + expect(webSocket.framesReceived, 20); + expect(webSocket.pingCount, 3); + expect(webSocket.pongCount, 3); + + expect(webSocket.closeCode, isNull); + expect(webSocket.closeReason, isNull); + expect(webSocket.error, isNull); + expect(webSocket.lastUpdated, lastUpdated); + }); + + test('maps NetworkRequest properties correctly', () { + final connection = createConnection(); + + final webSocket = WebSocket(connection); + + expect(webSocket.id, 'websocket:isolate-1:42'); + expect(webSocket.method, 'WEBSOCKET'); + expect(webSocket.uri, 'wss://example.com/socket'); + expect(webSocket.contentType, 'websocket'); + expect(webSocket.type, 'WebSocket'); + + expect(webSocket.startTimestamp, connectTimestamp); + expect(webSocket.endTimestamp, isNull); + expect(webSocket.duration, isNull); + + expect(webSocket.status, 'open'); + expect(webSocket.port, isNull); + + expect(webSocket.requestBytes, 100); + expect(webSocket.responseBytes, 200); + + expect(webSocket.didFail, false); + expect(webSocket.inProgress, true); + }); + + test('maps WebSocket events correctly', () { + final events = [ + WebSocketEvent(event: 'WebSocket.Connect', timestamp: connectTimestamp), + WebSocketEvent( + event: 'WebSocket.Send', + timestamp: openTimestamp, + frameNumber: 1, + direction: 'out', + opcode: 'text', + payloadSize: 25, + ), + WebSocketEvent( + event: 'WebSocket.Receive', + timestamp: lastUpdated, + frameNumber: 2, + direction: 'in', + opcode: 'text', + payloadSize: 40, + ), + ]; + + final webSocket = WebSocket(createConnection(events: events)); + + expect(webSocket.events, hasLength(3)); + + expect(webSocket.events[0].event, 'WebSocket.Connect'); + + expect(webSocket.events[1].frameNumber, 1); + expect(webSocket.events[1].direction, 'out'); + expect(webSocket.events[1].opcode, 'text'); + expect(webSocket.events[1].payloadSize, 25); + + expect(webSocket.events[2].frameNumber, 2); + expect(webSocket.events[2].direction, 'in'); + expect(webSocket.events[2].opcode, 'text'); + expect(webSocket.events[2].payloadSize, 40); + }); + + test('maps closed connection lifecycle correctly', () { + final closeTimestamp = openTimestamp.add(const Duration(seconds: 5)); + + final webSocket = WebSocket( + createConnection( + state: 'closed', + closeTimestamp: closeTimestamp, + closeCode: 1000, + closeReason: 'Normal closure', + ), + ); + + expect(webSocket.state, 'closed'); + expect(webSocket.inProgress, false); + expect(webSocket.didFail, false); + + expect(webSocket.endTimestamp, closeTimestamp); + expect(webSocket.duration, closeTimestamp.difference(connectTimestamp)); + + expect(webSocket.closeCode, 1000); + expect(webSocket.closeReason, 'Normal closure'); + }); + + test('maps error connection correctly', () { + final webSocket = WebSocket( + createConnection(state: 'error', error: 'Connection failed'), + ); + + expect(webSocket.state, 'error'); + expect(webSocket.error, 'Connection failed'); + expect(webSocket.didFail, true); + expect(webSocket.inProgress, false); + }); + + test('equality and hashCode use stable connection identity', () { + final first = WebSocket(createConnection()); + + final second = WebSocket(createConnection(bytesSent: 500)); + + final different = WebSocket(createConnection(id: '43')); + + expect(first == second, true); + expect(first.hashCode, second.hashCode); + expect(first == different, false); + }); + + test('toJson serializes connection and events', () { + final events = [ + WebSocketEvent( + event: 'WebSocket.Send', + timestamp: openTimestamp, + frameNumber: 1, + direction: 'out', + opcode: 'text', + payloadSize: 25, + ), + ]; + + final webSocket = WebSocket(createConnection(events: events)); + + final json = webSocket.toJson(); + + expect(json['connectionId'], '42'); + expect(json['uri'], 'wss://example.com/socket'); + expect(json['protocol'], 'chat'); + expect(json['state'], 'open'); + + expect(json['bytesSent'], 100); + expect(json['bytesReceived'], 200); + expect(json['framesSent'], 10); + expect(json['framesReceived'], 20); + expect(json['pingCount'], 3); + expect(json['pongCount'], 3); + + expect(json['events'], hasLength(1)); + + final event = (json['events'] as List).first as Map; + + expect(event['event'], 'WebSocket.Send'); + expect(event['frameNumber'], 1); + expect(event['direction'], 'out'); + expect(event['opcode'], 'text'); + expect(event['payloadSize'], 25); + }); + }); + group('DartIOHttpRequestData', () { NetworkController controller; FakeServiceConnectionManager fakeServiceConnection; @@ -199,15 +415,6 @@ void main() { expect(httpWsHandshake.port, 56744); }); - test('durationDisplay returns correct value', () { - expect(httpGet.durationDisplay, 'Duration: 811.7 ms'); - expect(httpGetWithError.durationDisplay, 'Duration: 2029.5 ms'); - expect(httpPost.durationDisplay, 'Duration: 1508.0 ms'); - expect(httpPut.durationDisplay, 'Duration: 1325.8 ms'); - expect(httpPatch.durationDisplay, 'Duration: 1243.7 ms'); - expect(httpWsHandshake.durationDisplay, 'Duration: 41.4 ms'); - }); - test('isValid returns correct value', () { expect(httpGet.isValid, isTrue); expect(httpGetWithError.isValid, isTrue); @@ -506,4 +713,183 @@ void main() { expect(httpWsHandshake.didFail, false); }); }); + + test('cancelled request should not remain pending', () { + // No controller needed — construct directly from fixture + final cancelledRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(Map.of(httpGetCancelledJson))!, + requestFullDataFromVmService: false, + ); + + expect(cancelledRequest.status, 'Cancelled'); + expect(cancelledRequest.inProgress, false); + expect(cancelledRequest.status, 'Cancelled'); + expect(cancelledRequest.duration, isNotNull); + }); + + test( + 'request with cancellation evidence is cancelled even with null endTime', + () { + final inFlightData = Map.of(httpGetCancelledJson) + ..['endTime'] = null; + + final inFlightRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(inFlightData)!, + requestFullDataFromVmService: false, + ); + + expect(inFlightRequest.status, 'Cancelled'); + expect(inFlightRequest.inProgress, false); + expect(inFlightRequest.status, 'Cancelled'); + expect(inFlightRequest.duration, isNull); + }, + ); + + test( + 'request without response and endTime remains in progress and not cancelled', + () { + final pendingRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(Map.of(httpGetPendingJson))!, + requestFullDataFromVmService: false, + ); + + expect(pendingRequest.status, isNull); + expect(pendingRequest.inProgress, true); + expect(pendingRequest.duration, isNull); + }, + ); + + test('request without response and null endTime remains pending', () { + final pendingData = Map.of(httpGetPendingJson) + ..['endTime'] = null; + + final pendingRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(pendingData)!, + requestFullDataFromVmService: false, + ); + + expect(pendingRequest.status, isNot('Cancelled')); + expect(pendingRequest.inProgress, true); + expect(pendingRequest.status, isNull); + expect(pendingRequest.duration, isNull); + }); + + test('request with incomplete response and status code is completed', () { + final responseData = Map.of( + httpGetJson['response'] as Map, + )..['endTime'] = null; + + final cancelledWithStatusData = Map.of(httpGetJson) + ..['response'] = responseData; + + final cancelledWithStatusRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(cancelledWithStatusData)!, + requestFullDataFromVmService: false, + ); + + expect(cancelledWithStatusRequest.status, isNot('Cancelled')); + expect(cancelledWithStatusRequest.inProgress, true); + expect(cancelledWithStatusRequest.status, '200'); + expect(cancelledWithStatusRequest.didFail, false); + }); + + test('request with partial response and cancellation error is cancelled', () { + final responseData = + Map.of(httpGetJson['response'] as Map) + ..['endTime'] = null + ..['error'] = 'Response stream aborted by client cancellation'; + + final cancelledDuringResponseData = Map.of(httpGetJson) + ..['response'] = responseData; + + final cancelledDuringResponseRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(cancelledDuringResponseData)!, + requestFullDataFromVmService: false, + ); + + expect(cancelledDuringResponseRequest.status, 'Cancelled'); + expect(cancelledDuringResponseRequest.inProgress, false); + expect(cancelledDuringResponseRequest.status, 'Cancelled'); + expect(cancelledDuringResponseRequest.duration, isNotNull); + }); + + test( + 'request with partial response and generic response error is not cancelled', + () { + final responseData = + Map.of( + httpGetJson['response'] as Map, + ) + ..['endTime'] = null + ..['error'] = 'Connection closed before full response was received'; + + final cancelledDuringResponseData = Map.of(httpGetJson) + ..['response'] = responseData; + + final cancelledDuringResponseRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(cancelledDuringResponseData)!, + requestFullDataFromVmService: false, + ); + + expect(cancelledDuringResponseRequest.status, isNot('Cancelled')); + expect(cancelledDuringResponseRequest.status, '200'); + }, + ); + + test('request with response hasError and status code is not cancelled', () { + final responseData = + Map.of(httpGetJson['response'] as Map) + ..['error'] = 'HttpException: connection terminated' + ..['endTime'] = httpGetJson['endTime']; + + final cancelledData = Map.of(httpGetJson) + ..['response'] = responseData; + + final cancelledRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(cancelledData)!, + requestFullDataFromVmService: false, + ); + + expect(cancelledRequest.status, isNot('Cancelled')); + expect(cancelledRequest.status, '200'); + }); + + test('request with request hasError and response present is cancelled', () { + final requestData = Map.of( + httpGetJson['request'] as Map, + )..['error'] = 'Cancelled by client before completion'; + + final cancelledData = Map.of(httpGetJson) + ..['request'] = requestData; + + final cancelledRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(cancelledData)!, + requestFullDataFromVmService: false, + ); + + expect(cancelledRequest.status, 'Cancelled'); + expect(cancelledRequest.status, 'Cancelled'); + }); + + test( + 'request with non-cancellation error and no response has error status', + () { + final requestData = Map.of( + httpGetJson['request'] as Map, + )..['error'] = 'SocketException: connection reset by peer'; + + final errorData = Map.of(httpGetJson) + ..['request'] = requestData + ..['response'] = null; + + final errorRequest = DartIOHttpRequestData( + HttpProfileRequest.parse(errorData)!, + requestFullDataFromVmService: false, + ); + + expect(errorRequest.status, 'Error'); + expect(errorRequest.status, isNot('Cancelled')); + expect(errorRequest.inProgress, false); + }, + ); } diff --git a/packages/devtools_app/test/screens/network/network_profiler_test.dart b/packages/devtools_app/test/screens/network/network_profiler_test.dart index ff469552771..9175ccbf598 100644 --- a/packages/devtools_app/test/screens/network/network_profiler_test.dart +++ b/packages/devtools_app/test/screens/network/network_profiler_test.dart @@ -14,8 +14,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../test_infra/test_data/network.dart'; @@ -65,6 +65,12 @@ void main() { group('Network Profiler', () { setUp(() { + // Reload the fixtures for every test. Clearing requests mutates these + // shared profiles in place (FakeVmServiceWrapper clears the same lists + // aliased by _startingRequests/_startingSockets), so a test that taps + // Clear would otherwise leave them empty for the next test. + socketProfile = loadSocketProfile(); + httpProfile = loadHttpProfile(); fakeServiceConnection = FakeServiceConnectionManager( service: FakeServiceManager.createFakeService( socketProfile: socketProfile, @@ -338,9 +344,59 @@ void main() { // Wait to ensure all the timers have been cancelled. await tester.pumpAndSettle(const Duration(seconds: 2)); }); + + testWidgetsWithWindowSize( + 'search field stays enabled after clearing', + windowSize, + (WidgetTester tester) async { + // Load the network profiler screen. + controller = NetworkController(); + await pumpNetworkScreen(tester); + + // Populate the screen with requests. + await loadRequestsAndCheck(tester); + + Finder searchFieldTextField() => find.descendant( + of: find.byType(SearchField), + matching: find.byType(TextField), + ); + + // The search field is enabled while requests are present. + expect( + tester.widget(searchFieldTextField()).enabled, + isTrue, + ); + + // Pause the profiler so polling does not repopulate the requests after + // they are cleared below. + await tester.tap(find.byType(StartStopRecordingButton)); + await tester.pumpAndSettle(); + + // Clear the results. + await tester.tap(find.byType(ClearButton)); + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(controller.requests.value, isEmpty); + + // The search field must stay enabled even with no requests present, so + // the query can still be edited before the next batch of requests + // arrives. + expect( + tester.widget(searchFieldTextField()).enabled, + isTrue, + ); + }, + ); }); group('NetworkRequestOverviewView', () { + setUp(() { + setGlobal( + ServiceConnectionManager, + FakeServiceConnectionManager( + service: FakeServiceManager.createFakeService(), + ), + ); + }); Future pumpView(WidgetTester tester, NetworkRequest data) async { final widget = wrap(NetworkRequestOverviewView(data)); await tester.pumpWidget(widget); @@ -510,7 +566,7 @@ void main() { expect(find.text('Start time: '), findsOneWidget); expect(find.text(formatDateTime(data.startTimestamp)), findsOneWidget); expect(find.text('End time: '), findsOneWidget); - expect(find.text('Pending'), findsOneWidget); + expect(find.text('Pending'), findsNWidgets(2)); expect( find.byKey(NetworkRequestOverviewView.socketTimingGraphKey), findsOneWidget, @@ -527,5 +583,176 @@ void main() { ); }, ); + testWidgetsWithWindowSize('displays for web socket request', windowSize, ( + tester, + ) async { + final connection = WebSocketConnection( + isolateId: 'isolate-1', + id: '42', + uri: Uri.parse('wss://example.com/socket'), + state: 'open', + protocol: 'chat', + connectTimestamp: DateTime.utc(2026, 8, 18, 10), + openTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 1), + bytesSent: 100, + bytesReceived: 200, + framesSent: 10, + framesReceived: 20, + pingCount: 3, + pongCount: 3, + lastUpdated: DateTime.utc(2026, 8, 18, 10, 0, 2), + events: [ + WebSocketEvent( + event: 'WebSocket.Send', + timestamp: DateTime.utc(2026, 8, 18, 10, 0, 1), + frameNumber: 1, + direction: 'out', + opcode: 'text', + payloadSize: 25, + ), + ], + ); + + final data = WebSocket(connection); + + await pumpView(tester, data); + + // Verify general information. + expect(find.text('Request uri: '), findsOneWidget); + expect(find.text('wss://example.com/socket'), findsOneWidget); + expect(find.text('Method: '), findsOneWidget); + expect(find.text('WEBSOCKET'), findsOneWidget); + expect(find.text('Status: '), findsOneWidget); + expect(find.text('open'), findsOneWidget); + expect(find.text('Port: '), findsNothing); + expect(find.text('Content type: '), findsNothing); + + expect(find.text('Connection id: '), findsOneWidget); + expect(find.text('42'), findsOneWidget); + expect(find.text('Protocol: '), findsOneWidget); + expect(find.text('chat'), findsOneWidget); + + expect(find.text('Bytes sent: '), findsOneWidget); + expect(find.text('100 B'), findsOneWidget); + expect(find.text('Bytes received: '), findsOneWidget); + expect(find.text('200 B'), findsOneWidget); + + expect(find.text('Frames sent: '), findsOneWidget); + expect(find.text('10'), findsOneWidget); + expect(find.text('Frames received: '), findsOneWidget); + expect(find.text('20'), findsOneWidget); + + expect(find.text('Ping count: '), findsOneWidget); + expect(find.text('3'), findsNWidgets(2)); + + expect(find.text('Pong count: '), findsOneWidget); + + // Verify timing information. + expect(find.text('Timing: '), findsOneWidget); + expect(find.text('Start time: '), findsOneWidget); + expect(find.text(formatDateTime(data.startTimestamp)), findsOneWidget); + expect(find.text('End time: '), findsOneWidget); + expect(find.text('Pending'), findsNWidgets(2)); + expect( + find.byKey(NetworkRequestOverviewView.webSocketTimingGraphKey), + findsOneWidget, + ); + }); + + testWidgetsWithWindowSize( + 'displays duration for closed web socket request', + windowSize, + (tester) async { + final connection = WebSocketConnection( + isolateId: 'isolate-1', + id: '42', + uri: Uri.parse('wss://example.com/socket'), + state: 'closed', + protocol: 'chat', + connectTimestamp: DateTime.utc(2026, 8, 18, 10), + openTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 1), + closeTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 6), + bytesSent: 100, + bytesReceived: 200, + framesSent: 10, + framesReceived: 20, + pingCount: 3, + pongCount: 3, + lastUpdated: DateTime.utc(2026, 8, 18, 10, 0, 7), + events: [ + WebSocketEvent( + event: 'WebSocket.Send', + timestamp: DateTime.utc(2026, 8, 18, 10, 0, 1), + frameNumber: 1, + direction: 'out', + opcode: 'text', + payloadSize: 25, + ), + WebSocketEvent( + event: 'WebSocket.Close', + timestamp: DateTime.utc(2026, 8, 18, 10, 0, 6), + frameNumber: 2, + direction: 'out', + opcode: 'close', + payloadSize: 0, + ), + ], + ); + + final data = WebSocket(connection); + + await pumpView(tester, data); + + expect(find.text('Status: '), findsOneWidget); + expect(find.text('closed'), findsOneWidget); + + expect(find.text('Connection id: '), findsOneWidget); + expect(find.text('42'), findsOneWidget); + + expect(find.text('Protocol: '), findsOneWidget); + expect(find.text('chat'), findsOneWidget); + + expect(find.text('Bytes sent: '), findsOneWidget); + expect(find.text('100 B'), findsOneWidget); + + expect(find.text('Bytes received: '), findsOneWidget); + expect(find.text('200 B'), findsOneWidget); + + expect(find.text('Frames sent: '), findsOneWidget); + expect(find.text('10'), findsOneWidget); + + expect(find.text('Frames received: '), findsOneWidget); + expect(find.text('20'), findsOneWidget); + + expect(find.text('Ping count: '), findsOneWidget); + expect(find.text('3'), findsNWidgets(2)); + + expect(find.text('Pong count: '), findsOneWidget); + + // The connection lasted from 10:00:00 to 10:00:06. + expect(data.duration, const Duration(seconds: 6)); + expect( + find.text(durationText(data.duration!, fractionDigits: 0)), + findsOneWidget, + ); + + expect(find.text('Start time: '), findsOneWidget); + expect(find.text(formatDateTime(data.startTimestamp)), findsOneWidget); + + expect(find.text('End time: '), findsOneWidget); + expect(find.text(formatDateTime(data.endTimestamp!)), findsOneWidget); + + expect(find.text('Last updated: '), findsOneWidget); + expect( + find.text(formatDateTime(connection.lastUpdated)), + findsOneWidget, + ); + + expect( + find.byKey(NetworkRequestOverviewView.webSocketTimingGraphKey), + findsOneWidget, + ); + }, + ); }); } diff --git a/packages/devtools_app/test/screens/network/network_request_inspector_test.dart b/packages/devtools_app/test/screens/network/network_request_inspector_test.dart index b2dbeca9165..2373bb6c18f 100644 --- a/packages/devtools_app/test/screens/network/network_request_inspector_test.dart +++ b/packages/devtools_app/test/screens/network/network_request_inspector_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart'; import '../../test_infra/test_data/network.dart'; @@ -286,5 +286,74 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 1)); }, ); + testWidgetsWithWindowSize( + 'WebSocket inspector shows Frames', + const Size(1200, 800), + (tester) async { + final connection = WebSocketConnection( + isolateId: 'isolate-1', + id: '42', + uri: Uri.parse('wss://example.com/socket'), + state: 'open', + protocol: 'chat', + connectTimestamp: DateTime.utc(2026, 8, 18, 10), + openTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 1), + bytesSent: 100, + bytesReceived: 200, + framesSent: 10, + framesReceived: 20, + pingCount: 3, + pongCount: 3, + lastUpdated: DateTime.utc(2026, 8, 18, 10, 0, 2), + events: [ + WebSocketEvent( + event: 'WebSocket.Send', + timestamp: DateTime.utc(2026, 8, 18, 10, 0, 1), + frameNumber: 1, + direction: 'out', + opcode: 'text', + payloadSize: 25, + ), + WebSocketEvent( + event: 'WebSocket.Receive', + timestamp: DateTime.utc(2026, 8, 18, 10, 0, 2), + frameNumber: 2, + direction: 'in', + opcode: 'text', + payloadSize: 40, + ), + ], + ); + + final webSocket = WebSocket(connection); + + await tester.pumpWidget( + wrapWithControllers( + const NetworkRequestInspector(), + network: NetworkController(), + debugger: createMockDebuggerControllerWithDefaults(), + ), + ); + + final controller = screenControllers.lookup(); + controller.selectedRequest.value = webSocket; + + await tester.pumpAndSettle(); + + // The Frames tab should exist for a WebSocket. + expect(find.text('Frames'), findsOneWidget); + + // Switch to the Frames tab. + await tester.tap(find.text('Frames')); + await tester.pumpAndSettle(); + + // The Frames view should now be visible. + expect(find.byType(WebSocketFramesView), findsWidgets); + + // Stop polling before disposing the test. + await controller.stopRecording(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + }, + ); }); } diff --git a/packages/devtools_app/test/screens/network/network_responsive_test.dart b/packages/devtools_app/test/screens/network/network_responsive_test.dart new file mode 100644 index 00000000000..cd42281b1a6 --- /dev/null +++ b/packages/devtools_app/test/screens/network/network_responsive_test.dart @@ -0,0 +1,309 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('vm') +library; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/shared/ui/file_import.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +import '../../test_infra/responsive_layout_utils.dart'; + +/// The width, in logical pixels, below which the search field is on its own +/// row. Matches the threshold in `NetworkScreen`. +const _searchOnOwnRowBelowWidth = 600.0; + +/// The width, in logical pixels, below which the filter field is on its own +/// row. Matches the threshold in `NetworkScreen`. +const _filterOnOwnRowBelowWidth = 900.0; + +Finder _inToolbar(Finder finder) { + return find.descendant(of: find.byType(NetworkScreenBody), matching: finder); +} + +/// Resizes the test window to [width] and pumps, rebuilding the screen at the +/// new size in the same widget tree. +Future _setWindowWidth(WidgetTester tester, double width) async { + tester.view.physicalSize = Size(width, responsiveLayoutHeight); + tester.view.devicePixelRatio = 1.0; + await tester.pump(); +} + +/// Whether the EditableText inside [field] holds primary focus. +bool _fieldHasFocus(WidgetTester tester, Finder field) { + final editable = find.descendant( + of: field, + matching: find.byType(EditableText), + ); + if (editable.evaluate().isEmpty) return false; + final focusNode = tester.widget(editable).focusNode; + return focusNode.hasFocus && + identical(focusNode, FocusManager.instance.primaryFocus); +} + +Finder get _search => _inToolbar(find.byType(SearchField)); + +Finder get _filter => + _inToolbar(find.byType(StandaloneFilterField)); + +Finder get _recordButton => _inToolbar(find.byType(StartStopRecordingButton)); + +/// Asserts the layout of the Network toolbar at [width]. +void _expectToolbarLayout(WidgetTester tester, {required double width}) { + final body = tester.getRect(find.byType(NetworkScreenBody)); + final search = tester.getRect(_search); + final filter = tester.getRect(_filter); + final record = tester.getRect(_recordButton); + final clear = tester.getRect(_inToolbar(find.byType(ClearButton))); + final export = tester.getRect(_inToolbar(find.byType(OpenSaveButtonGroup))); + + expectNoIntersections({ + 'record': record, + 'clear': clear, + 'search': search, + 'filter': filter, + 'export': export, + }); + expectWithinBounds({ + 'record': record, + 'clear': clear, + 'search': search, + 'filter': filter, + 'export': export, + }, body); + + // The field's own controls were the elements that overlapped in the report. + final fieldControls = { + for (final (i, control) in _inToolbar( + find.descendant( + of: _filter, + matching: find.byType(InputDecorationSuffixButton), + ), + ).evaluate().indexed) + 'filterSuffix$i': tester.getRect(find.byWidget(control.widget)), + }; + expect(fieldControls, isNotEmpty); + expectWithinBounds(fieldControls, filter); + expectNoIntersections(fieldControls); + + if (width < _searchOnOwnRowBelowWidth) { + expect(search.top, greaterThanOrEqualTo(record.bottom - geometryTolerance)); + } else { + expect(search.center.dy, closeTo(record.center.dy, geometryTolerance)); + } + if (width < _filterOnOwnRowBelowWidth) { + expect(filter.top, greaterThanOrEqualTo(search.bottom - geometryTolerance)); + } else { + expect(filter.center.dy, closeTo(search.center.dy, geometryTolerance)); + } + + [_recordButton, _search, _filter].forEach(expectInteractable); +} + +void main() { + setUpAll(loadFonts); + + late FakeServiceConnectionManager connection; + late NetworkController network; + late LoggingController logging; + late DebuggerController debugger; + + setUp(() { + connection = createLayoutServiceConnection(); + setUpLayoutGlobals(connection); + network = NetworkController(); + logging = LoggingController(); + debugger = DebuggerController(); + }); + + group('Network toolbar (standalone)', () { + for (final width in responsiveLayoutWidths) { + testWidgetsWithWindowSize( + 'has no overlap or clipping at $width', + Size(width, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: false, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectToolbarLayout(tester, width: width); + await stopNetworkPolling(tester, network); + }, + ); + } + }); + + // The toolbar does not read the embed mode, so one narrow embedded smoke + // test covers the embedded path. + testWidgetsWithWindowSize( + 'Network toolbar (embedded) has no overlap or clipping at 328', + const Size(328, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: true, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + _expectToolbarLayout(tester, width: 328); + await stopNetworkPolling(tester, network); + }, + ); + + group('Network toolbar state across resizes', () { + // Resizing across the breakpoints reparents the search and filter fields + // between rows. Their stable keys keep each field's state attached, so + // text, selection, and focus survive the move. + testWidgetsWithWindowSize( + 'search and filter keep text, selection, and focus across resizes', + const Size(650, responsiveLayoutHeight), + (WidgetTester tester) async { + final errors = await collectFlutterErrors( + () => pumpDevToolsScaffold( + tester, + pageId: ScreenMetaData.network.id, + embedded: false, + connected: true, + connection: connection, + network: network, + logging: logging, + debugger: debugger, + ), + ); + expect(errors, isEmpty, reason: errors.join('\n')); + + final searchEditable = find.descendant( + of: _search, + matching: find.byType(EditableText), + ); + final filterEditable = find.descendant( + of: _filter, + matching: find.byType(EditableText), + ); + + await tester.enterText(searchEditable, 'method:GET'); + await tester.enterText(filterEditable, 'status:200'); + tester.widget(searchEditable).controller.selection = + const TextSelection(baseOffset: 2, extentOffset: 7); + tester.widget(filterEditable).controller.selection = + const TextSelection(baseOffset: 1, extentOffset: 4); + await tester.pump(); + + // Focus the filter field so the test can catch a focus steal by the + // search field's autofocus when it is remounted. + await tester.showKeyboard(filterEditable); + await tester.pump(); + + final searchFieldState = tester.state(_search); + final searchEditableState = tester.state(searchEditable); + final filterFieldState = tester.state(_filter); + final filterEditableState = tester.state(filterEditable); + final searchCtl = tester + .widget(searchEditable) + .controller; + final filterCtl = tester + .widget(filterEditable) + .controller; + final searchText = searchCtl.value.text; + final searchSelection = searchCtl.value.selection; + final filterText = filterCtl.value.text; + final filterSelection = filterCtl.value.selection; + final activeQuery = network.activeFilter.value.queryFilter.query; + + expect(searchText, 'method:GET'); + expect(filterText, 'status:200'); + expect(network.search, 'method:GET'); + expect(_fieldHasFocus(tester, _filter), isTrue); + expect(_fieldHasFocus(tester, _search), isFalse); + + // Cross each boundary in both directions in the same tree, and finish + // on a wide desktop baseline. + for (final width in [328.0, 600.0, 900.0, 601.0, 901.0, 1400.0]) { + final resizeErrors = await collectFlutterErrors( + () => _setWindowWidth(tester, width), + ); + expect(resizeErrors, isEmpty, reason: resizeErrors.join('\n')); + + expect( + tester.state(_search), + same(searchFieldState), + reason: 'SearchField state lost at $width', + ); + expect( + tester.state(searchEditable), + same(searchEditableState), + reason: 'search EditableText state lost at $width', + ); + expect( + tester.state(_filter), + same(filterFieldState), + reason: 'StandaloneFilterField state lost at $width', + ); + expect( + tester.state(filterEditable), + same(filterEditableState), + reason: 'filter EditableText state lost at $width', + ); + expect( + tester.widget(searchEditable).controller, + same(searchCtl), + reason: 'search text controller replaced at $width', + ); + expect( + tester.widget(filterEditable).controller, + same(filterCtl), + reason: 'filter text controller replaced at $width', + ); + expect(searchCtl.value.text, searchText); + expect(searchCtl.value.selection, searchSelection); + expect(filterCtl.value.text, filterText); + expect(filterCtl.value.selection, filterSelection); + expect(network.search, 'method:GET'); + expect(network.activeFilter.value.queryFilter.query, activeQuery); + expect( + _fieldHasFocus(tester, _filter), + isTrue, + reason: 'filter focus lost at $width', + ); + expect( + _fieldHasFocus(tester, _search), + isFalse, + reason: 'remounted search field stole focus at $width', + ); + } + + // The wide baseline still places both fields in the top row. + final search = tester.getRect(_search); + final filter = tester.getRect(_filter); + final record = tester.getRect(_recordButton); + expect(search.center.dy, closeTo(record.center.dy, geometryTolerance)); + expect(filter.center.dy, closeTo(search.center.dy, geometryTolerance)); + + await stopNetworkPolling(tester, network); + }, + ); + }); +} diff --git a/packages/devtools_app/test/screens/network/network_screen_test.dart b/packages/devtools_app/test/screens/network/network_screen_test.dart index 1879f9c47f1..bea09dd4ae3 100644 --- a/packages/devtools_app/test/screens/network/network_screen_test.dart +++ b/packages/devtools_app/test/screens/network/network_screen_test.dart @@ -7,8 +7,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/screens/network/network_table_test.dart b/packages/devtools_app/test/screens/network/network_table_test.dart index e9191c3b97f..8d1e51aa53d 100644 --- a/packages/devtools_app/test/screens/network/network_table_test.dart +++ b/packages/devtools_app/test/screens/network/network_table_test.dart @@ -45,6 +45,7 @@ void main() { controller.processNetworkTrafficHelper( socketProfile.sockets, httpProfile.requests, + const [], 0, currentRequests: currentRequests, ); @@ -101,9 +102,59 @@ void main() { final column = TimestampColumn(); final getRequest = findRequestById('1'); - // The hours field may be unreliable since it depends on the timezone the - // test is running in. - expect(column.getDisplayValue(getRequest), contains(':45:26.279')); + // The hours and minutes field may be unreliable since it depends on the + // timezone the test is running in (e.g. UTC vs IST). + expect(column.getDisplayValue(getRequest), contains('26.279')); + }); + test('WebSocket columns', () { + final connection = WebSocketConnection( + isolateId: 'isolate-1', + id: '42', + uri: Uri.parse('wss://example.com/socket'), + state: 'open', + protocol: 'chat', + connectTimestamp: DateTime.utc(2026, 8, 18, 10), + openTimestamp: DateTime.utc(2026, 8, 18, 10, 0, 1), + bytesSent: 100, + bytesReceived: 200, + framesSent: 10, + framesReceived: 20, + pingCount: 3, + pongCount: 3, + lastUpdated: DateTime.utc(2026, 8, 18, 10, 0, 2), + events: const [], + ); + + final webSocket = WebSocket(connection); + + expect( + NetworkRequestsTable.addressColumn.getDisplayValue(webSocket), + connection.uri.toString(), + ); + expect( + NetworkRequestsTable.methodColumn.getDisplayValue(webSocket), + 'WEBSOCKET', + ); + expect( + NetworkRequestsTable.statusColumn.getDisplayValue(webSocket), + 'open', + ); + expect( + NetworkRequestsTable.typeColumn.getDisplayValue(webSocket), + 'WebSocket', + ); + expect( + NetworkRequestsTable.durationColumn.getDisplayValue(webSocket), + 'Pending', + ); + expect( + NetworkRequestsTable.timestampColumn.getDisplayValue(webSocket), + contains('00.000'), + ); + expect( + NetworkRequestsTable.responseSizeColumn.getDisplayValue(webSocket), + '200 B', + ); }); }); } diff --git a/packages/devtools_app/test/screens/network/offline_data_test.dart b/packages/devtools_app/test/screens/network/offline_data_test.dart index 8069682b3a6..6a653d392d2 100644 --- a/packages/devtools_app/test/screens/network/offline_data_test.dart +++ b/packages/devtools_app/test/screens/network/offline_data_test.dart @@ -131,6 +131,37 @@ void main() { // Validate selectedRequestId expect(offlineData.selectedRequestId, isNull); + + // Validate webSocketData. + expect(offlineData.webSocketData, hasLength(1)); + + final webSocket = offlineData.webSocketData.single; + + expect(webSocket.connectionId, '42'); + expect(webSocket.uri.toString(), 'wss://example.com/socket'); + expect(webSocket.state, 'open'); + expect(webSocket.protocol, 'chat'); + + expect(webSocket.bytesSent, 100); + expect(webSocket.bytesReceived, 200); + expect(webSocket.framesSent, 10); + expect(webSocket.framesReceived, 20); + expect(webSocket.pingCount, 3); + expect(webSocket.pongCount, 3); + + expect(webSocket.events, hasLength(2)); + + expect(webSocket.events[0].frameNumber, 1); + expect(webSocket.events[0].event, 'WebSocket.Send'); + expect(webSocket.events[0].direction, 'out'); + expect(webSocket.events[0].opcode, 'text'); + expect(webSocket.events[0].payloadSize, 25); + + expect(webSocket.events[1].frameNumber, 2); + expect(webSocket.events[1].event, 'WebSocket.Receive'); + expect(webSocket.events[1].direction, 'in'); + expect(webSocket.events[1].opcode, 'text'); + expect(webSocket.events[1].payloadSize, 40); }); test('OfflineNetworkData should serialize correctly', () { @@ -150,6 +181,7 @@ void main() { final emptyOfflineData = OfflineNetworkData( httpRequestData: [], socketData: [], + webSocketData: [], timelineMicrosOffset: 1731482170837171, ); @@ -161,6 +193,7 @@ void main() { final populatedHttpData = OfflineNetworkData( httpRequestData: offlineData.httpRequestData, socketData: [], + webSocketData: [], timelineMicrosOffset: 1731482170837171, ); @@ -177,6 +210,42 @@ void main() { ); expect(restoredData.socketData.length, offlineData.socketData.length); expect(restoredData.selectedRequestId, offlineData.selectedRequestId); + + expect( + restoredData.webSocketData.length, + offlineData.webSocketData.length, + ); + + final restoredWebSocket = restoredData.webSocketData.single; + final originalWebSocket = offlineData.webSocketData.single; + + expect(restoredWebSocket.connectionId, originalWebSocket.connectionId); + expect(restoredWebSocket.uri, originalWebSocket.uri); + expect(restoredWebSocket.state, originalWebSocket.state); + expect(restoredWebSocket.protocol, originalWebSocket.protocol); + + expect(restoredWebSocket.bytesSent, originalWebSocket.bytesSent); + expect(restoredWebSocket.bytesReceived, originalWebSocket.bytesReceived); + expect(restoredWebSocket.framesSent, originalWebSocket.framesSent); + expect( + restoredWebSocket.framesReceived, + originalWebSocket.framesReceived, + ); + expect(restoredWebSocket.pingCount, originalWebSocket.pingCount); + expect(restoredWebSocket.pongCount, originalWebSocket.pongCount); + + expect(restoredWebSocket.events, hasLength(2)); + + for (var i = 0; i < originalWebSocket.events.length; i++) { + final originalEvent = originalWebSocket.events[i]; + final restoredEvent = restoredWebSocket.events[i]; + + expect(restoredEvent.frameNumber, originalEvent.frameNumber); + expect(restoredEvent.event, originalEvent.event); + expect(restoredEvent.direction, originalEvent.direction); + expect(restoredEvent.opcode, originalEvent.opcode); + expect(restoredEvent.payloadSize, originalEvent.payloadSize); + } }); test('Handles serialization errors gracefully', () { diff --git a/packages/devtools_app/test/screens/network/sample_network_offline_data.json b/packages/devtools_app/test/screens/network/sample_network_offline_data.json index 4345951db15..204ef22238a 100644 --- a/packages/devtools_app/test/screens/network/sample_network_offline_data.json +++ b/packages/devtools_app/test/screens/network/sample_network_offline_data.json @@ -297,5 +297,45 @@ "writeBytes": 18247 } } + ], + "webSocketData": [ + { + "isolateId": "isolate-1", + "connectionId": "42", + "uri": "wss://example.com/socket", + "state": "open", + "protocol": "chat", + "connectTimestamp": "2026-08-18T10:00:00.000Z", + "openTimestamp": "2026-08-18T10:00:01.000Z", + "closeTimestamp": null, + "bytesSent": 100, + "bytesReceived": 200, + "framesSent": 10, + "framesReceived": 20, + "pingCount": 3, + "pongCount": 3, + "closeCode": null, + "closeReason": null, + "error": null, + "lastUpdated": "2026-08-18T10:00:02.000Z", + "events": [ + { + "event": "WebSocket.Send", + "timestamp": 1787047201000000, + "frameNumber": 1, + "direction": "out", + "opcode": "text", + "payloadSize": 25 + }, + { + "event": "WebSocket.Receive", + "timestamp": 1787047202000000, + "frameNumber": 2, + "direction": "in", + "opcode": "text", + "payloadSize": 40 + } + ] + } ] } \ No newline at end of file diff --git a/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart b/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart new file mode 100644 index 00000000000..cdd175e5a5c --- /dev/null +++ b/packages/devtools_app/test/screens/network/utils/hot_restart_network_vm_service.dart @@ -0,0 +1,196 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:vm_service/vm_service.dart'; + +/// A fake VM service that models hot-restart isolate lifecycle for Network View +/// tests. +/// +/// After a hot restart, the VM spawns a new main isolate whose HTTP timeline +/// logging and socket profiling are disabled until explicitly re-enabled. +/// [getHttpProfileWrapper] and [getSocketProfileWrapper] return empty data when +/// profiling is disabled for an isolate, matching real VM behavior. +class HotRestartNetworkVmService extends FakeVmServiceWrapper { + HotRestartNetworkVmService() + : super( + _testVmFlagManager, + null, + null, + SocketProfile(sockets: []), + HttpProfile( + requests: [], + timestamp: DateTime.fromMicrosecondsSinceEpoch(0), + ), + null, + null, + null, + null, + null, + null, + null, + ) { + isHttpProfilingAvailableResult = true; + _httpLoggingEnabled[_currentIsolateId] = false; + _socketProfilingEnabled[_currentIsolateId] = false; + } + + static final _testVmFlagManager = VmFlagManager(); + + String _currentIsolateId = 'isolates/1'; + + /// A monotonic microsecond counter simulating the VM timeline clock. + int _timelineMicros = 1_000_000; + + /// A map of isolate ID to the list of mock HTTP requests recorded on that + /// isolate. + final _httpProfiles = >{}; + + /// A map of isolate ID to the list of mock socket statistics recorded on that + /// isolate. + final _socketProfiles = >{}; + + /// A map tracking whether HTTP timeline logging is enabled (true/false) per + /// isolate ID. + final _httpLoggingEnabled = {}; + + /// A map tracking whether socket profiling is enabled (true/false) per isolate + /// ID. + final _socketProfilingEnabled = {}; + + /// The isolate ID currently returned by [forEachIsolate]. + String get currentIsolateId => _currentIsolateId; + + /// Sets the HTTP profile data that will be returned for [isolateId] when HTTP + /// timeline logging is enabled for that isolate. + void setHttpProfile(String isolateId, List requests) { + _httpProfiles[isolateId] = requests; + } + + /// Whether HTTP timeline logging is enabled for [isolateId]. + bool isHttpLoggingEnabled(String isolateId) => + _httpLoggingEnabled[isolateId] ?? false; + + /// Whether socket profiling is enabled for [isolateId]. + bool isSocketProfilingEnabled(String isolateId) => + _socketProfilingEnabled[isolateId] ?? false; + + /// Simulates a hot restart by replacing the current isolate with a new one + /// that has HTTP logging and socket profiling disabled. + /// + /// Returns the ID of the new isolate. + String simulateHotRestart({String? newIsolateId}) { + final nextId = newIsolateId ?? '${_currentIsolateId}_restarted'; + _currentIsolateId = nextId; + _httpLoggingEnabled[nextId] = false; + _socketProfilingEnabled[nextId] = false; + // Advance past pre-restart request timestamps so refresh logic treats them + // as stale on the new isolate. + _timelineMicros += 5_000_000; + return nextId; + } + + /// Appends [request] to the HTTP profile for [isolateId]. + void appendHttpRequest(String isolateId, HttpProfileRequest request) { + _httpProfiles[isolateId] = [...?_httpProfiles[isolateId], request]; + } + + @override + Future clearHttpProfileWrapper(String isolateId) async { + _httpProfiles[isolateId] = []; + return Success(); + } + + @override + Future clearSocketProfileWrapper(String isolateId) async { + _socketProfiles[isolateId] = []; + return Success(); + } + + /// When true, [getSocketProfileWrapper] throws an [RPCError] with + /// [RPCErrorKind.kServiceDisappeared], simulating service shutdown. + bool simulateServiceDisappeared = false; + + @override + Future forEachIsolate(Future Function(IsolateRef) callback) => + callback(IsolateRef.parse({'id': _currentIsolateId, 'name': 'main'})!); + + @override + Future httpEnableTimelineLoggingWrapper( + String isolateId, [ + bool? enabled, + ]) async { + if (enabled != null) { + _httpLoggingEnabled[isolateId] = enabled; + return HttpTimelineLoggingState(enabled: enabled); + } + return HttpTimelineLoggingState( + enabled: _httpLoggingEnabled[isolateId] ?? false, + ); + } + + @override + Future socketProfilingEnabledWrapper( + String isolateId, [ + bool? enabled, + ]) async { + if (enabled != null) { + _socketProfilingEnabled[isolateId] = enabled; + return SocketProfilingState(enabled: enabled); + } + return SocketProfilingState( + enabled: _socketProfilingEnabled[isolateId] ?? false, + ); + } + + @override + Future getHttpProfileWrapper( + String isolateId, { + DateTime? updatedSince, + }) async { + if (!(_httpLoggingEnabled[isolateId] ?? false)) { + return HttpProfile( + requests: [], + timestamp: DateTime.fromMicrosecondsSinceEpoch(_timelineMicros), + ); + } + + var requests = List.from( + _httpProfiles[isolateId] ?? const [], + ); + if (updatedSince != null) { + final sinceMicros = updatedSince.microsecondsSinceEpoch; + requests = requests + .where( + (request) => + request.startTime.microsecondsSinceEpoch >= sinceMicros, + ) + .toList(); + } + return HttpProfile( + requests: requests, + timestamp: DateTime.fromMicrosecondsSinceEpoch(_timelineMicros), + ); + } + + @override + Future getSocketProfileWrapper(String isolateId) async { + if (simulateServiceDisappeared) { + throw RPCError( + 'ext.dart.io.getSocketProfile', + RPCErrorKind.kServiceDisappeared.code, + 'Service has disappeared', + ); + } + if (!(_socketProfilingEnabled[isolateId] ?? false)) { + return SocketProfile(sockets: []); + } + return SocketProfile(sockets: _socketProfiles[isolateId] ?? const []); + } + + @override + Future getVMTimelineMicros() async => + Timestamp(timestamp: _timelineMicros); +} diff --git a/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart b/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart new file mode 100644 index 00000000000..7332fcb567a --- /dev/null +++ b/packages/devtools_app/test/screens/network/utils/network_lifecycle_test_utils.dart @@ -0,0 +1,51 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vm_service/vm_service.dart'; + +import 'hot_restart_network_vm_service.dart'; + +Future initNetworkLifecycleController({ + required HotRestartNetworkVmService vmService, + required FakeServiceConnectionManager fakeServiceConnection, + List? initialProfile, +}) async { + if (initialProfile != null) { + vmService.setHttpProfile(vmService.currentIsolateId, initialProfile); + } + final controller = setUpNetworkLifecycleController(fakeServiceConnection); + await pumpEventQueue(); + return controller; +} + +NetworkController setUpNetworkLifecycleController( + FakeServiceConnectionManager fakeServiceConnection, +) { + setGlobal(OfflineDataController, OfflineDataController()); + setGlobal(ScreenControllers, ScreenControllers()); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + setGlobal(PreferencesController, PreferencesController()); + screenControllers.register(() => NetworkController()); + return screenControllers.lookup(); +} + +void notifyMainIsolateChanged( + FakeServiceConnectionManager connection, + String newIsolateId, +) { + final isolateManager = + connection.serviceManager.isolateManager as FakeIsolateManager; + final isolateRef = IsolateRef.parse({'id': newIsolateId, 'name': 'main'})!; + (isolateManager.mainIsolate as ValueNotifier).value = isolateRef; + isolateManager.notifyIsolateCreated(isolateRef); +} + +void disposeNetworkLifecycleControllers() { + screenControllers.disposeConnectedControllers(); +} diff --git a/packages/devtools_app/test/screens/network/utils/network_test_utils.dart b/packages/devtools_app/test/screens/network/utils/network_test_utils.dart index 0d9506c787e..391f91c72e4 100644 --- a/packages/devtools_app/test/screens/network/utils/network_test_utils.dart +++ b/packages/devtools_app/test/screens/network/utils/network_test_utils.dart @@ -6,6 +6,32 @@ import 'package:vm_service/vm_service.dart'; import '../../../test_infra/test_data/network.dart'; +/// Creates a minimal [HttpProfileRequest] for use in Network View tests. +HttpProfileRequest createTestHttpRequest({ + required String id, + required String method, + int startTime = 2_000_000, + String uri = 'https://example.com/test', +}) { + final endTime = startTime + 1000; + return HttpProfileRequest.parse({ + 'type': 'HttpProfileRequest', + 'id': id, + 'isolateId': 'isolates/test', + 'method': method, + 'uri': uri, + 'events': [], + 'startTime': startTime, + 'endTime': endTime, + 'response': { + 'startTime': startTime, + 'endTime': endTime, + 'redirects': [], + 'statusCode': 200, + }, + })!; +} + SocketProfile loadSocketProfile() { return SocketProfile( sockets: [ diff --git a/packages/devtools_app/test/screens/performance/controls/enhance_tracing_test.dart b/packages/devtools_app/test/screens/performance/controls/enhance_tracing_test.dart index e81e0c9e198..34caaac2370 100644 --- a/packages/devtools_app/test/screens/performance/controls/enhance_tracing_test.dart +++ b/packages/devtools_app/test/screens/performance/controls/enhance_tracing_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/screens/performance/controls/performance_controls_test.dart b/packages/devtools_app/test/screens/performance/controls/performance_controls_test.dart index b31e0153a67..7a281725d4f 100644 --- a/packages/devtools_app/test/screens/performance/controls/performance_controls_test.dart +++ b/packages/devtools_app/test/screens/performance/controls/performance_controls_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_chart_test.dart b/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_chart_test.dart index 0945d8321f1..920463ba37f 100644 --- a/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_chart_test.dart +++ b/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_chart_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../test_infra/test_data/performance/sample_performance_data.dart'; @@ -155,7 +155,29 @@ void main() { expect(scrollController.offset, equals(expectedOffset)); } - testWidgets('is zero for no selected frame', (WidgetTester tester) async { + void verifyScrollAtLiveEdge(WidgetTester tester) { + final scrollbar = tester.widget(find.byType(Scrollbar)); + final scrollController = scrollbar.controller!; + expect( + scrollController.offset, + equals(scrollController.position.maxScrollExtent), + ); + } + + FlutterFrame createFrame(int number) { + return FlutterFrame.fromJson({ + 'number': number, + 'startTime': 10000 + number * 50000, + 'elapsed': 20000, + 'build': 10000, + 'raster': 12000, + 'vsyncOverhead': 10, + }); + } + + testWidgets('is at live edge for no selected frame', ( + WidgetTester tester, + ) async { expect(framesController.selectedFrame.value, isNull); await pumpChart(tester); @@ -165,7 +187,39 @@ void main() { findsNWidgets(totalFramesInView), ); - verifyScrollOffset(tester, 0.0); + verifyScrollAtLiveEdge(tester); + }); + + testWidgets('stays at live edge when frames are added', ( + WidgetTester tester, + ) async { + expect(framesController.selectedFrame.value, isNull); + + await pumpChart(tester); + verifyScrollAtLiveEdge(tester); + + framesController.addFrame(createFrame(totalNumFrames)); + await tester.pumpAndSettle(); + verifyScrollAtLiveEdge(tester); + }); + + testWidgets('docks to live edge after remount with no selection', ( + WidgetTester tester, + ) async { + expect(framesController.selectedFrame.value, isNull); + + await pumpChart(tester); + verifyScrollAtLiveEdge(tester); + + // Leave Performance (dispose chart), then remount after new frames. + await tester.pumpWidget(wrap(const SizedBox.shrink())); + await tester.pumpAndSettle(); + + framesController.addFrame(createFrame(totalNumFrames)); + framesController.addFrame(createFrame(totalNumFrames + 1)); + + await pumpChart(tester); + verifyScrollAtLiveEdge(tester); }); testWidgets('is offset for selected frame', (WidgetTester tester) async { diff --git a/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_controller_test.dart b/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_controller_test.dart index 1448a26f2be..fd5a718c7a0 100644 --- a/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_controller_test.dart +++ b/packages/devtools_app/test/screens/performance/flutter_frames/flutter_frames_controller_test.dart @@ -87,6 +87,43 @@ void main() { expect(framesController.selectedFrame.value, equals(FlutterFrame4.frame)); }); + test('notifies listeners when events are assigned to selected frame', () { + when( + // ignore: discarded_futures, by design + mockTimelineEventsController.handleSelectedFrame(any), + ).thenAnswer((_) => Future.value()); + + final frame = FlutterFrame.fromJson({ + 'number': 3, + 'startTime': 40000, + 'elapsed': 20000, + 'build': 16000, + 'raster': 16000, + 'vsyncOverhead': 1000, + }); + framesController.addFrame(frame); + framesController.handleSelectedFrame(frame); + expect(frame.frameAnalysis, isNull); + + var notifyCount = 0; + framesController.selectedFrame.addListener(() => notifyCount++); + + // Assigning an event to the selected frame should notify listeners, even + // though the selected frame instance has not changed. + framesController.assignEventToFrame(frame.id, FlutterFrame2.uiEvent); + expect(notifyCount, equals(1)); + expect(framesController.selectedFrame.value, equals(frame)); + expect(frame.frameAnalysis, isNotNull); + + // Assigning an event to a frame that is not selected should not notify. + framesController.addFrame(testFrame2); + framesController.assignEventToFrame( + testFrame2.id, + FlutterFrame2.rasterEvent, + ); + expect(notifyCount, equals(1)); + }); + test('can setOfflineData', () async { // Ensure we are starting in an empty state. expect(framesController.flutterFrames.value, isEmpty); diff --git a/packages/devtools_app/test/screens/performance/frame_analysis/frame_analysis_test.dart b/packages/devtools_app/test/screens/performance/frame_analysis/frame_analysis_test.dart index 085b5ca41b8..257bfa2f070 100644 --- a/packages/devtools_app/test/screens/performance/frame_analysis/frame_analysis_test.dart +++ b/packages/devtools_app/test/screens/performance/frame_analysis/frame_analysis_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../../test_infra/matchers/matchers.dart'; import '../../../test_infra/test_data/performance/sample_performance_data.dart'; diff --git a/packages/devtools_app/test/screens/performance/frame_analysis/frame_hints_test.dart b/packages/devtools_app/test/screens/performance/frame_analysis/frame_hints_test.dart index b8a3a404db7..89cc1eafead 100644 --- a/packages/devtools_app/test/screens/performance/frame_analysis/frame_hints_test.dart +++ b/packages/devtools_app/test/screens/performance/frame_analysis/frame_hints_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../../test_infra/test_data/performance/sample_performance_data.dart'; diff --git a/packages/devtools_app/test/screens/performance/performance_controller_test.dart b/packages/devtools_app/test/screens/performance/performance_controller_test.dart index b33f7381334..d239790cf27 100644 --- a/packages/devtools_app/test/screens/performance/performance_controller_test.dart +++ b/packages/devtools_app/test/screens/performance/performance_controller_test.dart @@ -49,5 +49,17 @@ void main() { expect(controller.flutterFramesController.isActiveFeature, isTrue); expect(controller.timelineEventsController.isActiveFeature, isTrue); }); + + test('fetchFullDataBeforeExport does not pull timeline events when showing ' + 'offline data', () async { + final mockServiceManager = + mockServiceConnection.serviceManager as MockServiceManager; + clearInteractions(mockServiceManager); + + await controller.fetchFullDataBeforeExport(); + + verifyNever(mockServiceManager.service); + expect(controller.timelineEventsController.fullPerfettoTrace, isEmpty); + }); }); } diff --git a/packages/devtools_app/test/screens/performance/performance_model_test.dart b/packages/devtools_app/test/screens/performance/performance_model_test.dart index 84f1f061a98..9dc2a3f1dd0 100644 --- a/packages/devtools_app/test/screens/performance/performance_model_test.dart +++ b/packages/devtools_app/test/screens/performance/performance_model_test.dart @@ -2,7 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/shared/primitives/encoding.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../test_infra/test_data/performance/sample_performance_data.dart'; @@ -16,6 +20,26 @@ void main() { expect(offlineData.selectedFrame, isNull); expect(offlineData.rebuildCountModel, isNull); expect(offlineData.displayRefreshRate, 60.0); + expect(offlineData.selectedTab, 0); + }); + + test('isEmpty', () { + expect(OfflinePerformanceData().isEmpty, isTrue); + expect( + OfflinePerformanceData(perfettoTraceBinary: Uint8List(0)).isEmpty, + isTrue, + ); + expect( + OfflinePerformanceData( + perfettoTraceBinary: Uint8List.fromList([1, 2, 3]), + ).isEmpty, + isFalse, + ); + expect(OfflinePerformanceData(frames: [testFrame0]).isEmpty, isFalse); + expect( + OfflinePerformanceData(rebuildCountModel: RebuildCountModel()).isEmpty, + isFalse, + ); }); test('init from parse', () { @@ -32,6 +56,7 @@ void main() { expect(offlineData.selectedFrame!.id, equals(2)); expect(offlineData.displayRefreshRate, equals(60)); expect(offlineData.rebuildCountModel, isNull); + expect(offlineData.selectedTab, equals(0)); }); test('to json', () { @@ -39,16 +64,103 @@ void main() { expect( offlineData.toJson(), equals({ - OfflinePerformanceData.traceBinaryKey: null, + OfflinePerformanceData.traceBinaryBase64Key: null, OfflinePerformanceData.flutterFramesKey: [], OfflinePerformanceData.selectedFrameIdKey: null, OfflinePerformanceData.displayRefreshRateKey: 60, OfflinePerformanceData.rebuildCountModelKey: null, + OfflinePerformanceData.selectedTabKey: 0, }), ); offlineData = OfflinePerformanceData.fromJson(rawPerformanceData); - expect(offlineData.toJson(), rawPerformanceData); + final json = offlineData.toJson(); + final expectedTrace = Uint8List.fromList( + (rawPerformanceData[OfflinePerformanceData.traceBinaryKey] as List) + .cast(), + ); + expect(json.containsKey(OfflinePerformanceData.traceBinaryKey), isFalse); + expect( + Uint8List.sublistView( + json[OfflinePerformanceData.traceBinaryBase64Key] as ByteData, + ), + equals(expectedTrace), + ); + expect( + {...json}..remove(OfflinePerformanceData.traceBinaryBase64Key), + {...rawPerformanceData}..remove(OfflinePerformanceData.traceBinaryKey), + ); + }); + + group('trace binary encoding', () { + final trace = Uint8List.fromList( + List.generate(1 << 20, (i) => (i * 31) % 256), + ); + + test('round trips through JSON as a base64 string', () { + final encoded = jsonEncode( + OfflinePerformanceData(perfettoTraceBinary: trace).toJson(), + toEncodable: toEncodable, + ); + final json = jsonDecode(encoded) as Map; + expect( + json[OfflinePerformanceData.traceBinaryBase64Key], + isA(), + ); + expect( + json.containsKey(OfflinePerformanceData.traceBinaryKey), + isFalse, + ); + // Base64 is ~1.33x the trace size, versus ~3.7x for a JSON number array. + expect(encoded.length, lessThan(trace.length * 1.5)); + + final parsed = OfflinePerformanceData.fromJson(json); + expect(parsed.perfettoTraceBinary, equals(trace)); + }); + + test('only encodes the bytes in a view of a larger buffer', () { + final largerBuffer = Uint8List(trace.length * 2) + ..setRange(0, trace.length, trace); + final view = Uint8List.sublistView(largerBuffer, 0, trace.length); + final encoded = jsonEncode( + OfflinePerformanceData(perfettoTraceBinary: view).toJson(), + toEncodable: toEncodable, + ); + final parsed = OfflinePerformanceData.fromJson( + jsonDecode(encoded) as Map, + ); + expect(parsed.perfettoTraceBinary, equals(trace)); + }); + + test('loads an in-memory ByteData without encoding', () { + final parsed = OfflinePerformanceData.fromJson( + OfflinePerformanceData(perfettoTraceBinary: trace).toJson(), + ); + expect(parsed.perfettoTraceBinary, equals(trace)); + }); + + test('loads the legacy list of numbers format', () { + final parsed = OfflinePerformanceData.fromJson({ + OfflinePerformanceData.traceBinaryKey: [1, 2, 3], + }); + expect( + parsed.perfettoTraceBinary, + equals(Uint8List.fromList([1, 2, 3])), + ); + }); + + test('is empty when no trace is present', () { + expect(OfflinePerformanceData.fromJson({}).isEmpty, isTrue); + }); + }); + + test('round trips a non-zero selectedTab', () { + final offlineData = OfflinePerformanceData(selectedTab: 2); + final json = offlineData.toJson(); + expect(json[OfflinePerformanceData.selectedTabKey], equals(2)); + + final parsed = OfflinePerformanceData.fromJson(json); + expect(parsed.selectedTab, equals(2)); }); }); diff --git a/packages/devtools_app/test/screens/performance/performance_screen_test.dart b/packages/devtools_app/test/screens/performance/performance_screen_test.dart index 6f23218ee51..0cae2d4edf0 100644 --- a/packages/devtools_app/test/screens/performance/performance_screen_test.dart +++ b/packages/devtools_app/test/screens/performance/performance_screen_test.dart @@ -9,6 +9,7 @@ import 'dart:async'; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/performance/panes/controls/performance_controls.dart'; +import 'package:devtools_app/src/screens/performance/panes/rebuild_stats/rebuild_stats.dart'; import 'package:devtools_app/src/screens/performance/panes/timeline_events/timeline_events_view.dart'; import 'package:devtools_app/src/screens/performance/tabbed_performance_view.dart'; import 'package:devtools_app/src/shared/feature_flags.dart'; @@ -18,9 +19,9 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_test_utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/test_data/performance/sample_performance_data.dart'; @@ -218,7 +219,6 @@ void main() { await tester.runAsync(() async { await pumpPerformanceScreen(tester, runAsync: true); await tester.pumpAndSettle(); - final chartButtonFinder = find.byType(VisibilityButton); expect(chartButtonFinder, findsOneWidget); @@ -231,7 +231,6 @@ void main() { await tester.tap(chartButtonFinder); await tester.pumpAndSettle(); - // The flutter frames chart should no longer be visible. expect(find.byType(FramesChartControls), findsNothing); expect( @@ -395,6 +394,64 @@ void main() { }, ); }); + + group('RebuildStatsView', () { + late FakeServiceConnectionManager fakeServiceConnection; + late RebuildCountModel model; + late ValueNotifier selectedFrame; + + setUp(() { + fakeServiceConnection = FakeServiceConnectionManager(); + mockConnectedApp(fakeServiceConnection.serviceManager.connectedApp!); + setGlobal(ServiceConnectionManager, fakeServiceConnection); + setGlobal(IdeTheme, IdeTheme()); + setGlobal(NotificationService, NotificationService()); + setGlobal(BannerMessagesController, BannerMessagesController()); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(OfflineDataController, OfflineDataController()); + model = RebuildCountModel(); + selectedFrame = ValueNotifier(null); + }); + + testWidgets('shows message when running in profile mode', ( + WidgetTester tester, + ) async { + mockConnectedApp( + fakeServiceConnection.serviceManager.connectedApp!, + isProfileBuild: true, + ); + + await tester.pumpWidget( + wrapWithControllers( + RebuildStatsView(model: model, selectedFrame: selectedFrame), + ), + ); + await tester.pump(); + + expect( + find.textContaining('Widget rebuild counts are only available'), + findsOneWidget, + ); + }); + + testWidgets('shows normal UI when running in debug mode', ( + WidgetTester tester, + ) async { + mockConnectedApp(fakeServiceConnection.serviceManager.connectedApp!); + + await tester.pumpWidget( + wrapWithControllers( + RebuildStatsView(model: model, selectedFrame: selectedFrame), + ), + ); + await tester.pump(); + + expect( + find.textContaining('Widget rebuild counts are only available'), + findsNothing, + ); + }); + }); }); } diff --git a/packages/devtools_app/test/screens/performance/tabbed_performance_view_test.dart b/packages/devtools_app/test/screens/performance/tabbed_performance_view_test.dart index 559e0b17a10..76543e19d32 100644 --- a/packages/devtools_app/test/screens/performance/tabbed_performance_view_test.dart +++ b/packages/devtools_app/test/screens/performance/tabbed_performance_view_test.dart @@ -14,8 +14,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/test_data/performance/sample_performance_data.dart'; diff --git a/packages/devtools_app/test/screens/performance/timeline_events/timeline_events_controller_test.dart b/packages/devtools_app/test/screens/performance/timeline_events/timeline_events_controller_test.dart index 740325d17c5..3b9ec5ac2ee 100644 --- a/packages/devtools_app/test/screens/performance/timeline_events/timeline_events_controller_test.dart +++ b/packages/devtools_app/test/screens/performance/timeline_events/timeline_events_controller_test.dart @@ -10,6 +10,7 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; +import 'package:vm_service/vm_service.dart'; import '../../../test_infra/test_data/performance/sample_performance_data.dart'; @@ -82,6 +83,64 @@ void main() { ); }); + group('pullLatestTimelineEvents', () { + setUp(() { + setGlobal(PreferencesController, PreferencesController()); + }); + + test('adds the latest VM timeline to the trace', () async { + expect(eventsController.fullPerfettoTrace, isEmpty); + + await eventsController.pullLatestTimelineEvents(); + + expect(eventsController.fullPerfettoTrace, isNotEmpty); + }); + + test('completes without throwing when the pull fails', () async { + // This fake service throws when the VM timeline is requested because + // it was not provided any timeline data. + setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); + + await expectLater( + eventsController.pullLatestTimelineEvents(), + completes, + ); + expect(eventsController.fullPerfettoTrace, isEmpty); + }); + + test('does not request the same time range twice', () async { + final mockService = MockVmServiceWrapper(); + var currentVmTimeMicros = 0; + when(mockService.getVMTimelineMicros()).thenAnswer((_) async { + currentVmTimeMicros += 100; + return Timestamp(timestamp: currentVmTimeMicros); + }); + final requestedTimeOrigins = []; + when( + mockService.getPerfettoVMTimeline( + timeOriginMicros: anyNamed('timeOriginMicros'), + timeExtentMicros: anyNamed('timeExtentMicros'), + ), + ).thenAnswer((invocation) async { + requestedTimeOrigins.add( + invocation.namedArguments[#timeOriginMicros] as int?, + ); + return perfettoVmTimeline; + }); + final serviceConnectionWithMock = FakeServiceConnectionManager(); + serviceConnectionWithMock.serviceManager.service = mockService; + setGlobal(ServiceConnectionManager, serviceConnectionWithMock); + + // Start the second pull before the first one has finished. + await [ + eventsController.pullLatestTimelineEvents(), + eventsController.pullLatestTimelineEvents(), + ].wait; + + expect(requestedTimeOrigins, equals([0, 101])); + }); + }); + group('can clearData', () { setUp(() async { // Set some data in the controller. It does not matter if it is connected diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/object_inspector_view_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/object_inspector_view_test.dart index 81734b09128..addff7f674d 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/object_inspector_view_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/object_inspector_view_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_class_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_class_display_test.dart index 429f0400320..bc45650d3a4 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_class_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_class_display_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_code_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_code_display_test.dart index 7fdf14aa9ed..cf7139c37e4 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_code_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_code_display_test.dart @@ -12,8 +12,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_developer_common_widgets_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_developer_common_widgets_test.dart index f2fe081c20e..c874fc7753f 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_developer_common_widgets_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_developer_common_widgets_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_field_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_field_display_test.dart index 200f79aa822..63a14e081de 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_field_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_field_display_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_function_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_function_display_test.dart index b0782e9e70c..b684900a5ca 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_function_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_function_display_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_ic_data_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_ic_data_display_test.dart index 6bec829909f..b8bd8d5ef19 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_ic_data_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_ic_data_display_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_library_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_library_display_test.dart index 21cc476035d..247bde893b3 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_library_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_library_display_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_object_pool_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_object_pool_display_test.dart index d0541ed1505..7bbffc88542 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_object_pool_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_object_pool_display_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; @@ -37,21 +37,15 @@ void main() { final objectPoolEntries = [ ObjectPoolEntry( offset: 0, - kind: ObjectPoolEntryKind.object, value: InstanceRef( id: 'fake-inst', kind: InstanceKind.kList, length: 0, ), ), - const ObjectPoolEntry( - offset: 10, - kind: ObjectPoolEntryKind.immediate, - value: 42, - ), + const ObjectPoolEntry(offset: 10, value: 42), ObjectPoolEntry( offset: 20, - kind: ObjectPoolEntryKind.nativeFunction, value: FuncRef(id: 'func-id', name: 'Foo'), ), ]; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_script_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_script_display_test.dart index f23afd86da5..a8c14e10910 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_script_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_script_display_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_simple_list_display_test.dart b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_simple_list_display_test.dart index 0ce285eca09..f6d6b9cdffa 100644 --- a/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_simple_list_display_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/object_inspector/vm_simple_list_display_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/screens/vm_developer/queued_microtasks/queued_microtasks_view_test.dart b/packages/devtools_app/test/screens/vm_developer/queued_microtasks/queued_microtasks_view_test.dart index f024b87f538..6744736695d 100644 --- a/packages/devtools_app/test/screens/vm_developer/queued_microtasks/queued_microtasks_view_test.dart +++ b/packages/devtools_app/test/screens/vm_developer/queued_microtasks/queued_microtasks_view_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../vm_developer_test_utils.dart'; diff --git a/packages/devtools_app/test/service/connection_info_test.dart b/packages/devtools_app/test/service/connection_info_test.dart index 19c8a3347dd..3eb00d45787 100644 --- a/packages/devtools_app/test/service/connection_info_test.dart +++ b/packages/devtools_app/test/service/connection_info_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { diff --git a/packages/devtools_app/test/service/message_port_connection_test.dart b/packages/devtools_app/test/service/message_port_connection_test.dart new file mode 100644 index 00000000000..843fcfc7ffc --- /dev/null +++ b/packages/devtools_app/test/service/message_port_connection_test.dart @@ -0,0 +1,50 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; + +import 'package:devtools_app/src/service/message_port_connection.dart'; +import 'package:devtools_shared/devtools_shared.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('isMessagePortUri', () { + test('accepts messageport URIs', () { + expect(isMessagePortUri(Uri.parse('messageport:*')), isTrue); + expect( + isMessagePortUri(Uri.parse('messageport:https://example.com')), + isTrue, + ); + expect(isMessagePortUri(Uri.parse('MessagePort:*')), isTrue); + }); + + test('rejects other URIs', () { + expect(isMessagePortUri(Uri.parse('ws://127.0.0.1:8181/ws')), isFalse); + expect(isMessagePortUri(Uri.parse('http://127.0.0.1:8181/')), isFalse); + expect(isMessagePortUri(Uri.parse('sse://127.0.0.1:8181/')), isFalse); + expect(isMessagePortUri(Uri.parse('messageport')), isFalse); + }); + + test('survives normalizeVmServiceUri', () { + // `FrameworkCore.initVmService` normalizes the `uri` query parameter + // before checking the scheme, so the target origin must survive it. + for (final targetOrigin in ['https://example.com', '*']) { + final uri = normalizeVmServiceUri('messageport:$targetOrigin')!; + expect(isMessagePortUri(uri), isTrue); + expect(uri.path, targetOrigin); + expect(uri.toString(), 'messageport:$targetOrigin'); + } + }); + }); + + test('connectWithMessagePort is not supported outside the web', () { + expect( + () => connectWithMessagePort( + uri: Uri.parse('messageport:*'), + finishedCompleter: Completer(), + ), + throwsUnsupportedError, + ); + }, testOn: 'vm'); +} diff --git a/packages/devtools_app/test/service/message_port_connection_web_test.dart b/packages/devtools_app/test/service/message_port_connection_web_test.dart new file mode 100644 index 00000000000..7e21a7aed32 --- /dev/null +++ b/packages/devtools_app/test/service/message_port_connection_web_test.dart @@ -0,0 +1,69 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +@TestOn('browser') +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; + +import 'package:devtools_app/src/service/message_port_connection.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web/web.dart'; + +void main() { + test('connects and closes over a port posted to the parent', () async { + // The test runner loads each test suite in a same-origin iframe, so the + // test can play the page embedding DevTools. + final connectEvent = EventStreamProviders.messageEvent + .forTarget(window.parent) + .firstWhere((event) => event.source.strictEquals(window).toDart); + final finishedCompleter = Completer(); + final serviceFuture = connectWithMessagePort( + uri: Uri.parse('messageport:${window.location.origin}'), + finishedCompleter: finishedCompleter, + ); + + final message = (await connectEvent).data as _ConnectMessage; + expect(message.action, 'connect'); + // Play the VM service. DevTools only calls `getSupportedProtocols` and + // `getVersion` here. + final port = message.port; + final closedByDevTools = Completer(); + port.onmessage = (MessageEvent event) { + final data = event.data; + if (data == null) { + closedByDevTools.complete(); + return; + } + final request = + jsonDecode((data as JSString).toDart) as Map; + port.postMessage( + jsonEncode({ + 'jsonrpc': '2.0', + 'id': request['id'], + 'result': request['method'] == 'getVersion' + ? {'type': 'Version', 'major': 4, 'minor': 23} + : {'type': 'ProtocolList', 'protocols': []}, + }).toJS, + ); + }.toJS; + + final service = await serviceFuture; + expect((await service.getVersion()).major, 4); + + // Either side posts `null` to close the connection. + port.postMessage(null); + await closedByDevTools.future; + expect(finishedCompleter.isCompleted, isTrue); + }); +} + +/// The message that DevTools posts to `window.parent`. +extension type _ConnectMessage._(JSObject _) implements JSObject { + external String get action; + + external MessagePort get port; +} diff --git a/packages/devtools_app/test/service/service_extension_widgets_test.dart b/packages/devtools_app/test/service/service_extension_widgets_test.dart index e100a754e81..236a5cafc4c 100644 --- a/packages/devtools_app/test/service/service_extension_widgets_test.dart +++ b/packages/devtools_app/test/service/service_extension_widgets_test.dart @@ -17,8 +17,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; void main() { @@ -222,6 +222,62 @@ void main() { expect(toggle.value, false, reason: 'The extension is disabled.'); }); }); + + group('ServiceExtensionCheckbox', () { + testWidgets('shows value state even when unavailable', ( + WidgetTester tester, + ) async { + final ext = extensions.disableClipLayers; + final customFake = _UnavailableServiceExtensionManager(); + customFake.makeUnavailable(ext.extension); + + // Override the serviceExtensionManager on mockServiceManager + when(mockServiceManager.serviceExtensionManager).thenReturn(customFake); + + // Set state to enabled: false (which means clips not disabled -> checked true) + await customFake.setServiceExtensionState( + ext.extension, + enabled: false, + value: false, + ); + + final checkbox = ServiceExtensionCheckbox(serviceExtension: ext); + await tester.pumpWidget(wrap(Scaffold(body: Center(child: checkbox)))); + await tester.pumpAndSettle(); + + final checkboxFinder = find.byType(Checkbox); + expect(checkboxFinder, findsOneWidget); + + final checkboxWidget = tester.widget(checkboxFinder); + expect(checkboxWidget.value, isTrue); + expect(checkboxWidget.onChanged, isNull); + }); + }); +} + +base class _UnavailableServiceExtensionManager + extends FakeServiceExtensionManager { + final _unavailableExtensions = {}; + + void makeUnavailable(String name) { + _unavailableExtensions.add(name); + } + + @override + Future waitForServiceExtensionAvailable(String name) { + if (_unavailableExtensions.contains(name)) { + return Future.value(false); + } + return super.waitForServiceExtensionAvailable(name); + } + + @override + bool isServiceExtensionAvailable(String name) { + if (_unavailableExtensions.contains(name)) { + return false; + } + return super.isServiceExtensionAvailable(name); + } } void registerServiceExtension( diff --git a/packages/devtools_app/test/service/timeline_streams_test.dart b/packages/devtools_app/test/service/timeline_streams_test.dart index 7c345297ab2..c51353c007c 100644 --- a/packages/devtools_app/test/service/timeline_streams_test.dart +++ b/packages/devtools_app/test/service/timeline_streams_test.dart @@ -22,30 +22,23 @@ void main() { await env.tearDownEnvironment(force: true); }); - test( - 'timeline streams initialized on vm service opened', - () async { - await env.setupEnvironment(); + test('timeline streams initialized on vm service opened', () async { + await env.setupEnvironment(); - // Await a short delay to make sure the timelineStreamManager is done - // initializing. - await delay(); + // Await a short delay to make sure the timelineStreamManager is done + // initializing. + await delay(); - expect(serviceConnection.serviceManager.service, equals(env.service)); - expect(serviceConnection.timelineStreamManager, isNotNull); - expect( - serviceConnection.timelineStreamManager.basicStreams, - isNotEmpty, - ); - expect( - serviceConnection.timelineStreamManager.advancedStreams, - isNotEmpty, - ); + expect(serviceConnection.serviceManager.service, equals(env.service)); + expect(serviceConnection.timelineStreamManager, isNotNull); + expect(serviceConnection.timelineStreamManager.basicStreams, isNotEmpty); + expect( + serviceConnection.timelineStreamManager.advancedStreams, + isNotEmpty, + ); - await env.tearDownEnvironment(); - }, - timeout: const Timeout.factor(4), - ); + await env.tearDownEnvironment(); + }, timeout: const Timeout.factor(4)); test('notifies on stream change', () async { await env.setupEnvironment(); diff --git a/packages/devtools_app/test/shared/analytics/analytics_prompt_test.dart b/packages/devtools_app/test/shared/analytics/analytics_prompt_test.dart index b73d7d5777c..3a1f0ed2e71 100644 --- a/packages/devtools_app/test/shared/analytics/analytics_prompt_test.dart +++ b/packages/devtools_app/test/shared/analytics/analytics_prompt_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:provider/provider.dart'; import 'package:unified_analytics/src/constants.dart' as ua; @@ -19,7 +19,6 @@ const windowSize = Size(2000.0, 1000.0); void main() { late AnalyticsController controller; - late bool didCallEnableAnalytics; late bool didMarkConsentMessageAsShown; Widget wrapWithAnalytics( @@ -61,7 +60,6 @@ void main() { group('AnalyticsPrompt', () { setUp(() { - didCallEnableAnalytics = false; didMarkConsentMessageAsShown = false; setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); setGlobal(IdeTheme, IdeTheme()); @@ -71,13 +69,9 @@ void main() { group('with analytics enabled', () { group('on first run', () { setUp(() { - didCallEnableAnalytics = false; controller = TestAnalyticsController( enabled: true, shouldShowConsentMessage: true, - legacyOnEnableAnalytics: () { - didCallEnableAnalytics = true; - }, onMarkConsentMessageAsShown: () { didMarkConsentMessageAsShown = true; }, @@ -90,11 +84,6 @@ void main() { windowSize, (WidgetTester tester) async { expect(controller.analyticsEnabled.value, isTrue); - expect( - didCallEnableAnalytics, - isTrue, - reason: 'Analytics is enabled on first run', - ); expect(didMarkConsentMessageAsShown, isFalse); final prompt = wrapWithAnalytics( const AnalyticsPrompt(child: Text('Child Text')), @@ -130,9 +119,6 @@ void main() { controller = AnalyticsController( enabled: true, shouldShowConsentMessage: false, - legacyOnEnableAnalytics: () { - didCallEnableAnalytics = true; - }, consentMessage: 'fake message', ); }); @@ -142,7 +128,6 @@ void main() { windowSize, (WidgetTester tester) async { expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isFalse); final prompt = wrapWithAnalytics( const AnalyticsPrompt(child: Text('Child Text')), ); @@ -153,7 +138,6 @@ void main() { findsNothing, ); expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isFalse); }, ); @@ -189,9 +173,6 @@ void main() { controller = AnalyticsController( enabled: false, shouldShowConsentMessage: true, - legacyOnEnableAnalytics: () { - didCallEnableAnalytics = true; - }, consentMessage: 'fake message', ); }); @@ -201,7 +182,6 @@ void main() { windowSize, (WidgetTester tester) async { expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isTrue); final prompt = wrapWithAnalytics( const AnalyticsPrompt(child: Text('Child Text')), ); @@ -212,7 +192,6 @@ void main() { findsOneWidget, ); expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isTrue); }, ); @@ -229,7 +208,6 @@ void main() { windowSize, (WidgetTester tester) async { expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isTrue); final prompt = wrapWithAnalytics( const AnalyticsPrompt(child: Text('Child Text')), ); @@ -240,7 +218,6 @@ void main() { findsOneWidget, ); expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isTrue); final closeButtonFinder = find.byType(IconButton); expect(closeButtonFinder, findsOneWidget); @@ -259,7 +236,6 @@ void main() { windowSize, (WidgetTester tester) async { expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isTrue); final prompt = wrapWithAnalytics( const AnalyticsPrompt(child: Text('Child Text')), ); @@ -270,7 +246,6 @@ void main() { findsOneWidget, ); expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isTrue); final soundsGoodFinder = find.text('Sounds good!'); expect(soundsGoodFinder, findsOneWidget); @@ -289,7 +264,6 @@ void main() { windowSize, (WidgetTester tester) async { expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isTrue); final prompt = wrapWithAnalytics( const AnalyticsPrompt(child: Text('Child Text')), ); @@ -300,7 +274,6 @@ void main() { findsOneWidget, ); expect(controller.analyticsEnabled.value, isTrue); - expect(didCallEnableAnalytics, isTrue); final noThanksFinder = find.text('No thanks'); expect(noThanksFinder, findsOneWidget); @@ -320,9 +293,6 @@ void main() { controller = AnalyticsController( enabled: false, shouldShowConsentMessage: false, - legacyOnEnableAnalytics: () { - didCallEnableAnalytics = true; - }, consentMessage: 'fake message', ); }); @@ -332,7 +302,6 @@ void main() { windowSize, (WidgetTester tester) async { expect(controller.analyticsEnabled.value, isFalse); - expect(didCallEnableAnalytics, isFalse); final prompt = wrapWithAnalytics( const AnalyticsPrompt(child: Text('Child Text')), ); @@ -343,7 +312,6 @@ void main() { findsNothing, ); expect(controller.analyticsEnabled.value, isFalse); - expect(didCallEnableAnalytics, isFalse); }, ); @@ -380,9 +348,6 @@ class TestAnalyticsController extends AnalyticsController { required super.enabled, required super.shouldShowConsentMessage, required super.consentMessage, - super.legacyOnEnableAnalytics, - super.legacyOnDisableAnalytics, - super.legacyOnSetupAnalytics, this.onMarkConsentMessageAsShown, }); diff --git a/packages/devtools_app/test/shared/ansi_output_test.dart b/packages/devtools_app/test/shared/ansi_output_test.dart index 639593cdf9d..1903fa05cca 100644 --- a/packages/devtools_app/test/shared/ansi_output_test.dart +++ b/packages/devtools_app/test/shared/ansi_output_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app/src/shared/primitives/ansi_utils.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../test_infra/utils/ansi.dart'; @@ -315,3 +315,37 @@ void main() { ); }); } + +extension on StyledText { + bool get dim => (textStyle & StyledText.kDim) == StyledText.kDim; + bool get italic => (textStyle & StyledText.kItalic) == StyledText.kItalic; + bool get underline => + (textStyle & StyledText.kUnderline) == StyledText.kUnderline; + bool get strikethrough => + (textStyle & StyledText.kStrikethrough) == StyledText.kStrikethrough; + bool get blink => (textStyle & StyledText.kBlink) == StyledText.kBlink; + bool get reverse => (textStyle & StyledText.kReverse) == StyledText.kReverse; + bool get invisible => + (textStyle & StyledText.kInvisible) == StyledText.kInvisible; + + bool get hasStyling => textStyle != 0 || fgColor != null || bgColor != null; + + @visibleForTesting + String get describeStyle { + String hex(int value) => value.toRadixString(16).padLeft(2, '0'); + String color(List rgb) => '${hex(rgb[0])}${hex(rgb[2])}${hex(rgb[2])}'; + + return [ + if (bgColor != null) 'background #${color(bgColor!)}', + if (fgColor != null) 'color #${color(fgColor!)}', + if (bold) 'bold', + if (dim) 'dim', + if (italic) 'italic', + if (underline) 'underline', + if (strikethrough) 'strikethrough', + if (blink) 'blink', + if (reverse) 'reverse', + if (invisible) 'invisible', + ].join(', '); + } +} diff --git a/packages/devtools_app/test/shared/charts/flame_chart_test.dart b/packages/devtools_app/test/shared/charts/flame_chart_test.dart index e250a77aab5..391566414a3 100644 --- a/packages/devtools_app/test/shared/charts/flame_chart_test.dart +++ b/packages/devtools_app/test/shared/charts/flame_chart_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/screens/profiler/cpu_profile_transformer.dart'; import 'package:devtools_app/src/screens/profiler/cpu_profiler_controller.dart'; import 'package:devtools_app/src/screens/profiler/panes/cpu_flame_chart.dart'; import 'package:devtools_app/src/shared/charts/flame_chart.dart'; @@ -11,9 +12,9 @@ import 'package:devtools_app/src/shared/ui/utils.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/test_data/cpu_profiler/cpu_profile.dart'; @@ -624,6 +625,56 @@ void main() { ); }); }); + + testWidgets('re-lays out elements when container width changes', ( + WidgetTester tester, + ) async { + final transformer = CpuProfileTransformer(); + final cpuProfileData = CpuProfileData.fromJson(cpuProfileResponseJson); + await tester.runAsync(() async { + await transformer.processData(cpuProfileData, processId: 'test'); + }); + + flameChart = CpuProfileFlameChart( + data: cpuProfileData, + width: 1000.0, + height: 1000.0, + selectionNotifier: ValueNotifier(null), + searchMatchesNotifier: controller.searchMatches, + activeSearchMatchNotifier: controller.activeSearchMatch, + onDataSelected: (_) {}, + ); + + await pumpFlameChart(tester); + expect(find.byWidget(flameChart), findsOneWidget); + final FlameChartState state = tester.state(find.byWidget(flameChart)); + + // Find first row that has nodes. + final rowWithNodes = state.rows.firstWhere((row) => row.nodes.isNotEmpty); + final node = rowWithNodes.nodes[0]; + final initialNodeWidth = node.rect.width; + expect(initialNodeWidth, greaterThan(0.0)); + + // Update the flame chart with a new width. + flameChart = CpuProfileFlameChart( + data: cpuProfileData, + width: 2000.0, + height: 1000.0, + selectionNotifier: ValueNotifier(null), + searchMatchesNotifier: controller.searchMatches, + activeSearchMatchNotifier: controller.activeSearchMatch, + onDataSelected: (_) {}, + ); + await pumpFlameChart(tester); + await tester.pumpAndSettle(); + + final updatedRowWithNodes = state.rows.firstWhere( + (row) => row.nodes.isNotEmpty, + ); + final updatedNode = updatedRowWithNodes.nodes[0]; + expect(updatedNode.rect.width, isNot(equals(initialNodeWidth))); + expect(updatedNode.rect.width, greaterThan(initialNodeWidth)); + }); }); group('ScrollingFlameChartRow', () { diff --git a/packages/devtools_app/test/shared/charts/treemap_test.dart b/packages/devtools_app/test/shared/charts/treemap_test.dart index 65805f87548..2324a985448 100644 --- a/packages/devtools_app/test/shared/charts/treemap_test.dart +++ b/packages/devtools_app/test/shared/charts/treemap_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:convert'; +import 'dart:ui'; import 'package:devtools_app/src/service/service_manager.dart'; import 'package:devtools_app/src/shared/charts/treemap.dart'; @@ -11,8 +12,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_snapshot_analysis/treemap.dart'; import '../../test_infra/matchers/matchers.dart'; @@ -29,7 +30,11 @@ void main() { } // Pump treemap widget with tree built with test data. - Future pumpTreemapWidget(WidgetTester tester, Key treemapKey) async { + Future pumpTreemapWidget( + WidgetTester tester, + Key treemapKey, { + bool isDarkTheme = false, + }) async { await tester.pumpWidget( wrap( LayoutBuilder( @@ -45,6 +50,7 @@ void main() { ); }, ), + isDarkTheme: isDarkTheme, ), ); await tester.pumpAndSettle(); @@ -221,6 +227,52 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 2)); }, skip: kIsWeb); }); + + group('Treemap tooltip text color', () { + setUp(() { + root = TreemapNode(name: 'root') + ..addChild( + TreemapNode(name: 'parent', byteSize: 1000) + ..addChild(TreemapNode(name: 'leaf_1', byteSize: 500)) + ..addChild(TreemapNode(name: 'leaf_2', byteSize: 500)), + ); + }); + for (final isDarkTheme in [false, true]) { + final themeName = isDarkTheme ? 'dark' : 'light'; + testWidgetsWithWindowSize( + 'tooltip has correct text color in $themeName theme', + windowSize, + (WidgetTester tester) async { + const treemapKey = Key('Treemap'); + await pumpTreemapWidget(tester, treemapKey, isDarkTheme: isDarkTheme); + + final tooltipFinder = find.byWidgetPredicate( + (widget) => widget is DevToolsTooltip && widget.richMessage != null, + ); + final customPaint = find.descendant( + of: tooltipFinder, + matching: find.byType(CustomPaint), + ); + + // Hover over the painted cell. + final gesture = await tester.createGesture( + kind: PointerDeviceKind.mouse, + ); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(customPaint)); + await tester.pumpAndSettle(); + + final tooltip = tester.widget(tooltipFinder); + final textSpan = tooltip.richMessage as TextSpan?; + final expectedColor = isDarkTheme ? Colors.black : Colors.white; + expect(textSpan, isNotNull); + expect(textSpan!.text, isNotEmpty); + expect(textSpan.style?.color, equals(expectedColor)); + }, + ); + } + }); } Future _loadSnapshotJsonAsTree(String snapshotJson) async { diff --git a/packages/devtools_app/test/shared/diagnostics/inspector_service_test.dart b/packages/devtools_app/test/shared/diagnostics/inspector_service_test.dart index 63657deee0c..655860a62a0 100644 --- a/packages/devtools_app/test/shared/diagnostics/inspector_service_test.dart +++ b/packages/devtools_app/test/shared/diagnostics/inspector_service_test.dart @@ -7,8 +7,6 @@ @TestOn('vm') library; -import 'dart:async'; - import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; @@ -26,6 +24,7 @@ void main() { final env = FlutterTestEnvironment( const FlutterRunConfiguration(withDebugger: true), testAppDirectory: 'test/test_infra/fixtures/inspector_app', + useTempDirectory: true, ); InspectorService? inspectorService; @@ -46,7 +45,10 @@ void main() { try { group('inspector service tests', () { tearDown(env.tearDownEnvironment); - tearDownAll(() => unawaited(env.tearDownEnvironment(force: true))); + tearDownAll(() async { + await env.tearDownEnvironment(force: true); + env.finalTeardown(); + }); test('track widget creation on', () async { await env.setupEnvironment(); diff --git a/packages/devtools_app/test/shared/eval_integration_test.dart b/packages/devtools_app/test/shared/eval_integration_test.dart index 36be55665f8..ccc6c7caa79 100644 --- a/packages/devtools_app/test/shared/eval_integration_test.dart +++ b/packages/devtools_app/test/shared/eval_integration_test.dart @@ -5,7 +5,6 @@ import 'package:devtools_app/src/shared/globals.dart'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:devtools_test/helpers.dart'; import 'package:flutter_test/flutter_test.dart'; import '../test_infra/flutter_test_driver.dart'; @@ -14,6 +13,7 @@ import '../test_infra/flutter_test_environment.dart'; void main() { final env = FlutterTestEnvironment( const FlutterRunConfiguration(withDebugger: true), + useTempDirectory: true, ); late Disposable isAlive; @@ -22,9 +22,13 @@ void main() { isAlive = Disposable(); }); - tearDown(() async { + tearDown(() { isAlive.dispose(); + }); + + tearDownAll(() async { await env.tearDownEnvironment(force: true); + env.finalTeardown(); }); group('EvalOnDartLibrary', () { @@ -66,39 +70,58 @@ void main() { expect(instance2.classRef!.name, '_Future'); }, timeout: const Timeout.factor(2), - // TODO(https://github.com/flutter/devtools/issues/6998): if this flake - // is addressed, we can unskip this for the Flutter customer tests. - tags: skipForCustomerTestsTag, ); - test( - 'returns the result of the future completion', - () async { - await env.setupEnvironment(); - final mainIsolate = - serviceConnection.serviceManager.isolateManager.mainIsolate; - expect(mainIsolate, isNotNull); - - final eval = EvalOnDartLibrary( - 'dart:core', - serviceConnection.serviceManager.service!, - serviceManager: serviceConnection.serviceManager, - isolate: mainIsolate, + test('returns the result of the future completion', () async { + await env.setupEnvironment(); + final mainIsolate = + serviceConnection.serviceManager.isolateManager.mainIsolate; + + final eval = EvalOnDartLibrary( + 'dart:core', + serviceConnection.serviceManager.service!, + serviceManager: serviceConnection.serviceManager, + isolate: mainIsolate, + ); + + final instance = (await eval.asyncEval( + // The delay asserts that there is no issue with garbage collection + 'await Future.delayed(const Duration(milliseconds: 500), () => 42)', + isAlive: isAlive, + ))!; + + expect(instance.valueAsString, '42'); + }, timeout: const Timeout.factor(2)); + + test('survives garbage collection while the future is pending', () async { + await env.setupEnvironment(); + final mainIsolate = + serviceConnection.serviceManager.isolateManager.mainIsolate; + + final eval = EvalOnDartLibrary( + 'dart:core', + serviceConnection.serviceManager.service!, + serviceManager: serviceConnection.serviceManager, + isolate: mainIsolate, + ); + + final evalFuture = eval.asyncEval( + 'await Future.delayed(const Duration(milliseconds: 500), () => 42)', + isAlive: isAlive, + ); + + // Force garbage collection in the target isolate while the future is pending. + for (var i = 0; i < 3; i++) { + await Future.delayed(const Duration(milliseconds: 100)); + await serviceConnection.serviceManager.service!.getAllocationProfile( + mainIsolate.value!.id!, + gc: true, ); + } - final instance = (await eval.asyncEval( - // The delay asserts that there is no issue with garbage collection - 'await Future.delayed(const Duration(milliseconds: 500), () => 42)', - isAlive: isAlive, - ))!; - - expect(instance.valueAsString, '42'); - }, - timeout: const Timeout.factor(2), - // TODO(https://github.com/flutter/devtools/issues/6998): if this flake - // is addressed, we can unskip this for the Flutter customer tests. - tags: skipForCustomerTestsTag, - ); + final instance = (await evalFuture)!; + expect(instance.valueAsString, '42'); + }, timeout: const Timeout.factor(2)); test( 'throws FutureFailedException when the future is rejected', @@ -111,17 +134,18 @@ void main() { serviceManager: serviceConnection.serviceManager, ); - final instance = await eval - .asyncEval( - 'await Future.error(StateError("foo"), StackTrace.current)', - isAlive: isAlive, - ) - .then( - (_) => throw Exception( - 'The FutureFailedException was not thrown as expected.', - ), - onError: (Object? err) => err, - ); + late final FutureFailedException instance; + try { + await eval.asyncEval( + 'await Future.error(StateError("foo"), StackTrace.current)', + isAlive: isAlive, + ); + throw Exception( + 'The FutureFailedException was not thrown as expected.', + ); + } on FutureFailedException catch (e) { + instance = e; + } expect( instance.expression, @@ -146,9 +170,6 @@ void main() { expect(error.valueAsString, 'foo'); }, timeout: const Timeout.factor(2), - // TODO(https://github.com/flutter/devtools/issues/6998): if this flake - // is addressed, we can unskip this for the Flutter customer tests. - tags: skipForCustomerTestsTag, ); }); }); diff --git a/packages/devtools_app/test/shared/framework/routing_test.dart b/packages/devtools_app/test/shared/framework/routing_test.dart index 5e74c370d09..29085ef6a68 100644 --- a/packages/devtools_app/test/shared/framework/routing_test.dart +++ b/packages/devtools_app/test/shared/framework/routing_test.dart @@ -9,8 +9,8 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; class TestController extends DisposableController with RouteStateHandlerMixin { int count = 0; diff --git a/packages/devtools_app/test/shared/framework/screens_test.dart b/packages/devtools_app/test/shared/framework/screens_test.dart index 14bf1d36f9e..d1483bb107d 100644 --- a/packages/devtools_app/test/shared/framework/screens_test.dart +++ b/packages/devtools_app/test/shared/framework/screens_test.dart @@ -10,8 +10,8 @@ import 'package:devtools_app/src/shared/primitives/query_parameters.dart'; import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { setUp(() { diff --git a/packages/devtools_app/test/shared/framework/visible_screens_test.dart b/packages/devtools_app/test/shared/framework/visible_screens_test.dart index 4cc7425d5cd..cb904698f66 100644 --- a/packages/devtools_app/test/shared/framework/visible_screens_test.dart +++ b/packages/devtools_app/test/shared/framework/visible_screens_test.dart @@ -4,6 +4,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/shared/framework/framework_controller.dart'; +import 'package:devtools_app_shared/service_extensions.dart' as extensions; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:devtools_test/devtools_test.dart'; @@ -86,7 +87,7 @@ void main() { NetworkScreen, LoggingScreen, AppSizeScreen, - DeepLinksScreen, + // DeepLinksScreen, // VMDeveloperToolsScreen, // DTDToolsScreen, ]), @@ -166,6 +167,37 @@ void main() { ); }); + testWidgets('explain unavailable evaluation in an unattached debug app', ( + WidgetTester tester, + ) async { + // Failed expression evaluation makes the build-mode heuristic report + // profile mode, even though debug-only extensions are registered. + setupMockConnectedApp(flutter: true, debugMode: false); + final extensionManager = + fakeServiceConnection.serviceManager.serviceExtensionManager; + // The extension may still be pending its first frame. + await extensionManager.fakeAddServiceExtension( + extensions.debugAllowBanner.extension, + ); + + final result = shouldShowScreen(DebuggerScreen()); + expect(result.show, isFalse); + expect(result.disabledReason?.message, contains('flutter attach')); + expect( + result.disabledReason?.message, + isNot(contains('only supports debug')), + ); + }); + + testWidgets('keep the profile-build explanation for a real profile app', ( + WidgetTester tester, + ) async { + setupMockConnectedApp(flutter: true, debugMode: false); + final result = shouldShowScreen(DebuggerScreen()); + expect(result.show, isFalse); + expect(result.disabledReason?.message, 'only supports debug builds.'); + }); + testWidgets('are correct for Flutter (non-web) profile app', ( WidgetTester tester, ) async { @@ -369,7 +401,7 @@ void main() { NetworkScreen, LoggingScreen, AppSizeScreen, - DeepLinksScreen, + // DeepLinksScreen, VMDeveloperToolsScreen, DTDToolsScreen, ]), diff --git a/packages/devtools_app/test/shared/http/http_request_data_test.dart b/packages/devtools_app/test/shared/http/http_request_data_test.dart new file mode 100644 index 00000000000..ba09d7f1c5b --- /dev/null +++ b/packages/devtools_app/test/shared/http/http_request_data_test.dart @@ -0,0 +1,95 @@ +import 'package:devtools_app/src/shared/http/http_request_data.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('responseBytes', () { + Map baseJson(Map responseHeaders) { + return { + 'isolateId': 'isolate-1', + 'id': 'request-1', + 'method': 'GET', + 'uri': 'https://example.com', + 'events': [], + 'startTime': DateTime.now().microsecondsSinceEpoch, + 'endTime': DateTime.now().microsecondsSinceEpoch, + 'request': { + 'headers': {}, + 'connectionInfo': null, + 'contentLength': null, + 'cookies': [], + 'followRedirects': true, + 'maxRedirects': 5, + 'persistentConnection': true, + }, + 'response': { + 'headers': responseHeaders, + 'connectionInfo': null, + 'contentLength': null, + 'cookies': [], + 'compressionState': 'ResponseBodyCompressionState.notCompressed', + 'isRedirect': false, + 'persistentConnection': true, + 'reasonPhrase': 'OK', + 'redirects': >[], + 'statusCode': 200, + 'startTime': DateTime.now().microsecondsSinceEpoch, + }, + }; + } + + // Verifies parsing when content-length is a string value. + test('parses content-length from string', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({'content-length': '1234'}), + null, + null, + ); + + expect(request.responseBytes, 1234); + }); + + // Verifies parsing when content-length is a list of strings. + test('parses content-length from list of strings', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({ + 'content-length': ['5678'], + }), + null, + null, + ); + + expect(request.responseBytes, 5678); + }); + + // Ensures integer values inside a list are handled correctly. + test('handles integer in list', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({ + 'content-length': [91011], + }), + null, + null, + ); + + expect(request.responseBytes, 91011); + }); + + // Returns null when header is missing. + test('returns null for missing header', () { + final request = DartIOHttpRequestData.fromJson(baseJson({}), null, null); + + expect(request.responseBytes, null); + }); + + // Returns null when parsing fails. + test('returns null for invalid value', () { + final request = DartIOHttpRequestData.fromJson( + baseJson({'content-length': 'invalid'}), + null, + null, + ); + + expect(request.responseBytes, null); + }); + }); +} diff --git a/packages/devtools_app/test/shared/http/http_utils_test.dart b/packages/devtools_app/test/shared/http/http_utils_test.dart new file mode 100644 index 00000000000..94853734b4d --- /dev/null +++ b/packages/devtools_app/test/shared/http/http_utils_test.dart @@ -0,0 +1,19 @@ +import 'package:devtools_app/src/screens/network/utils/http_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('formatBytes', () { + // Verifies correct formatting across different unit ranges. + test('formats bytes correctly', () { + expect(formatBytes(512), '512 B'); // bytes + expect(formatBytes(2000), '2.0 kB'); // kilobytes (base-10) + expect(formatBytes(1000000), '1.0 MB'); // megabytes (base-10) + }); + + // Ensures handling of invalid or missing values. + test('handles null and negative values', () { + expect(formatBytes(null), '-'); + expect(formatBytes(-1), '-'); + }); + }); +} diff --git a/packages/devtools_app/test/shared/import_export_test.dart b/packages/devtools_app/test/shared/import_export_test.dart index 409531bfd3a..c723bb89e42 100644 --- a/packages/devtools_app/test/shared/import_export_test.dart +++ b/packages/devtools_app/test/shared/import_export_test.dart @@ -88,6 +88,46 @@ void main() { equals(attemptingToImportMessage('example')), ); }); + + test('importData pushes notification when activeScreenId is missing', () { + importController.importData(devToolsFileJsonWithoutActiveScreenId); + expect(notifications.activeMessages.length, equals(1)); + expect( + notifications.activeMessages.first.text, + equals( + 'The imported file is not a valid DevTools snapshot because it does ' + 'not contain an activeScreenId field.', + ), + ); + }); + + test( + 'importData pushes notification when activeScreenId is not a String', + () { + importController.importData( + devToolsFileJsonWithNonStringActiveScreenId, + ); + expect(notifications.activeMessages.length, equals(1)); + expect( + notifications.activeMessages.first.text, + equals( + 'The imported file is not a valid DevTools snapshot because it does ' + 'not contain an activeScreenId field.', + ), + ); + }, + ); + + test('importData pushes notification when screen data is missing', () { + importController.importData(devToolsFileJsonWithoutScreenData); + expect(notifications.activeMessages.length, equals(1)); + expect( + notifications.activeMessages.first.text, + equals( + 'The imported file does not contain data for screen \'example\'.', + ), + ); + }); }); } @@ -110,3 +150,28 @@ final devToolsFileJson = DevToolsJsonFile( 'example': {'title': 'example custom tools'}, }, ); +final devToolsFileJsonWithoutActiveScreenId = DevToolsJsonFile( + name: 'devToolsFileJsonWithoutActiveScreenId', + lastModifiedTime: DateTime.fromMicrosecondsSinceEpoch(3000), + data: { + 'devToolsSnapshot': true, + 'example': {'title': 'example custom tools'}, + }, +); +final devToolsFileJsonWithNonStringActiveScreenId = DevToolsJsonFile( + name: 'devToolsFileJsonWithNonStringActiveScreenId', + lastModifiedTime: DateTime.fromMicrosecondsSinceEpoch(3500), + data: { + 'devToolsSnapshot': true, + 'activeScreenId': 12345, + 'example': {'title': 'example custom tools'}, + }, +); +final devToolsFileJsonWithoutScreenData = DevToolsJsonFile( + name: 'devToolsFileJsonWithoutScreenData', + lastModifiedTime: DateTime.fromMicrosecondsSinceEpoch(4000), + data: { + 'devToolsSnapshot': true, + 'activeScreenId': 'example', + }, +); diff --git a/packages/devtools_app/test/shared/managers/banner_messages_test.dart b/packages/devtools_app/test/shared/managers/banner_messages_test.dart index dedf7e206cb..6f7467143c5 100644 --- a/packages/devtools_app/test/shared/managers/banner_messages_test.dart +++ b/packages/devtools_app/test/shared/managers/banner_messages_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { late FakeServiceConnectionManager fakeServiceConnection; diff --git a/packages/devtools_app/test/shared/managers/error_badge_manager_test.dart b/packages/devtools_app/test/shared/managers/error_badge_manager_test.dart index 69b74f5b25a..bbd3d15cd83 100644 --- a/packages/devtools_app/test/shared/managers/error_badge_manager_test.dart +++ b/packages/devtools_app/test/shared/managers/error_badge_manager_test.dart @@ -4,7 +4,7 @@ import 'package:devtools_app/src/screens/app_size/app_size_screen.dart'; import 'package:devtools_app/src/screens/debugger/debugger_screen.dart'; -import 'package:devtools_app/src/screens/inspector_shared/inspector_screen.dart'; +import 'package:devtools_app/src/screens/inspector/inspector_screen.dart'; import 'package:devtools_app/src/screens/logging/logging_screen.dart'; import 'package:devtools_app/src/screens/memory/framework/memory_screen.dart'; import 'package:devtools_app/src/screens/network/network_screen.dart'; @@ -13,7 +13,7 @@ import 'package:devtools_app/src/screens/profiler/profiler_screen.dart'; import 'package:devtools_app/src/shared/managers/error_badge_manager.dart'; import 'package:flutter_test/flutter_test.dart'; -final supportedScreenIds = [ +final screensWithBadgeCounts = [ InspectorScreen.id, PerformanceScreen.id, NetworkScreen.id, @@ -34,9 +34,6 @@ void main() { late ErrorBadgeManager errorBadgeManager; group('ErrorBadgeManager', () { - int getActiveErrorCount(screenId) => - errorBadgeManager.erroredItemsForPage(screenId).value.entries.length; - setUp(() { errorBadgeManager = ErrorBadgeManager(); }); @@ -55,7 +52,7 @@ void main() { allScreenIds.forEach(errorBadgeManager.incrementBadgeCount); for (final id in allScreenIds) { - if (supportedScreenIds.contains(id)) { + if (screensWithBadgeCounts.contains(id)) { expect(errorBadgeManager.errorCountNotifier(id).value, equals(1)); } else { expect(errorBadgeManager.errorCountNotifier(id).value, equals(0)); @@ -63,11 +60,36 @@ void main() { } }); + test('decrementBadgeCount decrements supported tabs', () { + // First increment + allScreenIds.forEach(errorBadgeManager.incrementBadgeCount); + + for (final id in screensWithBadgeCounts) { + expect(errorBadgeManager.errorCountNotifier(id).value, equals(1)); + } + + // Then decrement + allScreenIds.forEach(errorBadgeManager.decrementBadgeCount); + + for (final id in screensWithBadgeCounts) { + expect(errorBadgeManager.errorCountNotifier(id).value, equals(0)); + } + }); + + test('decrementBadgeCount does not go below zero', () { + // Decrement without any prior increment + errorBadgeManager.decrementBadgeCount(InspectorScreen.id); + expect( + errorBadgeManager.errorCountNotifier(InspectorScreen.id).value, + equals(0), + ); + }); + test('clearErrorCount resets counts', () { allScreenIds.forEach(errorBadgeManager.incrementBadgeCount); for (final id in allScreenIds) { - if (supportedScreenIds.contains(id)) { + if (screensWithBadgeCounts.contains(id)) { expect(errorBadgeManager.errorCountNotifier(id).value, equals(1)); } else { expect(errorBadgeManager.errorCountNotifier(id).value, equals(0)); @@ -81,44 +103,23 @@ void main() { } }); - // TODO(https://github.com/flutter/devtools/issues/9105): This logic should - // be moved to the inspector. - test('appendError works for inspector screen only', () { - for (final id in allScreenIds) { - errorBadgeManager.appendError(id, DevToolsError('An error', id)); - } - - for (final id in allScreenIds) { - if (id == InspectorScreen.id) { - expect(getActiveErrorCount(id), equals(1)); - } else { - expect(getActiveErrorCount(id), equals(0)); - } - } - }); - - test('clearErrors resets counts and removes errors', () { - expect(getActiveErrorCount(InspectorScreen.id), equals(0)); + test('clearErrorCount is a no-op for manageErrorCount screens', () { + errorBadgeManager.manageErrorCount(InspectorScreen.id); + errorBadgeManager.incrementBadgeCount(InspectorScreen.id); expect( errorBadgeManager.errorCountNotifier(InspectorScreen.id).value, - equals(0), - ); - - errorBadgeManager.appendError( - InspectorScreen.id, - DevToolsError('An error', InspectorScreen.id), + equals(1), ); - errorBadgeManager.incrementBadgeCount(InspectorScreen.id); - expect(getActiveErrorCount(InspectorScreen.id), equals(1)); + // Simulates scaffold clearing the badge on tab navigation. + errorBadgeManager.clearErrorCount(InspectorScreen.id); expect( errorBadgeManager.errorCountNotifier(InspectorScreen.id).value, equals(1), ); - errorBadgeManager.clearErrors(InspectorScreen.id); - - expect(getActiveErrorCount(InspectorScreen.id), equals(0)); + // Controllers that own unread state can still reset explicitly. + errorBadgeManager.resetErrorCount(InspectorScreen.id); expect( errorBadgeManager.errorCountNotifier(InspectorScreen.id).value, equals(0), diff --git a/packages/devtools_app/test/shared/managers/survey_test.dart b/packages/devtools_app/test/shared/managers/survey_test.dart index eee3fe8ab58..44a5e05ed92 100644 --- a/packages/devtools_app/test/shared/managers/survey_test.dart +++ b/packages/devtools_app/test/shared/managers/survey_test.dart @@ -4,6 +4,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/shared/managers/survey.dart'; +import 'package:devtools_app/src/shared/primitives/query_parameters.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -135,6 +136,291 @@ void main() { expect(vsCodeOnlySurvey.meetsEnvironmentRequirement, isTrue); expect(intelliJSurvey.meetsEnvironmentRequirement, isFalse); }); + + group('meetsEnvironmentRequirementFor', () { + final vsCodeSurvey = DevToolsSurvey.fromJson({ + 'devEnvironments': ['VSCode'], + }); + final intelliJSurvey = DevToolsSurvey.fromJson({ + 'devEnvironments': ['Android-Studio', 'IntelliJ-IDEA'], + }); + final vsCodeDashToolSurvey = DevToolsSurvey.fromJson({ + 'devEnvironments': ['vscode-plugins'], + }); + final intelliJDashToolSurvey = DevToolsSurvey.fromJson({ + 'devEnvironments': ['intellij-plugins'], + }); + final cliSurvey = DevToolsSurvey.fromJson({ + 'devEnvironments': ['CLI'], + }); + + bool meetsRequirement( + DevToolsSurvey survey, { + required String ideLaunched, + Map queryParams = const {}, + }) => survey.meetsEnvironmentRequirementFor( + ideLaunched: ideLaunched, + queryParams: DevToolsQueryParams(queryParams), + ); + + test('matches the legacy ide value', () { + // IDE integrations that predate the dashTool parameter. + const vsCodeParams = {DevToolsQueryParams.ideKey: 'VSCode'}; + expect( + meetsRequirement( + vsCodeSurvey, + ideLaunched: 'VSCode', + queryParams: vsCodeParams, + ), + isTrue, + ); + expect( + meetsRequirement( + intelliJSurvey, + ideLaunched: 'VSCode', + queryParams: vsCodeParams, + ), + isFalse, + ); + + const androidStudioParams = { + DevToolsQueryParams.ideKey: 'Android-Studio', + }; + expect( + meetsRequirement( + intelliJSurvey, + ideLaunched: 'Android-Studio', + queryParams: androidStudioParams, + ), + isTrue, + ); + expect( + meetsRequirement( + vsCodeSurvey, + ideLaunched: 'Android-Studio', + queryParams: androidStudioParams, + ), + isFalse, + ); + }); + + test('matches the dashTool', () { + const vsCodeParams = { + DevToolsQueryParams.ideKey: 'VSCode', + DevToolsQueryParams.dashToolKey: 'vscode-plugins', + DevToolsQueryParams.dashIdeNameKey: 'Visual Studio Code', + }; + expect( + meetsRequirement( + vsCodeDashToolSurvey, + ideLaunched: 'VSCode', + queryParams: vsCodeParams, + ), + isTrue, + ); + expect( + meetsRequirement( + intelliJDashToolSurvey, + ideLaunched: 'VSCode', + queryParams: vsCodeParams, + ), + isFalse, + ); + + // Android Studio and IntelliJ share the same dashTool. + const androidStudioParams = { + DevToolsQueryParams.ideKey: 'Android-Studio', + DevToolsQueryParams.dashToolKey: 'intellij-plugins', + DevToolsQueryParams.dashIdeNameKey: 'Android Studio', + }; + expect( + meetsRequirement( + intelliJDashToolSurvey, + ideLaunched: 'Android-Studio', + queryParams: androidStudioParams, + ), + isTrue, + ); + expect( + meetsRequirement( + vsCodeDashToolSurvey, + ideLaunched: 'Android-Studio', + queryParams: androidStudioParams, + ), + isFalse, + ); + }); + + test( + 'matches dashTool environments when only the legacy ide value is passed', + () { + const vsCodeParams = {DevToolsQueryParams.ideKey: 'VSCode'}; + expect( + meetsRequirement( + vsCodeDashToolSurvey, + ideLaunched: 'VSCode', + queryParams: vsCodeParams, + ), + isTrue, + ); + expect( + meetsRequirement( + intelliJDashToolSurvey, + ideLaunched: 'VSCode', + queryParams: vsCodeParams, + ), + isFalse, + ); + + for (final ide in ['Android-Studio', 'IntelliJ-IDEA']) { + final params = {DevToolsQueryParams.ideKey: ide}; + expect( + meetsRequirement( + intelliJDashToolSurvey, + ideLaunched: ide, + queryParams: params, + ), + isTrue, + reason: ide, + ); + expect( + meetsRequirement( + vsCodeDashToolSurvey, + ideLaunched: ide, + queryParams: params, + ), + isFalse, + reason: ide, + ); + } + }, + ); + + test('matches VS Code forks for the legacy VSCode environment', () { + // A fork does not have to pass 'VSCode' as its legacy ide value. + const cursorParams = { + DevToolsQueryParams.ideKey: 'Cursor', + DevToolsQueryParams.dashToolKey: 'vscode-plugins', + DevToolsQueryParams.dashIdeNameKey: 'Cursor', + }; + expect( + meetsRequirement( + vsCodeSurvey, + ideLaunched: 'Cursor', + queryParams: cursorParams, + ), + isTrue, + ); + expect( + meetsRequirement( + intelliJSurvey, + ideLaunched: 'Cursor', + queryParams: cursorParams, + ), + isFalse, + ); + + // Once IDE integrations stop passing the legacy ide parameter, the + // launch environment defaults to 'CLI'. + const dashToolOnlyParams = { + DevToolsQueryParams.dashToolKey: 'vscode-plugins', + }; + expect( + meetsRequirement( + vsCodeSurvey, + ideLaunched: 'CLI', + queryParams: dashToolOnlyParams, + ), + isTrue, + ); + expect( + meetsRequirement( + intelliJSurvey, + ideLaunched: 'CLI', + queryParams: dashToolOnlyParams, + ), + isFalse, + ); + expect( + meetsRequirement( + cliSurvey, + ideLaunched: 'CLI', + queryParams: dashToolOnlyParams, + ), + isFalse, + ); + + for (final dashTool in [ + 'intellij-plugins', + 'android-studio-plugins', + ]) { + final intelliJDashToolOnlyParams = { + DevToolsQueryParams.dashToolKey: dashTool, + }; + expect( + meetsRequirement( + intelliJSurvey, + ideLaunched: 'CLI', + queryParams: intelliJDashToolOnlyParams, + ), + isTrue, + reason: dashTool, + ); + expect( + meetsRequirement( + vsCodeSurvey, + ideLaunched: 'CLI', + queryParams: intelliJDashToolOnlyParams, + ), + isFalse, + reason: dashTool, + ); + expect( + meetsRequirement( + cliSurvey, + ideLaunched: 'CLI', + queryParams: intelliJDashToolOnlyParams, + ), + isFalse, + reason: dashTool, + ); + } + }); + + test('does not match other environments', () { + for (final survey in [ + vsCodeSurvey, + intelliJSurvey, + vsCodeDashToolSurvey, + intelliJDashToolSurvey, + ]) { + expect(meetsRequirement(survey, ideLaunched: 'CLI'), isFalse); + } + expect(meetsRequirement(cliSurvey, ideLaunched: 'CLI'), isTrue); + expect( + meetsRequirement( + cliSurvey, + ideLaunched: 'CLI', + queryParams: {DevToolsQueryParams.dashToolKey: 'flutter-tool'}, + ), + isTrue, + ); + }); + + test('matches any environment when none is specified or known', () { + final anyEnvironmentSurvey = DevToolsSurvey.fromJson({}); + expect( + meetsRequirement(anyEnvironmentSurvey, ideLaunched: 'CLI'), + isTrue, + ); + + // The launch environment is not known until the analytics + // dimensions are computed. + for (final survey in [vsCodeSurvey, vsCodeDashToolSurvey]) { + expect(meetsRequirement(survey, ideLaunched: ''), isTrue); + } + }); + }); }); }); } diff --git a/packages/devtools_app/test/shared/offline/offline_data_test.dart b/packages/devtools_app/test/shared/offline/offline_data_test.dart new file mode 100644 index 00000000000..d0f08a810ba --- /dev/null +++ b/packages/devtools_app/test/shared/offline/offline_data_test.dart @@ -0,0 +1,178 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/shared/config_specific/import_export/import_export.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _TestScreenController extends DevToolsScreenController + with + AutoDisposeControllerMixin, + OfflineScreenControllerMixin> { + Map? loadedData; + + @override + String get screenId => 'test_screen'; + + Future initOfflineData( + String screenId, { + bool shouldLoad = true, + bool throwOnLoad = false, + }) { + return maybeLoadOfflineData( + screenId, + createData: (json) { + if (json.containsKey('throw')) { + throw Exception('Corrupted data'); + } + return json; + }, + shouldLoad: (data) => shouldLoad && data.isNotEmpty, + loadData: (data) { + if (throwOnLoad) { + throw Exception('Failed during loadData'); + } + loadedData = data; + }, + ); + } + + @override + OfflineScreenData prepareOfflineScreenData() => + OfflineScreenData(screenId: 'test_screen', data: loadedData ?? {}); +} + +void main() { + group('OfflineScreenControllerMixin', () { + late _TestScreenController controller; + late NotificationService notifications; + + setUp(() { + notifications = NotificationService(); + setGlobal(NotificationService, notifications); + setGlobal(OfflineDataController, OfflineDataController()); + setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); + controller = _TestScreenController(); + }); + + test('loads offline data when data is valid and non-empty', () async { + offlineDataController + ..startShowingOfflineData(offlineApp: MockConnectedApp()) + ..offlineDataJson = { + DevToolsExportKeys.activeScreenId.name: 'test_screen', + 'test_screen': {'key': 'value'}, + }; + + final result = await controller.initOfflineData('test_screen'); + expect(result, isTrue); + expect(controller.loadedData, equals({'key': 'value'})); + expect(notifications.activeMessages, isEmpty); + }); + + test('notifies when screen data is empty', () async { + offlineDataController + ..startShowingOfflineData(offlineApp: MockConnectedApp()) + ..offlineDataJson = { + DevToolsExportKeys.activeScreenId.name: 'test_screen', + 'test_screen': {}, + }; + + final result = await controller.initOfflineData('test_screen'); + expect(result, isFalse); + expect(controller.loadedData, isNull); + expect(notifications.activeMessages.length, equals(1)); + expect( + notifications.activeMessages.first.text, + equals( + 'The imported file does not contain any data for screen \'test_screen\'.', + ), + ); + }); + + test('notifies when shouldLoad returns false', () async { + offlineDataController + ..startShowingOfflineData(offlineApp: MockConnectedApp()) + ..offlineDataJson = { + DevToolsExportKeys.activeScreenId.name: 'test_screen', + 'test_screen': {'key': 'value'}, + }; + + final result = await controller.initOfflineData( + 'test_screen', + shouldLoad: false, + ); + expect(result, isFalse); + expect(controller.loadedData, isNull); + expect(notifications.activeMessages.length, equals(1)); + expect( + notifications.activeMessages.first.text, + equals( + 'The imported file does not contain any data for screen \'test_screen\'.', + ), + ); + }); + + test('notifies when creating/loading data throws an error', () async { + offlineDataController + ..startShowingOfflineData(offlineApp: MockConnectedApp()) + ..offlineDataJson = { + DevToolsExportKeys.activeScreenId.name: 'test_screen', + 'test_screen': {'throw': true}, + }; + + final result = await controller.initOfflineData('test_screen'); + expect(result, isFalse); + expect(controller.loadedData, isNull); + expect(notifications.activeMessages.length, equals(1)); + expect( + notifications.activeMessages.first.text, + contains('Failed to load offline data for screen \'test_screen\':'), + ); + }); + + test( + 'resets loadingOfflineData and notifies when loadData throws', + () async { + offlineDataController + ..startShowingOfflineData(offlineApp: MockConnectedApp()) + ..offlineDataJson = { + DevToolsExportKeys.activeScreenId.name: 'test_screen', + 'test_screen': {'key': 'value'}, + }; + + expect(controller.loadingOfflineData.value, isFalse); + final result = await controller.initOfflineData( + 'test_screen', + throwOnLoad: true, + ); + expect(result, isFalse); + expect(controller.loadingOfflineData.value, isFalse); + expect(notifications.activeMessages.length, equals(1)); + expect( + notifications.activeMessages.first.text, + contains('Failed to load offline data for screen \'test_screen\':'), + ); + }, + ); + + test( + 'returns false without notifying if screen is not in offlineDataJson', + () async { + offlineDataController + ..startShowingOfflineData(offlineApp: MockConnectedApp()) + ..offlineDataJson = { + DevToolsExportKeys.activeScreenId.name: 'other_screen', + 'other_screen': {'key': 'value'}, + }; + + final result = await controller.initOfflineData('test_screen'); + expect(result, isFalse); + expect(controller.loadedData, isNull); + expect(notifications.activeMessages, isEmpty); + }, + ); + }); +} diff --git a/packages/devtools_app/test/shared/primitives/encoding_test.dart b/packages/devtools_app/test/shared/primitives/encoding_test.dart index cae53d1f12f..edfabe623ac 100644 --- a/packages/devtools_app/test/shared/primitives/encoding_test.dart +++ b/packages/devtools_app/test/shared/primitives/encoding_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:convert'; +import 'dart:typed_data'; import 'package:devtools_app/src/shared/primitives/encoding.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -22,6 +23,16 @@ void main() { expect(decoded.objects.length, graph.objects.length); }); + test('$ByteDataEncodeDecode only encodes the bytes in the view', () { + final buffer = Uint8List.fromList([9, 1, 2, 3, 9]); + final view = ByteData.sublistView(buffer, 1, 4); + final encodeDecode = ByteDataEncodeDecode.instance; + + final decoded = encodeDecode.decode(encodeDecode.toEncodable(view)); + + expect(Uint8List.sublistView(decoded), [1, 2, 3]); + }); + test('$DateTimeEncodeDecode', () { final date = DateTime.now(); final encodeDecode = DateTimeEncodeDecode.instance; diff --git a/packages/devtools_app/test/shared/primitives/feature_flags_test.dart b/packages/devtools_app/test/shared/primitives/feature_flags_test.dart index 826708d5cec..959e78a5cf5 100644 --- a/packages/devtools_app/test/shared/primitives/feature_flags_test.dart +++ b/packages/devtools_app/test/shared/primitives/feature_flags_test.dart @@ -17,11 +17,8 @@ void main() { expect(enableBeta, false); expect(isExternalBuild, true); expect(FeatureFlags.memorySaveLoad.isEnabled, false); - expect(FeatureFlags.networkSaveLoad.isEnabled, true); expect(FeatureFlags.devToolsExtensions.isEnabled, isExternalBuild); expect(FeatureFlags.dapDebugging.isEnabled, false); - expect(FeatureFlags.inspectorV2.isEnabled, true); - expect(FeatureFlags.aiAssistant.isEnabled, false); }); group('FlutterChannelFeatureFlag', () { diff --git a/packages/devtools_app/test/shared/primitives/linked_scroll_controller_test.dart b/packages/devtools_app/test/shared/primitives/linked_scroll_controller_test.dart index 13f004632ee..aa182b2758b 100644 --- a/packages/devtools_app/test/shared/primitives/linked_scroll_controller_test.dart +++ b/packages/devtools_app/test/shared/primitives/linked_scroll_controller_test.dart @@ -5,8 +5,8 @@ import 'dart:async'; import 'package:devtools_app/src/shared/primitives/flutter_widgets/linked_scroll_controller.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; // This file was originally forked from package:flutter_widgets. Note that the // source may diverge over time. @@ -345,6 +345,13 @@ class TestState extends State { _numbers = _controllers.addAndGet(); } + @override + void dispose() { + _letters.dispose(); + _numbers.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Directionality( diff --git a/packages/devtools_app/test/shared/primitives/query_parameters_test.dart b/packages/devtools_app/test/shared/primitives/query_parameters_test.dart index 9f70cf598d4..1d926a24178 100644 --- a/packages/devtools_app/test/shared/primitives/query_parameters_test.dart +++ b/packages/devtools_app/test/shared/primitives/query_parameters_test.dart @@ -24,6 +24,8 @@ void main() { AppSizeApi.testAppSizeFilePropertyName: '/path/to/test/file.json', DevToolsQueryParams.ideKey: 'Android-Studio', DevToolsQueryParams.ideFeatureKey: 'onDebugAutomatic', + DevToolsQueryParams.dashToolKey: 'intellij-plugins', + DevToolsQueryParams.dashIdeNameKey: 'Android Studio', DevToolsQueryParams.legacyPageKey: 'memory', // IdeThemeQueryParams values 'embedMode': 'one', @@ -47,6 +49,10 @@ void main() { expect(params.appSizeTestFilePath, '/path/to/test/file.json'); expect(params.ide, 'Android-Studio'); expect(params.ideFeature, 'onDebugAutomatic'); + expect(params.dashTool, 'intellij-plugins'); + expect(params.dashIdeName, 'Android Studio'); + expect(params.ideDisplayName, 'Android Studio'); + expect(params.isVsCode, false); expect(params.legacyPage, 'memory'); expect(params.ideThemeParams.params, isNotEmpty); expect(params.embedMode, EmbedMode.embedOne); @@ -68,6 +74,10 @@ void main() { expect(params.appSizeTestFilePath, isNull); expect(params.ide, isNull); expect(params.ideFeature, isNull); + expect(params.dashTool, isNull); + expect(params.dashIdeName, isNull); + expect(params.ideDisplayName, isNull); + expect(params.isVsCode, false); expect(params.legacyPage, isNull); expect(params.ideThemeParams.params, isEmpty); expect(params.embedMode, EmbedMode.none); @@ -83,6 +93,8 @@ void main() { AppSizeApi.testAppSizeFilePropertyName: '/path/to/test/file.json', DevToolsQueryParams.ideKey: 'Android-Studio', DevToolsQueryParams.ideFeatureKey: 'onDebugAutomatic', + DevToolsQueryParams.dashToolKey: 'intellij-plugins', + DevToolsQueryParams.dashIdeNameKey: 'Android Studio', DevToolsQueryParams.legacyPageKey: 'memory', // IdeThemeQueryParams values 'embedMode': 'one', @@ -99,6 +111,8 @@ void main() { expect(params.appSizeTestFilePath, '/path/to/test/file.json'); expect(params.ide, 'Android-Studio'); expect(params.ideFeature, 'onDebugAutomatic'); + expect(params.dashTool, 'intellij-plugins'); + expect(params.dashIdeName, 'Android Studio'); expect(params.legacyPage, 'memory'); expect(params.ideThemeParams.params, isNotEmpty); expect(params.embedMode, EmbedMode.embedOne); @@ -125,6 +139,8 @@ void main() { expect(params.appSizeTestFilePath, '/path/to/test/file.json'); expect(params.ide, 'Android-Studio'); expect(params.ideFeature, 'onDebugAutomatic'); + expect(params.dashTool, 'intellij-plugins'); + expect(params.dashIdeName, 'Android Studio'); expect(params.legacyPage, 'memory'); expect(params.ideThemeParams.params, isNotEmpty); expect(params.embedMode, EmbedMode.embedMany); @@ -156,5 +172,100 @@ void main() { ); } }); + + test('parses the dashTool and dashIdeName that IDEs pass in the URL', () { + // Dart-Code percent-encodes spaces, and flutter-intellij encodes them as + // '+'. Both should be decoded. + final vsCodeParams = DevToolsQueryParams.fromUrl( + 'http://localhost:9101/#/inspector?ide=VSCode&dashTool=vscode-plugins' + '&dashIdeName=Visual%20Studio%20Code', + ); + expect(vsCodeParams.ide, 'VSCode'); + expect(vsCodeParams.dashTool, 'vscode-plugins'); + expect(vsCodeParams.dashIdeName, 'Visual Studio Code'); + + final intelliJParams = DevToolsQueryParams.fromUrl( + 'http://localhost:9101/?ide=Android-Studio&dashTool=intellij-plugins' + '&dashIdeName=Android+Studio', + ); + expect(intelliJParams.ide, 'Android-Studio'); + expect(intelliJParams.dashTool, 'intellij-plugins'); + expect(intelliJParams.dashIdeName, 'Android Studio'); + }); + + group('isVsCode', () { + test('is true for the vscode-plugins dashTool', () { + final params = DevToolsQueryParams({ + DevToolsQueryParams.dashToolKey: 'vscode-plugins', + DevToolsQueryParams.dashIdeNameKey: 'Visual Studio Code', + }); + expect(params.isVsCode, true); + }); + + test('is true for VS Code forks, which share the dashTool', () { + final params = DevToolsQueryParams({ + // A fork does not have to use the legacy 'VSCode' ide value. + DevToolsQueryParams.ideKey: 'Cursor', + DevToolsQueryParams.dashToolKey: 'vscode-plugins', + DevToolsQueryParams.dashIdeNameKey: 'Cursor', + }); + expect(params.isVsCode, true); + }); + + test('falls back to the legacy ide parameter', () { + final params = DevToolsQueryParams({ + DevToolsQueryParams.ideKey: 'VSCode', + }); + expect(params.isVsCode, true); + }); + + test('is false for IntelliJ and Android Studio', () { + // Both pass the intellij-plugins dashTool, and are told apart by their + // dashIdeName. + for (final (ide, dashIdeName) in const [ + ('IntelliJ-IDEA', 'IntelliJ Community'), + ('Android-Studio', 'Android Studio'), + ]) { + final params = DevToolsQueryParams({ + DevToolsQueryParams.ideKey: ide, + DevToolsQueryParams.dashToolKey: 'intellij-plugins', + DevToolsQueryParams.dashIdeNameKey: dashIdeName, + }); + expect(params.isVsCode, false, reason: dashIdeName); + } + }); + + test('is false for IDEs that only pass the legacy ide parameter', () { + for (final ide in ['IntelliJ-IDEA', 'Android-Studio']) { + final params = DevToolsQueryParams({DevToolsQueryParams.ideKey: ide}); + expect(params.isVsCode, false, reason: ide); + } + }); + + test('is false when DevTools is not launched from an IDE', () { + expect(DevToolsQueryParams.empty().isVsCode, false); + }); + }); + + group('ideDisplayName', () { + test('prefers dashIdeName, which identifies VS Code forks', () { + final params = DevToolsQueryParams({ + DevToolsQueryParams.ideKey: 'VSCode', + DevToolsQueryParams.dashIdeNameKey: 'Cursor', + }); + expect(params.ideDisplayName, 'Cursor'); + }); + + test('falls back to the legacy ide parameter', () { + final params = DevToolsQueryParams({ + DevToolsQueryParams.ideKey: 'Android-Studio', + }); + expect(params.ideDisplayName, 'Android-Studio'); + }); + + test('is null when DevTools is not launched from an IDE', () { + expect(DevToolsQueryParams.empty().ideDisplayName, isNull); + }); + }); }); } diff --git a/packages/devtools_app/test/shared/primitives/utils_test.dart b/packages/devtools_app/test/shared/primitives/utils_test.dart index 3fe14a5dd13..3a48931f28f 100644 --- a/packages/devtools_app/test/shared/primitives/utils_test.dart +++ b/packages/devtools_app/test/shared/primitives/utils_test.dart @@ -5,12 +5,65 @@ import 'package:collection/collection.dart'; import 'package:devtools_app/src/shared/primitives/utils.dart'; import 'package:devtools_shared/devtools_test_utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { group('utils', () { + group('textSpansFromAnsi', () { + const defaultStyle = TextStyle(); + // Theme surface colors used by DevTools (see devtools_app_shared). + const lightSurface = Color(0xFFFFFFFF); + const darkSurface = Color(0xFF1B1B1F); + + test('adjusts low-contrast foreground for a light background', () { + // ansi-white is invisible on a white background. + final spans = textSpansFromAnsi( + '\x1B[37mhello\x1B[0m', + defaultStyle, + backgroundColor: lightSurface, + ); + final color = spans.single.style!.color!; + expect(color.computeLuminance(), lessThan(1.0)); + expect(color, isNot(equals(lightSurface))); + }); + + test('adjusts low-contrast foreground for a dark background', () { + // ansi-black is invisible on a near-black background. + final spans = textSpansFromAnsi( + '\x1B[30mhello\x1B[0m', + defaultStyle, + backgroundColor: darkSurface, + ); + final color = spans.single.style!.color!; + expect( + color.computeLuminance(), + greaterThan(darkSurface.computeLuminance()), + ); + }); + + test('keeps colors that already have sufficient contrast', () { + // ansi-red is legible on a white background and must be unchanged. + final spans = textSpansFromAnsi( + '\x1B[31mhello\x1B[0m', + defaultStyle, + backgroundColor: lightSurface, + ); + expect( + spans.single.style!.color, + equals(const Color.fromRGBO(187, 0, 0, 1)), + ); + }); + + test('does not adjust colors when no background is provided', () { + final spans = textSpansFromAnsi('\x1B[37mhello\x1B[0m', defaultStyle); + expect( + spans.single.style!.color, + equals(const Color.fromRGBO(255, 255, 255, 1)), + ); + }); + }); group('durationText', () { test('infers unit based on duration', () { expect(durationText(Duration.zero), equals('0 μs')); @@ -158,20 +211,6 @@ void main() { }); }); - test('nullSafeMin', () { - expect(nullSafeMin(1, 2), equals(1)); - expect(nullSafeMin(1, null), equals(1)); - expect(nullSafeMin(null, 2), equals(2)); - expect(nullSafeMin(null, null), equals(null)); - }); - - test('nullSafeMin', () { - expect(nullSafeMax(1, 2), equals(2)); - expect(nullSafeMax(1, null), equals(1)); - expect(nullSafeMax(null, 2), equals(2)); - expect(nullSafeMax(null, null), equals(null)); - }); - test('log2', () { expect(log2(1), equals(0)); expect(log2(1.5), equals(0)); @@ -189,47 +228,6 @@ void main() { expect(roundToNearestPow10(6581), equals(10000)); }); - test('executeWithDelay', () async { - const delayMs = 500; - int n = 1; - int start = DateTime.now().millisecondsSinceEpoch; - int? end; - - // Condition n >= 2 is false, so we should execute with a delay. - executeWithDelay(const Duration(milliseconds: 500), () { - n++; - end = DateTime.now().millisecondsSinceEpoch; - }, executeNow: n >= 2); - - expect(n, equals(1)); - expect(end, isNull); - await Future.delayed(const Duration(milliseconds: 250)); - expect(n, equals(1)); - expect(end, isNull); - await Future.delayed(const Duration(milliseconds: 250)); - expect(n, equals(2)); - expect(end, isNotNull); - - // 1000ms is arbitrary. We want to ensure it doesn't run in less time than - // we requested (checked above), but we don't want to be too strict because - // shared CI CPUs can be slow. - const epsilonMs = 1000; - expect((end! - start - delayMs).abs(), lessThan(epsilonMs)); - - // Condition n >= 2 is true, so we should not execute with a delay. - end = null; - start = DateTime.now().millisecondsSinceEpoch; - executeWithDelay(const Duration(milliseconds: 500), () { - n++; - end = DateTime.now().millisecondsSinceEpoch; - }, executeNow: true); - expect(n, equals(3)); - expect(end, isNotNull); - // 400ms is arbitrary. It is less than 500, which is what matters. This - // can be increased if this test starts to flake. - expect(end! - start, lessThan(400)); - }); - test('timeout', () async { int value = 0; Future operation() async { @@ -337,107 +335,6 @@ void main() { expect(formatDateTime(DateTime(2020, 1, 16, 13)), '13:00:00.000'); }); - test('longestFittingSubstring', () { - const asciiStr = 'ComponentElement.performRebuild'; - const nonAsciiStr = 'ԪElement.updateChildԪ'; - num slowMeasureCallback(_) => 100; - - expect( - longestFittingSubstring( - asciiStr, - 0, - asciiMeasurements, - slowMeasureCallback, - ), - equals(''), - ); - expect( - longestFittingSubstring( - asciiStr, - 50, - asciiMeasurements, - slowMeasureCallback, - ), - equals('Compo'), - ); - expect( - longestFittingSubstring( - asciiStr, - 224, - asciiMeasurements, - slowMeasureCallback, - ), - equals('ComponentElement.performRebuild'), - ); - expect( - longestFittingSubstring( - asciiStr, - 300, - asciiMeasurements, - slowMeasureCallback, - ), - equals('ComponentElement.performRebuild'), - ); - - expect(nonAsciiStr.codeUnitAt(0), greaterThanOrEqualTo(128)); - expect( - longestFittingSubstring( - nonAsciiStr, - 99, - asciiMeasurements, - slowMeasureCallback, - ), - equals(''), - ); - expect( - longestFittingSubstring( - nonAsciiStr, - 100, - asciiMeasurements, - slowMeasureCallback, - ), - equals('Ԫ'), - ); - expect( - longestFittingSubstring( - nonAsciiStr, - 230, - asciiMeasurements, - slowMeasureCallback, - ), - equals('ԪElement.updateChild'), - ); - expect( - longestFittingSubstring( - nonAsciiStr, - 329, - asciiMeasurements, - slowMeasureCallback, - ), - equals('ԪElement.updateChild'), - ); - expect( - longestFittingSubstring( - nonAsciiStr, - 330, - asciiMeasurements, - slowMeasureCallback, - ), - equals('ԪElement.updateChildԪ'), - ); - }); - - test('isLetter', () { - expect(isLetter('@'.codeUnitAt(0)), isFalse); - expect(isLetter('['.codeUnitAt(0)), isFalse); - expect(isLetter('`'.codeUnitAt(0)), isFalse); - expect(isLetter('{'.codeUnitAt(0)), isFalse); - expect(isLetter('A'.codeUnitAt(0)), isTrue); - expect(isLetter('Z'.codeUnitAt(0)), isTrue); - expect(isLetter('a'.codeUnitAt(0)), isTrue); - expect(isLetter('z'.codeUnitAt(0)), isTrue); - }); - test('getSimpleStackFrameName', () { String name = '_WidgetsFlutterBinding&BindingBase&GestureBinding&ServicesBinding&' @@ -509,89 +406,6 @@ void main() { }); }); - group('Reporter', () { - int called = 0; - late Reporter reporter; - void call() { - called++; - } - - setUp(() { - called = 0; - reporter = Reporter(); - }); - test('notifies listeners', () { - expect(reporter.hasListeners, false); - reporter.addListener(call); - expect(called, 0); - expect(reporter.hasListeners, true); - reporter.notify(); - expect(called, 1); - reporter.notify(); - reporter.notify(); - expect(called, 3); - reporter.removeListener(call); - expect(called, 3); - }); - - test('notifies multiple listeners', () { - reporter.addListener(() => called++); - reporter.addListener(() => called++); - reporter.addListener(() => called++); - reporter.notify(); - expect(called, 3); - // Note that because we passed in anonymous callbacks, there's no way - // to remove them. - }); - - test('deduplicates listeners', () { - reporter.addListener(call); - reporter.addListener(call); - reporter.notify(); - expect(called, 1); - reporter.removeListener(call); - reporter.notify(); - expect(called, 1); - }); - - test('safely removes multiple times', () { - reporter.removeListener(call); - reporter.addListener(call); - reporter.notify(); - expect(called, 1); - reporter.removeListener(call); - reporter.removeListener(call); - reporter.notify(); - expect(called, 1); - }); - }); - - group('ValueReporter', () { - int called = 0; - void call() { - called++; - } - - late ValueReporter reporter; - setUp(() { - reporter = ValueReporter(null); - }); - test('notifies listeners', () { - expect(reporter.hasListeners, false); - reporter.addListener(call); - expect(called, 0); - expect(reporter.hasListeners, true); - reporter.value = 'first call'; - expect(called, 1); - reporter.value = 'second call'; - reporter.value = 'third call'; - expect(called, 3); - reporter.removeListener(call); - reporter.value = 'fourth call'; - expect(called, 3); - }); - }); - group('SafeListOperations', () { test('safeFirst', () { final list = []; @@ -667,506 +481,207 @@ void main() { }); }); - group('MovingAverage', () { - const simpleDataSet = [ - 100, - 200, - 300, - 500, - 1000, - 2000, - 3000, - 4000, - 10000, - 100000, - ]; - - /// Data only has spikes. - const memorySizeDataSet = [ - 190432640, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 201045160, - 198200392, - 200144872, - 210110632, - 234077984, - 229029504, - 229029544, - 231396416, - 240465152, - 303434344, // Spike @ [25] (clear) - 302925712, - 356093472, - 354292096, - 400654120, - 400538848, - 402336872, - 444325760, - 444933104, - 341888120, - 406070376, - 343798216, - 392421072, - 392441080, - 481891656, - 481447920, - 433271776, - 464727280, - 494727280, - 564727280, - 524727280, - 534727280, - 564727280, - 764727280, // Spike @ [48] - 964727280, // Spike @ [49] - 1064727280, // Spike @ [50] - 1464727280, // Spike @ [51] - 2264727280, // Spike @ [52] - 2500000000, // Spike @ [53] - ]; - - /// Data has 5 spikes and 3 dips. - const dipsSpikesDataSet = [ - 190432640, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 190443808, - 5500000, // Dips @ [12] - 5600000, - 7443808, - 9043808, - 11045160, - 49800392, // Spikes @ [17] - 60144872, - 210110632, // Spikes @ [19] - 234077984, - 229029504, - 229029544, - 194000000, - 80000000, // Dips @ [24] - 100000000, - 150000000, - 240465152, // Spike @ [27] - 303434344, - 302925712, - 356093472, - 354292096, - 400654120, - 400538848, - 402336872, - 444325760, - 444933104, - 341888120, - 406070376, - 343798216, - 392421072, - 392441080, - 481891656, - 3000000, // Dips @ [43] - 3100000, - 3200000, - 330000000, // Spike @ [46] - 330000000, - 330000000, - 340000000, - 340000000, - 340000000, - 964727280, - 1064727280, - 1464727280, - 2264727280, // Spike @ [52] - 2500000000, - ]; - - void checkNewItemsAddedToDataSet(MovingAverage mA) { - mA.add(1000000); - mA.add(2000000); - mA.add(3000000); - expect(mA.dataSet.length, lessThan(mA.averagePeriod)); - expect(mA.mean.toInt(), equals(470853)); - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - } - - test('basic MA', () { - // Creation of MovingAverage statically. - final simpleMA = MovingAverage(newDataSet: simpleDataSet); - expect(simpleMA.dataSet.length, lessThan(simpleMA.averagePeriod)); - expect(simpleMA.mean.toInt(), equals(12110)); - checkNewItemsAddedToDataSet(simpleMA); - - simpleMA.clear(); - expect(simpleMA.mean, equals(0)); - - // Dynamically add data to MovingAverage data set. - for (int i = 0; i < simpleDataSet.length; i++) { - simpleMA.add(simpleDataSet[i]); - } - // Should be identical to static one from above. - expect(simpleMA.mean.toInt(), equals(12110)); - checkNewItemsAddedToDataSet(simpleMA); - }); - - test('normal static MA', () { - // Creation of MovingAverage statically. - final mA = MovingAverage(newDataSet: memorySizeDataSet); - // Mean only calculated on last averagePeriod entries (50 default). - expect(mA.mean.toInt(), equals(462271799)); - expect(mA.dataSet.length, equals(mA.averagePeriod)); - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - - mA.clear(); - expect(mA.mean, equals(0)); - expect(mA.dataSet.length, equals(0)); + group('ListExtension', () { + test('joinWith generates correct list', () { + expect([1, 2, 3, 4].joinWith(0), equals([1, 0, 2, 0, 3, 0, 4])); + expect([1].joinWith(0), equals([1])); + expect(['a', 'b'].joinWith('z'), equals(['a', 'z', 'b'])); }); + }); - test('dynamic spikes MA', () { - final mA = MovingAverage(); - - // Dynamically add data to MovingAverage data set. - for (int i = 0; i < 20; i++) { - mA.add(memorySizeDataSet[i]); - expect(mA.hasSpike(), isFalse); - expect(mA.isDipping(), isFalse); - } - expect(mA.mean.toInt(), equals(192829540)); - - for (int i = 20; i < 50; i++) { - mA.add(memorySizeDataSet[i]); - switch (i) { - case 25: - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - mA.clear(); - expect(mA.dataSet.length, 0); - break; - case 48: - case 49: - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - break; - default: - expect(mA.dataSet.length, i < 25 ? i + 1 : i - 25); - expect(mA.hasSpike(), isFalse); - expect(mA.isDipping(), isFalse); - } - } - expect(mA.mean.toInt(), equals(469047851)); - - expect(mA.dataSet.length, 24); - - for (int i = 50; i < memorySizeDataSet.length; i++) { - mA.add(memorySizeDataSet[i]); - switch (i) { - case 50: - expect(mA.mean.toInt(), equals(492875028)); - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - expect(mA.dataSet.length, equals(25)); - break; - case 51: - expect(mA.mean.toInt(), equals(530253961)); - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - expect(mA.dataSet.length, equals(26)); - break; - case 52: - expect(mA.mean.toInt(), equals(594493714)); - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - expect(mA.dataSet.length, equals(27)); - break; - case 53: - expect(mA.mean.toInt(), equals(662547510)); - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - expect(mA.dataSet.length, equals(28)); - break; - default: - expect(false, isTrue); - } - } - - // dataSet was cleared on first spike @ item 25 so - // dataSet only has the remaining 28 entries. - expect(mA.dataSet.length, 28); - expect(mA.mean.toInt(), equals(662547510)); - - mA.clear(); - expect(mA.mean, equals(0)); - expect(mA.dataSet.length, equals(0)); + group('NullableListExtension', () { + test('isNullOrEmpty', () { + List? nullableList; + expect(nullableList.isNullOrEmpty, true); + nullableList = []; + expect(nullableList.isNullOrEmpty, true); + nullableList.add(1); + expect(nullableList.isNullOrEmpty, false); }); + }); - test('dips and spikes MA', () { - final mA = MovingAverage(); - - // Dynamically add data to MovingAverage data set. - for (int i = 0; i < memorySizeDataSet.length; i++) { - mA.add(dipsSpikesDataSet[i]); - switch (i) { - case 12: - case 24: - case 43: - expect(mA.hasSpike(), isFalse); - expect(mA.isDipping(), isTrue); - break; - case 17: - case 19: - case 27: - case 46: - case 52: - expect(mA.hasSpike(), isTrue); - expect(mA.isDipping(), isFalse); - break; - default: - expect(mA.hasSpike(), isFalse); - expect(mA.isDipping(), isFalse); - } - if (mA.hasSpike() || mA.isDipping()) { - mA.clear(); - expect(mA.dataSet.length, 0); - } - } + group('SetExtension', () { + test('containsAny', () { + final test = {1, 2, 3, 4}; + final subSet = {1, 2}; + final disjointSet = {5, 6, 7}; + expect(test.containsAny(test), true); + expect(test.containsAny(subSet), true); + expect(test.containsAny(disjointSet), false); }); + }); - group('ListExtension', () { - test('joinWith generates correct list', () { - expect([1, 2, 3, 4].joinWith(0), equals([1, 0, 2, 0, 3, 0, 4])); - expect([1].joinWith(0), equals([1])); - expect(['a', 'b'].joinWith('z'), equals(['a', 'z', 'b'])); - }); - - test('allIndicesWhere', () { - final list = [1, 2, 1, 2, 3, 4]; - expect(list.allIndicesWhere((element) => element.isEven), [1, 3, 5]); - expect(list.allIndicesWhere((element) => element.isOdd), [0, 2, 4]); - expect(list.allIndicesWhere((element) => element + 2 == 3), [0, 2]); - }); + group('NullableStringExtension', () { + test('isNullOrEmpty', () { + String? str; + expect(str.isNullOrEmpty, isTrue); + str = ''; + expect(str.isNullOrEmpty, isTrue); + str = 'hello'; + expect(str.isNullOrEmpty, isFalse); + str = null; + expect(str.isNullOrEmpty, isTrue); }); + }); - group('NullableListExtension', () { - test('isNullOrEmpty', () { - List? nullableList; - expect(nullableList.isNullOrEmpty, true); - nullableList = []; - expect(nullableList.isNullOrEmpty, true); - nullableList.add(1); - expect(nullableList.isNullOrEmpty, false); - }); + group('StringExtension', () { + test('caseInsensitiveContains', () { + const str = 'This is a test string with a path/to/uri'; + expect(str.caseInsensitiveContains('test'), isTrue); + expect(str.caseInsensitiveContains('with a PATH/'), isTrue); + expect(str.caseInsensitiveContains('THIS IS A'), isTrue); + expect(str.caseInsensitiveContains('not a match'), isFalse); + expect(str.caseInsensitiveContains('test bool'), isFalse); + expect( + str.caseInsensitiveContains(RegExp('is.*path', caseSensitive: false)), + isTrue, + ); + expect( + () => str.caseInsensitiveContains(RegExp('is.*path')), + throwsAssertionError, + ); + expect( + str.caseInsensitiveContains( + RegExp('THIS IS.*TO/uri', caseSensitive: false), + ), + isTrue, + ); + expect( + str.caseInsensitiveContains( + RegExp('this.*does not match', caseSensitive: false), + ), + isFalse, + ); }); - group('SetExtension', () { - test('containsAny', () { - final test = {1, 2, 3, 4}; - final subSet = {1, 2}; - final disjointSet = {5, 6, 7}; - expect(test.containsAny(test), true); - expect(test.containsAny(subSet), true); - expect(test.containsAny(disjointSet), false); - }); - }); + test('caseInsensitiveEquals', () { + const str = 'hello, world!'; + expect(str.caseInsensitiveEquals(str), isTrue); + expect(str.caseInsensitiveEquals('HELLO, WORLD!'), isTrue); + expect(str.caseInsensitiveEquals('hElLo, WoRlD!'), isTrue); + expect(str.caseInsensitiveEquals('hello'), isFalse); + expect(str.caseInsensitiveEquals(''), isFalse); + expect(str.caseInsensitiveEquals(null), isFalse); + expect(''.caseInsensitiveEquals(''), isTrue); + expect(''.caseInsensitiveEquals(null), isFalse); - group('NullableStringExtension', () { - test('isNullOrEmpty', () { - String? str; - expect(str.isNullOrEmpty, isTrue); - str = ''; - expect(str.isNullOrEmpty, isTrue); - str = 'hello'; - expect(str.isNullOrEmpty, isFalse); - str = null; - expect(str.isNullOrEmpty, isTrue); - }); + // Complete match. + expect( + str.caseInsensitiveEquals(RegExp('h.*o.*', caseSensitive: false)), + isTrue, + ); + // Incomplete match. + expect( + str.caseInsensitiveEquals(RegExp('h.*o', caseSensitive: false)), + isFalse, + ); + // No match. + expect( + str.caseInsensitiveEquals( + RegExp('hello.* this does not match', caseSensitive: false), + ), + isFalse, + ); }); - group('StringExtension', () { - test('caseInsensitiveContains', () { - const str = 'This is a test string with a path/to/uri'; - expect(str.caseInsensitiveContains('test'), isTrue); - expect(str.caseInsensitiveContains('with a PATH/'), isTrue); - expect(str.caseInsensitiveContains('THIS IS A'), isTrue); - expect(str.caseInsensitiveContains('not a match'), isFalse); - expect(str.caseInsensitiveContains('test bool'), isFalse); - expect( - str.caseInsensitiveContains(RegExp('is.*path', caseSensitive: false)), - isTrue, - ); - expect( - () => str.caseInsensitiveContains(RegExp('is.*path')), - throwsAssertionError, - ); - expect( - str.caseInsensitiveContains( - RegExp('THIS IS.*TO/uri', caseSensitive: false), - ), - isTrue, - ); - expect( - str.caseInsensitiveContains( - RegExp('this.*does not match', caseSensitive: false), - ), - isFalse, - ); - }); - - test('caseInsensitiveEquals', () { - const str = 'hello, world!'; - expect(str.caseInsensitiveEquals(str), isTrue); - expect(str.caseInsensitiveEquals('HELLO, WORLD!'), isTrue); - expect(str.caseInsensitiveEquals('hElLo, WoRlD!'), isTrue); - expect(str.caseInsensitiveEquals('hello'), isFalse); - expect(str.caseInsensitiveEquals(''), isFalse); - expect(str.caseInsensitiveEquals(null), isFalse); - expect(''.caseInsensitiveEquals(''), isTrue); - expect(''.caseInsensitiveEquals(null), isFalse); - - // Complete match. - expect( - str.caseInsensitiveEquals(RegExp('h.*o.*', caseSensitive: false)), - isTrue, - ); - // Incomplete match. - expect( - str.caseInsensitiveEquals(RegExp('h.*o', caseSensitive: false)), - isFalse, - ); - // No match. - expect( - str.caseInsensitiveEquals( - RegExp('hello.* this does not match', caseSensitive: false), - ), - isFalse, - ); - }); + test('caseInsensitiveAllMatches', () { + const str = 'This is a TEST. Test string is "test"'; + final matches = 'test'.caseInsensitiveAllMatches(str).toList(); + expect(matches.length, equals(3)); - test('caseInsensitiveAllMatches', () { - const str = 'This is a TEST. Test string is "test"'; - final matches = 'test'.caseInsensitiveAllMatches(str).toList(); - expect(matches.length, equals(3)); + // First match: 'TEST' + expect(matches[0].start, equals(10)); + expect(matches[0].end, equals(14)); - // First match: 'TEST' - expect(matches[0].start, equals(10)); - expect(matches[0].end, equals(14)); + // Second match: 'Test' + expect(matches[1].start, equals(16)); + expect(matches[1].end, equals(20)); - // Second match: 'Test' - expect(matches[1].start, equals(16)); - expect(matches[1].end, equals(20)); + // Third match: 'test' + expect(matches[2].start, equals(32)); + expect(matches[2].end, equals(36)); - // Third match: 'test' - expect(matches[2].start, equals(32)); - expect(matches[2].end, equals(36)); + // Dart's allMatches returns 1 char matches when pattern is an empty string + expect( + ''.caseInsensitiveAllMatches('hello world').length, + equals('hello world'.length + 1), + ); + expect('*'.caseInsensitiveAllMatches('hello world'), isEmpty); + expect('test'.caseInsensitiveAllMatches(''), isEmpty); + expect('test'.caseInsensitiveAllMatches(null), isEmpty); + }); + }); - // Dart's allMatches returns 1 char matches when pattern is an empty string - expect( - ''.caseInsensitiveAllMatches('hello world').length, - equals('hello world'.length + 1), - ); - expect('*'.caseInsensitiveAllMatches('hello world'), isEmpty); - expect('test'.caseInsensitiveAllMatches(''), isEmpty); - expect('test'.caseInsensitiveAllMatches(null), isEmpty); - }); + group('BoolExtension', () { + test('boolCompare', () { + expect(true.boolCompare(true), equals(0)); + expect(false.boolCompare(false), equals(0)); + expect(true.boolCompare(false), equals(-1)); + expect(false.boolCompare(true), equals(1)); }); + }); - group('BoolExtension', () { - test('boolCompare', () { - expect(true.boolCompare(true), equals(0)); - expect(false.boolCompare(false), equals(0)); - expect(true.boolCompare(false), equals(-1)); - expect(false.boolCompare(true), equals(1)); - }); + group('subtractMaps', () { + test('subtracts non-null maps', () { + final subtract = {1: 'subtract'}; + final from = {1: 1.0, 2: 2.0}; + _SubtractionResult? elementSubtractor({ + required String? subtract, + required double? from, + }) => _SubtractionResult(subtract: subtract, from: from); + + final result = subtractMaps( + subtract: subtract, + from: from, + subtractor: elementSubtractor, + ); + + expect( + const SetEquality().equals(result.keys.toSet(), {1, 2}), + true, + ); + expect( + result[1], + equals(_SubtractionResult(subtract: 'subtract', from: 1.0)), + ); + expect(result[2], equals(_SubtractionResult(subtract: null, from: 2.0))); }); - group('subtractMaps', () { - test('subtracts non-null maps', () { - final subtract = {1: 'subtract'}; - final from = {1: 1.0, 2: 2.0}; - _SubtractionResult? elementSubtractor({ - required String? subtract, - required double? from, - }) => _SubtractionResult(subtract: subtract, from: from); - - final result = subtractMaps( - subtract: subtract, - from: from, - subtractor: elementSubtractor, - ); + test('subtracts null', () { + final from = {1: 1.0}; + _SubtractionResult? elementSubtractor({ + required String? subtract, + required double? from, + }) => _SubtractionResult(subtract: subtract, from: from); - expect( - const SetEquality().equals(result.keys.toSet(), {1, 2}), - true, - ); - expect( - result[1], - equals(_SubtractionResult(subtract: 'subtract', from: 1.0)), - ); - expect( - result[2], - equals(_SubtractionResult(subtract: null, from: 2.0)), - ); - }); + final result = subtractMaps( + subtract: null, + from: from, + subtractor: elementSubtractor, + ); - test('subtracts null', () { - final from = {1: 1.0}; - _SubtractionResult? elementSubtractor({ - required String? subtract, - required double? from, - }) => _SubtractionResult(subtract: subtract, from: from); - - final result = subtractMaps( - subtract: null, - from: from, - subtractor: elementSubtractor, - ); + expect(const SetEquality().equals(result.keys.toSet(), {1}), true); + expect(result[1], equals(_SubtractionResult(subtract: null, from: 1.0))); + }); - expect(const SetEquality().equals(result.keys.toSet(), {1}), true); - expect( - result[1], - equals(_SubtractionResult(subtract: null, from: 1.0)), - ); - }); + test('subtracts from null', () { + final subtract = {1: 'subtract'}; + _SubtractionResult? elementSubtractor({ + required String? subtract, + required double? from, + }) => _SubtractionResult(subtract: subtract, from: from); - test('subtracts from null', () { - final subtract = {1: 'subtract'}; - _SubtractionResult? elementSubtractor({ - required String? subtract, - required double? from, - }) => _SubtractionResult(subtract: subtract, from: from); - - final result = subtractMaps( - subtract: subtract, - from: null, - subtractor: elementSubtractor, - ); + final result = subtractMaps( + subtract: subtract, + from: null, + subtractor: elementSubtractor, + ); - expect(const SetEquality().equals(result.keys.toSet(), {1}), true); - expect( - result[1], - equals(_SubtractionResult(subtract: 'subtract', from: null)), - ); - }); + expect(const SetEquality().equals(result.keys.toSet(), {1}), true); + expect( + result[1], + equals(_SubtractionResult(subtract: 'subtract', from: null)), + ); }); }); @@ -1182,6 +697,67 @@ void main() { }); }); + group('textSpansFromAnsi', () { + const defaultStyle = TextStyle( + color: Color(0xFFEEEEEE), + fontSize: 12.0, + fontFamily: 'Roboto', + ); + + test('uses the default style for text without ANSI codes', () { + final spans = textSpansFromAnsi('plain', defaultStyle); + expect(spans, hasLength(1)); + expect(spans.first.text, 'plain'); + expect(spans.first.style, defaultStyle); + }); + + test('keeps the default color when only a background is set', () { + final spans = textSpansFromAnsi('\x1B[45mmagenta\x1B[0m', defaultStyle); + expect(spans, hasLength(1)); + final style = spans.first.style!; + expect(style.color, defaultStyle.color); + expect(style.backgroundColor, const Color.fromRGBO(187, 0, 187, 1)); + expect(style.fontSize, defaultStyle.fontSize); + expect(style.fontFamily, defaultStyle.fontFamily); + }); + + test('keeps the default font when a foreground is set', () { + final spans = textSpansFromAnsi( + 'plain \x1B[32mgreen\x1B[0m plain', + defaultStyle, + ); + expect(spans.map((s) => s.text), ['plain ', 'green', ' plain']); + final style = spans[1].style!; + expect(style.color, const Color.fromRGBO(0, 187, 0, 1)); + expect(style.fontSize, defaultStyle.fontSize); + expect(style.fontFamily, defaultStyle.fontFamily); + }); + + test('applies bold', () { + final spans = textSpansFromAnsi('\x1B[1mbold\x1B[0m', defaultStyle); + final style = spans.first.style!; + expect(style.fontWeight, FontWeight.bold); + expect(style.color, defaultStyle.color); + expect(style.fontSize, defaultStyle.fontSize); + }); + }); + + group('stripAnsi', () { + test('returns text without ANSI codes unchanged', () { + expect(stripAnsi('plain text'), 'plain text'); + }); + + test('removes ANSI codes', () { + expect( + stripAnsi( + '\x1B[45mComponentChanged\x1B[0m ' + '\x1B[38;5;208morange\x1B[0m \x1B[1;32mbold green\x1B[0m', + ), + 'ComponentChanged orange bold green', + ); + }); + }); + test('devtoolsAssetsBasePath', () { // This is how a DevTools url will be structured when DevTools is served // directly from DDS using the `--observe` flag. @@ -1242,135 +818,3 @@ class _SubtractionResult { @override String toString() => '$from - $subtract'; } - -// This was generated from a canvas with font size 14.0. -const asciiMeasurements = [ - 0, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 0, - 3.8896484375, - 3.8896484375, - 3.8896484375, - 3.8896484375, - 3.8896484375, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 4.6619873046875, - 0, - 4.6619873046875, - 4.6619873046875, - 3.8896484375, - 3.8896484375, - 4.9697265625, - 7.7861328125, - 7.7861328125, - 12.4482421875, - 9.337890625, - 2.6728515625, - 4.662109375, - 4.662109375, - 5.4482421875, - 8.17578125, - 3.8896484375, - 4.662109375, - 3.8896484375, - 3.8896484375, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 3.8896484375, - 3.8896484375, - 8.17578125, - 8.17578125, - 8.17578125, - 7.7861328125, - 14.2119140625, - 9.337890625, - 9.337890625, - 10.1103515625, - 10.1103515625, - 9.337890625, - 8.5517578125, - 10.8896484375, - 10.1103515625, - 3.8896484375, - 7, - 9.337890625, - 7.7861328125, - 11.662109375, - 10.1103515625, - 10.8896484375, - 9.337890625, - 10.8896484375, - 10.1103515625, - 9.337890625, - 8.5517578125, - 10.1103515625, - 9.337890625, - 13.2138671875, - 9.337890625, - 9.337890625, - 8.5517578125, - 3.8896484375, - 3.8896484375, - 3.8896484375, - 6.5693359375, - 7.7861328125, - 4.662109375, - 7.7861328125, - 7.7861328125, - 7, - 7.7861328125, - 7.7861328125, - 3.8896484375, - 7.7861328125, - 7.7861328125, - 3.1103515625, - 3.1103515625, - 7, - 3.1103515625, - 11.662109375, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 7.7861328125, - 4.662109375, - 7, - 3.8896484375, - 7.7861328125, - 7, - 10.1103515625, - 7, - 7, - 7, - 4.67578125, - 3.63671875, - 4.67578125, - 8.17578125, - 0, -]; diff --git a/packages/devtools_app/test/shared/table/table_test.dart b/packages/devtools_app/test/shared/table/table_test.dart index dd2dcd97446..a2ab62f8b22 100644 --- a/packages/devtools_app/test/shared/table/table_test.dart +++ b/packages/devtools_app/test/shared/table/table_test.dart @@ -11,9 +11,9 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart' hide TableRow, Table; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart' hide TableRow, Table; class _NonSortableFlatNameColumn extends ColumnData { _NonSortableFlatNameColumn.wide(super.title) : super.wide(); @@ -1193,7 +1193,7 @@ void main() { final tableState = tester.state( find.byType(DevToolsTable), ); - final scrollController = tableState.scrollController; + final scrollController = tableState.verticalScrollController; expect(scrollController.offset, 0.0); expect(scrollController.position.maxScrollExtent, greaterThan(0.0)); @@ -1221,7 +1221,7 @@ void main() { final tableState = tester.state( find.byType(DevToolsTable), ); - final scrollController = tableState.scrollController; + final scrollController = tableState.verticalScrollController; expect(scrollController.offset, isNot(0.0)); expect(scrollController.position.maxScrollExtent, greaterThan(0.0)); diff --git a/packages/devtools_app/test/shared/ui/auto_complete_test.dart b/packages/devtools_app/test/shared/ui/auto_complete_test.dart index 47d70a132f2..2f1dfc2dc5e 100644 --- a/packages/devtools_app/test/shared/ui/auto_complete_test.dart +++ b/packages/devtools_app/test/shared/ui/auto_complete_test.dart @@ -5,8 +5,8 @@ // ignore_for_file: avoid_print import 'package:devtools_app/src/shared/ui/search.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; // Set debug to true displays editing parts. bool debug = false; diff --git a/packages/devtools_app/test/shared/ui/common_widgets_test.dart b/packages/devtools_app/test/shared/ui/common_widgets_test.dart index 6b65b52aa4b..8f02c37edcb 100644 --- a/packages/devtools_app/test/shared/ui/common_widgets_test.dart +++ b/packages/devtools_app/test/shared/ui/common_widgets_test.dart @@ -7,14 +7,12 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; // TODO(kenz): add tests for other widgets in common_widgets.dart void main() { - const windowSize = Size(1000.0, 1000.0); - setUp(() { setGlobal( DevToolsEnvironmentParameters, @@ -25,41 +23,6 @@ void main() { setGlobal(IdeTheme, IdeTheme()); }); - group('Common widgets', () { - testWidgetsWithWindowSize( - 'processingInfo builds for progressValue', - windowSize, - (WidgetTester tester) async { - await tester.pumpWidget( - wrap( - const ProcessingInfo( - progressValue: 0.0, - processedObject: 'fake object', - ), - ), - ); - - final progressIndicatorFinder = find.byType(LinearProgressIndicator); - LinearProgressIndicator progressIndicator = tester.widget( - progressIndicatorFinder, - ); - - expect(progressIndicator.value, equals(0.0)); - - await tester.pumpWidget( - wrap( - const ProcessingInfo( - progressValue: 0.5, - processedObject: 'fake object', - ), - ), - ); - progressIndicator = tester.widget(progressIndicatorFinder); - expect(progressIndicator.value, equals(0.5)); - }, - ); - }); - group('NotifierCheckbox', () { bool? findCheckboxValue() { final checkboxWidget = diff --git a/packages/devtools_app/test/shared/ui/editable_list_test.dart b/packages/devtools_app/test/shared/ui/editable_list_test.dart index 64d7e0bb556..4fb28d40b27 100644 --- a/packages/devtools_app/test/shared/ui/editable_list_test.dart +++ b/packages/devtools_app/test/shared/ui/editable_list_test.dart @@ -7,8 +7,8 @@ import 'package:devtools_app/src/shared/ui/editable_list.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../test_infra/utils/test_utils.dart'; diff --git a/packages/devtools_app/test/shared/ui/hover_positioning_test.dart b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart new file mode 100644 index 00000000000..29025ddc6c0 --- /dev/null +++ b/packages/devtools_app/test/shared/ui/hover_positioning_test.dart @@ -0,0 +1,231 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/src/shared/ui/hover.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +const _windowWidth = 800.0; +const _windowHeight = 600.0; +const _windowSize = Size(_windowWidth, _windowHeight); + +void main() { + Future pumpHoverCardTooltip( + WidgetTester tester, { + required Alignment alignment, + String? title, + }) async { + await tester.pumpWidget( + wrapSimple( + Align( + alignment: alignment, + child: HoverCardTooltip.sync( + enabled: () => true, + generateHoverCardData: (event) => HoverCardData( + title: title, + contents: const SizedBox( + width: 200, + height: 250, + child: Text('Hover Content'), + ), + ), + child: const Text('Hover Me'), + ), + ), + ), + ); + + // Trigger hover + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + final center = tester.getCenter(find.text('Hover Me')); + await gesture.moveTo(center); + await tester.pump(HoverCardTooltip.hoverDelay); + await tester.pumpAndSettle(); + } + + testWidgetsWithWindowSize( + 'HoverCard at the bottom of the window should not overflow', + _windowSize, + (WidgetTester tester) async { + // Use a title to increase the height beyond the base content height. + await pumpHoverCardTooltip( + tester, + alignment: Alignment.bottomCenter, + title: 'A Very Important Title', + ); + + final hoverContentFinder = find.text('Hover Content'); + expect(hoverContentFinder, findsOneWidget); + + final overlayContainer = find + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) + .last; // The outermost container of the HoverCard + + final renderBox = tester.renderObject(overlayContainer) as RenderBox; + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + + expect( + position.dy + size.height, + lessThanOrEqualTo(_windowHeight - hoverMargin), + ); + }, + ); + + testWidgetsWithWindowSize( + 'HoverCard at the right of the window should not overflow', + _windowSize, + (WidgetTester tester) async { + await pumpHoverCardTooltip(tester, alignment: Alignment.centerRight); + + final hoverContentFinder = find.text('Hover Content'); + expect(hoverContentFinder, findsOneWidget); + + final overlayContainer = find + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) + .last; + + final renderBox = tester.renderObject(overlayContainer) as RenderBox; + final position = renderBox.localToGlobal(Offset.zero); + final size = renderBox.size; + + expect( + position.dx + size.width, + lessThanOrEqualTo(_windowWidth - hoverMargin), + ); + }, + ); + + testWidgetsWithWindowSize( + 'HoverCard in very small window should not crash', + const Size(100, 100), // Smaller than tooltip + (WidgetTester tester) async { + await pumpHoverCardTooltip(tester, alignment: Alignment.center); + + final hoverContentFinder = find.text('Hover Content'); + expect(hoverContentFinder, findsOneWidget); + + final overlayContainer = find + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) + .last; + + expect(overlayContainer, findsOneWidget); + }, + ); + + testWidgetsWithWindowSize('HoverCard height clamping with title', _windowSize, ( + WidgetTester tester, + ) async { + await pumpHoverCardTooltip( + tester, + alignment: Alignment.bottomCenter, + title: 'An Important Title', + ); + + final hoverContentFinderWithTitle = find.text('Hover Content'); + expect(hoverContentFinderWithTitle, findsOneWidget); + + final containerWithTitle = find + .ancestor( + of: hoverContentFinderWithTitle, + matching: find.byType(Container), + ) + .last; + + final renderBoxWithTitle = + tester.renderObject(containerWithTitle) as RenderBox; + final positionWithTitle = renderBoxWithTitle.localToGlobal(Offset.zero); + + // Clamps strictly at y = 274.0 because of dynamic height containing title/divider. + expect(positionWithTitle.dy, equals(274.0)); + }); + + testWidgetsWithWindowSize( + 'HoverCard height clamping without title', + _windowSize, + (WidgetTester tester) async { + await pumpHoverCardTooltip(tester, alignment: Alignment.bottomCenter); + + final hoverContentFinderNoTitle = find.text('Hover Content'); + expect(hoverContentFinderNoTitle, findsOneWidget); + + final containerNoTitle = find + .ancestor( + of: hoverContentFinderNoTitle, + matching: find.byType(Container), + ) + .last; + + final renderBoxNoTitle = + tester.renderObject(containerNoTitle) as RenderBox; + final positionNoTitle = renderBoxNoTitle.localToGlobal(Offset.zero); + + // Clamps lower down at y = 314.0 because max height is smaller without title gaps. + expect(positionNoTitle.dy, equals(314.0)); + }, + ); + + testWidgetsWithWindowSize( + 'HoverCard translates global coordinates to local coordinates for offset overlays', + _windowSize, + (WidgetTester tester) async { + final overlayKey = GlobalKey(); + + await tester.pumpWidget( + wrapSimple( + Padding( + padding: const EdgeInsets.only(left: 50.0, top: 100.0), + child: Overlay( + key: overlayKey, + initialEntries: [ + OverlayEntry( + builder: (context) => Align( + alignment: Alignment.topLeft, + child: HoverCardTooltip.sync( + enabled: () => true, + generateHoverCardData: (event) => HoverCardData( + contents: const SizedBox( + width: 200, + height: 250, + child: Text('Hover Content'), + ), + ), + child: const Text('Hover Me Offset'), + ), + ), + ), + ], + ), + ), + ), + ); + + // Trigger hover + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + + final center = tester.getCenter(find.text('Hover Me Offset')); + await gesture.moveTo(center); + await tester.pump(HoverCardTooltip.hoverDelay); + await tester.pumpAndSettle(); + + final hoverContentFinder = find.text('Hover Content'); + expect(hoverContentFinder, findsOneWidget); + + final overlayContainer = find + .ancestor(of: hoverContentFinder, matching: find.byType(Container)) + .last; + + final renderBox = tester.renderObject(overlayContainer) as RenderBox; + final position = renderBox.localToGlobal(Offset.zero); + + // Dynamic margin is 16.0. Since overlay is offset by 50px globally at the left, + // dynamic local X is 16.0, mapped to global X = 50.0 + 16.0 = 66.0. + expect(position.dx, equals(66.0)); + }, + ); +} diff --git a/packages/devtools_app/test/shared/ui/search_highlighter_test.dart b/packages/devtools_app/test/shared/ui/search_highlighter_test.dart index 00b172d8b37..c65860e46fe 100644 --- a/packages/devtools_app/test/shared/ui/search_highlighter_test.dart +++ b/packages/devtools_app/test/shared/ui/search_highlighter_test.dart @@ -5,8 +5,8 @@ import 'package:devtools_app/src/shared/primitives/utils.dart'; import 'package:devtools_app/src/shared/ui/search_highlighter.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { const style = TextStyle(fontSize: 12.0); diff --git a/packages/devtools_app/test/shared/ui/search_test.dart b/packages/devtools_app/test/shared/ui/search_test.dart index 57c85993829..5bef4c19f01 100644 --- a/packages/devtools_app/test/shared/ui/search_test.dart +++ b/packages/devtools_app/test/shared/ui/search_test.dart @@ -4,9 +4,9 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/utils.dart'; +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; - -// TODO(https://github.com/flutter/devtools/issues/3514): increase test coverage +import 'package:material_ui/material_ui.dart'; void main() { late TestSearchController searchController; @@ -43,6 +43,59 @@ void main() { } }); + test('nextMatch and previousMatch', () { + searchController.search = 'foo'; + expect(searchController.matchIndex.value, equals(1)); + expect(searchController.activeSearchMatch.value!.name, equals('Foo')); + + searchController.nextMatch(); + expect(searchController.matchIndex.value, equals(2)); + expect(searchController.activeSearchMatch.value!.name, equals('FooBar')); + + searchController.nextMatch(); + expect(searchController.matchIndex.value, equals(3)); + expect(searchController.activeSearchMatch.value!.name, equals('FooBaz')); + + searchController.nextMatch(); + expect(searchController.matchIndex.value, equals(1)); + expect(searchController.activeSearchMatch.value!.name, equals('Foo')); + + searchController.previousMatch(); + expect(searchController.matchIndex.value, equals(3)); + expect(searchController.activeSearchMatch.value!.name, equals('FooBaz')); + + searchController.previousMatch(); + expect(searchController.matchIndex.value, equals(2)); + expect(searchController.activeSearchMatch.value!.name, equals('FooBar')); + }); + + test('resetSearch', () { + searchController.search = 'foo'; + expect(searchController.search, equals('foo')); + expect(searchController.searchMatches.value.length, equals(3)); + + searchController.resetSearch(); + expect(searchController.search, isEmpty); + expect(searchController.searchMatches.value, isEmpty); + }); + + test('searchPreviousMatches', () { + searchController.search = 'foo'; + expect(searchController.searchMatches.value.length, equals(3)); + + // Add a new item that matches 'foob' but was not in the previous matches. + searchController.data.add(TestSearchData('FooBarBaz')); + + // Since 'foob' contains 'foo', it will search previous matches. + // 'FooBarBaz' was not in the previous matches, so it should not be found. + searchController.search = 'foob'; + expect(searchController.searchMatches.value.length, equals(2)); + expect( + searchController.searchMatches.value.map((e) => e.name), + equals(['FooBar', 'FooBaz']), + ); + }); + test('updates values for empty query', () { searchController.search = 'foo'; expect(searchController.search, equals('foo')); @@ -67,6 +120,225 @@ void main() { expect(data.isSearchMatch, isFalse); } }); + + test('debounce', () { + fakeAsync((async) { + final debounceController = TestDebounceSearchController() + ..data.addAll(testData); + expect(debounceController.search, isEmpty); + expect(debounceController.searchMatches.value, isEmpty); + + debounceController.search = 'foo'; + expect(debounceController.search, equals('foo')); + expect( + debounceController.searchMatches.value, + isEmpty, + ); // Has not updated yet + expect(debounceController.isSearchInProgress, isTrue); + + async.elapse(debounceController.debounceDelay! * 1.5); + + expect(debounceController.isSearchInProgress, isFalse); + expect(debounceController.searchMatches.value.length, equals(3)); + }); + }); + }); + + group('AutoCompleteMatch', () { + test('transformAutoCompleteMatch without matched segments', () { + final match = AutoCompleteMatch('test'); + final result = match.transformAutoCompleteMatch( + transformMatchedSegment: (s) => '[$s]', + transformUnmatchedSegment: (s) => '<$s>', + combineSegments: (segments) => segments.join(), + ); + expect(result, equals('')); + }); + + test('transformAutoCompleteMatch with matched segments', () { + final match = AutoCompleteMatch( + 'testSuggestion', + matchedSegments: [ + const Range(0, 4), // 'test' + const Range(10, 14), // 'tion' + ], + ); + final result = match.transformAutoCompleteMatch( + transformMatchedSegment: (s) => '[$s]', + transformUnmatchedSegment: (s) => '<$s>', + combineSegments: (segments) => segments.join(), + ); + expect(result, equals('[test][tion]')); + }); + }); + + group('AutoCompleteSearchControllerMixin', () { + late TestAutoCompleteSearchController autoCompleteController; + + setUp(() { + autoCompleteController = TestAutoCompleteSearchController(); + }); + + tearDown(() { + autoCompleteController.dispose(); + }); + + test('clearSearchAutoComplete', () { + autoCompleteController.searchAutoComplete.value = [ + AutoCompleteMatch('test'), + ]; + autoCompleteController.setCurrentHoveredIndexValue(1); + + autoCompleteController.clearSearchAutoComplete(); + + expect(autoCompleteController.searchAutoComplete.value, isEmpty); + expect(autoCompleteController.currentHoveredIndex.value, equals(0)); + }); + + test('updateCurrentSuggestion / clearCurrentSuggestion', () { + autoCompleteController.searchAutoComplete.value = [ + AutoCompleteMatch('testSuggestion'), + ]; + autoCompleteController.setCurrentHoveredIndexValue(0); + + autoCompleteController.updateCurrentSuggestion('test'); + expect( + autoCompleteController.currentSuggestion.value, + equals('Suggestion'), + ); + + autoCompleteController.updateCurrentSuggestion('testSuggest'); + expect(autoCompleteController.currentSuggestion.value, equals('ion')); + + // Active word is longer than hovered text (should not happen in practice but handled) + autoCompleteController.updateCurrentSuggestion('testSuggestionWithMore'); + expect(autoCompleteController.currentSuggestion.value, isNull); + + autoCompleteController.clearCurrentSuggestion(); + expect(autoCompleteController.currentSuggestion.value, isNull); + }); + + test('activeEditingParts', () { + final parts1 = AutoCompleteSearchControllerMixin.activeEditingParts( + 'addOne.yName + 1000 + myChart.tra', + const TextSelection.collapsed(offset: 33), + ); + expect(parts1.activeWord, equals('tra')); + expect(parts1.leftSide, equals('addOne.yName + 1000 + myChart.')); + expect(parts1.rightSide, equals('')); + expect(parts1.isField, isTrue); + + final parts2 = AutoCompleteSearchControllerMixin.activeEditingParts( + 'controller.cl + 1000 + myChart.tra', + const TextSelection.collapsed(offset: 13), + ); + expect(parts2.activeWord, equals('cl')); + expect(parts2.leftSide, equals('controller.')); + expect(parts2.rightSide, equals(' + 1000 + myChart.tra')); + expect(parts2.isField, isTrue); + + final parts3 = AutoCompleteSearchControllerMixin.activeEditingParts( + 'foo', + const TextSelection.collapsed(offset: 3), + ); + expect(parts3.activeWord, equals('foo')); + expect(parts3.leftSide, equals('')); + expect(parts3.rightSide, equals('')); + expect(parts3.isField, isFalse); + }); + + test('clearSearchField', () { + autoCompleteController.search = 'foo'; + autoCompleteController.clearSearchField(); + expect(autoCompleteController.search, isEmpty); + + autoCompleteController.clearSearchField(force: true); + expect(autoCompleteController.search, isEmpty); + }); + + test('updateSearchField', () { + autoCompleteController.updateSearchField( + newValue: 'foo bar', + caretPosition: 3, + ); + expect( + autoCompleteController.searchTextFieldController.text, + equals('foo bar'), + ); + expect( + autoCompleteController.searchTextFieldController.selection.baseOffset, + equals(3), + ); + }); + }); + + group('StatelessSearchField', () { + testWidgets('calls onChanged and onClose', (WidgetTester tester) async { + final searchController = TestSearchController()..init(); + bool closeCalled = false; + String lastChangedValue = ''; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StatelessSearchField( + controller: searchController, + searchFieldEnabled: true, + shouldRequestFocus: false, + onClose: () { + closeCalled = true; + }, + onChanged: (value) { + lastChangedValue = value; + }, + ), + ), + ), + ); + + final textField = find.byType(TextField); + expect(textField, findsOneWidget); + + await tester.enterText(textField, 'test input'); + await tester.pumpAndSettle(); + + expect(lastChangedValue, equals('test input')); + + final closeButton = find.byIcon(Icons.close); + expect(closeButton, findsOneWidget); + + await tester.tap(closeButton); + expect(closeCalled, isTrue); + }); + }); + + group('SearchField', () { + testWidgets('calls onClose', (WidgetTester tester) async { + final searchController = TestSearchController()..init(); + bool closeCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SearchField( + searchController: searchController, + onClose: () { + closeCalled = true; + }, + ), + ), + ), + ); + + await tester.enterText(find.byType(TextField), 'foo'); + await tester.pumpAndSettle(); + // find the close button + final closeButton = find.byIcon(Icons.close); + expect(closeButton, findsOneWidget); + + await tester.tap(closeButton); + expect(closeCalled, isTrue); + }); }); } @@ -75,18 +347,40 @@ class TestSearchController extends DisposableController final data = []; @override - List matchesForSearch( - String search, { - bool searchPreviousMatches = false, - }) { - return data - .where((element) => element.name.caseInsensitiveContains(search)) - .toList(); - } + Iterable get currentDataToSearchThrough => data; +} + +class TestDebounceSearchController extends DisposableController + with SearchControllerMixin { + final data = []; + + @override + Iterable get currentDataToSearchThrough => data; + + @override + Duration? get debounceDelay => const Duration(milliseconds: 100); } class TestSearchData with SearchableDataMixin { TestSearchData(this.name); final String name; + + @override + bool matchesSearchToken(RegExp regExpSearch) { + return name.caseInsensitiveContains(regExpSearch); + } +} + +class TestAutoCompleteSearchController extends DisposableController + with SearchControllerMixin, AutoCompleteSearchControllerMixin { + TestAutoCompleteSearchController() { + init(); + } + + @override + final searchFieldKey = GlobalKey(); + + @override + Iterable get currentDataToSearchThrough => []; } diff --git a/packages/devtools_app/test/shared/ui/side_panel_test.dart b/packages/devtools_app/test/shared/ui/side_panel_test.dart new file mode 100644 index 00000000000..73a4700bb37 --- /dev/null +++ b/packages/devtools_app/test/shared/ui/side_panel_test.dart @@ -0,0 +1,105 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; + +/// The smallest contrast ratio WCAG 2.1 accepts for body text at level AA. +/// +/// See https://www.w3.org/TR/WCAG21/#contrast-minimum. +const _minimumContrastRatio = 4.5; + +void main() { + setUp(() { + setGlobal(IdeTheme, IdeTheme()); + }); + + group('$SidePanelViewer', () { + // `MarkdownStyleSheet.fromTheme` fills blockquotes with + // `Colors.blue.shade100` but takes the text color from the theme, so a + // release note that opens with a blockquote drew `onSurface` text on light + // blue in the dark theme. + // Regression test for https://github.com/flutter/devtools/issues/9945. + for (final useDarkTheme in [true, false]) { + final themeName = useDarkTheme ? 'dark' : 'light'; + testWidgets('blockquote text is legible in the $themeName theme', ( + tester, + ) async { + const summary = 'Release notes for Dart and Flutter DevTools.'; + final controller = SidePanelController(); + await tester.pumpWidget( + MaterialApp( + theme: themeFor( + isDarkTheme: useDarkTheme, + ideTheme: IdeTheme(), + theme: ThemeData( + useMaterial3: true, + colorScheme: useDarkTheme ? darkColorScheme : lightColorScheme, + ), + ), + builder: (context, child) => + // ignore: deprecated_member_use, needed for markdown package + MaterialUiCompatibilityBridge(child: child!), + home: SidePanelViewer(controller: controller), + ), + ); + controller.markdown.value = '# Release notes\n\n> $summary'; + controller.toggleVisibility(true); + await tester.pumpAndSettle(); + + final summaryFinder = find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText() == summary, + ); + expect(summaryFinder, findsOneWidget); + + final textColor = _colorOfSpan( + tester.widget(summaryFinder).text, + summary, + ); + final fillColor = tester + .widgetList( + find.ancestor( + of: summaryFinder, + matching: find.byType(DecoratedBox), + ), + ) + .map((box) => box.decoration) + .whereType() + .map((decoration) => decoration.color) + .nonNulls + .first; + + expect( + _contrastRatio(textColor!, fillColor), + greaterThanOrEqualTo(_minimumContrastRatio), + ); + }); + } + }); +} + +/// The color the span holding [text] is painted with, or null if [root] holds +/// no such span. +Color? _colorOfSpan(InlineSpan root, String text) { + Color? color; + root.visitChildren((span) { + if (span is TextSpan && span.text == text) { + color = span.style?.color; + return false; + } + return true; + }); + return color; +} + +/// The WCAG contrast ratio between [a] and [b], from 1 (identical) to 21 +/// (black on white). +double _contrastRatio(Color a, Color b) { + final luminances = [a.computeLuminance(), b.computeLuminance()]..sort(); + return (luminances.last + 0.05) / (luminances.first + 0.05); +} diff --git a/packages/devtools_app/test/shared/ui/ui_utils_test.dart b/packages/devtools_app/test/shared/ui/ui_utils_test.dart index 16982a068e1..5b00688ee0f 100644 --- a/packages/devtools_app/test/shared/ui/ui_utils_test.dart +++ b/packages/devtools_app/test/shared/ui/ui_utils_test.dart @@ -8,8 +8,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../../test_infra/matchers/matchers.dart'; diff --git a/packages/devtools_app/test/shared/ui/vm_flag_widgets_test.dart b/packages/devtools_app/test/shared/ui/vm_flag_widgets_test.dart index 65ad73d806d..1c38d4aa9b4 100644 --- a/packages/devtools_app/test/shared/ui/vm_flag_widgets_test.dart +++ b/packages/devtools_app/test/shared/ui/vm_flag_widgets_test.dart @@ -11,8 +11,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/shared/utils/future_work_tracker_test.dart b/packages/devtools_app/test/shared/utils/future_work_tracker_test.dart index 7f329df94ea..f5a24ea8328 100644 --- a/packages/devtools_app/test/shared/utils/future_work_tracker_test.dart +++ b/packages/devtools_app/test/shared/utils/future_work_tracker_test.dart @@ -36,12 +36,12 @@ void main() { advanceClock(async); expect(tracker.active.value, isTrue); - completer1.complete(null); + completer1.complete(); unawaited(completer1.future); advanceClock(async); expect(tracker.active.value, isTrue); - completer2.complete(null); + completer2.complete(); unawaited(completer2.future); advanceClock(async); expect(tracker.active.value, isFalse); @@ -66,7 +66,7 @@ void main() { advanceClock(async); expect(tracker.active.value, isTrue); - completer2.complete(null); + completer2.complete(); unawaited(completer2.future); advanceClock(async); expect(tracker.active.value, isFalse); diff --git a/packages/devtools_app/test/shared/utils/utils_test.dart b/packages/devtools_app/test/shared/utils/utils_test.dart index b9cbdf7c9ec..ee6eeed1603 100644 --- a/packages/devtools_app/test/shared/utils/utils_test.dart +++ b/packages/devtools_app/test/shared/utils/utils_test.dart @@ -212,6 +212,10 @@ void main() { ); }); + tearDown(() { + worker.dispose(); + }); + test('0 length', () async { final result = await worker.doWork(0); expect(result, true); diff --git a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_panel_test.dart b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_panel_test.dart new file mode 100644 index 00000000000..8a9a44b9e18 --- /dev/null +++ b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_panel_test.dart @@ -0,0 +1,73 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app/src/shared/primitives/query_parameters.dart'; +import 'package:devtools_app/src/standalone_ui/ide_shared/property_editor/property_editor_panel.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('propertyEditorDocumentationLink', () { + const embedModeKey = 'embedMode'; + const embedOneValue = 'one'; + const vsCodeLink = 'https://docs.flutter.dev/tools/vs-code#property-editor'; + const androidStudioLink = + 'https://docs.flutter.dev/tools/android-studio#property-editor'; + + test('is null when not embedded', () { + expect( + propertyEditorDocumentationLink( + DevToolsQueryParams({ + DevToolsQueryParams.dashToolKey: 'vscode-plugins', + }), + ), + isNull, + ); + }); + + test('links to VS Code docs for dashTool=vscode-plugins', () { + expect( + propertyEditorDocumentationLink( + DevToolsQueryParams({ + embedModeKey: embedOneValue, + DevToolsQueryParams.dashToolKey: 'vscode-plugins', + }), + ), + vsCodeLink, + ); + }); + + test('links to VS Code docs for legacy ide=VSCode', () { + expect( + propertyEditorDocumentationLink( + DevToolsQueryParams({ + embedModeKey: embedOneValue, + DevToolsQueryParams.ideKey: 'VSCode', + }), + ), + vsCodeLink, + ); + }); + + test('links to Android Studio docs for dashTool=intellij-plugins', () { + expect( + propertyEditorDocumentationLink( + DevToolsQueryParams({ + embedModeKey: embedOneValue, + DevToolsQueryParams.dashToolKey: 'intellij-plugins', + }), + ), + androidStudioLink, + ); + }); + + test('defaults to Android Studio docs when no IDE is specified', () { + expect( + propertyEditorDocumentationLink( + DevToolsQueryParams({embedModeKey: embedOneValue}), + ), + androidStudioLink, + ); + }); + }); +} diff --git a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart index 1145e402241..a14c937b599 100644 --- a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart +++ b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart @@ -17,9 +17,9 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; typedef Location = ({TextDocument document, CursorPosition position}); diff --git a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/utils_test.dart b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/utils_test.dart index 8b45772d59b..4b9080a4b44 100644 --- a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/utils_test.dart +++ b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/utils_test.dart @@ -4,8 +4,8 @@ import 'package:devtools_app/src/shared/primitives/utils.dart'; import 'package:devtools_app/src/standalone_ui/ide_shared/property_editor/utils/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { const regularFontStyle = TextStyle(color: Colors.black); @@ -91,8 +91,6 @@ void main() { final firstChild = children.first; final secondChild = children.second; final thirdChild = children.third; - final fourthChild = children.fourth; - final fifthChild = children.fifth; expect( text.textSpan?.toPlainText(), @@ -104,10 +102,6 @@ void main() { expect(_hasStyle(secondChild, style: fixedFontStyle), isTrue); expect(thirdChild.toPlainText(), equals(' and ')); expect(_hasStyle(thirdChild, style: regularFontStyle), isTrue); - expect(fourthChild.toPlainText(), equals('some code')); - expect(_hasStyle(fourthChild, style: fixedFontStyle), isTrue); - expect(fifthChild.toPlainText(), equals('.')); - expect(_hasStyle(fifthChild, style: regularFontStyle), isTrue); }); testWidgets('DartDocConverter handles unmatched brackets', ( diff --git a/packages/devtools_app/test/standalone_ui/vs_code/debug_sessions_test.dart b/packages/devtools_app/test/standalone_ui/vs_code/debug_sessions_test.dart index 1bb122e5542..90933f11046 100644 --- a/packages/devtools_app/test/standalone_ui/vs_code/debug_sessions_test.dart +++ b/packages/devtools_app/test/standalone_ui/vs_code/debug_sessions_test.dart @@ -4,32 +4,26 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/shared/constants.dart'; -import 'package:devtools_app/src/shared/editor/api_classes.dart'; import 'package:devtools_app/src/standalone_ui/vs_code/debug_sessions.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; -import '../../test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart'; import '../../test_infra/utils/sidebar_utils.dart'; void main() { const windowSize = Size(2000.0, 2000.0); late MockEditorClient mockEditorClient; - late final Map deviceMap; setUpAll(() { // Set test mode so that the debug list of extensions will be used. setTestMode(); setGlobal(ServiceConnectionManager, FakeServiceConnectionManager()); - - final devices = stubbedDevices.map((d) => MapEntry(d.id, d)); - deviceMap = {for (final d in devices) d.key: d.value}; }); setUp(() { @@ -52,7 +46,6 @@ void main() { sessions: Map.fromEntries( _debugSessions.map((s) => MapEntry(s.id, s)), ), - devices: deviceMap, ), ), ); diff --git a/packages/devtools_app/test/standalone_ui/vs_code/devtools_test.dart b/packages/devtools_app/test/standalone_ui/vs_code/devtools_test.dart index 2eb7207e809..2c0a342dea6 100644 --- a/packages/devtools_app/test/standalone_ui/vs_code/devtools_test.dart +++ b/packages/devtools_app/test/standalone_ui/vs_code/devtools_test.dart @@ -13,8 +13,8 @@ import 'package:devtools_shared/devtools_extensions.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import '../../test_infra/utils/sidebar_utils.dart'; diff --git a/packages/devtools_app/test/test_infra/fixtures/custom_widgets/lib/src/widgets.dart b/packages/devtools_app/test/test_infra/fixtures/custom_widgets/lib/src/widgets.dart index 4084be21830..2203b4681e2 100644 --- a/packages/devtools_app/test/test_infra/fixtures/custom_widgets/lib/src/widgets.dart +++ b/packages/devtools_app/test/test_infra/fixtures/custom_widgets/lib/src/widgets.dart @@ -2,8 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:material_ui/material_ui.dart'; class CustomApp extends StatelessWidget { const CustomApp({ diff --git a/packages/devtools_app/test/test_infra/fixtures/custom_widgets/pubspec.yaml b/packages/devtools_app/test/test_infra/fixtures/custom_widgets/pubspec.yaml index ffe19a4c0cf..69050892da4 100644 --- a/packages/devtools_app/test/test_infra/fixtures/custom_widgets/pubspec.yaml +++ b/packages/devtools_app/test/test_infra/fixtures/custom_widgets/pubspec.yaml @@ -8,3 +8,4 @@ environment: dependencies: flutter: sdk: flutter + material_ui: ^1.4.0 diff --git a/packages/devtools_app/test/test_infra/fixtures/debugging_app.dart b/packages/devtools_app/test/test_infra/fixtures/debugging_app.dart deleted file mode 100644 index bb732d9187d..00000000000 --- a/packages/devtools_app/test/test_infra/fixtures/debugging_app.dart +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2019 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -// ignore_for_file: avoid_print - -import 'dart:async'; - -void main() { - print('starting debugging app'); - - final cat = Cat('Fluffy'); - - void run() { - Timer(const Duration(milliseconds: 100), () { - cat.performAction(); - - run(); - }); - } - - run(); -} - -class Cat { - Cat(this.name); - - final String name; - - String get type => 'cat'; - - int actionCount = 0; - - void performAction() { - String actionStr = 'catAction'; - actionStr = '$actionStr!'; - - actionCount++; // breakpoint - } -} diff --git a/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/main.dart b/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/main.dart index cd774680642..a1b1a85e09b 100644 --- a/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/main.dart +++ b/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/main.dart @@ -8,7 +8,7 @@ // debugger panel integration test will need to be updated with new line numbers // (the test verifies that breakpoints are hit at specific lines). -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'src/autocomplete.dart'; import 'src/other_classes.dart'; diff --git a/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/src/autocomplete.dart b/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/src/autocomplete.dart index 24ed3d8b975..befc2b5be92 100644 --- a/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/src/autocomplete.dart +++ b/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/src/autocomplete.dart @@ -56,15 +56,14 @@ class AnotherClass { var someField = 3; static var someStaticField = 2; int get someProperty => 42; - // ignore: avoid-dynamic, gets in the way of testing. - set someSomeProperty(v) {} + set someSomeProperty(int v) {} } var someTopLevelField = 9; int get someTopLevelGetter => 42; -set someTopLevelSetter(v) {} +set someTopLevelSetter(int v) {} void someTopLevelMember() {} diff --git a/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/src/other_classes.dart b/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/src/other_classes.dart index 1cd9568ab20..6eee6929fb4 100644 --- a/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/src/other_classes.dart +++ b/packages/devtools_app/test/test_infra/fixtures/flutter_app/lib/src/other_classes.dart @@ -8,7 +8,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; // Unused class in the sample application that is a widget. // diff --git a/packages/devtools_app/test/test_infra/fixtures/flutter_app/pubspec.yaml b/packages/devtools_app/test/test_infra/fixtures/flutter_app/pubspec.yaml index 483b8c7d618..3b331f49ac8 100644 --- a/packages/devtools_app/test/test_infra/fixtures/flutter_app/pubspec.yaml +++ b/packages/devtools_app/test/test_infra/fixtures/flutter_app/pubspec.yaml @@ -11,10 +11,13 @@ environment: flutter: '>=3.0.0' dependencies: + # ignore: dependencies, required for Flutter SDK. flutter: sdk: flutter + material_ui: ^1.4.0 dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter diff --git a/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/lib/main.dart b/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/lib/main.dart index 0ca44301163..da90771fa5a 100644 --- a/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/lib/main.dart +++ b/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/lib/main.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'missing_material_error.dart'; diff --git a/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/lib/missing_material_error.dart b/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/lib/missing_material_error.dart index 1c48982d9b5..f84eb7ac2af 100644 --- a/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/lib/missing_material_error.dart +++ b/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/lib/missing_material_error.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; class MissingMaterialError extends StatelessWidget { const MissingMaterialError({super.key}); @@ -33,6 +33,12 @@ class ExampleWidget extends StatefulWidget { class _ExampleWidgetState extends State { final _controller = TextEditingController(); + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Column( diff --git a/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/pubspec.yaml b/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/pubspec.yaml index fdc6fb6cf07..ec2552088b0 100644 --- a/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/pubspec.yaml +++ b/packages/devtools_app/test/test_infra/fixtures/flutter_error_app/pubspec.yaml @@ -17,10 +17,13 @@ environment: flutter: '>=3.0.0' dependencies: + # ignore: dependencies, required for Flutter SDK. flutter: sdk: flutter + material_ui: ^1.4.0 dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter diff --git a/packages/devtools_app/test/test_infra/fixtures/inspector_app/lib/main.dart b/packages/devtools_app/test/test_infra/fixtures/inspector_app/lib/main.dart index 8387b9d658d..356c799db4c 100644 --- a/packages/devtools_app/test/test_infra/fixtures/inspector_app/lib/main.dart +++ b/packages/devtools_app/test/test_infra/fixtures/inspector_app/lib/main.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:custom_widgets/custom_widgets.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; void main() { runApp( diff --git a/packages/devtools_app/test/test_infra/fixtures/inspector_app/lib/overflow_errors.dart b/packages/devtools_app/test/test_infra/fixtures/inspector_app/lib/overflow_errors.dart index 09cd1f1f13c..b0e7f604d00 100644 --- a/packages/devtools_app/test/test_infra/fixtures/inspector_app/lib/overflow_errors.dart +++ b/packages/devtools_app/test/test_infra/fixtures/inspector_app/lib/overflow_errors.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; void main() => runApp(const OverflowingApp()); diff --git a/packages/devtools_app/test/test_infra/fixtures/inspector_app/pubspec.yaml b/packages/devtools_app/test/test_infra/fixtures/inspector_app/pubspec.yaml index 5c9e2f08421..9c4e76dff4b 100644 --- a/packages/devtools_app/test/test_infra/fixtures/inspector_app/pubspec.yaml +++ b/packages/devtools_app/test/test_infra/fixtures/inspector_app/pubspec.yaml @@ -11,12 +11,15 @@ environment: flutter: '>=3.0.0' dependencies: + # ignore: dependencies, required for Flutter SDK. flutter: sdk: flutter custom_widgets: path: ../custom_widgets + material_ui: ^1.4.0 dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter diff --git a/packages/devtools_app/test/test_infra/fixtures/logging_app.dart b/packages/devtools_app/test/test_infra/fixtures/logging_app.dart deleted file mode 100644 index afe3700a6a2..00000000000 --- a/packages/devtools_app/test/test_infra/fixtures/logging_app.dart +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2018 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. - -// ignore_for_file: avoid_print - -import 'dart:async'; -import 'dart:developer'; -import 'dart:io'; - -// Allow a test driver to communicate with this app through the controller. -final controller = Controller(); - -void main() { - print('starting logging app'); - log('starting logging app'); - - // Don't exit until it's indicated we should by the controller. - Timer(const Duration(days: 1), () {}); -} - -class Controller { - int count = 0; - - void emitLog() { - count++; - - print('emitLog called'); - log('emit log $count', name: 'logging'); - } - - void shutdown() { - print('stopping app'); - log('stopping app'); - - exit(0); - } -} diff --git a/packages/devtools_app/test/test_infra/fixtures/memory_app/lib/main.dart b/packages/devtools_app/test/test_infra/fixtures/memory_app/lib/main.dart index 34c6757c12b..a74e7cddfab 100644 --- a/packages/devtools_app/test/test_infra/fixtures/memory_app/lib/main.dart +++ b/packages/devtools_app/test/test_infra/fixtures/memory_app/lib/main.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; void main() { runApp(const MyApp()); diff --git a/packages/devtools_app/test/test_infra/fixtures/memory_app/pubspec.yaml b/packages/devtools_app/test/test_infra/fixtures/memory_app/pubspec.yaml index 67edb8247ab..8d6a6f7965b 100644 --- a/packages/devtools_app/test/test_infra/fixtures/memory_app/pubspec.yaml +++ b/packages/devtools_app/test/test_infra/fixtures/memory_app/pubspec.yaml @@ -11,10 +11,13 @@ environment: flutter: '>=3.0.0' dependencies: + # ignore: dependencies, required for Flutter SDK. flutter: sdk: flutter + material_ui: ^1.4.0 dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter diff --git a/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart b/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart index 73d1d30a4ab..c875688e804 100644 --- a/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart +++ b/packages/devtools_app/test/test_infra/fixtures/networking_app/bin/main.dart @@ -100,11 +100,6 @@ class _HttpClient { final _dio = Dio(); - void close() { - _client.close(force: true); - _dio.close(force: true); - } - void get() async { print('Sending GET...'); final request = await _client.getUrl(_uri); diff --git a/packages/devtools_app/test/test_infra/flutter_test_driver.dart b/packages/devtools_app/test/test_infra/flutter_test_driver.dart index 99eda70e734..5aa28a639f0 100644 --- a/packages/devtools_app/test/test_infra/flutter_test_driver.dart +++ b/packages/devtools_app/test/test_infra/flutter_test_driver.dart @@ -43,17 +43,9 @@ abstract class FlutterTestDriver { final errorBuffer = StringBuffer(); late String lastResponse; late Uri _vmServiceWsUri; - bool hasExited = false; VmServiceWrapper? vmService; - String get lastErrorInfo => errorBuffer.toString(); - - Stream get stderr => stderrController.stream; - Stream get stdout => stdoutController.stream; - - Uri get vmServiceUri => _vmServiceWsUri; - String _debugPrint(String msg) { const maxLength = 500; final truncatedMsg = msg.length > maxLength @@ -94,7 +86,6 @@ abstract class FlutterTestDriver { unawaited( proc.exitCode.then((int code) { _debugPrint('Process exited ($code)'); - hasExited = true; }), ); transformToLines( diff --git a/packages/devtools_app/test/test_infra/flutter_test_environment.dart b/packages/devtools_app/test/test_infra/flutter_test_environment.dart index 54da10aa78d..b2741f3ede8 100644 --- a/packages/devtools_app/test/test_infra/flutter_test_environment.dart +++ b/packages/devtools_app/test/test_infra/flutter_test_environment.dart @@ -34,9 +34,10 @@ class FlutterTestEnvironment { _flutterDriverFactory = flutterDriverFactory ?? defaultFlutterRunDriver, _flutterExe = _parseFlutterExeFromEnv() { if (useTempDirectory) { - final tempDirectory = Directory.systemTemp.createTempSync( - 'flutter_test_temp', - ); + final parentDir = p.dirname(testAppDirectory); + final tempDirectory = Directory( + parentDir, + ).createTempSync('flutter_test_temp_'); _tempTestAppDirectory = tempDirectory.path; _copyToTempDirectory(testAppDirectory, tempDirectory); } @@ -71,11 +72,6 @@ class FlutterTestEnvironment { /// test/my_test.dart`). final String _flutterExe; - // This function will be called after we have ran the Flutter app and the - // vmService is opened. - Future Function()? _afterNewSetup; - set afterNewSetup(Future Function() f) => _afterNewSetup = f; - // This function will be called for every call to [setupEnvironment], even // when the setup is not forced or triggered by a new FlutterRunConfiguration. Future Function()? _afterEverySetup; @@ -89,12 +85,6 @@ class FlutterTestEnvironment { set beforeEveryTearDown(Future Function() f) => _beforeEveryTearDown = f; - // The function will be called before the final forced teardown at the end - // of the test suite (which will then stop the Flutter app). - Future Function()? _beforeFinalTearDown; - set beforeFinalTearDown(Future Function() f) => - _beforeFinalTearDown = f; - bool _needsSetup = true; Completer? _setupInProgress; @@ -117,7 +107,8 @@ class FlutterTestEnvironment { if (force || _needsSetup || !reuseTestEnvironment || - _isNewRunConfig(config)) { + _isNewRunConfig(config) || + !serviceConnection.serviceManager.connectedState.value.connected) { _setupInProgress = Completer(); try { // If we already have a running test device, stop it before setting up a @@ -173,8 +164,6 @@ class FlutterTestEnvironment { } finally { _setupInProgress!.complete(!_needsSetup); } - - if (_afterNewSetup != null) await _afterNewSetup!(); } if (_afterEverySetup != null) await _afterEverySetup!(); } @@ -192,16 +181,14 @@ class FlutterTestEnvironment { return; } - if (_beforeFinalTearDown != null) await _beforeFinalTearDown!(); - await serviceConnection.serviceManager.manuallyDisconnect(); await _service.allFuturesCompleted.timeout( const Duration(seconds: 20), onTimeout: () { throw 'Timed out waiting for futures to complete during teardown. ' - '${_service.activeFutures.length} futures remained:\n\n' - ' ${_service.activeFutures.map((tf) => tf.name).join('\n ')}'; + '${_service.activeFutureNames.length} futures remained:\n\n' + ' ${_service.activeFutureNames.join('\\n ')}'; }, ); await _flutter!.stop(); @@ -213,12 +200,12 @@ class FlutterTestEnvironment { _needsSetup = true; } + /// Deletes the temporary directory created for the test suite. void finalTeardown() { - // Delete the temporary directory created for the test suite. - if (_tempTestAppDirectory != null) { - final tempDirectory = Directory(_tempTestAppDirectory!); + if (_tempTestAppDirectory case final tempTestAppDirectory?) { + final tempDirectory = Directory(tempTestAppDirectory); if (tempDirectory.existsSync()) { - Directory(_tempTestAppDirectory!).deleteSync(recursive: true); + tempDirectory.deleteSync(recursive: true); } } } diff --git a/packages/devtools_app/test/test_infra/goldens/codeview_scrollbars.png b/packages/devtools_app/test/test_infra/goldens/codeview_scrollbars.png index 3773f9d2fb1..f36c85af0a2 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/codeview_scrollbars.png and b/packages/devtools_app/test/test_infra/goldens/codeview_scrollbars.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_modified.png b/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_modified.png index 277d5487235..b1fde61d79f 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_modified.png and b/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_modified.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_bidirectional_horizontal.png b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_bidirectional_horizontal.png index d4db9f0be33..2461c0f4fb8 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_bidirectional_horizontal.png and b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_bidirectional_horizontal.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_bidirectional_vertical.png b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_bidirectional_vertical.png index dd855d25754..af0df39e79c 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_bidirectional_vertical.png and b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_bidirectional_vertical.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_down.png b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_down.png index 6e864b5a7e8..b27202302d9 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_down.png and b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_down.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_left.png b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_left.png index ad28c36593f..d8d65e94f1a 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_left.png and b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_left.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_right.png b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_right.png index aca93792545..3696ed11bf4 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_right.png and b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_right.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_up.png b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_up.png index d3f2615bbf7..cb12c9d2c53 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_up.png and b/packages/devtools_app/test/test_infra/goldens/inspector/layout_explorer/flex/arrow_unidirectional_up.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_1_initial_load.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_1_initial_load.png index 3c50814ca9c..d427fa3c69a 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_1_initial_load.png and b/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_1_initial_load.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png index 80f0bf9c286..284f6c09d2b 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png and b/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_collapsed.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_collapsed.png new file mode 100644 index 00000000000..4544741ca37 Binary files /dev/null and b/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_collapsed.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_hidden.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_hidden.png new file mode 100644 index 00000000000..4c6d3d03ce0 Binary files /dev/null and b/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_hidden.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_initial_load.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_initial_load.png index fc7ac52325f..5a29ff42ad7 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_initial_load.png and b/packages/devtools_app/test/test_infra/goldens/integration_inspector_initial_load.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png index 18394b8db9b..211c291cdb3 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png and b/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_after_hot_restart.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_after_hot_restart.png deleted file mode 100644 index 06631268d43..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_after_hot_restart.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_animated_physical_model_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_animated_physical_model_selected.png deleted file mode 100644 index 8eefb4fced8..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_animated_physical_model_selected.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_errors_1_initial_load.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_errors_1_initial_load.png deleted file mode 100644 index 5904b379b54..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_errors_1_initial_load.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_errors_2_error_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_errors_2_error_selected.png deleted file mode 100644 index d64b611d80f..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_errors_2_error_selected.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_hideable_widget_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_hideable_widget_selected.png deleted file mode 100644 index 58b46a7b7f3..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_hideable_widget_selected.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_hideable_widget_selected_from_search.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_hideable_widget_selected_from_search.png deleted file mode 100644 index 58b46a7b7f3..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_hideable_widget_selected_from_search.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_collapsed.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_collapsed.png deleted file mode 100644 index e148d4679f1..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_collapsed.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_expanded.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_expanded.png deleted file mode 100644 index 1bf929100dd..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_expanded.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_hidden.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_hidden.png deleted file mode 100644 index f1347bd6ecc..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_implementation_widgets_hidden.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_initial_load.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_initial_load.png deleted file mode 100644 index 26d0044cf97..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_initial_load.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_revert_to_legacy.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_revert_to_legacy.png deleted file mode 100644 index 60a194d6b46..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_revert_to_legacy.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_select_center.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_select_center.png deleted file mode 100644 index d2d00d92589..00000000000 Binary files a/packages/devtools_app/test/test_infra/goldens/integration_inspector_v2_select_center.png and /dev/null differ diff --git a/packages/devtools_app/test/test_infra/goldens/logging/metadata_chips.png b/packages/devtools_app/test/test_infra/goldens/logging/metadata_chips.png index 96156b81e92..d60663e60c1 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/logging/metadata_chips.png and b/packages/devtools_app/test/test_infra/goldens/logging/metadata_chips.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png index 97595ba6d0f..390d7a41393 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png and b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_trace_tab.png b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_trace_tab.png index 21eb64c9555..36114f04d99 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_trace_tab.png and b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_trace_tab.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_chart_scaled_all.png b/packages/devtools_app/test/test_infra/goldens/memory_chart_scaled_all.png index 60905aab4d4..bed044a359a 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_chart_scaled_all.png and b/packages/devtools_app/test/test_infra/goldens/memory_chart_scaled_all.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_chart_scaled_five_minute.png b/packages/devtools_app/test/test_infra/goldens/memory_chart_scaled_five_minute.png index 3207aaf60bf..34427a5fd71 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_chart_scaled_five_minute.png and b/packages/devtools_app/test/test_infra/goldens/memory_chart_scaled_five_minute.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png index 9b618a3ee36..e8811c31fff 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty1.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png index 9b618a3ee36..e8811c31fff 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_empty2.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_custom.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_custom.png index 967e17f468e..863fa804e08 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_custom.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_custom.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_except.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_except.png index 7f1f3763d78..bd6d343d1e2 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_except.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_except.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_only.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_only.png index 967e17f468e..863fa804e08 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_only.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_only.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_showAll.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_showAll.png index 759d60e0e80..e5ca02c0fa5 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_showAll.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_filter_dialog_showAll.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_selected_class.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_selected_class.png index 5f17de55bc4..59418a73388 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_selected_class.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_selected_class.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_custom_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_custom_diff.png index 0ec10ac20d0..88d287ddd33 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_custom_diff.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_custom_diff.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_custom_single.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_custom_single.png index 5c804981c46..d3a5fe6c209 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_custom_single.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_custom_single.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png index d2cc323afcc..ee09823da8e 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_diff.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_single.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_single.png index a3bcbb48009..a32c9767ec5 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_single.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_except_single.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_only_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_only_diff.png index 0ec10ac20d0..88d287ddd33 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_only_diff.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_only_diff.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_only_single.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_only_single.png index 5c804981c46..d3a5fe6c209 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_only_single.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_only_single.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png index d2cc323afcc..ee09823da8e 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_diff.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_single.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_single.png index a3bcbb48009..a32c9767ec5 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_single.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_scene_single.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png index d2cc323afcc..ee09823da8e 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_diff.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_single.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_single.png index a3bcbb48009..a32c9767ec5 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_single.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_snapshot_showAll_single.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots1.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots1.png index 5f17de55bc4..59418a73388 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots1.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots1.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots2.png b/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots2.png index 5f17de55bc4..59418a73388 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots2.png and b/packages/devtools_app/test/test_infra/goldens/memory_diff_three_snapshots2.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png index 0bdf4f1e717..3b55ee8bf0f 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png and b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_connected.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png index 7d39dd2dd42..1c97e3efcfa 100644 Binary files a/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png and b/packages/devtools_app/test/test_infra/goldens/shared/disconnect_observer_disconnected.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/comments.dart.golden b/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/comments.dart.golden index d010c2f573b..de79f28de32 100644 --- a/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/comments.dart.golden +++ b/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/comments.dart.golden @@ -100,3 +100,59 @@ >var h; #^^^ storage.type.primitive.dart # ^ punctuation.terminator.dart +> +>/// Multiline dartdoc comment with more than three backticks. +#^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ comment.block.documentation.dart +>/// +#^^^ comment.block.documentation.dart +>/// `````` +#^^^^^^^^^^ comment.block.documentation.dart +>/// code in six backticks +#^^^^ comment.block.documentation.dart +# ^^^^^^^^^^^^^^^^^^^^^ comment.block.documentation.dart variable.other.source.dart +>/// ``` +#^^^^ comment.block.documentation.dart +# ^^^ comment.block.documentation.dart variable.other.source.dart +>/// three backticks is nested code +#^^^^ comment.block.documentation.dart +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ comment.block.documentation.dart variable.other.source.dart +>/// ``` +#^^^^ comment.block.documentation.dart +# ^^^ comment.block.documentation.dart variable.other.source.dart +>/// more code +#^^^^ comment.block.documentation.dart +# ^^^^^^^^^ comment.block.documentation.dart variable.other.source.dart +>/// `````` +#^^^^^^^^^^ comment.block.documentation.dart +>/// +#^^^ comment.block.documentation.dart +>/// Text +#^^^^^^^^ comment.block.documentation.dart +>var moreBackticks; +#^^^ storage.type.primitive.dart +# ^ punctuation.terminator.dart +> +>/// Multiline dartdoc comment with more closing backticks. +#^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ comment.block.documentation.dart +>/// +#^^^ comment.block.documentation.dart +>/// `````` +#^^^^^^^^^^ comment.block.documentation.dart +>/// code in six backticks +#^^^^ comment.block.documentation.dart +# ^^^^^^^^^^^^^^^^^^^^^ comment.block.documentation.dart variable.other.source.dart +>/// ``` +#^^^^ comment.block.documentation.dart +# ^^^ comment.block.documentation.dart variable.other.source.dart +>/// still code +#^^^^ comment.block.documentation.dart +# ^^^^^^^^^^ comment.block.documentation.dart variable.other.source.dart +>/// `````````````````` +#^^^^^^^^^^^^^^^^^^^^^^ comment.block.documentation.dart +>/// +#^^^ comment.block.documentation.dart +>/// Text +#^^^^^^^^ comment.block.documentation.dart +>var moreClosingBackticks; +#^^^ storage.type.primitive.dart +# ^ punctuation.terminator.dart diff --git a/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/keywords.dart.golden b/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/keywords.dart.golden index d49ca9161cd..508d8f4d2b4 100644 --- a/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/keywords.dart.golden +++ b/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/keywords.dart.golden @@ -410,7 +410,7 @@ > var a = new String.fromCharCode(1); # ^^^ storage.type.primitive.dart # ^ keyword.operator.assignment.dart -# ^^^ keyword.control.new.dart +# ^^^ keyword.new.dart # ^^^^^^ support.class.dart # ^ punctuation.dot.dart # ^^^^^^^^^^^^ entity.name.function.dart diff --git a/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/open_code_block.golden b/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/open_code_block.golden index 83962b341ec..0c2faa00eb8 100644 --- a/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/open_code_block.golden +++ b/packages/devtools_app/test/test_infra/goldens/syntax_highlighting/open_code_block.golden @@ -5,7 +5,8 @@ >/// #^^^ comment.block.documentation.dart >/// This should not cause parsing to fail. -#^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ comment.block.documentation.dart variable.other.source.dart +#^^^^ comment.block.documentation.dart +# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ comment.block.documentation.dart variable.other.source.dart > >void main() { #^^^^ storage.type.primitive.dart diff --git a/packages/devtools_app/test/test_infra/matchers/matchers.dart b/packages/devtools_app/test/test_infra/matchers/matchers.dart index 6005a4540a8..e13237043ee 100644 --- a/packages/devtools_app/test/test_infra/matchers/matchers.dart +++ b/packages/devtools_app/test/test_infra/matchers/matchers.dart @@ -34,14 +34,6 @@ String treeToDebugString(RemoteDiagnosticsNode node) { return node.toDiagnosticsNode().toStringDeep(); } -String treeToDebugStringTruncated(RemoteDiagnosticsNode node, int maxLines) { - List lines = node.toDiagnosticsNode().toStringDeep().split('\n'); - if (lines.length > maxLines) { - lines = lines.take(maxLines).toList()..add('...'); - } - return lines.join('\n'); -} - /// Asserts that a [path] matches a golden file after normalizing likely hash /// codes. /// diff --git a/packages/devtools_app/test/test_infra/responsive_layout_utils.dart b/packages/devtools_app/test/test_infra/responsive_layout_utils.dart new file mode 100644 index 00000000000..bd4599ebf2d --- /dev/null +++ b/packages/devtools_app/test/test_infra/responsive_layout_utils.dart @@ -0,0 +1,300 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; + +import 'package:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/framework/scaffold/scaffold.dart'; +import 'package:devtools_app/src/shared/analytics/constants.dart' as gac; +import 'package:devtools_app/src/shared/framework/framework_controller.dart'; +import 'package:devtools_app/src/shared/managers/survey.dart'; +import 'package:devtools_app/src/shared/primitives/message_bus.dart'; +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/shared.dart'; +import 'package:devtools_app_shared/ui.dart'; +import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:devtools_test/helpers.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:mockito/mockito.dart'; +import 'package:provider/provider.dart'; + +import '../screens/network/utils/network_test_utils.dart'; + +/// The viewport widths, in logical pixels, used by the responsive layout tests. +/// +/// These include a realistic narrow width from the issue (328), representative +/// widths just above the `MediaSize` boundaries used by the Network toolbar and +/// the status line (600, 601, and 900), and a desktop baseline. +const responsiveLayoutWidths = [328, 600, 601, 900, 1400]; + +/// The viewport height, in logical pixels, used by the responsive layout tests. +const responsiveLayoutHeight = 900.0; + +/// The tolerance, in logical pixels, used when comparing geometry. +const geometryTolerance = 0.5; + +/// Creates a fake service connection to a Dart VM app, with the VM details that +/// the About dialog reads. +FakeServiceConnectionManager createLayoutServiceConnection() { + final connection = FakeServiceConnectionManager( + service: FakeServiceManager.createFakeService( + socketProfile: loadSocketProfile(), + httpProfile: loadHttpProfile(), + ), + ); + final vm = connection.serviceManager.vm; + when(vm.version).thenReturn('1.9.1'); + when(vm.targetCPU).thenReturn('arm64'); + when(vm.architectureBits).thenReturn(64); + when(vm.operatingSystem).thenReturn('android'); + mockConnectedApp( + connection.serviceManager.connectedApp!, + isFlutterApp: false, + ); + for (final screenId in [ + ScreenMetaData.network.id, + ScreenMetaData.logging.id, + ]) { + when( + connection.errorBadgeManager.errorCountNotifier(screenId), + ).thenReturn(ValueNotifier(0)); + } + return connection; +} + +/// Registers the globals that a full [DevToolsScaffold] needs in tests. +void setUpLayoutGlobals(FakeServiceConnectionManager connection) { + setGlobal(ServiceConnectionManager, connection); + setGlobal(OfflineDataController, OfflineDataController()); + setGlobal(IdeTheme, IdeTheme()); + setGlobal( + DevToolsEnvironmentParameters, + ExternalDevToolsEnvironmentParameters(), + ); + setGlobal(PreferencesController, PreferencesController()); + setGlobal(NotificationService, NotificationService()); + setGlobal(DTDManager, MockDTDManager()); + setGlobal(FrameworkController, FrameworkController()); + setGlobal(SurveyService, SurveyService()); + setGlobal(BannerMessagesController, BannerMessagesController()); + setGlobal(BreakpointManager, BreakpointManager()); + setGlobal(MessageBus, MessageBus()); +} + +/// Pumps a full [DevToolsScaffold] showing the screen identified by [pageId]. +/// +/// The scaffold contains the real application-wide `StatusLine`, so the footer +/// and the screen body are laid out together as they are in the app. +Future pumpDevToolsScaffold( + WidgetTester tester, { + required String pageId, + required bool embedded, + required bool connected, + required FakeServiceConnectionManager connection, + required NetworkController network, + required LoggingController logging, + required DebuggerController debugger, +}) async { + final embedMode = embedded ? EmbedMode.embedOne : EmbedMode.none; + setGlobal(IdeTheme, IdeTheme(embedMode: embedMode)); + connection.serviceManager.setConnectedState(connected); + // The providers sit above the app so that dialogs opened from the footer can + // read them, as they do in the app. + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value( + value: AnalyticsController( + enabled: false, + shouldShowConsentMessage: false, + consentMessage: 'fake message', + ), + ), + Provider.value(value: ReleaseNotesController()), + ], + child: wrapWithControllers( + DevToolsScaffold( + screens: [NetworkScreen(), LoggingScreen()], + page: pageId, + embedMode: embedMode, + ), + network: network, + logging: logging, + debugger: debugger, + ), + ), + ); + await tester.pump(const Duration(seconds: 1)); +} + +/// Runs [action] and returns the message of every [FlutterError] it reports. +/// +/// Layout exceptions are reported through [FlutterError.onError]. Collecting +/// them lets tests assert on them after the handler is restored. +Future> collectFlutterErrors( + Future Function() action, +) async { + final errors = []; + final originalOnError = FlutterError.onError; + FlutterError.onError = errors.add; + try { + await action(); + } finally { + FlutterError.onError = originalOnError; + } + return [for (final error in errors) error.exceptionAsString()]; +} + +/// Stops the polling of [network] and lets pending timers complete, so that no +/// timers are left pending when a test ends. +Future stopNetworkPolling( + WidgetTester tester, + NetworkController network, +) async { + await network.stopRecording(); + await tester.pumpAndSettle(const Duration(seconds: 1)); +} + +/// Asserts that no two rectangles in [named] intersect. +void expectNoIntersections(Map named) { + final entries = named.entries.toList(); + for (var i = 0; i < entries.length; i++) { + for (var j = i + 1; j < entries.length; j++) { + final a = entries[i]; + final b = entries[j]; + expect( + a.value + .deflate(geometryTolerance) + .overlaps(b.value.deflate(geometryTolerance)), + isFalse, + reason: '${a.key} ${a.value} intersects ${b.key} ${b.value}', + ); + } + } +} + +/// Asserts that every rectangle in [named] lies entirely within [bounds]. +/// +/// A rectangle that extends past [bounds] is clipped or overflowing. +void expectWithinBounds(Map named, Rect bounds) { + for (final entry in named.entries) { + final rect = entry.value; + expect( + rect.width > 0 && rect.height > 0, + isTrue, + reason: '${entry.key} has no size: $rect', + ); + expect( + rect.left >= bounds.left - geometryTolerance && + rect.right <= bounds.right + geometryTolerance && + rect.top >= bounds.top - geometryTolerance && + rect.bottom <= bounds.bottom + geometryTolerance, + isTrue, + reason: '${entry.key} $rect is not within $bounds', + ); + } +} + +/// Asserts that the text in [finder] is laid out at its full intrinsic width, +/// so that it is not ellipsized or wrapped. +/// +// TODO: this can be used to test ellipsis behavior in tabs, bread crumbs, +// status lines, and table cells. If it is used more generally, move it to +// `packages/devtools_test/lib/src/helpers/`. +void expectTextNotTruncated(WidgetTester tester, Finder finder) { + final paragraph = tester.renderObject(finder); + final fullWidth = paragraph.getMaxIntrinsicWidth(double.infinity); + expect( + paragraph.size.width + geometryTolerance >= fullWidth, + isTrue, + reason: + 'Text "${paragraph.text.toPlainText()}" is truncated: laid out at ' + '${paragraph.size.width}, needs $fullWidth', + ); +} + +/// Records the URLs that DevTools asks the platform to open. +/// +/// Launching is intercepted at the `url_launcher` method channel, so the test +/// checks the URL without opening a browser. +/// +// TODO: use this to test other external links. If it is used more generally, +// move it to `packages/devtools_test/lib/src/helpers/url_launcher.dart`. +class UrlLaunchRecorder { + final launched = []; + + void install() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, (call) async { + switch (call.method) { + case 'canLaunch': + return true; + case 'launch': + launched.add((call.arguments as Map)['url'] as String); + return true; + } + return null; + }); + } + + void dispose() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_channel, null); + } + + static const _channel = MethodChannel('plugins.flutter.io/url_launcher'); +} + +/// Records the analytics events that DevTools reports. +/// +/// The analytics implementation used in tests logs each selection, so the +/// events are captured from the log rather than from a network request. +/// +// TODO: other tests have fake analytics controllers; this could be moved to +// `packages/devtools_test/lib/src/helpers/analytics.dart` and used more +// generally. +class AnalyticsRecorder { + final events = []; + + StreamSubscription? _subscription; + Level? _originalLevel; + + void install() { + _originalLevel = Logger.root.level; + Logger.root.level = Level.ALL; + _subscription = Logger.root.onRecord.listen((record) { + if (record.loggerName == '_analytics_stub') { + events.add(record.message); + } + }); + } + + void dispose() { + unawaited(_subscription?.cancel()); + Logger.root.level = _originalLevel ?? Level.INFO; + } + + /// Returns whether a selection of [item] on [screenId] was recorded. + bool recordedSelection({required String screenId, required String item}) { + return events.any( + (event) => event.contains('screenName:$screenId, selectedItem:$item, '), + ); + } + + /// The selected item description for the tutorial link on [screenId]. + static String tutorialItem(String screenId) { + return '${gac.videoTutorialLink}-$screenId'; + } +} + +/// Asserts that [finder] is hit-testable, so a user tapping its center reaches +/// it. +void expectInteractable(Finder finder) { + expect(finder.hitTestable(), findsOneWidget); +} diff --git a/packages/devtools_app/test/test_infra/scenes/cpu_profiler/default.dart b/packages/devtools_app/test/test_infra/scenes/cpu_profiler/default.dart index a4ff98df8f3..a014a6f70de 100644 --- a/packages/devtools_app/test/test_infra/scenes/cpu_profiler/default.dart +++ b/packages/devtools_app/test/test_infra/scenes/cpu_profiler/default.dart @@ -9,7 +9,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:stager/stager.dart'; import 'package:vm_service/vm_service.dart'; diff --git a/packages/devtools_app/test/test_infra/scenes/hello.dart b/packages/devtools_app/test/test_infra/scenes/hello.dart index c459ecb6047..a5a4b4424d2 100644 --- a/packages/devtools_app/test/test_infra/scenes/hello.dart +++ b/packages/devtools_app/test/test_infra/scenes/hello.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:stager/stager.dart'; /// To run: diff --git a/packages/devtools_app/test/test_infra/scenes/memory/default.dart b/packages/devtools_app/test/test_infra/scenes/memory/default.dart index 1827a0fb6fe..95a02461e27 100644 --- a/packages/devtools_app/test/test_infra/scenes/memory/default.dart +++ b/packages/devtools_app/test/test_infra/scenes/memory/default.dart @@ -7,13 +7,14 @@ import 'package:devtools_app/src/screens/memory/framework/memory_tabs.dart'; import 'package:devtools_app/src/screens/memory/panes/diff/controller/diff_pane_controller.dart'; import 'package:devtools_app/src/screens/memory/panes/profile/profile_pane_controller.dart'; import 'package:devtools_app/src/screens/memory/shared/heap/class_filter.dart'; +import 'package:devtools_app_shared/service.dart' show RootInfo; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:stager/stager.dart'; import 'package:vm_service/vm_service.dart'; @@ -97,9 +98,11 @@ class MemoryDefaultScene extends Scene { /// /// [classList] will be returned by VmService.getClassList. /// [heapProviders] will be used to for heap snapshotting. + /// [rootInfo] will be returned by ServiceManager.rootInfoNow. Future setUp({ ClassList? classList, List? heapProviders, + RootInfo? rootInfo, }) async { heapProviders = heapProviders ?? MemoryDefaultSceneHeaps.all; @@ -129,6 +132,7 @@ class MemoryDefaultScene extends Scene { allocationData: allocationJson, classList: classList, ), + rootInfo: rootInfo, ); final app = fakeServiceConnection.serviceManager.connectedApp!; mockConnectedApp(app, isProfileBuild: true); diff --git a/packages/devtools_app/test/test_infra/scenes/memory/diff_snapshot.dart b/packages/devtools_app/test/test_infra/scenes/memory/diff_snapshot.dart index aa79a9d08dc..a5b4eb61353 100644 --- a/packages/devtools_app/test/test_infra/scenes/memory/diff_snapshot.dart +++ b/packages/devtools_app/test/test_infra/scenes/memory/diff_snapshot.dart @@ -10,8 +10,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:devtools_test/helpers.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:stager/stager.dart'; import '../../test_data/memory/heap/heap_data.dart'; @@ -60,6 +60,4 @@ class DiffSnapshotScene extends Scene { ClassFilter(filterType: ClassFilterType.showAll, except: '', only: ''), ); } - - void tearDown() {} } diff --git a/packages/devtools_app/test/test_infra/scenes/performance/default.dart b/packages/devtools_app/test/test_infra/scenes/performance/default.dart index aec9c04b805..40db77dbb84 100644 --- a/packages/devtools_app/test/test_infra/scenes/performance/default.dart +++ b/packages/devtools_app/test/test_infra/scenes/performance/default.dart @@ -10,7 +10,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_test/helpers.dart'; import 'package:devtools_test/test_data.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:stager/stager.dart'; /// To run: diff --git a/packages/devtools_app/test/test_infra/scenes/scene_test_extensions.dart b/packages/devtools_app/test/test_infra/scenes/scene_test_extensions.dart index 7e648578457..ddd5440de27 100644 --- a/packages/devtools_app/test/test_infra/scenes/scene_test_extensions.dart +++ b/packages/devtools_app/test/test_infra/scenes/scene_test_extensions.dart @@ -2,8 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:stager/stager.dart'; extension StagerTestExtensions on WidgetTester { diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart index 4a18e78421a..d2d66ef6531 100644 --- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart +++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_service/simulated_editor.dart @@ -44,6 +44,7 @@ class SimulatedEditor { DartToolingDaemon? _dtd; /// A controller for emitting to [log]. + // ignore: dispose-class-fields, only used in tests. final _logger = StreamController(); /// A stream of protocol traffic between the editor and DTD (or postMessage @@ -204,10 +205,6 @@ class SimulatedEditor { await _postEvent(DebugSessionStartedEvent(debugSession: debugSession)); } - void sendDebugSessionChanged(EditorDebugSession debugSession) async { - await _postEvent(DebugSessionChangedEvent(debugSession: debugSession)); - } - void sendDebugSessionStopped(EditorDebugSession debugSession) async { await _postEvent(DebugSessionStoppedEvent(debugSessionId: debugSession.id)); } @@ -310,10 +307,10 @@ class SimulatedEditor { FutureOr openDevToolsPage( String? _, - String? __, - bool ___, - bool ____, - bool _____, + String? _, + bool _, + bool _, + bool _, ) {} } diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_sidebar.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_sidebar.dart index 57cbd4fa9e4..2e0bb46ccb0 100644 --- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_sidebar.dart +++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/editor_sidebar.dart @@ -9,8 +9,7 @@ import 'package:devtools_app/src/standalone_ui/standalone_screen.dart'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:stager/stager.dart'; import 'editor_service/simulated_editor.dart'; @@ -24,7 +23,6 @@ import 'shared/utils.dart'; /// flutter run -t test/test_infra/scenes/standalone_ui/editor_sidebar.stager_app.g.dart -d chrome class EditorSidebarScene extends Scene { late Stream clientLog; - late DartToolingDaemon clientDtd; late SimulatedEditor editor; @override diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/mock_editor_widget.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/mock_editor_widget.dart index 08329bab45c..f058e3a3b66 100644 --- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/mock_editor_widget.dart +++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/mock_editor_widget.dart @@ -10,7 +10,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/shared/primitives/list_queue_value_notifier.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'editor_service/simulated_editor.dart'; import 'shared/common_ui.dart'; @@ -87,6 +87,13 @@ class _MockEditorWidgetState extends State ); } + @override + void dispose() { + clientLogRing.dispose(); + editorLogRing.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final editorTheme = VsCodeTheme.of(context); diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/property_editor_sidebar.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/property_editor_sidebar.dart index 889c170f248..6fffa6948c8 100644 --- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/property_editor_sidebar.dart +++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/property_editor_sidebar.dart @@ -9,7 +9,7 @@ import 'package:devtools_app/src/standalone_ui/standalone_screen.dart'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:stager/stager.dart'; import 'editor_service/simulated_editor.dart'; @@ -67,8 +67,9 @@ class _PropertyEditorState extends State<_PropertyEditorSidebar> { SimulatedEditor? editor; @override - void initState() { - super.initState(); + void dispose() { + unawaited(editor?.close()); + super.dispose(); } @override diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/common_ui.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/common_ui.dart index 7a089a811b1..28b3a1fe676 100644 --- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/common_ui.dart +++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/common_ui.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/shared.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; class IdeThemedMaterialApp extends StatelessWidget { const IdeThemedMaterialApp({super.key, required this.home}); diff --git a/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/utils.dart b/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/utils.dart index 0156a7f2603..bfda887b284 100644 --- a/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/utils.dart +++ b/packages/devtools_app/test/test_infra/scenes/standalone_ui/shared/utils.dart @@ -9,10 +9,6 @@ import 'package:dtd/dtd.dart'; import 'package:stream_channel/stream_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; -/// A record of a [StreamChannel] and a [Stream] of logs of protocol traffic -/// in both directions across it. -typedef LoggedChannel = ({StreamChannel channel, Stream log}); - /// Connects to the websocket at [wsUri] and returns a [StreamSink]. /// /// All traffic is logged into [sink]. diff --git a/packages/devtools_app/test/test_infra/test_data/deep_link/fake_responses.dart b/packages/devtools_app/test/test_infra/test_data/deep_link/fake_responses.dart index 1f8ebc5272f..beaf9c50d45 100644 --- a/packages/devtools_app/test/test_infra/test_data/deep_link/fake_responses.dart +++ b/packages/devtools_app/test/test_infra/test_data/deep_link/fake_responses.dart @@ -429,18 +429,6 @@ const iosValidationResponseWithError = ''' } '''; -const androidDeepLinkWithPathErrors = '''{ - "host": "example.com", - "path": "/path", - "intentFilterCheck": { - "hasBrowsableCategory": false, - "hasActionView": true, - "hasDefaultCategory": true, - "hasAutoVerify": true - } - } -'''; - const defaultDomain = 'example.com'; String androidDeepLinkJson( diff --git a/packages/devtools_app/test/test_infra/test_data/http_get_cancelled_json.dart b/packages/devtools_app/test/test_infra/test_data/http_get_cancelled_json.dart new file mode 100644 index 00000000000..646c2efe58e --- /dev/null +++ b/packages/devtools_app/test/test_infra/test_data/http_get_cancelled_json.dart @@ -0,0 +1,34 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +// Fixture for a cancelled HTTP request: +// - isRequestComplete: true (client finished sending) +// - isResponseComplete: false (no response arrived) +// - response: null +// - endTime is set (request is done, not still in-flight) + +const httpGetCancelledJson = { + 'id': '99', + 'isolateId': 'isolates/123', + 'method': 'GET', + 'uri': 'https://jsonplaceholder.typicode.com/albums/1', + 'events': >[ + {'timestamp': 6326379935, 'event': 'Request cancelled by client'}, + ], + 'startTime': 6326279935, // microseconds + 'endTime': 6326479935, // 200ms later + 'request': { + 'headers': {}, + 'compressionState': 'HttpClientRequestCompressionState.notCompressed', + 'connectionInfo': null, + 'contentLength': 0, + 'cookies': [], + 'followRedirects': true, + 'maxRedirects': 5, + 'method': 'GET', + 'persistentConnection': true, + 'uri': 'https://jsonplaceholder.typicode.com/albums/1', + }, + 'response': null, +}; diff --git a/packages/devtools_app/test/test_infra/test_data/memory/heap/heap_data.dart b/packages/devtools_app/test/test_infra/test_data/memory/heap/heap_data.dart index 867dff6eae7..be031245f44 100644 --- a/packages/devtools_app/test/test_infra/test_data/memory/heap/heap_data.dart +++ b/packages/devtools_app/test/test_infra/test_data/memory/heap/heap_data.dart @@ -32,15 +32,6 @@ class GoldenHeapTest extends HeapTest { } } -class MockedHeapTest extends HeapTest { - MockedHeapTest({required super.appClassName}); - - @override - Future loadHeap() { - throw UnimplementedError(); - } -} - /// Provides test snapshots from pre-saved goldens. class HeapGraphLoaderGoldens implements HeapGraphLoader { int _nextIndex = 0; diff --git a/packages/devtools_app/test/test_infra/test_data/performance/sample_performance_data.dart b/packages/devtools_app/test/test_infra/test_data/performance/sample_performance_data.dart index 8a9362ca124..e453bb56b31 100644 --- a/packages/devtools_app/test/test_infra/test_data/performance/sample_performance_data.dart +++ b/packages/devtools_app/test/test_infra/test_data/performance/sample_performance_data.dart @@ -140,32 +140,6 @@ extension FlutterFrame2 on Never { ..setEventFlow(uiEvent) ..setEventFlow(rasterEvent); - static const uiEventAsString = - ''' Animator::BeginFrame [713834379092 μs - 713834379102 μs] -'''; - - static const rasterEventAsString = - ''' Rasterizer::DoDraw [713836088591 μs - 713836108931 μs] - Rasterizer::DrawToSurfaces [713836088592 μs - 713836108917 μs] - GPUSurfaceMetalImpeller::AcquireFrame [713836088607 μs - 713836093553 μs] - SurfaceMTL::WrapCurrentMetalLayerDrawable [713836088654 μs - 713836093545 μs] - WaitForNextDrawable [713836088655 μs - 713836093541 μs] - CompositorContext::ScopedFrame::Raster [713836093557 μs - 713836093615 μs] - LayerTree::Preroll [713836093580 μs - 713836093597 μs] - IOSExternalViewEmbedder::PostPrerollAction [713836093598 μs - 713836093598 μs] - LayerTree::Paint [713836093599 μs - 713836093615 μs] - SurfaceFrame::Submit [713836093616 μs - 713836108864 μs] - SurfaceFrame::BuildDisplayList [713836093616 μs - 713836093621 μs] - DisplayListDispatcher::EndRecordingAsPicture [713836094185 μs - 713836094188 μs] - Renderer::Render [713836094188 μs - 713836108846 μs] - EntityPass::OnRender [713836094556 μs - 713836108700 μs] - CreateGlyphAtlas [713836099800 μs - 713836108025 μs] - CanAppendToExistingAtlas [713836099807 μs - 713836099810 μs] - OptimumAtlasSizeForFontGlyphPairs [713836099811 μs - 713836099835 μs] - CreateAtlasBitmap [713836099845 μs - 713836103975 μs] - UploadGlyphTextureAtlas [713836103979 μs - 713836108020 μs] -'''; - static final uiEvent = animatorBeginFrameEvent; static final animatorBeginFrameEvent = testTimelineEvent( name: 'Animator::BeginFrame', @@ -385,40 +359,6 @@ extension FlutterFrame4 on Never { 'vsyncOverhead': 12625, }; - static const uiEventAsString = - ''' Animator::BeginFrame [713836200161 μs - 713836206957 μs] - LAYOUT (root) [713836202351 μs - 713836202383 μs] - LAYOUT [713836202373 μs - 713836202380 μs] - UPDATING COMPOSITING BITS (root) [713836202387 μs - 713836202402 μs] - UPDATING COMPOSITING BITS [713836202397 μs - 713836202400 μs] - PAINT (root) [713836202408 μs - 713836202429 μs] - PAINT [713836202422 μs - 713836202427 μs] - COMPOSITING [713836202440 μs - 713836206727 μs] - Animator::Render [713836206671 μs - 713836206714 μs] - SEMANTICS (root) [713836206752 μs - 713836206828 μs] - SEMANTICS [713836206785 μs - 713836206825 μs] - FINALIZE TREE [713836206834 μs - 713836206878 μs] - POST_FRAME [713836206903 μs - 713836206925 μs] -'''; - - static const rasterEventAsString = - ''' Rasterizer::DoDraw [713836206748 μs - 713836211160 μs] - Rasterizer::DrawToSurfaces [713836206750 μs - 713836211143 μs] - GPUSurfaceMetalImpeller::AcquireFrame [713836206755 μs - 713836210203 μs] - SurfaceMTL::WrapCurrentMetalLayerDrawable [713836206763 μs - 713836210196 μs] - WaitForNextDrawable [713836206764 μs - 713836210193 μs] - CompositorContext::ScopedFrame::Raster [713836210206 μs - 713836210251 μs] - LayerTree::Preroll [713836210219 μs - 713836210225 μs] - IOSExternalViewEmbedder::PostPrerollAction [713836210226 μs - 713836210226 μs] - LayerTree::Paint [713836210240 μs - 713836210250 μs] - SurfaceFrame::Submit [713836210251 μs - 713836211118 μs] - SurfaceFrame::BuildDisplayList [713836210251 μs - 713836210254 μs] - DisplayListDispatcher::EndRecordingAsPicture [713836210613 μs - 713836210616 μs] - Renderer::Render [713836210616 μs - 713836211105 μs] - EntityPass::OnRender [713836210642 μs - 713836211061 μs] - CreateGlyphAtlas [713836210893 μs - 713836210899 μs] -'''; - static final uiEvent = animatorBeginFrameEvent ..addAllChildren([ layoutRootEvent..addChild(layoutEvent), @@ -943,40 +883,6 @@ extension FlutterFrame6 on Never { 'vsyncOverhead': 1108, }; - static const uiEventAsString = - ''' Animator::BeginFrame [713836329948 μs - 713836331003 μs] - LAYOUT (root) [713836330239 μs - 713836330280 μs] - LAYOUT [713836330262 μs - 713836330277 μs] - UPDATING COMPOSITING BITS (root) [713836330284 μs - 713836330307 μs] - UPDATING COMPOSITING BITS [713836330302 μs - 713836330306 μs] - PAINT (root) [713836330324 μs - 713836330348 μs] - PAINT [713836330337 μs - 713836330346 μs] - COMPOSITING [713836330357 μs - 713836330723 μs] - Animator::Render [713836330691 μs - 713836330716 μs] - SEMANTICS (root) [713836330738 μs - 713836330844 μs] - SEMANTICS [713836330783 μs - 713836330842 μs] - FINALIZE TREE [713836330848 μs - 713836330920 μs] - POST_FRAME [713836330964 μs - 713836330989 μs] -'''; - - static const rasterEventAsString = - ''' Rasterizer::DoDraw [713836330790 μs - 713836331692 μs] - Rasterizer::DrawToSurfaces [713836330791 μs - 713836331684 μs] - GPUSurfaceMetalImpeller::AcquireFrame [713836330801 μs - 713836330844 μs] - SurfaceMTL::WrapCurrentMetalLayerDrawable [713836330814 μs - 713836330839 μs] - WaitForNextDrawable [713836330817 μs - 713836330836 μs] - CompositorContext::ScopedFrame::Raster [713836330846 μs - 713836330888 μs] - LayerTree::Preroll [713836330862 μs - 713836330870 μs] - IOSExternalViewEmbedder::PostPrerollAction [713836330870 μs - 713836330870 μs] - LayerTree::Paint [713836330870 μs - 713836330888 μs] - SurfaceFrame::Submit [713836330888 μs - 713836331669 μs] - SurfaceFrame::BuildDisplayList [713836330889 μs - 713836330894 μs] - DisplayListDispatcher::EndRecordingAsPicture [713836331274 μs - 713836331276 μs] - Renderer::Render [713836331277 μs - 713836331661 μs] - EntityPass::OnRender [713836331302 μs - 713836331633 μs] - CreateGlyphAtlas [713836331499 μs - 713836331505 μs] -'''; - static final uiEvent = animatorBeginFrameEvent ..addAllChildren([ layoutRootEvent..addChild(layoutEvent), diff --git a/packages/devtools_app/test/test_infra/test_data/syntax_highlighting/comments.dart b/packages/devtools_app/test/test_infra/test_data/syntax_highlighting/comments.dart index 44e03b17114..a052b8d5f05 100644 --- a/packages/devtools_app/test/test_infra/test_data/syntax_highlighting/comments.dart +++ b/packages/devtools_app/test/test_infra/test_data/syntax_highlighting/comments.dart @@ -46,3 +46,27 @@ var g; /// Dartdoc with reference to [a]. /// And a link to [example.org](http://example.org/). var h; + +/// Multiline dartdoc comment with more than three backticks. +/// +/// `````` +/// code in six backticks +/// ``` +/// three backticks is nested code +/// ``` +/// more code +/// `````` +/// +/// Text +var moreBackticks; + +/// Multiline dartdoc comment with more closing backticks. +/// +/// `````` +/// code in six backticks +/// ``` +/// still code +/// `````````````````` +/// +/// Text +var moreClosingBackticks; diff --git a/packages/devtools_app/test/test_infra/utils/debugger_utils.dart b/packages/devtools_app/test/test_infra/utils/debugger_utils.dart index 8a8e75e8064..4f9b74e1e1c 100644 --- a/packages/devtools_app/test/test_infra/utils/debugger_utils.dart +++ b/packages/devtools_app/test/test_infra/utils/debugger_utils.dart @@ -3,8 +3,8 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; Finder findDebuggerButtonWithIcon(IconData icon) => find.ancestor( of: find.byWidgetPredicate( diff --git a/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart b/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart index 4242f4ee753..2a17ff4eeb9 100644 --- a/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart +++ b/packages/devtools_app/test/test_infra/utils/deep_links_utils.dart @@ -5,6 +5,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/screens/deep_link_validation/deep_links_model.dart'; import 'package:devtools_app/src/screens/deep_link_validation/deep_links_services.dart'; +import 'package:devtools_shared/devtools_deeplink.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart'; import 'package:http/testing.dart'; @@ -56,9 +57,9 @@ class TestDeepLinksController extends DeepLinksController { List fakeAndroidDeepLinks = []; bool hasAndroidDomainErrors = false; - bool hasAndroidPathErrors = false; String iosValidationResponse = ''; List fakeIosDomains = []; + String? fakeIosError; late DeepLinksService _deepLinksService; @@ -75,9 +76,9 @@ class TestDeepLinksController extends DeepLinksController { androidAppLinks[selectedAndroidVariantIndex.value] = fakeAppLinkSettings( fakeAndroidDeepLinks, ); - iosLinks[selectedIosConfigurationIndex.value] = fakeUniversalLinkSettings( - fakeIosDomains, - ); + iosLinks[selectedIosConfigurationIndex.value] = fakeIosError != null + ? UniversalLinkSettings.error(fakeIosError!) + : fakeUniversalLinkSettings(fakeIosDomains); await super.validateLinks(); } diff --git a/packages/devtools_app/test/test_infra/utils/extent_delegate_utils.dart b/packages/devtools_app/test/test_infra/utils/extent_delegate_utils.dart index 6893cf1909f..e3ef8c0f765 100644 --- a/packages/devtools_app/test/test_infra/utils/extent_delegate_utils.dart +++ b/packages/devtools_app/test/test_infra/utils/extent_delegate_utils.dart @@ -16,6 +16,7 @@ class TestRenderSliverBoxChildManager extends RenderSliverBoxChildManager { }); late RenderSliverExtentDelegateBoxAdaptor _renderObject; + // ignore: unused-code, used in asserts. bool _renderObjectInitialized = false; List children; diff --git a/packages/devtools_app/test/test_infra/utils/rendering_tester.dart b/packages/devtools_app/test/test_infra/utils/rendering_tester.dart index 5b054988ed0..b74d9fcaad3 100644 --- a/packages/devtools_app/test/test_infra/utils/rendering_tester.dart +++ b/packages/devtools_app/test/test_infra/utils/rendering_tester.dart @@ -34,8 +34,7 @@ class TestRenderingFlutterBinding extends BindingBase /// /// If [onErrors] is not null, it is called if [FlutterError] caught any errors /// while drawing the frame. If [onErrors] is null and [FlutterError] caught at least - /// one error, this function fails the test. A test may override [onErrors] and - /// inspect errors using [takeFlutterErrorDetails]. + /// one error, this function fails the test. /// /// Errors caught between frames will cause the test to fail unless /// [FlutterError.onError] has been overridden. @@ -119,83 +118,11 @@ class TestRenderingFlutterBinding extends BindingBase /// A function called after drawing a frame if [FlutterError] caught any errors. /// /// This function is expected to inspect these errors and decide whether they - /// are expected or not. Use [takeFlutterErrorDetails] to take one error at a - /// time, or [takeAllFlutterErrorDetails] to iterate over all errors. + /// are expected or not. VoidCallback? onErrors; - /// Returns the error least recently caught by [FlutterError] and removes it - /// from the list of captured errors. - /// - /// Returns null if no errors were captures, or if the list was exhausted by - /// calling this method repeatedly. - FlutterErrorDetails? takeFlutterErrorDetails() { - if (_errors.isEmpty) { - return null; - } - return _errors.removeAt(0); - } - - /// Returns all error details caught by [FlutterError] from least recently caught to - /// most recently caught, and removes them from the list of captured errors. - /// - /// The returned iterable takes errors lazily. If, for example, you iterate over 2 - /// errors, but there are 5 errors total, this binding will still fail the test. - /// Tests are expected to take and inspect all errors. - Iterable takeAllFlutterErrorDetails() sync* { - // sync* and yield are used for lazy evaluation. Otherwise, the list would be - // drained eagerly and allow a test pass with unexpected errors. - while (_errors.isNotEmpty) { - yield _errors.removeAt(0); - } - } - - /// Returns all exceptions caught by [FlutterError] from least recently caught to - /// most recently caught, and removes them from the list of captured errors. - /// - /// The returned iterable takes errors lazily. If, for example, you iterate over 2 - /// errors, but there are 5 errors total, this binding will still fail the test. - /// Tests are expected to take and inspect all errors. - Iterable takeAllFlutterExceptions() sync* { - // sync* and yield are used for lazy evaluation. Otherwise, the list would be - // drained eagerly and allow a test pass with unexpected errors. - while (_errors.isNotEmpty) { - yield _errors.removeAt(0).exception; - } - } - EnginePhase phase = EnginePhase.composite; - /// Pumps a frame and runs its entire life cycle. - /// - /// This method runs all of the [SchedulerPhase]s in a frame, this is useful - /// to test [SchedulerPhase.postFrameCallbacks]. - void pumpCompleteFrame() { - final FlutterExceptionHandler? oldErrorHandler = FlutterError.onError; - FlutterError.onError = _errors.add; - try { - TestRenderingFlutterBinding.instance.handleBeginFrame(null); - TestRenderingFlutterBinding.instance.handleDrawFrame(); - } finally { - FlutterError.onError = oldErrorHandler; - if (_errors.isNotEmpty) { - if (onErrors != null) { - onErrors!(); - if (_errors.isNotEmpty) { - _errors.forEach(FlutterError.dumpErrorToConsole); - fail( - 'There are more errors than the test inspected using TestRenderingFlutterBinding.takeFlutterErrorDetails.', - ); - } - } else { - _errors.forEach(FlutterError.dumpErrorToConsole); - fail( - 'Caught error while rendering frame. See preceding logs for details.', - ); - } - } - } - } - @override void drawFrame() { assert( @@ -315,20 +242,6 @@ void pumpFrame({ TestRenderingFlutterBinding.instance.drawFrame(); } -class TestCallbackPainter extends CustomPainter { - const TestCallbackPainter({required this.onPaint}); - - final VoidCallback onPaint; - - @override - void paint(Canvas canvas, Size size) { - onPaint(); - } - - @override - bool shouldRepaint(TestCallbackPainter oldPainter) => true; -} - class RenderSizedBox extends RenderBox { RenderSizedBox(this._size); @@ -368,70 +281,3 @@ class RenderSizedBox extends RenderBox { @override bool hitTestSelf(Offset position) => true; } - -class TestClipPaintingContext extends PaintingContext { - TestClipPaintingContext() : super(ContainerLayer(), Rect.zero); - - @override - ClipRectLayer? pushClipRect( - bool needsCompositing, - Offset offset, - Rect clipRect, - PaintingContextCallback painter, { - Clip clipBehavior = Clip.hardEdge, - ClipRectLayer? oldLayer, - }) { - this.clipBehavior = clipBehavior; - return null; - } - - Clip clipBehavior = Clip.none; -} - -class TestPushLayerPaintingContext extends PaintingContext { - TestPushLayerPaintingContext() : super(ContainerLayer(), Rect.zero); - - final pushedLayers = []; - - @override - void pushLayer( - ContainerLayer childLayer, - PaintingContextCallback painter, - Offset offset, { - Rect? childPaintBounds, - }) { - pushedLayers.add(childLayer); - super.pushLayer( - childLayer, - painter, - offset, - childPaintBounds: childPaintBounds, - ); - } -} - -// Absorbs errors that don't have "overflowed" in their error details. -void absorbOverflowedErrors() { - final errorDetails = TestRenderingFlutterBinding.instance - .takeAllFlutterErrorDetails(); - final filtered = errorDetails.where((FlutterErrorDetails details) { - return !details.toString().contains('overflowed'); - }); - if (filtered.isNotEmpty) { - filtered.forEach(FlutterError.reportError); - } -} - -// Reports any FlutterErrors. -void expectNoFlutterErrors() { - final errorDetails = TestRenderingFlutterBinding.instance - .takeAllFlutterErrorDetails(); - errorDetails.forEach(FlutterError.reportError); -} - -RenderConstrainedBox get box200x200 => RenderConstrainedBox( - additionalConstraints: const BoxConstraints.tightFor( - height: 200.0, - width: 200.0, - ), -); diff --git a/packages/devtools_app/web/devtools_analytics.js b/packages/devtools_app/web/devtools_analytics.js deleted file mode 100644 index cb6ddb0f838..00000000000 --- a/packages/devtools_app/web/devtools_analytics.js +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright 2019 The Chromium Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// Used for GA collecting (communicating to https://www.googletagmanager.com/gtag/js script). -window.dataLayer = window.dataLayer || []; -function gtag() { - dataLayer.push(arguments); -} - -// InitializeGA with our dimensions. Both the name and order (dimension #) should match the those in gtags.dart. -// Note that you will also need add the custom dimension(1) or metric(2) in the Analytics 360 Admin console: -// [1]: https://analytics.google.com/analytics/web/#/a26406144w199489242p193859628/admin/custom-dimensions/ -// [2]: https://analytics.google.com/analytics/web/#/a26406144w199489242p193859628/admin/custom-metrics/ -// The index number assigned there should match the dimension or metric number assigned in this configuration. -function initializeGA() { - gtag('js', new Date()); - gtag('event', 'config', { - 'send_to': DEVTOOLS_GOOGLE_TAG_ID, - 'custom_map': { - // Custom dimensions: - 'dimension1': 'user_app', - 'dimension2': 'user_build', - 'dimension3': 'user_platform', - 'dimension4': 'devtools_platform', - 'dimension5': 'devtools_chrome', - 'dimension6': 'devtools_version', - 'dimension7': 'ide_launched', - 'dimension8': 'flutter_client_id', - 'dimension9': 'is_external_build', - 'dimension10': 'is_embedded', - 'dimension11': 'g3_username', - 'dimension12': 'ide_launched_feature', - // Custom metrics: - 'metric1': 'ui_duration_micros', - 'metric2': 'raster_duration_micros', - 'metric3': 'shader_compilation_duration_micros', - 'metric4': 'cpu_sample_count', - 'metric5': 'cpu_stack_depth', - 'metric6': 'trace_event_count', - 'metric7': 'heap_diff_objects_before', - 'metric8': 'heap_diff_objects_after', - 'metric9': 'heap_objects_total', - 'metric10': 'root_set_count', - 'metric11': 'row_count', - 'metric12': 'inspector_tree_controller_id', - 'metric13': 'android_app_id', - 'metric14': 'ios_bundle_id', - 'metric15': 'is_v2_inspector', - }, - cookie_flags: 'SameSite=None;Secure', - }); -} - -function hookupListenerForGA() { - // Record when DevTools browser tab is selected (visible), not selected (hidden) or browser minimized. - document.addEventListener('visibilitychange', function (e) { - gtag('event', document.visibilityState, { - event_category: 'application', - }); - }); -} diff --git a/packages/devtools_app/web/flutter_bootstrap.js b/packages/devtools_app/web/flutter_bootstrap.js index ee6a54d68db..dbe1a2f8918 100644 --- a/packages/devtools_app/web/flutter_bootstrap.js +++ b/packages/devtools_app/web/flutter_bootstrap.js @@ -40,7 +40,12 @@ async function getDevToolsWasmOptOutPreference() { // running from `flutter run` anyway. const request = 'api/getPreferenceValue?key=experiment.wasmOptOut'; try { - const response = await fetch(request); + // This header name must match `devToolsRequestHeader` in + // `packages/devtools_shared/lib/src/devtools_api.dart`. + const response = await fetch(request, { + method: 'POST', + headers: { 'X-DevTools-Request': '1' }, + }); if (!response.ok) { console.warn(`[${response.status} response] ${request}`); return false; diff --git a/packages/devtools_app/web/index.html b/packages/devtools_app/web/index.html index 7200b511f99..4ad835276da 100644 --- a/packages/devtools_app/web/index.html +++ b/packages/devtools_app/web/index.html @@ -17,23 +17,6 @@ - - - - - - - diff --git a/packages/devtools_app/webdriver_test/web_compiler_test.dart b/packages/devtools_app/webdriver_test/web_compiler_test.dart index 7bc64c58d23..db60bc6c5de 100644 --- a/packages/devtools_app/webdriver_test/web_compiler_test.dart +++ b/packages/devtools_app/webdriver_test/web_compiler_test.dart @@ -14,40 +14,88 @@ import 'package:webdriver/async_io.dart'; import '../integration_test/test_infra/run/run_test.dart'; void main() { - late Process devtoolsProcess; - late WebDriver driver; + ChromeDriver? chromedriver; + Process? devtoolsProcess; + WebDriver? driver; late String devToolsServerAddress; const serverStartupTimeout = Duration(minutes: 1); + Future cleanUp() async { + // Close the browser session (ignore errors if it already crashed). + try { + await driver?.quit(); + } catch (e) { + // ignore: avoid_print, this is test code. + print('Warning: Failed to quit WebDriver cleanly: $e'); + } + + // Stop background processes. + devtoolsProcess?.kill(); + try { + await chromedriver?.stop(debugLogging: true); + } catch (e) { + // ignore: avoid_print, this is test code. + print('Warning: Failed to stop chromedriver cleanly: $e'); + } + + // Reset state. + driver = null; + devtoolsProcess = null; + chromedriver = null; + } + setUpAll(() async { - await ChromeDriver().start(debugLogging: true); - - // TODO(https://github.com/flutter/devtools/issues/9197): Launch the - // DevTools server from source. - // For now, this test uses the version of DevTools bundled in the Dart SDK - // because building and running from source is too prohibitive until - // DevTools is moved into the Dart SDK. See issue for details. - devtoolsProcess = await startDevToolsServer(); - devToolsServerAddress = await listenForDevToolsAddress( - devtoolsProcess, - timeout: serverStartupTimeout, - ); - - driver = await createDriver( - uri: Uri.parse('http://127.0.0.1:${ChromeDriver.port}'), - desired: { - ...Capabilities.chrome, - Capabilities.chromeOptions: { - 'args': ['--headless'], + try { + chromedriver = ChromeDriver(); + await chromedriver!.start(debugLogging: true); + + // TODO(https://github.com/flutter/devtools/issues/9197): Launch the + // DevTools server from source. + // For now, this test uses the version of DevTools bundled in the Dart SDK + // because building and running from source is too prohibitive until + // DevTools is moved into the Dart SDK. See issue for details. + final startedDevToolsProcess = await startDevToolsServer(); + devtoolsProcess = startedDevToolsProcess; + devToolsServerAddress = await listenForDevToolsAddress( + startedDevToolsProcess, + timeout: serverStartupTimeout, + ); + + driver = await createDriver( + uri: Uri.parse('http://127.0.0.1:${ChromeDriver.port}'), + desired: { + ...Capabilities.chrome, + Capabilities.chromeOptions: { + 'binary': ?Platform.environment['CHROME_EXECUTABLE'], + 'args': [ + '--headless', + // Apparently, on Ubuntu 24.04+, AppArmor restricts unprivileged user + // namespaces, so this Chrome must be run with '--no-sandbox'. + // Flutter's `ChromiumLauncher.launch` similarly adds '--no-sandbox' + // when headless. + if (Platform.isLinux) '--no-sandbox', + // Explicitly enable SwiftShader on headless Linux and Windows VMs + // without a hardware GPU so WebGL is available for the skwasm + // renderer. See https://github.com/flutter/devtools/issues/10032. + if (Platform.isLinux || Platform.isWindows) ...[ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--disable-gpu-sandbox', + ], + ], + }, }, - }, - ); + ); + } catch (_) { + await cleanUp(); + rethrow; + } }); tearDownAll(() async { - await driver.quit(); - devtoolsProcess.kill(); + await cleanUp(); }); /// Reads the "flt-renderer" attribute on the body element. @@ -56,7 +104,7 @@ void main() { /// https://github.com/flutter/devtools/pull/9406#pullrequestreview-3142210823 Future readRendererAttribute() => retryAsync( () async { - final body = await driver.findElement(const By.tagName('body')); + final body = await driver!.findElement(const By.tagName('body')); return body.attributes['flt-renderer']; }, condition: (result) => result != null, @@ -68,7 +116,7 @@ void main() { timeout: longTimeout, () async { // Open the DevTools URL with ?compiler=wasm. - await driver.get( + await driver!.get( _addQueryParam(devToolsServerAddress, param: 'compiler', value: 'wasm'), ); @@ -76,7 +124,7 @@ void main() { expect(await readRendererAttribute(), equals('skwasm')); // Open the DevTools URL with ?compiler=js. - await driver.get( + await driver!.get( _addQueryParam(devToolsServerAddress, param: 'compiler', value: 'js'), ); diff --git a/packages/devtools_app_shared/CHANGELOG.md b/packages/devtools_app_shared/CHANGELOG.md index 9a7873ddf0e..ee3e1246427 100644 --- a/packages/devtools_app_shared/CHANGELOG.md +++ b/packages/devtools_app_shared/CHANGELOG.md @@ -3,8 +3,26 @@ Copyright 2025 The Flutter Authors Use of this source code is governed by a BSD-style license that can be found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. --> -## 0.5.1 (not released) +## 0.5.2-wip +* Removed redundant argument values. +* Fix `forEachIsolateHelper` to rethrow isolate callback exceptions directly + using `Future.wait` instead of wrapping them in a `ParallelWaitError`. +* Fix a `RangeError` thrown by `SplitPane` when the number of children + changes between rebuilds. +* Fix garbage collection and timeout issues with the result list in `asyncEval` + on both native VM and web. +* Safely handle RPC errors and unexpected exceptions when calling service + extensions in `ServiceExtensionManager`. +* The minimum Dart SDK version is bumped to 3.11.0. +* The minimum Flutter SDK version is bumped to 3.41.0. +* Fix `RootInfo.package` to be `null` when the root library is not a `package:` URI, + such as the `file:` URI of a Dart CLI app started with `dart run`. Previously it + was the scheme of the URI, such as `file:`, which is not a package. +* Updates `devtools_shared` constraint to `^14.0.1`. + +## 0.5.1 * Add DevTools-styled text field `DevToolsTextField`. +* Updates `devtools_shared` constraint to `^13.0.0`. ## 0.5.0 * **Breaking change:** remove `scaleByFontFactor`. diff --git a/packages/devtools_app_shared/example/service_example.dart b/packages/devtools_app_shared/example/service_example.dart index 95c7e8c841a..aec00ae4c14 100644 --- a/packages/devtools_app_shared/example/service_example.dart +++ b/packages/devtools_app_shared/example/service_example.dart @@ -40,10 +40,8 @@ void main() async { ); /// Example: Get a service extension state. - final performanceOverlayEnabled = - serviceManager.serviceExtensionManager.getServiceExtensionState( - extensions.performanceOverlay.extension, - ); + final performanceOverlayEnabled = serviceManager.serviceExtensionManager + .getServiceExtensionState(extensions.performanceOverlay.extension); // Example: Set a service extension state. await serviceManager.serviceExtensionManager.setServiceExtensionState( diff --git a/packages/devtools_app_shared/example/ui/common_example.dart b/packages/devtools_app_shared/example/ui/common_example.dart index e047dc2f6e6..90935eaf4c7 100644 --- a/packages/devtools_app_shared/example/ui/common_example.dart +++ b/packages/devtools_app_shared/example/ui/common_example.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart' as devtools_shared_ui; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; class ExampleWidget extends StatelessWidget { const ExampleWidget({super.key}); diff --git a/packages/devtools_app_shared/example/ui/dialog_example.dart b/packages/devtools_app_shared/example/ui/dialog_example.dart index 3100916b0c5..3cffa0a0d4f 100644 --- a/packages/devtools_app_shared/example/ui/dialog_example.dart +++ b/packages/devtools_app_shared/example/ui/dialog_example.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart' as devtools_shared_ui; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Example of using a [devtools_shared_ui.DevToolsDialog] widget from /// 'package:devtools_app_shared/ui.dart'. diff --git a/packages/devtools_app_shared/example/ui/split_example.dart b/packages/devtools_app_shared/example/ui/split_example.dart index ef3f83267ab..01d5f43a005 100644 --- a/packages/devtools_app_shared/example/ui/split_example.dart +++ b/packages/devtools_app_shared/example/ui/split_example.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart' as devtools_shared_ui; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Example of using the [devtools_shared_ui.SplitPane] widget from /// 'package:devtools_app_shared/ui.dart' with two children laid across a @@ -20,10 +20,7 @@ class SplitExample extends StatelessWidget { axis: Axis.horizontal, initialFractions: const [0.3, 0.7], minSizes: const [50.0, 100.0], - children: const [ - Text('Left side'), - Text('Right side'), - ], + children: const [Text('Left side'), Text('Right side')], ); } } @@ -42,15 +39,8 @@ class MultiSplitExample extends StatelessWidget { axis: Axis.vertical, initialFractions: const [0.3, 0.3, 0.4], minSizes: const [50.0, 50.0, 100.0], - splitters: const [ - CustomSplitter(), - CustomSplitter(), - ], - children: const [ - Text('Top'), - Text('Middle'), - Text('Bottom'), - ], + splitters: const [CustomSplitter(), CustomSplitter()], + children: const [Text('Top'), Text('Middle'), Text('Bottom')], ); } } @@ -62,10 +52,7 @@ class CustomSplitter extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { - return const SizedBox( - height: _size, - child: Icon(Icons.front_hand), - ); + return const SizedBox(height: _size, child: Icon(Icons.front_hand)); } @override diff --git a/packages/devtools_app_shared/lib/src/service/connected_app.dart b/packages/devtools_app_shared/lib/src/service/connected_app.dart index 39f1fc22475..5c926ef4b06 100644 --- a/packages/devtools_app_shared/lib/src/service/connected_app.dart +++ b/packages/devtools_app_shared/lib/src/service/connected_app.dart @@ -52,8 +52,8 @@ class ConnectedApp { String? _operatingSystem; // TODO(kenz): investigate if we can use `libraryUriAvailableNow` instead. - Future get isFlutterApp async => _isFlutterApp ??= - await serviceManager!.libraryUriAvailable(flutterLibraryUri); + Future get isFlutterApp async => _isFlutterApp ??= await serviceManager! + .libraryUriAvailable(flutterLibraryUri); bool? get isFlutterAppNow { assert(_isFlutterApp != null); @@ -85,8 +85,8 @@ class ConnectedApp { bool? _isProfileBuild; // TODO(kenz): investigate if we can use `libraryUriAvailableNow` instead. - Future get isDartWebApp async => _isDartWebApp ??= - await serviceManager!.libraryUriAvailable(dartHtmlLibraryUri); + Future get isDartWebApp async => _isDartWebApp ??= await serviceManager! + .libraryUriAvailable(dartHtmlLibraryUri); bool? get isDartWebAppNow { assert(_isDartWebApp != null); @@ -189,14 +189,14 @@ class ConnectedApp { } Map toJson() => { - isFlutterAppKey: isFlutterAppNow, - isProfileBuildKey: isProfileBuildNow, - isDartWebAppKey: isDartWebAppNow, - isRunningOnDartVMKey: isRunningOnDartVM, - operatingSystemKey: operatingSystem, - if (flutterVersionNow != null && !flutterVersionNow!.unknown) - flutterVersionKey: flutterVersionNow!.version, - }; + isFlutterAppKey: isFlutterAppNow, + isProfileBuildKey: isProfileBuildNow, + isDartWebAppKey: isDartWebAppNow, + isRunningOnDartVMKey: isRunningOnDartVM, + operatingSystemKey: operatingSystem, + if (flutterVersionNow != null && !flutterVersionNow!.unknown) + flutterVersionKey: flutterVersionNow!.version, + }; } final class OfflineConnectedApp extends ConnectedApp { @@ -215,7 +215,8 @@ final class OfflineConnectedApp extends ConnectedApp { isProfileBuildNow: json[ConnectedApp.isProfileBuildKey] as bool?, isDartWebAppNow: json[ConnectedApp.isDartWebAppKey] as bool?, isRunningOnDartVM: json[ConnectedApp.isRunningOnDartVMKey] as bool?, - operatingSystem: (json[ConnectedApp.operatingSystemKey] as String?) ?? + operatingSystem: + (json[ConnectedApp.operatingSystemKey] as String?) ?? ConnectedApp.unknownOS, ); } diff --git a/packages/devtools_app_shared/lib/src/service/dtd_manager.dart b/packages/devtools_app_shared/lib/src/service/dtd_manager.dart index 0035f7ac5e3..9f78fddbcc1 100644 --- a/packages/devtools_app_shared/lib/src/service/dtd_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/dtd_manager.dart @@ -25,8 +25,9 @@ class DTDManager { /// The current state of the connection. ValueListenable get connectionState => _connectionState; - final _connectionState = - ValueNotifier(NotConnectedDTDState()); + final _connectionState = ValueNotifier( + NotConnectedDTDState(), + ); /// The URI of the current DTD connection. Uri? get uri => _uri; @@ -77,13 +78,15 @@ class DTDManager { // // If this happens, just disconnect (without disabling reconnect) so the // done event fires and then the usual handling occurs. - _periodicConnectionCheck = - Timer.periodic(_periodicConnectionCheckInterval, (timer) async { - if (_dtd.isClosed) { - _log.warning('The DTD connection has dropped'); - await disconnectImpl(allowReconnect: true); - } - }); + _periodicConnectionCheck = Timer.periodic( + _periodicConnectionCheckInterval, + (timer) async { + if (_dtd.isClosed) { + _log.warning('The DTD connection has dropped'); + await disconnectImpl(allowReconnect: true); + } + }, + ); return dtd; } @@ -182,12 +185,16 @@ class DTDManager { // If a connection drops (and we hadn't disabled auto-reconnect, such // as by explicitly calling disconnect/dispose), we should attempt to // reconnect. - unawaited(connection.done - .then((_) => _reconnectAfterDroppedConnection(uri, onError: onError)) - .catchError((_) { - // TODO(dantup): Create a devtools_app_shared version of safeUnawaited. - // https://github.com/flutter/devtools/pull/9587#discussion_r2624306047 - })); + unawaited( + connection.done + .then( + (_) => _reconnectAfterDroppedConnection(uri, onError: onError), + ) + .catchError((_) { + // TODO(dantup): Create a devtools_app_shared version of safeUnawaited. + // https://github.com/flutter/devtools/pull/9587#discussion_r2624306047 + }), + ); } catch (e, st) { onError?.call(e, st); } @@ -229,8 +236,8 @@ class DTDManager { }) { // On explicit connections, we capture the connect function so that we // can call it again if [reconnect()] is called. - final connectFunc = _lastConnectFunc = - () => _connectImpl(uri, onError: onError, maxRetries: maxRetries); + final connectFunc = _lastConnectFunc = () => + _connectImpl(uri, onError: onError, maxRetries: maxRetries); return connectFunc(); } @@ -276,20 +283,26 @@ class DTDManager { await disconnect(); await _currentServiceRegistrationSubscription?.cancel(); await _serviceRegistrationController.close(); + _periodicConnectionCheck?.cancel(); + _periodicConnectionCheck = null; + _connectionState.dispose(); _connection.dispose(); } /// Listens for service registration events on the [dtd] connection. Future _listenForServiceRegistrationEvents( - DartToolingDaemon dtd) async { + DartToolingDaemon dtd, + ) async { // We immediately begin listening for service registration events on the new // DTD connection before canceling the previous subscription. This // guarantees that we don't miss any events across reconnects. // ignore: cancel_subscriptions, false positive, it is canceled below. final nextServiceRegistrationSubscription = dtd .onEvent(CoreDtdServiceConstants.servicesStreamId) - .listen(_forwardServiceRegistrationEvents, - onError: _logServiceStreamError); + .listen( + _forwardServiceRegistrationEvents, + onError: _logServiceStreamError, + ); await dtd.streamListen(CoreDtdServiceConstants.servicesStreamId); // Cancel the previous subscription. @@ -304,7 +317,7 @@ class DTDManager { final kind = event.kind; final isRegistrationEvent = kind == CoreDtdServiceConstants.serviceRegisteredKind || - kind == CoreDtdServiceConstants.serviceUnregisteredKind; + kind == CoreDtdServiceConstants.serviceUnregisteredKind; if (isRegistrationEvent) { _serviceRegistrationController.add(event); diff --git a/packages/devtools_app_shared/lib/src/service/eval_on_dart_library.dart b/packages/devtools_app_shared/lib/src/service/eval_on_dart_library.dart index bde16b1bce6..ba55c03fba6 100644 --- a/packages/devtools_app_shared/lib/src/service/eval_on_dart_library.dart +++ b/packages/devtools_app_shared/lib/src/service/eval_on_dart_library.dart @@ -5,15 +5,15 @@ // This code is directly based on src/io/flutter/inspector/EvalOnDartLibrary.java // If you add a method to this class you should also add it to EvalOnDartLibrary.java import 'dart:async'; -import 'dart:core' hide Error; import 'dart:core' as core; +import 'dart:core' hide Error; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; -import 'package:vm_service/vm_service.dart' hide Error; import 'package:vm_service/vm_service.dart' as vm_service; +import 'package:vm_service/vm_service.dart' hide Error; import '../utils/auto_dispose.dart'; import 'service_manager.dart'; @@ -105,8 +105,9 @@ class EvalOnDartLibrary extends DisposableController } try { - final isolate = - await serviceManager.isolateManager.isolateState(isolateRef).isolate; + final isolate = await serviceManager.isolateManager + .isolateState(isolateRef) + .isolate; if (_currentRequestId != requestId) { // The initialize request is obsolete. return; @@ -150,11 +151,7 @@ class EvalOnDartLibrary extends DisposableController } return await addRequest( isAlive, - () => _eval( - expression, - scope: scope, - shouldLogError: shouldLogError, - ), + () => _eval(expression, scope: scope, shouldLogError: shouldLogError), ); } @@ -345,6 +342,21 @@ class EvalOnDartLibrary extends DisposableController static int _nextAsyncEvalId = 0; + Future _safeEvalWithRetry( + EvalOnDartLibrary eval, + String expression, { + required Disposable? isAlive, + Map? scope, + }) async { + try { + return await eval.safeEval(expression, isAlive: isAlive, scope: scope); + } catch (_) { + // In some environments, bootstrap evals can race isolate readiness. + await Future.delayed(const Duration(milliseconds: 50)); + return await eval.safeEval(expression, isAlive: isAlive, scope: scope); + } + } + EvalOnDartLibrary? _dartDeveloperEvalCache; EvalOnDartLibrary get _dartDeveloperEval { return _dartDeveloperEvalCache ??= EvalOnDartLibrary( @@ -395,35 +407,61 @@ class EvalOnDartLibrary extends DisposableController final readerGroup = 'asyncEval-$futureId'; /// Workaround to not being able to import libraries directly from an evaluation - final postEventRef = await _dartDeveloperEval.safeEval( + final postEventRef = await _safeEvalWithRetry( + _dartDeveloperEval, 'postEvent', isAlive: isAlive, ); - final widgetInspectorServiceRef = await _widgetInspectorEval.safeEval( + final widgetInspectorServiceRef = await _safeEvalWithRetry( + _widgetInspectorEval, 'WidgetInspectorService.instance', isAlive: isAlive, ); - final readerId = await safeEval( - // since we are awaiting the Future, we need to make sure that during the awaiting, - // the "reader" is not GCed - 'widgetInspectorService.toId([], "$readerGroup")', - isAlive: isAlive, - scope: {'widgetInspectorService': widgetInspectorServiceRef.id!}, - ).then((ref) => ref.valueAsString!); - await safeEval( '() async {' - ' final reader = widgetInspectorService.toObject("$readerId", "$readerGroup") as List;' + ' String? readerId;' ' try {' + ' final reader = [];' + ' readerId = widgetInspectorService.toId(reader, "$readerGroup") as String;' + ' try {' // Cast as dynamic so that it is possible to await Future - ' dynamic result = ($expression) as dynamic;' - ' reader.add(result);' - ' } catch (err, stack) {' - ' reader.add(err);' - ' reader.add(stack);' - ' } finally {' - ' postEvent("future_completed", {"future_id": $futureId, "client_id": $_clientId});' + ' dynamic result = ($expression) as dynamic;' + ' reader.add(result);' + ' } catch (err, stack) {' + ' reader.add(err);' + ' reader.add(stack);' + ' } finally {' + ' postEvent("future_completed", {' + ' "future_id": $futureId,' + ' "client_id": $_clientId,' + ' "reader_id": readerId,' + ' });' + ' /* Keep a strong reference to `reader` in the target app to prevent it' + ' from being garbage collected by Chrome/VM before DevTools retrieves it.' + ' Without this, the reader is only weakly referenced by the inspector' + ' service and is aggressively GCed, causing a TypeError/TimeoutException' + ' or failing the assertion that the retrieved result length is 1 or 2.' + ' We use Future.delayed in a loop instead of Timer because Future is in' + ' dart:core and guaranteed to be resolved without requiring dart:async. */' + ' for (int i = 0; i < 200; i++) {' + ' await Future.delayed(const Duration(milliseconds: 50));' + ' try {' + ' if (widgetInspectorService.toObject(readerId!, "$readerGroup") == null) {' + ' break;' + ' }' + ' } catch (_) {' + ' break;' + ' }' + ' final _ = reader.length;' + ' }' + ' }' + ' } catch (err) {' + ' postEvent("future_completed", {' + ' "future_id": $futureId,' + ' "client_id": $_clientId,' + ' "error": err.toString(),' + ' });' ' }' '}()', isAlive: isAlive, @@ -434,11 +472,19 @@ class EvalOnDartLibrary extends DisposableController }, ); - await future; + final event = await future; + if (event.extensionData?.data['error'] != null) { + throw UnknownEvalException( + expression: expression, + scope: scope, + exception: event.extensionData!.data['error'], + ); + } + final readerId = event.extensionData!.data['reader_id'] as String; final resultRef = await evalInstance( '() {' - ' final result = widgetInspectorService.toObject("$readerId", "$readerGroup") as List;' + ' final result = widgetInspectorService.toObject("$readerId", "$readerGroup");' ' widgetInspectorService.disposeGroup("$readerGroup");' ' return result;' '}()', @@ -559,7 +605,7 @@ class EvalOnDartLibrary extends DisposableController // This is an optimization to avoid sending stale requests across the wire. void wrappedRequest() async { if (isAlive != null && isAlive.disposed || disposed) { - response.complete(null); + response.complete(); return; } try { @@ -567,11 +613,11 @@ class EvalOnDartLibrary extends DisposableController if (!disposed && value is! Sentinel) { response.complete(value); } else { - response.complete(null); + response.complete(); } } catch (e) { if (disposed || isAlive?.disposed == true) { - response.complete(null); + response.complete(); } else { response.completeError(e); } @@ -583,7 +629,7 @@ class EvalOnDartLibrary extends DisposableController wrappedRequest(); } else { if (isAlive != null && isAlive.disposed || disposed) { - response.complete(null); + response.complete(); return response.future; } @@ -609,12 +655,14 @@ class EvalOnDartLibrary extends DisposableController int? count, }) { return addRequest(isAlive, () async { - final T value = await service.getObject( - _isolateRef!.id!, - instance.id!, - offset: offset, - count: count, - ) as T; + final T value = + await service.getObject( + _isolateRef!.id!, + instance.id!, + offset: offset, + count: count, + ) + as T; return value; }); } diff --git a/packages/devtools_app_shared/lib/src/service/flutter_version.dart b/packages/devtools_app_shared/lib/src/service/flutter_version.dart index 5e4145499e7..af02f824b55 100644 --- a/packages/devtools_app_shared/lib/src/service/flutter_version.dart +++ b/packages/devtools_app_shared/lib/src/service/flutter_version.dart @@ -94,14 +94,14 @@ final class FlutterVersion extends SemanticVersion { @override int get hashCode => Object.hash( - version, - channel, - repositoryUrl, - frameworkRevision, - frameworkCommitDate, - engineRevision, - dartSdkVersion, - ); + version, + channel, + repositoryUrl, + frameworkRevision, + frameworkCommitDate, + engineRevision, + dartSdkVersion, + ); static final _stableVersionRegex = RegExp(r'^\d+\.\d+\.\d+$'); static final _isNumericRegex = RegExp(r'\d'); diff --git a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart index 9da67d86278..71ac7d41341 100644 --- a/packages/devtools_app_shared/lib/src/service/isolate_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/isolate_manager.dart @@ -40,6 +40,13 @@ final class IsolateManager with DisposerMixin { final _isolateCreatedController = StreamController.broadcast(); final _isolateExitedController = StreamController.broadcast(); + /// Stream of isolates that have been created since the VM service connection + /// was opened. + /// + /// This can be used to detect when a new isolate is spawned, such as after a + /// hot restart. + Stream get onIsolateCreated => _isolateCreatedController.stream; + ValueListenable get selectedIsolate => _selectedIsolate; final _selectedIsolate = ValueNotifier(null); diff --git a/packages/devtools_app_shared/lib/src/service/isolate_state.dart b/packages/devtools_app_shared/lib/src/service/isolate_state.dart index ae8ec45c3d6..7ef89c66498 100644 --- a/packages/devtools_app_shared/lib/src/service/isolate_state.dart +++ b/packages/devtools_app_shared/lib/src/service/isolate_state.dart @@ -34,7 +34,8 @@ class IsolateState { void handleIsolateLoad(Isolate isolate) { _isolateNow = isolate; - _isPaused.value = isolate.pauseEvent != null && + _isPaused.value = + isolate.pauseEvent != null && isolate.pauseEvent!.kind != EventKind.kResume; rootInfo = RootInfo(_isolateNow!.rootLib?.uri); @@ -46,8 +47,9 @@ class IsolateState { _isolateNow = null; _isolateLoadCompleter.safeComplete( null, - () => _isolateLoadCompleter = Completer()..complete(null), + () => _isolateLoadCompleter = Completer()..complete(), ); + _isPaused.dispose(); } void handleDebugEvent(String? kind) { @@ -67,14 +69,29 @@ class IsolateState { } } +/// Information about the root library of an isolate of the connected app. +/// +/// The root library is the library that contains the `main` function of the +/// isolate. See also [Isolate.rootLib]. class RootInfo { RootInfo(this.library) : package = _libraryToPackage(library); + /// The URI of the root library, or `null` if it is unknown. + /// + /// This is not necessarily a `package:` URI. For example, the root library of + /// a Dart CLI app started with `dart run` is a `file:` URI like + /// `file:///app/bin/main.dart`. final String? library; + + /// The package that contains the root library, in the form `package:name`, or + /// `null` if it is unknown. + /// + /// This is `null` if [library] is `null` or is not a `package:` URI (for + /// example, a `file:` URI), since the name of the package is not known then. final String? package; static String? _libraryToPackage(String? library) { - if (library == null) return null; + if (library == null || !library.startsWith('package:')) return null; final slashIndex = library.indexOf('/'); if (slashIndex == -1) return library; return library.substring(0, slashIndex); diff --git a/packages/devtools_app_shared/lib/src/service/resolved_uri_manager.dart b/packages/devtools_app_shared/lib/src/service/resolved_uri_manager.dart index aaed6145e30..1de7b8177ab 100644 --- a/packages/devtools_app_shared/lib/src/service/resolved_uri_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/resolved_uri_manager.dart @@ -31,8 +31,10 @@ final class ResolvedUriManager { Future fetchPackageUris(String isolateId, List uris) async { if (uris.isEmpty) return; if (_packagePathMappings != null) { - final packageUris = - (await _service!.lookupPackageUris(isolateId, uris)).uris; + final packageUris = (await _service!.lookupPackageUris( + isolateId, + uris, + )).uris; if (packageUris != null) { _packagePathMappings!.addMappings( @@ -54,9 +56,10 @@ final class ResolvedUriManager { /// * [packageUris] - List of URIs to fetch full file paths for. Future fetchFileUris(String isolateId, List packageUris) async { if (_packagePathMappings != null) { - final fileUris = - (await _service!.lookupResolvedPackageUris(isolateId, packageUris)) - .uris; + final fileUris = (await _service!.lookupResolvedPackageUris( + isolateId, + packageUris, + )).uris; // [_packagePathMappings] could have been set to null during the async gap // so check that it is non-null again here. @@ -95,15 +98,11 @@ class _PackagePathMappings { String? lookupPackageToFullPathMapping( String isolateId, String packagePath, - ) => - _isolatePackageToFullPathMappings[isolateId]?[packagePath]; + ) => _isolatePackageToFullPathMappings[isolateId]?[packagePath]; /// Returns the full path to package path mapping if it has already /// been fetched. - String? lookupFullPathToPackageMapping( - String isolateId, - String fullPath, - ) => + String? lookupFullPathToPackageMapping(String isolateId, String fullPath) => _isolateFullPathToPackageMappings[isolateId]?[fullPath]; /// Saves the mappings of [fullPaths] to [packagePaths]. @@ -118,16 +117,10 @@ class _PackagePathMappings { required List packagePaths, }) { assert(fullPaths.length == packagePaths.length); - final fullPathToPackageMappings = - _isolateFullPathToPackageMappings.putIfAbsent( - isolateId, - () => {}, - ); - final packageToFullPathMappings = - _isolatePackageToFullPathMappings.putIfAbsent( - isolateId, - () => {}, - ); + final fullPathToPackageMappings = _isolateFullPathToPackageMappings + .putIfAbsent(isolateId, () => {}); + final packageToFullPathMappings = _isolatePackageToFullPathMappings + .putIfAbsent(isolateId, () => {}); assert(fullPaths.length == packagePaths.length); diff --git a/packages/devtools_app_shared/lib/src/service/service_extension_manager.dart b/packages/devtools_app_shared/lib/src/service/service_extension_manager.dart index 7e1eda1b61a..c2d9bafafd2 100644 --- a/packages/devtools_app_shared/lib/src/service/service_extension_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/service_extension_manager.dart @@ -148,7 +148,7 @@ final class ServiceExtensionManager with DisposerMixin { _pendingServiceExtensions.clear(); await [ for (final extension in extensionsToProcess) - _addServiceExtension(extension) + _addServiceExtension(extension), ].wait; } @@ -184,11 +184,12 @@ final class ServiceExtensionManager with DisposerMixin { } await [ for (final extension in extensionRpcs) - _maybeAddServiceExtension(extension) + _maybeAddServiceExtension(extension), ].wait; } else { await [ - for (final extension in extensionRpcs) _addServiceExtension(extension) + for (final extension in extensionRpcs) + _addServiceExtension(extension), ].wait; } } @@ -221,12 +222,21 @@ final class ServiceExtensionManager with DisposerMixin { if (didSendFirstFrameEvent) { await _onFrameEventReceived(); } + } on SentinelException catch (_) { + // Service extension or isolate stopped existing while calling, so do + // nothing. This typically happens during hot restarts. + return; } on RPCError catch (e) { - if (e.code == RPCErrorKind.kServerError.code) { + if (e.code == RPCErrorKind.kServerError.code || + e.isServiceDisposedError) { // Connection disappeared return; } - rethrow; + _log.warning('Error checking for first Flutter frame: $e'); + return; + } catch (e, st) { + _log.warning('Error checking for first Flutter frame: $e', e, st); + return; } } @@ -273,6 +283,8 @@ final class ServiceExtensionManager with DisposerMixin { } on SentinelException catch (_) { // Service extension stopped existing while calling, so do nothing. // This typically happens during hot restarts. + } catch (e, st) { + _log.warning('Error adding service extension $name: $e', e, st); } } else { // Set any extensions that are already enabled on the device. This will @@ -337,6 +349,10 @@ final class ServiceExtensionManager with DisposerMixin { ); await _maybeRestoreExtension(name, value); } + } on SentinelException catch (_) { + // Service extension or isolate stopped existing while restoring, so do + // nothing. This typically happens during hot restarts. + return false; } on RPCError catch (e) { if (e.isServiceDisposedError) { return false; @@ -437,12 +453,21 @@ final class ServiceExtensionManager with DisposerMixin { args: {name.substring(name.lastIndexOf('.') + 1): value}, ); } + } on SentinelException catch (_) { + // Service extension or isolate stopped existing while calling, so do + // nothing. This typically happens during hot restarts. + return false; } on RPCError catch (e) { - if (e.code == RPCErrorKind.kServerError.code) { + if (e.isServiceDisposedError || + e.code == RPCErrorKind.kServerError.code) { // The connection disappeared. return false; } - rethrow; + _log.warning('Failed to call service extension $name: $e'); + return false; + } catch (e, st) { + _log.warning('Failed to call service extension $name: $e', e, st); + return false; } return true; @@ -489,10 +514,8 @@ final class ServiceExtensionManager with DisposerMixin { _performActionAndClearMap( _serviceExtensionStates, - action: (state) => state.value = ServiceExtensionState( - enabled: false, - value: null, - ), + action: (state) => + state.value = ServiceExtensionState(enabled: false, value: null), ); } @@ -572,21 +595,15 @@ final class ServiceExtensionManager with DisposerMixin { } ValueNotifier _serviceExtensionState(String name) { - return _serviceExtensionStates.putIfAbsent( - name, - () { - final state = _enabledServiceExtensions[name]; - return ValueNotifier( - state ?? ServiceExtensionState(enabled: false, value: null), - ); - }, - ); + return _serviceExtensionStates.putIfAbsent(name, () { + final state = _enabledServiceExtensions[name]; + return ValueNotifier( + state ?? ServiceExtensionState(enabled: false, value: null), + ); + }); } - void vmServiceOpened( - VmService service, - ConnectedApp connectedApp, - ) async { + void vmServiceOpened(VmService service, ConnectedApp connectedApp) async { _checkForFirstFrameStarted = false; cancelStreamSubscriptions(); cancelListeners(); @@ -610,8 +627,9 @@ final class ServiceExtensionManager with DisposerMixin { final mainIsolateRef = _isolateManager.mainIsolate.value; if (mainIsolateRef != null) { _checkForFirstFrameStarted = false; - final mainIsolate = - await _isolateManager.isolateState(mainIsolateRef).isolate; + final mainIsolate = await _isolateManager + .isolateState(mainIsolateRef) + .isolate; if (mainIsolate != null) { await _registerMainIsolate(mainIsolate, mainIsolateRef); } @@ -638,10 +656,7 @@ class ServiceExtensionState { } @override - int get hashCode => Object.hash( - enabled, - value, - ); + int get hashCode => Object.hash(enabled, value); @override String toString() { diff --git a/packages/devtools_app_shared/lib/src/service/service_extensions.dart b/packages/devtools_app_shared/lib/src/service/service_extensions.dart index cada4b02b97..89d75f118d5 100644 --- a/packages/devtools_app_shared/lib/src/service/service_extensions.dart +++ b/packages/devtools_app_shared/lib/src/service/service_extensions.dart @@ -3,10 +3,10 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; class ServiceExtension { ServiceExtension({ @@ -201,6 +201,13 @@ final togglePlatformMode = ServiceExtension( values: ['iOS', 'android', 'fuchsia', 'macOS', 'linux'], ); +/// Override the platform brightness (light mode, dark mode, or system default). +final brightnessMode = ServiceExtension( + extension: + '$flutterExtensionPrefix${FoundationServiceExtensions.brightnessOverride.name}', + values: ['system', 'Brightness.light', 'Brightness.dark'], +); + /// Toggle whether interacting with the device selects widgets or triggers /// normal interactions. final toggleSelectWidgetMode = ToggleableServiceExtension( @@ -259,6 +266,7 @@ final _extensionDescriptions = >[ toggleSelectWidgetMode, countWidgetBuilds, profilePlatformChannels, + brightnessMode, ]; /// Service extensions that are not safe to call unless a frame has already @@ -281,6 +289,7 @@ final _unsafeBeforeFirstFrameFlutterExtensions = Set.of( enableOnDeviceInspector, togglePlatformMode, slowAnimations, + brightnessMode, ].map((extension) => extension.extension), ); diff --git a/packages/devtools_app_shared/lib/src/service/service_manager.dart b/packages/devtools_app_shared/lib/src/service/service_manager.dart index 8745c2c0b87..da70413c75a 100644 --- a/packages/devtools_app_shared/lib/src/service/service_manager.dart +++ b/packages/devtools_app_shared/lib/src/service/service_manager.dart @@ -49,9 +49,7 @@ enum ServiceManagerLifecycle { afterCloseVmService, } -enum ServiceManagerOverride { - initIsolates, -} +enum ServiceManagerOverride { initIsolates } // TODO(kenz): add an offline service manager implementation. // TODO(https://github.com/flutter/devtools/issues/6239): try to remove this. @@ -149,8 +147,9 @@ class ServiceManager { ValueListenable get connectedState => _connectedState; - final _connectedState = - ValueNotifier(const ConnectedState(false)); + final _connectedState = ValueNotifier( + const ConnectedState(false), + ); final _deviceBusy = ValueNotifier(false); @@ -210,10 +209,7 @@ class ServiceManager { ServiceManagerCallback callback, ) { _lifecycleCallbacks - .putIfAbsent( - lifecycle, - () => >[], - ) + .putIfAbsent(lifecycle, () => >[]) .add(callback); } @@ -319,9 +315,7 @@ class ServiceManager { try { await vmService.setFlag('pause_isolates_on_start', 'true'); - await vmService.requirePermissionToResume( - onPauseStart: true, - ); + await vmService.requirePermissionToResume(onPauseStart: true); } catch (error) { _log.warning('$error'); } @@ -433,8 +427,10 @@ class ServiceManager { Future manuallyDisconnect() async { if (connectedState.value.connected) { await vmServiceClosed( - connectionState: - const ConnectedState(false, userInitiatedConnectionState: true), + connectionState: const ConnectedState( + false, + userInitiatedConnectionState: true, + ), ); } } @@ -475,9 +471,7 @@ class ServiceManager { } Future get flutterVersion async { - return await callServiceOnMainIsolate( - flutterVersionService.service, - ); + return await callServiceOnMainIsolate(flutterVersionService.service); } /// This can throw an [RPCError]. @@ -513,8 +507,9 @@ class ServiceManager { /// running the test runner, and not the test library itself, so we have to do /// some extra work to find the package root of the test target. Future connectedAppPackageRoot(DTDManager dtdManager) async { - var packageRootUriString = - await rootPackageDirectoryForMainIsolate(dtdManager); + var packageRootUriString = await rootPackageDirectoryForMainIsolate( + dtdManager, + ); _log.fine( '[connectedAppPackageRoot] root package directory for main isolate: ' '$packageRootUriString', @@ -525,14 +520,12 @@ class ServiceManager { if (packageRootUriString?.endsWith('.dart') ?? false) { final rootLibrary = await _mainIsolateRootLibrary(); if (rootLibrary != null) { - packageRootUriString = (await _lookupPackageRootByEval(rootLibrary)) ?? + packageRootUriString = + (await _lookupPackageRootByEval(rootLibrary)) ?? // TODO(kenz): remove this fallback once all test bootstrap // generators include the `packageConfigLocation` constant we // can evaluate. - await _lookupPackageRootByImportPrefix( - rootLibrary, - dtdManager, - ); + await _lookupPackageRootByImportPrefix(rootLibrary, dtdManager); } } _log.fine( @@ -559,14 +552,15 @@ class ServiceManager { final packageConfig = (await eval.evalInstance( 'packageConfigLocation', isAlive: evalDisposable, - )) - .valueAsString; + )).valueAsString; // TODO(https://github.com/flutter/devtools/issues/7944): return the // unmodified package config location. For this case, be sure to handle // invalid values like the empty String or 'null'. - final packageConfigIdentifier = - path.join('.dart_tool', 'package_config.json'); + final packageConfigIdentifier = path.join( + '.dart_tool', + 'package_config.json', + ); if (packageConfig?.endsWith(packageConfigIdentifier) ?? false) { _log.fine( '[connectedAppPackageRoot] detected test package config from root ' diff --git a/packages/devtools_app_shared/lib/src/service/service_utils.dart b/packages/devtools_app_shared/lib/src/service/service_utils.dart index 5470605f681..82e00516528 100644 --- a/packages/devtools_app_shared/lib/src/service/service_utils.dart +++ b/packages/devtools_app_shared/lib/src/service/service_utils.dart @@ -88,6 +88,6 @@ Future forEachIsolateHelper( ) async { final vm = await vmService.getVM(); if (vm.isolates case final vmIsolates? when vmIsolates.isNotEmpty) { - await [for (final isolate in vmIsolates) callback(isolate)].wait; + await Future.wait([for (final isolate in vmIsolates) callback(isolate)]); } } diff --git a/packages/devtools_app_shared/lib/src/ui/buttons.dart b/packages/devtools_app_shared/lib/src/ui/buttons.dart index 9fe953497e7..7665669e7c1 100644 --- a/packages/devtools_app_shared/lib/src/ui/buttons.dart +++ b/packages/devtools_app_shared/lib/src/ui/buttons.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'common.dart'; import 'theme/theme.dart'; @@ -25,9 +25,9 @@ class DevToolsButton extends StatelessWidget { this.outlined = true, this.tooltipPadding, }) : assert( - label != null || icon != null, - 'Either icon or label must be specified.', - ); + label != null || icon != null, + 'Either icon or label must be specified.', + ); factory DevToolsButton.iconOnly({ required IconData icon, @@ -92,8 +92,9 @@ class DevToolsButton extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; var textColor = color; if (textColor == null && elevated) { - textColor = - onPressed == null ? colorScheme.onSurface : colorScheme.onPrimary; + textColor = onPressed == null + ? colorScheme.onSurface + : colorScheme.onPrimary; } final iconLabel = MaterialIconLabel( label: label!, @@ -116,10 +117,7 @@ class DevToolsButton extends StatelessWidget { child: maybeWrapWithTooltip( tooltip: tooltip, tooltipPadding: tooltipPadding, - child: ElevatedButton( - onPressed: onPressed, - child: iconLabel, - ), + child: ElevatedButton(onPressed: onPressed, child: iconLabel), ), ); } @@ -239,8 +237,9 @@ final class DevToolsToggleButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return DevToolsToggleButtonGroup( - borderColor: - outlined || isSelected ? theme.focusColor : Colors.transparent, + borderColor: outlined || isSelected + ? theme.focusColor + : Colors.transparent, selectedStates: [isSelected], onPressed: (_) => onPressed(), fillColor: fillColor, @@ -291,9 +290,7 @@ final class RoundedButtonGroup extends StatelessWidget { button = Container( decoration: BoxDecoration( border: Border( - left: BorderSide( - color: Theme.of(context).focusColor, - ), + left: BorderSide(color: Theme.of(context).focusColor), ), ), child: button, @@ -306,9 +303,7 @@ final class RoundedButtonGroup extends StatelessWidget { height: defaultButtonHeight, child: RoundedOutlinedBorder( child: Row( - children: [ - for (int i = 0; i < items.length; i++) buildButton(i), - ], + children: [for (int i = 0; i < items.length; i++) buildButton(i)], ), ), ); @@ -366,15 +361,15 @@ final class ButtonGroupItemData { String? tooltip, this.onPressed, this.autofocus = false, - }) : tooltip = tooltip ?? label, - assert( - label != null || icon != null || iconAsset != null, - 'At least one of icon, iconAsset, or label must be specified.', - ), - assert( - icon == null || iconAsset == null, - 'Only one of icon and iconAsset may be specified.', - ); + }) : tooltip = tooltip ?? label, + assert( + label != null || icon != null || iconAsset != null, + 'At least one of icon, iconAsset, or label must be specified.', + ), + assert( + icon == null || iconAsset == null, + 'Only one of icon and iconAsset may be specified.', + ); final String? label; final IconData? icon; diff --git a/packages/devtools_app_shared/lib/src/ui/common.dart b/packages/devtools_app_shared/lib/src/ui/common.dart index 90b196e04a2..322ebcb402c 100644 --- a/packages/devtools_app_shared/lib/src/ui/common.dart +++ b/packages/devtools_app_shared/lib/src/ui/common.dart @@ -5,7 +5,7 @@ import 'dart:convert'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../utils/url/url.dart'; import '../utils/utils.dart'; @@ -34,9 +34,7 @@ class DevToolsAreaPane extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ header, - Expanded( - child: child, - ), + Expanded(child: child), ], ), ); @@ -94,7 +92,8 @@ class AreaPaneHeader extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final borderSide = defaultBorderSide(theme); - final decoration = !roundedTopBorder && + final decoration = + !roundedTopBorder && (includeTopBorder || includeBottomBorder || includeLeftBorder || @@ -130,10 +129,7 @@ class AreaPaneHeader extends StatelessWidget implements PreferredSizeWidget { if (roundedTopBorder) { container = RoundedOutlinedBorder.onlyTop(child: container); } - return SizedBox.fromSize( - size: preferredSize, - child: container, - ); + return SizedBox.fromSize(size: preferredSize, child: container); } @override @@ -183,24 +179,22 @@ final class RoundedOutlinedBorder extends StatelessWidget { factory RoundedOutlinedBorder.onlyTop({ required Widget? child, bool clip = false, - }) => - RoundedOutlinedBorder( - showBottomLeft: false, - showBottomRight: false, - clip: clip, - child: child, - ); + }) => RoundedOutlinedBorder( + showBottomLeft: false, + showBottomRight: false, + clip: clip, + child: child, + ); factory RoundedOutlinedBorder.onlyBottom({ required Widget? child, bool clip = false, - }) => - RoundedOutlinedBorder( - showTopLeft: false, - showTopRight: false, - clip: clip, - child: child, - ); + }) => RoundedOutlinedBorder( + showTopLeft: false, + showTopRight: false, + clip: clip, + child: child, + ); final bool showTopLeft; final bool showTopRight; @@ -326,22 +320,19 @@ final class PaddedDivider extends StatelessWidget { }); const PaddedDivider.thin({super.key}) - : padding = const EdgeInsets.only(bottom: 4.0); + : padding = const EdgeInsets.only(bottom: 4.0); const PaddedDivider.noPadding({super.key}) : padding = EdgeInsets.zero; PaddedDivider.vertical({super.key, double padding = densePadding}) - : padding = EdgeInsets.symmetric(vertical: padding); + : padding = EdgeInsets.symmetric(vertical: padding); /// The padding to place around the divider. final EdgeInsets padding; @override Widget build(BuildContext context) { - return Padding( - padding: padding, - child: const Divider(thickness: 1.0), - ); + return Padding(padding: padding, child: const Divider(thickness: 1.0)); } } @@ -416,10 +407,7 @@ final class ImageIconLabel extends StatelessWidget { icon, // TODO(jacobr): animate showing and hiding the text. if (isScreenWiderThan(context, unscaledMinIncludeTextWidth)) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: Text(text), - ), + Padding(padding: const EdgeInsets.only(left: 8.0), child: Text(text)), ], ); } @@ -434,14 +422,14 @@ final class MaterialIconLabel extends StatelessWidget { this.iconSize, this.color, this.minScreenWidthForText, - }) : assert( - label != null || iconData != null || iconAsset != null, - 'At least one of iconData, iconAsset, or label must be specified.', - ), - assert( - iconData == null || iconAsset == null, - 'Only one of iconData and iconAsset may be specified.', - ); + }) : assert( + label != null || iconData != null || iconAsset != null, + 'At least one of iconData, iconAsset, or label must be specified.', + ), + assert( + iconData == null || iconAsset == null, + 'Only one of iconData and iconAsset may be specified.', + ); final IconData? iconData; final String? iconAsset; @@ -520,8 +508,9 @@ final class FormattedJson extends StatelessWidget { return SelectionArea( child: Text( json != null ? encoder.convert(json) : formattedString!, - style: - useSubtleStyle ? theme.subtleFixedFontStyle : theme.fixedFontStyle, + style: useSubtleStyle + ? theme.subtleFixedFontStyle + : theme.fixedFontStyle, ), ); } @@ -530,8 +519,8 @@ final class FormattedJson extends StatelessWidget { /// An extension on [ScrollController] to facilitate having the scrolling widget /// auto scroll to the bottom on new content. extension ScrollControllerAutoScroll on ScrollController { -// TODO(devoncarew): We lose dock-to-bottom when we receive content when we're -// off screen. + // TODO(devoncarew): We lose dock-to-bottom when we receive content when we're + // off screen. /// Return whether the view is currently scrolled to the bottom. bool get atScrollBottom { @@ -575,14 +564,14 @@ class LinkTextSpan extends TextSpan { VoidCallback? onLaunchUrlError, TextStyle? style, }) : super( - text: link.display, - style: style ?? Theme.of(context).linkTextStyle, - recognizer: TapGestureRecognizer() - ..onTap = () async { - onTap?.call(); - await launchUrl(link.url, onError: onLaunchUrlError); - }, - ); + text: link.display, + style: style ?? Theme.of(context).linkTextStyle, + recognizer: TapGestureRecognizer() + ..onTap = () async { + onTap?.call(); + await launchUrl(link.url, onError: onLaunchUrlError); + }, + ); } /// A data model for a clickable link in a UI. @@ -647,9 +636,10 @@ class RoundedLabel extends StatelessWidget { overflow: TextOverflow.clip, softWrap: false, style: theme.regularTextStyle.copyWith( - color: textColor ?? colorScheme.onSecondary, - backgroundColor: backgroundColor ?? colorScheme.secondary, - fontSize: fontSize ?? defaultFontSize), + color: textColor ?? colorScheme.onSecondary, + backgroundColor: backgroundColor ?? colorScheme.secondary, + fontSize: fontSize ?? defaultFontSize, + ), ), ); return tooltipText != null diff --git a/packages/devtools_app_shared/lib/src/ui/dialogs.dart b/packages/devtools_app_shared/lib/src/ui/dialogs.dart index 07c46ffb574..bb20d792c2e 100644 --- a/packages/devtools_app_shared/lib/src/ui/dialogs.dart +++ b/packages/devtools_app_shared/lib/src/ui/dialogs.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:pointer_interceptor/pointer_interceptor.dart'; import 'common.dart'; @@ -189,10 +189,7 @@ final class DialogHelpText extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( - helpText, - style: textStyle(context), - ); + return Text(helpText, style: textStyle(context)); } } @@ -260,10 +257,7 @@ final class DialogTextButton extends StatelessWidget { @override Widget build(BuildContext context) { - return TextButton( - onPressed: onPressed, - child: child, - ); + return TextButton(onPressed: onPressed, child: child); } } @@ -280,12 +274,10 @@ void showDevToolsDialog({ title: DialogTitleText(title), includeDivider: false, content: content, - actionsAlignment: - actions.isNotEmpty ? MainAxisAlignment.spaceBetween : null, - actions: [ - ...actions, - const DialogCloseButton(), - ], + actionsAlignment: actions.isNotEmpty + ? MainAxisAlignment.spaceBetween + : null, + actions: [...actions, const DialogCloseButton()], ), ), ); diff --git a/packages/devtools_app_shared/lib/src/ui/flex_split_column.dart b/packages/devtools_app_shared/lib/src/ui/flex_split_column.dart index 5b39be915a0..08f5ea4c5af 100644 --- a/packages/devtools_app_shared/lib/src/ui/flex_split_column.dart +++ b/packages/devtools_app_shared/lib/src/ui/flex_split_column.dart @@ -5,7 +5,7 @@ /// @docImport 'package:devtools_app_shared/src/ui/common.dart'; library; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'split_pane.dart'; @@ -32,17 +32,17 @@ final class FlexSplitColumn extends StatelessWidget { required List children, required List initialFractions, required List minSizes, - }) : assert(children.length >= 2), - assert(initialFractions.length >= 2), - assert(children.length == initialFractions.length), - assert(minSizes.length == children.length), - _children = buildChildrenWithFirstHeader(children, headers), - _initialFractions = modifyInitialFractionsToIncludeFirstHeader( - initialFractions, - headers, - totalHeight, - ), - _minSizes = modifyMinSizesToIncludeFirstHeader(minSizes, headers); + }) : assert(children.length >= 2), + assert(initialFractions.length >= 2), + assert(children.length == initialFractions.length), + assert(minSizes.length == children.length), + _children = buildChildrenWithFirstHeader(children, headers), + _initialFractions = modifyInitialFractionsToIncludeFirstHeader( + initialFractions, + headers, + totalHeight, + ), + _minSizes = modifyMinSizesToIncludeFirstHeader(minSizes, headers); /// The headers that will be laid out above each corresponding child in /// `children`. diff --git a/packages/devtools_app_shared/lib/src/ui/icons.dart b/packages/devtools_app_shared/lib/src/ui/icons.dart index 977cb957db5..89be41ce137 100644 --- a/packages/devtools_app_shared/lib/src/ui/icons.dart +++ b/packages/devtools_app_shared/lib/src/ui/icons.dart @@ -2,20 +2,24 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'theme/theme.dart'; /// A widget that renders either an [icon] from a font glyph or an [iconAsset] /// from the app bundle. final class DevToolsIcon extends StatelessWidget { - const DevToolsIcon( - {super.key, this.icon, this.iconAsset, this.color, double? size}) - : assert( - (icon == null) != (iconAsset == null), - 'Exactly one of icon and iconAsset must be specified.', - ), - size = size ?? defaultIconSize; + const DevToolsIcon({ + super.key, + this.icon, + this.iconAsset, + this.color, + double? size, + }) : assert( + (icon == null) != (iconAsset == null), + 'Exactly one of icon and iconAsset must be specified.', + ), + size = size ?? defaultIconSize; /// The icon to use for this screen's tab. /// @@ -53,8 +57,8 @@ final class AssetImageIcon extends StatelessWidget { this.color, double? height, double? width, - }) : _width = width, - _height = height; + }) : _width = width, + _height = height; final String asset; final Color? color; diff --git a/packages/devtools_app_shared/lib/src/ui/split_pane.dart b/packages/devtools_app_shared/lib/src/ui/split_pane.dart index 59e5ef576dc..5f0fdc96995 100644 --- a/packages/devtools_app_shared/lib/src/ui/split_pane.dart +++ b/packages/devtools_app_shared/lib/src/ui/split_pane.dart @@ -5,7 +5,7 @@ import 'dart:math' as math; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../utils/utils.dart'; @@ -29,9 +29,9 @@ final class SplitPane extends StatefulWidget { required this.initialFractions, this.minSizes, this.splitters, - }) : assert(children.length >= 2), - assert(initialFractions.length >= 2), - assert(children.length == initialFractions.length) { + }) : assert(children.length >= 2), + assert(initialFractions.length >= 2), + assert(children.length == initialFractions.length) { _verifyFractionsSumTo1(initialFractions); if (minSizes != null) { assert(minSizes!.length == children.length); @@ -88,7 +88,7 @@ final class SplitPane extends StatefulWidget { } final class _SplitPaneState extends State { - late final List fractions; + late List fractions; bool get isHorizontal => widget.axis == Axis.horizontal; @@ -98,6 +98,18 @@ final class _SplitPaneState extends State { fractions = List.of(widget.initialFractions); } + @override + void didUpdateWidget(SplitPane oldWidget) { + super.didUpdateWidget(oldWidget); + // When the number of children changes, the previously stored [fractions] + // list will be out of sync with [widget.minSizes] and [widget.children], + // which causes a RangeError during layout. Reset to the new + // [initialFractions] when the child count changes. + if (oldWidget.children.length != widget.children.length) { + fractions = List.of(widget.initialFractions); + } + } + @override Widget build(BuildContext context) { return LayoutBuilder(builder: _buildLayout); @@ -130,8 +142,10 @@ final class _SplitPaneState extends State { minSizeForIndex(index) / availableSize; void clampFraction(int index) { - fractions[index] = - fractions[index].clamp(minFractionForIndex(index), 1.0); + fractions[index] = fractions[index].clamp( + minFractionForIndex(index), + 1.0, + ); } double sizeForIndex(int index) => availableSize * fractions[index]; @@ -178,8 +192,9 @@ final class _SplitPaneState extends State { final sizes = List.generate(fractions.length, (i) => sizeForIndex(i)); void updateSpacing(DragUpdateDetails dragDetails, int splitterIndex) { - final dragDelta = - isHorizontal ? dragDetails.delta.dx : dragDetails.delta.dy; + final dragDelta = isHorizontal + ? dragDetails.delta.dx + : dragDetails.delta.dy; final fractionalDelta = dragDelta / axisSize; // Returns the actual delta applied to elements before the splitter. @@ -229,12 +244,14 @@ final class _SplitPaneState extends State { // the shrinking children first so that we do not over-increase the size // of the growing children and cause layout overflow errors. if (fractionalDelta <= 0.0) { - final appliedDelta = - updateSpacingBeforeSplitterIndex(fractionalDelta); + final appliedDelta = updateSpacingBeforeSplitterIndex( + fractionalDelta, + ); updateSpacingAfterSplitterIndex(-appliedDelta); } else { - final appliedDelta = - updateSpacingAfterSplitterIndex(-fractionalDelta); + final appliedDelta = updateSpacingAfterSplitterIndex( + -fractionalDelta, + ); updateSpacingBeforeSplitterIndex(-appliedDelta); } }); diff --git a/packages/devtools_app_shared/lib/src/ui/text_field.dart b/packages/devtools_app_shared/lib/src/ui/text_field.dart index eda5451d8ba..2ba1676175a 100644 --- a/packages/devtools_app_shared/lib/src/ui/text_field.dart +++ b/packages/devtools_app_shared/lib/src/ui/text_field.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'common.dart'; import 'theme/theme.dart'; @@ -36,8 +36,9 @@ final class DevToolsTextField extends StatelessWidget { /// This is the default border radius used by the [OutlineInputBorder] /// constructor. - static const _defaultInputBorderRadius = - BorderRadius.all(Radius.circular(4.0)); + static const _defaultInputBorderRadius = BorderRadius.all( + Radius.circular(4.0), + ); @override Widget build(BuildContext context) { @@ -151,30 +152,27 @@ final class InputDecorationSuffixButton extends StatelessWidget { factory InputDecorationSuffixButton.clear({ required VoidCallback? onPressed, - }) => - InputDecorationSuffixButton( - icon: Icons.clear, - onPressed: onPressed, - tooltip: 'Clear', - ); + }) => InputDecorationSuffixButton( + icon: Icons.clear, + onPressed: onPressed, + tooltip: 'Clear', + ); factory InputDecorationSuffixButton.close({ required VoidCallback? onPressed, - }) => - InputDecorationSuffixButton( - icon: Icons.close, - onPressed: onPressed, - tooltip: 'Close', - ); + }) => InputDecorationSuffixButton( + icon: Icons.close, + onPressed: onPressed, + tooltip: 'Close', + ); factory InputDecorationSuffixButton.help({ required VoidCallback? onPressed, - }) => - InputDecorationSuffixButton( - icon: Icons.question_mark, - onPressed: onPressed, - tooltip: 'Help', - ); + }) => InputDecorationSuffixButton( + icon: Icons.question_mark, + onPressed: onPressed, + tooltip: 'Help', + ); final IconData icon; final VoidCallback? onPressed; diff --git a/packages/devtools_app_shared/lib/src/ui/theme/_ide_theme_web.dart b/packages/devtools_app_shared/lib/src/ui/theme/_ide_theme_web.dart index 7640fbd0758..993e9b70ac1 100644 --- a/packages/devtools_app_shared/lib/src/ui/theme/_ide_theme_web.dart +++ b/packages/devtools_app_shared/lib/src/ui/theme/_ide_theme_web.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, this file is exported via config specific exports. + import 'package:web/web.dart'; import '../../utils/url/url.dart'; @@ -22,8 +24,9 @@ IdeTheme getIdeTheme() { // If the environment has provided a background color, set it immediately // to avoid a white page until the first Flutter frame is rendered. if (overrides.backgroundColor != null) { - document.body!.style.backgroundColor = - toCssHexColor(overrides.backgroundColor!); + document.body!.style.backgroundColor = toCssHexColor( + overrides.backgroundColor!, + ); } return overrides; diff --git a/packages/devtools_app_shared/lib/src/ui/theme/theme.dart b/packages/devtools_app_shared/lib/src/ui/theme/theme.dart index 07c1bf1e6a4..ab3a100e6bf 100644 --- a/packages/devtools_app_shared/lib/src/ui/theme/theme.dart +++ b/packages/devtools_app_shared/lib/src/ui/theme/theme.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../ui_utils.dart'; import 'ide_theme.dart'; @@ -26,35 +26,23 @@ ThemeData themeFor({ : _lightTheme(ideTheme: ideTheme, theme: theme); return colorTheme.copyWith( - primaryTextTheme: - theme.primaryTextTheme.merge(colorTheme.primaryTextTheme), - textTheme: theme.textTheme.merge(colorTheme.textTheme)); + primaryTextTheme: theme.primaryTextTheme.merge(colorTheme.primaryTextTheme), + textTheme: theme.textTheme.merge(colorTheme.textTheme), + ); } -ThemeData _darkTheme({ - required IdeTheme ideTheme, - required ThemeData theme, -}) { +ThemeData _darkTheme({required IdeTheme ideTheme, required ThemeData theme}) { final background = isValidDarkColor(ideTheme.backgroundColor) ? ideTheme.backgroundColor! : theme.colorScheme.surface; - return _baseTheme( - theme: theme, - backgroundColor: background, - ); + return _baseTheme(theme: theme, backgroundColor: background); } -ThemeData _lightTheme({ - required IdeTheme ideTheme, - required ThemeData theme, -}) { +ThemeData _lightTheme({required IdeTheme ideTheme, required ThemeData theme}) { final background = isValidLightColor(ideTheme.backgroundColor) ? ideTheme.backgroundColor! : theme.colorScheme.surface; - return _baseTheme( - theme: theme, - backgroundColor: background, - ); + return _baseTheme(theme: theme, backgroundColor: background); } ThemeData _baseTheme({ @@ -68,8 +56,9 @@ ThemeData _baseTheme({ tabAlignment: TabAlignment.start, dividerColor: Colors.transparent, labelStyle: theme.regularTextStyle, - labelPadding: - const EdgeInsets.symmetric(horizontal: defaultTabBarPadding), + labelPadding: const EdgeInsets.symmetric( + horizontal: defaultTabBarPadding, + ), ), canvasColor: backgroundColor, scaffoldBackgroundColor: backgroundColor, @@ -110,17 +99,13 @@ ThemeData _baseTheme({ fixedSize: const WidgetStatePropertyAll(Size.fromHeight(24.0)), ), ), - dropdownMenuTheme: DropdownMenuThemeData( - textStyle: theme.regularTextStyle, - ), + dropdownMenuTheme: DropdownMenuThemeData(textStyle: theme.regularTextStyle), progressIndicatorTheme: const ProgressIndicatorThemeData( linearMinHeight: defaultLinearProgressIndicatorHeight, ), primaryTextTheme: _devToolsTextTheme(theme, theme.primaryTextTheme), textTheme: _devToolsTextTheme(theme, theme.textTheme), - colorScheme: theme.colorScheme.copyWith( - surface: backgroundColor, - ), + colorScheme: theme.colorScheme.copyWith(surface: backgroundColor), ); } @@ -224,10 +209,11 @@ const darkColorScheme = ColorScheme( ); const searchMatchColor = Colors.yellow; -final searchMatchColorOpaque = Colors.yellow.withValues(alpha: 255 / 2); +final searchMatchColorTranslucent = Colors.yellow.withValues(alpha: 0.5); const activeSearchMatchColor = Colors.orangeAccent; -final activeSearchMatchColorOpaque = - Colors.orangeAccent.withValues(alpha: 255 / 2); +final activeSearchMatchColorTranslucent = Colors.orangeAccent.withValues( + alpha: 0.5, +); /// Gets an alternating color to use for indexed UI elements. Color alternatingColorForIndex(int index, ColorScheme colorScheme) { @@ -380,11 +366,8 @@ extension ThemeDataExtension on ThemeData { bool get isDarkTheme => brightness == Brightness.dark; TextStyle get regularTextStyle => fixBlurryText( - TextStyle( - color: colorScheme.onSurface, - fontSize: defaultFontSize, - ), - ); + TextStyle(color: colorScheme.onSurface, fontSize: defaultFontSize), + ); TextStyle regularTextStyleWithColor(Color? color, {Color? backgroundColor}) => regularTextStyle.copyWith(color: color, backgroundColor: backgroundColor); @@ -404,66 +387,58 @@ extension ThemeDataExtension on ThemeData { regularTextStyle.copyWith(color: colorScheme.subtleTextColor); TextStyle get fixedFontStyle => fixBlurryText( - regularTextStyle.copyWith( - fontFamily: 'RobotoMono', - // Slightly smaller for fixes font text since it will appear larger - // to begin with. - fontSize: defaultFontSize - 1, - ), - ); - - TextStyle get subtleFixedFontStyle => fixedFontStyle.copyWith( - color: colorScheme.subtleTextColor, - ); + regularTextStyle.copyWith( + fontFamily: 'RobotoMono', + // Slightly smaller for fixes font text since it will appear larger + // to begin with. + fontSize: defaultFontSize - 1, + ), + ); + + TextStyle get subtleFixedFontStyle => + fixedFontStyle.copyWith(color: colorScheme.subtleTextColor); TextStyle get selectedSubtleTextStyle => subtleTextStyle.copyWith(color: colorScheme.onSurface); - TextStyle get tooltipFixedFontStyle => fixedFontStyle.copyWith( - color: colorScheme.tooltipTextColor, - ); + TextStyle get tooltipFixedFontStyle => + fixedFontStyle.copyWith(color: colorScheme.tooltipTextColor); TextStyle get fixedFontLinkStyle => fixedFontStyle.copyWith( - color: colorScheme._devtoolsLink, - decoration: TextDecoration.underline, - ); + color: colorScheme._devtoolsLink, + decoration: TextDecoration.underline, + ); TextStyle get linkTextStyle => fixBlurryText( - TextStyle( - color: colorScheme._devtoolsLink, - decoration: TextDecoration.underline, - fontSize: defaultFontSize, - ), - ); + TextStyle( + color: colorScheme._devtoolsLink, + decoration: TextDecoration.underline, + fontSize: defaultFontSize, + ), + ); TextStyle get subtleChartTextStyle => fixBlurryText( - TextStyle( - color: colorScheme._chartSubtleColor, - fontSize: smallFontSize, - ), - ); + TextStyle(color: colorScheme._chartSubtleColor, fontSize: smallFontSize), + ); TextStyle get searchMatchHighlightStyle => fixBlurryText( - const TextStyle( - color: Colors.black, - backgroundColor: activeSearchMatchColor, - ), - ); + const TextStyle( + color: Colors.black, + backgroundColor: activeSearchMatchColor, + ), + ); TextStyle get searchMatchHighlightStyleFocused => fixBlurryText( - const TextStyle( - color: Colors.black, - backgroundColor: searchMatchColor, - ), - ); + const TextStyle(color: Colors.black, backgroundColor: searchMatchColor), + ); TextStyle get legendTextStyle => fixBlurryText( - const TextStyle( - fontWeight: FontWeight.normal, - fontSize: smallFontSize, - decoration: TextDecoration.none, - ), - ); + const TextStyle( + fontWeight: FontWeight.normal, + fontSize: smallFontSize, + decoration: TextDecoration.none, + ), + ); } /// Returns a [TextStyle] with [FontFeature.proportionalFigures] applied to @@ -540,10 +515,7 @@ CurvedAnimation defaultCurvedAnimation(AnimationController parent) => /// Measures the screen size to determine whether it is strictly larger /// than [width], scaled to the current font factor. -bool isScreenWiderThan( - BuildContext context, - double? width, -) { +bool isScreenWiderThan(BuildContext context, double? width) { return width == null || MediaQuery.of(context).size.width > width; } diff --git a/packages/devtools_app_shared/lib/src/utils/auto_dispose.dart b/packages/devtools_app_shared/lib/src/utils/auto_dispose.dart index a0eff8a0249..a151245c517 100644 --- a/packages/devtools_app_shared/lib/src/utils/auto_dispose.dart +++ b/packages/devtools_app_shared/lib/src/utils/auto_dispose.dart @@ -191,8 +191,9 @@ mixin DisposerMixin { if (listener == null) return; assert(_listenables.length == _listeners.length); - final foundIndex = - _listeners.indexWhere((currentListener) => currentListener == listener); + final foundIndex = _listeners.indexWhere( + (currentListener) => currentListener == listener, + ); if (foundIndex == -1) return; _listenables[foundIndex].removeListener(_listeners[foundIndex]); _listenables.removeAt(foundIndex); @@ -350,9 +351,7 @@ extension _AutoDisposeListExtension on List { /// /// If any index in [indices] is out of range, an exception will be thrown. void removeAllExceptIndices(List indices) { - final tmp = [ - for (final index in indices) this[index], - ]; + final tmp = [for (final index in indices) this[index]]; clear(); addAll(tmp); } diff --git a/packages/devtools_app_shared/lib/src/utils/url/_url_web.dart b/packages/devtools_app_shared/lib/src/utils/url/_url_web.dart index 67e84eeff1f..5fa176657cc 100644 --- a/packages/devtools_app_shared/lib/src/utils/url/_url_web.dart +++ b/packages/devtools_app_shared/lib/src/utils/url/_url_web.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +// ignore_for_file: unused-code, this file is exported via config specific exports. + import 'package:web/web.dart'; Map loadQueryParams({String Function(String)? urlModifier}) { @@ -27,13 +29,10 @@ void updateQueryParameter(String key, String? value, {bool reload = false}) { } else { newQueryParams[key] = value; } - final newUri = Uri.parse(window.location.toString()) - .replace(queryParameters: newQueryParams); - window.history.replaceState( - window.history.state, - '', - newUri.toString(), - ); + final newUri = Uri.parse( + window.location.toString(), + ).replace(queryParameters: newQueryParams); + window.history.replaceState(window.history.state, '', newUri.toString()); if (reload) { window.location.reload(); diff --git a/packages/devtools_app_shared/lib/src/utils/utils.dart b/packages/devtools_app_shared/lib/src/utils/utils.dart index df6db6176cf..53f84f922c7 100644 --- a/packages/devtools_app_shared/lib/src/utils/utils.dart +++ b/packages/devtools_app_shared/lib/src/utils/utils.dart @@ -6,7 +6,7 @@ import 'dart:async'; import 'dart:math'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; // Method to convert degrees to radians double degToRad(num deg) => deg * (pi / 180.0); diff --git a/packages/devtools_app_shared/pubspec.yaml b/packages/devtools_app_shared/pubspec.yaml index 5e1fd8f3131..786defea6ca 100644 --- a/packages/devtools_app_shared/pubspec.yaml +++ b/packages/devtools_app_shared/pubspec.yaml @@ -3,23 +3,24 @@ # found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. name: devtools_app_shared description: Package of Dart & Flutter structures shared between devtools_app and devtools extensions. -version: 0.5.1 +version: 0.5.2-wip repository: https://github.com/flutter/devtools/tree/master/packages/devtools_app_shared environment: - sdk: ">=3.6.0 <4.0.0" - flutter: ">=3.27.1" + sdk: ^3.11.0 + flutter: ^3.41.0 resolution: workspace dependencies: collection: ^1.15.0 dds_service_extensions: ^2.0.0 - devtools_shared: ^12.0.0 + devtools_shared: ^14.0.1 dtd: ^4.0.0 flutter: sdk: flutter logging: ^1.1.1 + material_ui: ^1.4.0 meta: ^1.9.1 path: ^1.8.0 pointer_interceptor: ^0.10.1+1 @@ -28,9 +29,9 @@ dependencies: web: ^1.0.0 dev_dependencies: + flutter_lints: ^6.0.0 flutter_test: sdk: flutter - test: ^1.21.0 flutter: uses-material-design: true diff --git a/packages/devtools_app_shared/test/service/flutter_version_test.dart b/packages/devtools_app_shared/test/service/flutter_version_test.dart index a1904d6c2a4..8c8059f2e92 100644 --- a/packages/devtools_app_shared/test/service/flutter_version_test.dart +++ b/packages/devtools_app_shared/test/service/flutter_version_test.dart @@ -8,40 +8,45 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('FlutterVersion', () { test('infers semantic version', () { - var flutterVersion = - FlutterVersion.parse({'frameworkVersion': '1.10.7-pre.42'}); + var flutterVersion = FlutterVersion.parse({ + 'frameworkVersion': '1.10.7-pre.42', + }); expect(flutterVersion.major, equals(1)); expect(flutterVersion.minor, equals(10)); expect(flutterVersion.patch, equals(7)); expect(flutterVersion.preReleaseMajor, equals(42)); expect(flutterVersion.preReleaseMinor, equals(0)); - flutterVersion = - FlutterVersion.parse({'frameworkVersion': '1.10.7-pre42'}); + flutterVersion = FlutterVersion.parse({ + 'frameworkVersion': '1.10.7-pre42', + }); expect(flutterVersion.major, equals(1)); expect(flutterVersion.minor, equals(10)); expect(flutterVersion.patch, equals(7)); expect(flutterVersion.preReleaseMajor, equals(42)); expect(flutterVersion.preReleaseMinor, equals(0)); - flutterVersion = - FlutterVersion.parse({'frameworkVersion': '1.10.11-pre42'}); + flutterVersion = FlutterVersion.parse({ + 'frameworkVersion': '1.10.11-pre42', + }); expect(flutterVersion.major, equals(1)); expect(flutterVersion.minor, equals(10)); expect(flutterVersion.patch, equals(11)); expect(flutterVersion.preReleaseMajor, equals(42)); expect(flutterVersion.preReleaseMinor, equals(0)); - flutterVersion = - FlutterVersion.parse({'frameworkVersion': '2.3.0-17.0.pre.355'}); + flutterVersion = FlutterVersion.parse({ + 'frameworkVersion': '2.3.0-17.0.pre.355', + }); expect(flutterVersion.major, equals(2)); expect(flutterVersion.minor, equals(3)); expect(flutterVersion.patch, equals(0)); expect(flutterVersion.preReleaseMajor, equals(17)); expect(flutterVersion.preReleaseMinor, equals(0)); - flutterVersion = - FlutterVersion.parse({'frameworkVersion': '2.3.0-17.0.pre'}); + flutterVersion = FlutterVersion.parse({ + 'frameworkVersion': '2.3.0-17.0.pre', + }); expect(flutterVersion.major, equals(2)); expect(flutterVersion.minor, equals(3)); expect(flutterVersion.patch, equals(0)); @@ -62,8 +67,9 @@ void main() { expect(flutterVersion.preReleaseMajor, isNull); expect(flutterVersion.preReleaseMinor, isNull); - flutterVersion = - FlutterVersion.parse({'frameworkVersion': 'bad-version'}); + flutterVersion = FlutterVersion.parse({ + 'frameworkVersion': 'bad-version', + }); expect(flutterVersion.major, equals(0)); expect(flutterVersion.minor, equals(0)); expect(flutterVersion.patch, equals(0)); @@ -72,33 +78,44 @@ void main() { group('identifies correct Flutter channel', () { test('uses channel string if it exists', () { expect( - FlutterVersion.identifyChannel('ignored-version', - channelStr: 'dev'), - equals(FlutterChannel.dev)); + FlutterVersion.identifyChannel('ignored-version', channelStr: 'dev'), + equals(FlutterChannel.dev), + ); expect( - FlutterVersion.identifyChannel('ignored-version', - channelStr: 'beta'), - equals(FlutterChannel.beta)); + FlutterVersion.identifyChannel('ignored-version', channelStr: 'beta'), + equals(FlutterChannel.beta), + ); expect( - FlutterVersion.identifyChannel('ignored-version', - channelStr: 'stable'), - equals(FlutterChannel.stable)); + FlutterVersion.identifyChannel( + 'ignored-version', + channelStr: 'stable', + ), + equals(FlutterChannel.stable), + ); }); test('identifies channel from version string', () { - expect(FlutterVersion.identifyChannel('2.3.0-17.0.pre.355'), - equals(FlutterChannel.dev)); + expect( + FlutterVersion.identifyChannel('2.3.0-17.0.pre.355'), + equals(FlutterChannel.dev), + ); - expect(FlutterVersion.identifyChannel('2.3.0-17.0.pre-355'), - equals(FlutterChannel.dev)); + expect( + FlutterVersion.identifyChannel('2.3.0-17.0.pre-355'), + equals(FlutterChannel.dev), + ); - expect(FlutterVersion.identifyChannel('2.3.0-17.0.pre'), - equals(FlutterChannel.beta)); + expect( + FlutterVersion.identifyChannel('2.3.0-17.0.pre'), + equals(FlutterChannel.beta), + ); - expect(FlutterVersion.identifyChannel('2.3.0'), - equals(FlutterChannel.stable)); + expect( + FlutterVersion.identifyChannel('2.3.0'), + equals(FlutterChannel.stable), + ); expect(FlutterVersion.identifyChannel('bad-version'), isNull); diff --git a/packages/devtools_app_shared/test/service/isolate_state_test.dart b/packages/devtools_app_shared/test/service/isolate_state_test.dart new file mode 100644 index 00000000000..1fcbe6351f1 --- /dev/null +++ b/packages/devtools_app_shared/test/service/isolate_state_test.dart @@ -0,0 +1,45 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/src/service/isolate_state.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RootInfo', () { + test('handles package URIs', () { + final info = RootInfo('package:my_package/main.dart'); + expect(info.library, 'package:my_package/main.dart'); + expect(info.package, 'package:my_package'); + }); + + test('handles package URIs without path', () { + final info = RootInfo('package:my_package'); + expect(info.library, 'package:my_package'); + expect(info.package, 'package:my_package'); + }); + + test('handles null library', () { + final info = RootInfo(null); + expect(info.library, isNull); + expect(info.package, isNull); + }); + + test('handles file URIs (non-package)', () { + final info = RootInfo( + 'file:///Users/viktor/Projects/tests/gui_1/gui_1_server/bin/main.dart', + ); + expect( + info.library, + 'file:///Users/viktor/Projects/tests/gui_1/gui_1_server/bin/main.dart', + ); + expect(info.package, isNull); + }); + + test('handles dart: URIs (non-package)', () { + final info = RootInfo('dart:core'); + expect(info.library, 'dart:core'); + expect(info.package, isNull); + }); + }); +} diff --git a/packages/devtools_app_shared/test/service/service_extensions_test.dart b/packages/devtools_app_shared/test/service/service_extensions_test.dart new file mode 100644 index 00000000000..c1391d6c22c --- /dev/null +++ b/packages/devtools_app_shared/test/service/service_extensions_test.dart @@ -0,0 +1,124 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/service.dart'; +import 'package:devtools_app_shared/service_extensions.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vm_service/vm_service.dart'; + +void main() { + group('ServiceExtensions', () { + test('brightnessMode is properly configured', () { + expect( + brightnessMode.extension, + equals('ext.flutter.brightnessOverride'), + ); + expect( + brightnessMode.values, + equals(['system', 'Brightness.light', 'Brightness.dark']), + ); + expect( + serviceExtensionsAllowlist[brightnessMode.extension], + equals(brightnessMode), + ); + }); + + test('brightnessMode is in unsafe before first frame set', () { + expect( + isUnsafeBeforeFirstFlutterFrame('ext.flutter.brightnessOverride'), + isTrue, + ); + }); + + test( + 'ServiceExtensionManager handles RPCError / DWDS Promise collected gracefully', + () async { + var extensionCalls = 0; + final fakeService = _FakeVmService( + onCallServiceExtension: (method, isolateId, args) { + extensionCalls++; + throw RPCError( + 'callServiceExtension', + -32603, + 'Unexpected DWDS error for callServiceExtension: WipError\n-32000 Promise was collected', + ); + }, + ); + + final isolateManager = IsolateManager(); + isolateManager.vmServiceOpened(fakeService); + + final manager = ServiceExtensionManager(isolateManager); + manager.vmServiceOpened(fakeService, _FakeConnectedApp()); + + // Pre-enable an extension (like ErrorBadgeManager does for structuredErrors). + await manager.setServiceExtensionState( + structuredErrors.extension, + enabled: true, + value: true, + callExtension: false, + ); + + // Initialize isolates on IsolateManager. + final isolateRef = IsolateRef( + id: 'isolate-1', + number: '1', + name: 'main', + ); + await isolateManager.init([isolateRef]); + + // Wait for all async listeners and microtasks to complete. + await pumpEventQueue(); + + // Verify that the manager attempted to restore the pre-enabled extension + // state on the newly registered isolate (and gracefully handled the error). + expect(extensionCalls, equals(1)); + }, + ); + }); +} + +/// A fake [VmService] implementation for testing service extension calls. +class _FakeVmService extends Fake implements VmService { + _FakeVmService({required this.onCallServiceExtension}); + + final Future Function( + String method, + String? isolateId, + Map? args, + ) + onCallServiceExtension; + + @override + Stream get onIsolateEvent => const Stream.empty(); + + @override + Stream get onExtensionEvent => const Stream.empty(); + + @override + Stream get onDebugEvent => const Stream.empty(); + + @override + Future getIsolate(String isolateId) async => Isolate( + id: isolateId, + number: '1', + name: 'main', + extensionRPCs: [structuredErrors.extension], + ); + + @override + Future callServiceExtension( + String method, { + String? isolateId, + Map? args, + }) { + return onCallServiceExtension(method, isolateId, args); + } +} + +/// A fake [ConnectedApp] implementation that simulates a connected Flutter app. +class _FakeConnectedApp extends Fake implements ConnectedApp { + @override + Future get isFlutterApp async => true; +} diff --git a/packages/devtools_app_shared/test/service/service_utils_test.dart b/packages/devtools_app_shared/test/service/service_utils_test.dart new file mode 100644 index 00000000000..9d3c7c2933d --- /dev/null +++ b/packages/devtools_app_shared/test/service/service_utils_test.dart @@ -0,0 +1,110 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/service.dart' hide SentinelException; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vm_service/vm_service.dart'; + +void main() { + group('forEachIsolateHelper', () { + test('calls callback for each isolate', () async { + final isolates = [ + IsolateRef(id: 'isolate_1', number: '1', name: 'main_1'), + IsolateRef(id: 'isolate_2', number: '2', name: 'main_2'), + ]; + final fakeService = _FakeVmService(isolates: isolates); + final visited = []; + await forEachIsolateHelper(fakeService, (isolate) async { + visited.add(isolate.id!); + }); + expect(visited, equals(['isolate_1', 'isolate_2'])); + }); + + test('is a no-op when isolates list is empty or null', () async { + final emptyService = _FakeVmService(isolates: []); + var called = false; + await forEachIsolateHelper(emptyService, (isolate) async { + called = true; + }); + expect(called, isFalse); + + final nullService = _FakeVmService(isolates: null); + await forEachIsolateHelper(nullService, (isolate) async { + called = true; + }); + expect(called, isFalse); + }); + + test( + 'rethrows RPCError directly without wrapping in ParallelWaitError', + () async { + final isolates = [ + IsolateRef(id: 'isolate_1', number: '1', name: 'main_1'), + IsolateRef(id: 'isolate_2', number: '2', name: 'main_2'), + ]; + final fakeService = _FakeVmService(isolates: isolates); + final error = RPCError( + 'ext.dart.io.getSocketProfile', + RPCErrorKind.kServiceDisappeared.code, + 'Service has disappeared', + ); + + await expectLater( + () => forEachIsolateHelper( + fakeService, + (isolate) => Future.error(error), + ), + throwsA(same(error)), + ); + }, + ); + + test( + 'rethrows SentinelException directly without wrapping in ParallelWaitError', + () async { + final isolates = [ + IsolateRef(id: 'isolate_1', number: '1', name: 'main_1'), + ]; + final fakeService = _FakeVmService(isolates: isolates); + final sentinel = SentinelException.parse('getIsolate', { + 'type': 'Sentinel', + 'kind': 'Collected', + 'valueAsString': '', + }); + + await expectLater( + () => forEachIsolateHelper( + fakeService, + (isolate) => Future.error(sentinel), + ), + throwsA(same(sentinel)), + ); + }, + ); + + test( + 'VmServiceExtension.forEachIsolate delegates to forEachIsolateHelper', + () async { + final isolates = [ + IsolateRef(id: 'isolate_1', number: '1', name: 'main_1'), + ]; + final fakeService = _FakeVmService(isolates: isolates); + final visited = []; + await fakeService.forEachIsolate((isolate) async { + visited.add(isolate.id!); + }); + expect(visited, equals(['isolate_1'])); + }, + ); + }); +} + +class _FakeVmService extends Fake implements VmService { + _FakeVmService({required this.isolates}); + + final List? isolates; + + @override + Future getVM() async => VM(isolates: isolates); +} diff --git a/packages/devtools_app_shared/test/test_utils.dart b/packages/devtools_app_shared/test/test_utils.dart index 8c04ae0b97f..29f61e0fc6f 100644 --- a/packages/devtools_app_shared/test/test_utils.dart +++ b/packages/devtools_app_shared/test/test_utils.dart @@ -3,9 +3,9 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; // NOTE: these helpers are duplicated from `package:devtools_test`. We copied // them instead of importing `devtools_test`, because `devtools_test` is not @@ -21,15 +21,9 @@ Widget wrap(Widget widget) { theme: themeFor( isDarkTheme: false, ideTheme: IdeTheme(), - theme: ThemeData( - useMaterial3: true, - colorScheme: lightColorScheme, - ), - ), - home: Directionality( - textDirection: TextDirection.ltr, - child: widget, + theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme), ), + home: Directionality(textDirection: TextDirection.ltr, child: widget), ); } @@ -40,15 +34,11 @@ void testWidgetsWithWindowSize( WidgetTesterCallback test, { bool skip = false, }) { - testWidgets( - name, - (WidgetTester tester) async { - await _setWindowSize(tester, windowSize); - await test(tester); - await _resetWindowSize(tester); - }, - skip: skip, - ); + testWidgets(name, (WidgetTester tester) async { + await _setWindowSize(tester, windowSize); + await test(tester); + await _resetWindowSize(tester); + }, skip: skip); } Future _setWindowSize(WidgetTester tester, Size windowSize) async { diff --git a/packages/devtools_app_shared/test/ui/common_test.dart b/packages/devtools_app_shared/test/ui/common_test.dart index c0cecdeded6..3898a738941 100644 --- a/packages/devtools_app_shared/test/ui/common_test.dart +++ b/packages/devtools_app_shared/test/ui/common_test.dart @@ -4,8 +4,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../test_utils.dart'; @@ -17,52 +17,30 @@ void main() { group('AreaPaneHeader', () { const titleText = 'The title'; - testWidgets( - 'actions do not take up space when not present', - (WidgetTester tester) async { - await tester.pumpWidget( - wrap( - const AreaPaneHeader( - title: Text(titleText), - ), - ), - ); + testWidgets('actions do not take up space when not present', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + wrap(const AreaPaneHeader(title: Text(titleText))), + ); - final row = tester.widget(find.byType(Row)) as Row; - expect( - row.children.length, - equals(1), - ); - expect( - find.text(titleText), - findsOneWidget, - ); - }, - ); + final row = tester.widget(find.byType(Row)) as Row; + expect(row.children.length, equals(1)); + expect(find.text(titleText), findsOneWidget); + }); testWidgets('shows actions', (WidgetTester tester) async { const actionText = 'The Action Text'; const action = Text(actionText); await tester.pumpWidget( - wrap( - const AreaPaneHeader( - title: Text(titleText), - actions: [action], - ), - ), + wrap(const AreaPaneHeader(title: Text(titleText), actions: [action])), ); final row = tester.widget(find.byType(Row)) as Row; - expect( - row.children.length, - equals(2), - ); + expect(row.children.length, equals(2)); expect(find.text(actionText), findsOneWidget); - expect( - find.text(titleText), - findsOneWidget, - ); + expect(find.text(titleText), findsOneWidget); }); }); } diff --git a/packages/devtools_app_shared/test/ui/flex_split_column_test.dart b/packages/devtools_app_shared/test/ui/flex_split_column_test.dart index abeecd25049..0ca388a9d49 100644 --- a/packages/devtools_app_shared/test/ui/flex_split_column_test.dart +++ b/packages/devtools_app_shared/test/ui/flex_split_column_test.dart @@ -4,8 +4,8 @@ import 'package:collection/collection.dart'; import 'package:devtools_app_shared/src/ui/flex_split_column.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { group('FlexSplitColumn', () { @@ -40,33 +40,31 @@ void main() { test('modifyInitialFractionsToIncludeFirstHeader', () { final adjustedFractions = FlexSplitColumn.modifyInitialFractionsToIncludeFirstHeader( - initialFractions, - headers, - totalHeight, - ); + initialFractions, + headers, + totalHeight, + ); expect( - const DeepCollectionEquality().equals( - adjustedFractions, - [ - 0.2857142857142857, - 0.23809523809523808, - 0.23809523809523808, - 0.23809523809523808, - ], - ), + const DeepCollectionEquality().equals(adjustedFractions, [ + 0.2857142857142857, + 0.23809523809523808, + 0.23809523809523808, + 0.23809523809523808, + ]), isTrue, ); }); test('modifyMinSizesToIncludeFirstHeader', () { final adjustedFractions = - FlexSplitColumn.modifyMinSizesToIncludeFirstHeader( - minSizes, - headers, - ); + FlexSplitColumn.modifyMinSizesToIncludeFirstHeader(minSizes, headers); expect( - const DeepCollectionEquality() - .equals(adjustedFractions, [60.0, 10.0, 10.0, 10.0]), + const DeepCollectionEquality().equals(adjustedFractions, [ + 60.0, + 10.0, + 10.0, + 10.0, + ]), isTrue, ); }); @@ -77,10 +75,10 @@ void main() { // Wrap each child in a container so we can build the elements in a // arbitrary column to check for [firstHeaderKey]. - final adjustedChildren = - FlexSplitColumn.buildChildrenWithFirstHeader(children, headers) - .map((child) => SizedBox(height: 100.0, child: child)) - .toList(); + final adjustedChildren = FlexSplitColumn.buildChildrenWithFirstHeader( + children, + headers, + ).map((child) => SizedBox(height: 100.0, child: child)).toList(); await tester.pumpWidget(Column(children: adjustedChildren)); expect(find.byKey(firstHeaderKey), findsOneWidget); }); diff --git a/packages/devtools_app_shared/test/ui/split_pane_test.dart b/packages/devtools_app_shared/test/ui/split_pane_test.dart index 326d3c477e9..567d3fe2fd9 100644 --- a/packages/devtools_app_shared/test/ui/split_pane_test.dart +++ b/packages/devtools_app_shared/test/ui/split_pane_test.dart @@ -6,8 +6,8 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../test_utils.dart'; @@ -83,8 +83,10 @@ void main() { }); testWidgets('with 0% space to first child', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.horizontal, initialFractions: [0.0, 1.0]); + final split = buildSplitPane( + Axis.horizontal, + initialFractions: [0.0, 1.0], + ); await tester.pumpWidget(wrap(split)); expect(find.byKey(_k1), findsOneWidget); expect(find.byKey(_k2), findsOneWidget); @@ -103,31 +105,30 @@ void main() { ); }); - testWidgets( - 'with 100% space to first child', - (WidgetTester tester) async { - final split = buildSplitPane( - Axis.horizontal, - initialFractions: [1.0, 0.0], - ); - await tester.pumpWidget(wrap(split)); - expect(find.byKey(_k1), findsOneWidget); - expect(find.byKey(_k2), findsOneWidget); - expect(find.byKey(split.dividerKey(0)), findsOneWidget); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(788, 600), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(0, 600), - ); - expectEqualSizes( - tester.element(find.byKey(split.dividerKey(0))).size!, - const Size(12, 600), - ); - }, - ); + testWidgets('with 100% space to first child', ( + WidgetTester tester, + ) async { + final split = buildSplitPane( + Axis.horizontal, + initialFractions: [1.0, 0.0], + ); + await tester.pumpWidget(wrap(split)); + expect(find.byKey(_k1), findsOneWidget); + expect(find.byKey(_k2), findsOneWidget); + expect(find.byKey(split.dividerKey(0)), findsOneWidget); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(788, 600), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(0, 600), + ); + expectEqualSizes( + tester.element(find.byKey(split.dividerKey(0))).size!, + const Size(12, 600), + ); + }); testWidgets('with n children', (WidgetTester tester) async { final split = buildSplitPane( @@ -172,36 +173,37 @@ void main() { ); }); - testWidgets( - 'with initialFraction rounding errors', - (WidgetTester tester) async { - const oneThird = 0.333333; - final split = buildSplitPane( - Axis.horizontal, - children: [_w1, _w2, _w3], - initialFractions: [oneThird, oneThird, oneThird], - ); - await tester.pumpWidget(wrap(split)); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(258.666416, 600), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(258.666416, 600), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(258.666416, 600), - ); - }, - ); + testWidgets('with initialFraction rounding errors', ( + WidgetTester tester, + ) async { + const oneThird = 0.333333; + final split = buildSplitPane( + Axis.horizontal, + children: [_w1, _w2, _w3], + initialFractions: [oneThird, oneThird, oneThird], + ); + await tester.pumpWidget(wrap(split)); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(258.666416, 600), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(258.666416, 600), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(258.666416, 600), + ); + }); }); group('builds vertical layout', () { testWidgets('with 25% space to first child', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.vertical, initialFractions: [0.25, 0.75]); + final split = buildSplitPane( + Axis.vertical, + initialFractions: [0.25, 0.75], + ); await tester.pumpWidget(wrap(split)); expect(find.byKey(_k1), findsOneWidget); expect(find.byKey(_k2), findsOneWidget); @@ -221,8 +223,10 @@ void main() { }); testWidgets('with 50% space to first child', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.vertical, initialFractions: [0.5, 0.5]); + final split = buildSplitPane( + Axis.vertical, + initialFractions: [0.5, 0.5], + ); await tester.pumpWidget(wrap(split)); expectEqualSizes( tester.element(find.byKey(_k1)).size!, @@ -235,8 +239,10 @@ void main() { }); testWidgets('with 75% space to first child', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.vertical, initialFractions: [0.75, 0.25]); + final split = buildSplitPane( + Axis.vertical, + initialFractions: [0.75, 0.25], + ); await tester.pumpWidget(wrap(split)); expectEqualSizes( tester.element(find.byKey(_k1)).size!, @@ -249,8 +255,10 @@ void main() { }); testWidgets('with 0% space to first child', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.vertical, initialFractions: [0.0, 1.0]); + final split = buildSplitPane( + Axis.vertical, + initialFractions: [0.0, 1.0], + ); await tester.pumpWidget(wrap(split)); expect(find.byKey(_k1), findsOneWidget); expect(find.byKey(_k2), findsOneWidget); @@ -269,29 +277,30 @@ void main() { ); }); - testWidgets( - 'with 100% space to first child', - (WidgetTester tester) async { - final split = - buildSplitPane(Axis.vertical, initialFractions: [1.0, 0.0]); - await tester.pumpWidget(wrap(split)); - expect(find.byKey(_k1), findsOneWidget); - expect(find.byKey(_k2), findsOneWidget); - expect(find.byKey(split.dividerKey(0)), findsOneWidget); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(800, 588), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(800, 0), - ); - expectEqualSizes( - tester.element(find.byKey(split.dividerKey(0))).size!, - const Size(800, 12), - ); - }, - ); + testWidgets('with 100% space to first child', ( + WidgetTester tester, + ) async { + final split = buildSplitPane( + Axis.vertical, + initialFractions: [1.0, 0.0], + ); + await tester.pumpWidget(wrap(split)); + expect(find.byKey(_k1), findsOneWidget); + expect(find.byKey(_k2), findsOneWidget); + expect(find.byKey(split.dividerKey(0)), findsOneWidget); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(800, 588), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(800, 0), + ); + expectEqualSizes( + tester.element(find.byKey(split.dividerKey(0))).size!, + const Size(800, 12), + ); + }); testWidgets('with n children', (WidgetTester tester) async { final split = buildSplitPane( @@ -336,36 +345,37 @@ void main() { ); }); - testWidgets( - 'with initialFraction rounding errors', - (WidgetTester tester) async { - const oneThird = 0.333333; - final split = buildSplitPane( - Axis.vertical, - children: [_w1, _w2, _w3], - initialFractions: [oneThird, oneThird, oneThird], - ); - await tester.pumpWidget(wrap(split)); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(800, 191.99981599999998), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(800, 191.99981599999998), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(800, 191.99981599999998), - ); - }, - ); + testWidgets('with initialFraction rounding errors', ( + WidgetTester tester, + ) async { + const oneThird = 0.333333; + final split = buildSplitPane( + Axis.vertical, + children: [_w1, _w2, _w3], + initialFractions: [oneThird, oneThird, oneThird], + ); + await tester.pumpWidget(wrap(split)); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(800, 191.99981599999998), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(800, 191.99981599999998), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(800, 191.99981599999998), + ); + }); }); group('drags properly', () { testWidgets('with horizontal layout', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.horizontal, initialFractions: [0.5, 0.5]); + final split = buildSplitPane( + Axis.horizontal, + initialFractions: [0.5, 0.5], + ); await tester.pumpWidget(wrap(split)); // We start at 0.5 size. @@ -470,8 +480,10 @@ void main() { }); testWidgets('with vertical layout', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.vertical, initialFractions: [0.5, 0.5]); + final split = buildSplitPane( + Axis.vertical, + initialFractions: [0.5, 0.5], + ); await tester.pumpWidget(wrap(split)); // We start at 0.5 size. @@ -811,13 +823,13 @@ void main() { group('resizes contents', () { testWidgets('in a horizontal layout', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.horizontal, initialFractions: [0.0, 1.0]); + final split = buildSplitPane( + Axis.horizontal, + initialFractions: [0.0, 1.0], + ); await tester.pumpWidget( wrap( - Center( - child: SizedBox(width: 300.0, height: 300.0, child: split), - ), + Center(child: SizedBox(width: 300.0, height: 300.0, child: split)), ), ); expectEqualSizes( @@ -831,9 +843,7 @@ void main() { await tester.pumpWidget( wrap( - Center( - child: SizedBox(width: 200.0, height: 200.0, child: split), - ), + Center(child: SizedBox(width: 200.0, height: 200.0, child: split)), ), ); expectEqualSizes( @@ -846,235 +856,214 @@ void main() { ); }); - testWidgets( - 'in a horizontal layout with n children', - (WidgetTester tester) async { - final split = buildSplitPane( - Axis.horizontal, - children: [_w1, _w2, _w3], - initialFractions: [0.2, 0.4, 0.4], - ); - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 400.0, height: 400.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(75.2, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(150.4, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(150.4, 400), - ); + testWidgets('in a horizontal layout with n children', ( + WidgetTester tester, + ) async { + final split = buildSplitPane( + Axis.horizontal, + children: [_w1, _w2, _w3], + initialFractions: [0.2, 0.4, 0.4], + ); + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 400.0, height: 400.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(75.2, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(150.4, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(150.4, 400), + ); - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 200.0, height: 200.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(35.2, 200), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(70.4, 200), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(70.4, 200), - ); - }, - ); + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 200.0, height: 200.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(35.2, 200), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(70.4, 200), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(70.4, 200), + ); + }); - testWidgets( - 'with violated minsize constraints', - (WidgetTester tester) async { - final split = buildSplitPane( - Axis.horizontal, - children: [_w1, _w2, _w3], - initialFractions: [0.1, 0.7, 0.2], - minSizes: [100.0, 0, 100.0], - ); - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 400.0, height: 400.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(100, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(176.0, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(100, 400), - ); + testWidgets('with violated minsize constraints', ( + WidgetTester tester, + ) async { + final split = buildSplitPane( + Axis.horizontal, + children: [_w1, _w2, _w3], + initialFractions: [0.1, 0.7, 0.2], + minSizes: [100.0, 0, 100.0], + ); + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 400.0, height: 400.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(100, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(176.0, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(100, 400), + ); - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 230.0, height: 200.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(100.0, 200), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(6.0, 200), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(100.0, 200), - ); + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 230.0, height: 200.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(100.0, 200), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(6.0, 200), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(100.0, 200), + ); - // It would be nice if we restored the size of w2 in this case but the - // logic is simpler if we don't as this way the layout calculation can - // avoid tracking state about what the previous fractions were before - // clipping which would add more complexity and shouldn't really matter. - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 400.0, height: 400.0, child: split), - ), - ), - ); - // TODO(dantup): These now fail, as the results are 100/176/100. It's not - // clear why these expectations are different to the above when it's - // in the same size box? - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(182.5242718446602, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(10.951456310679607, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(182.5242718446602, 400), - ); - }, - ); + // It would be nice if we restored the size of w2 in this case but the + // logic is simpler if we don't as this way the layout calculation can + // avoid tracking state about what the previous fractions were before + // clipping which would add more complexity and shouldn't really matter. + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 400.0, height: 400.0, child: split)), + ), + ); + // TODO(dantup): These now fail, as the results are 100/176/100. It's not + // clear why these expectations are different to the above when it's + // in the same size box? + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(182.5242718446602, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(10.951456310679607, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(182.5242718446602, 400), + ); + }); - testWidgets( - 'with impossible minsize constraints', - (WidgetTester tester) async { - final split = buildSplitPane( - Axis.horizontal, - children: [_w1, _w2, _w3], - initialFractions: [0.2, 0.4, 0.4], - minSizes: [200.0, 0, 400.0], - ); - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 400.0, height: 400.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(125.33333333333333, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(0, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(250.66666666666666, 400), - ); + testWidgets('with impossible minsize constraints', ( + WidgetTester tester, + ) async { + final split = buildSplitPane( + Axis.horizontal, + children: [_w1, _w2, _w3], + initialFractions: [0.2, 0.4, 0.4], + minSizes: [200.0, 0, 400.0], + ); + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 400.0, height: 400.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(125.33333333333333, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(0, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(250.66666666666666, 400), + ); - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 200.0, height: 200.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(58.666666666666664, 200), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(0, 200), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(117.33333333333333, 200), - ); + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 200.0, height: 200.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(58.666666666666664, 200), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(0, 200), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(117.33333333333333, 200), + ); - // Min size constraints still violated but not violated by as much. - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 400.0, height: 400.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(125.33333333333333, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(0, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(250.66666666666666, 400), - ); + // Min size constraints still violated but not violated by as much. + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 400.0, height: 400.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(125.33333333333333, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(0, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(250.66666666666666, 400), + ); - // Min size constraints are now satisfied. - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 800.0, height: 400.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(258.66666666666666, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(0, 400), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(517.3333333333333, 400), - ); - }, - ); + // Min size constraints are now satisfied. + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 800.0, height: 400.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(258.66666666666666, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(0, 400), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(517.3333333333333, 400), + ); + }); testWidgets('in a vertical layout', (WidgetTester tester) async { - final split = - buildSplitPane(Axis.vertical, initialFractions: [0.0, 1.0]); + final split = buildSplitPane( + Axis.vertical, + initialFractions: [0.0, 1.0], + ); await tester.pumpWidget( wrap( - Center( - child: SizedBox(width: 300.0, height: 300.0, child: split), - ), + Center(child: SizedBox(width: 300.0, height: 300.0, child: split)), ), ); expectEqualSizes( @@ -1088,9 +1077,7 @@ void main() { await tester.pumpWidget( wrap( - Center( - child: SizedBox(width: 200.0, height: 200.0, child: split), - ), + Center(child: SizedBox(width: 200.0, height: 200.0, child: split)), ), ); expectEqualSizes( @@ -1103,55 +1090,109 @@ void main() { ); }); - testWidgets( - 'in a vertical layout with n children', - (WidgetTester tester) async { - final split = buildSplitPane( - Axis.vertical, - children: [_w1, _w2, _w3], - initialFractions: [0.2, 0.4, 0.4], - ); - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 400.0, height: 400.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(400, 75.2), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(400, 150.4), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(400, 150.4), - ); + testWidgets('in a vertical layout with n children', ( + WidgetTester tester, + ) async { + final split = buildSplitPane( + Axis.vertical, + children: [_w1, _w2, _w3], + initialFractions: [0.2, 0.4, 0.4], + ); + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 400.0, height: 400.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(400, 75.2), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(400, 150.4), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(400, 150.4), + ); - await tester.pumpWidget( - wrap( - Center( - child: SizedBox(width: 200.0, height: 200.0, child: split), - ), - ), - ); - expectEqualSizes( - tester.element(find.byKey(_k1)).size!, - const Size(200, 35.2), - ); - expectEqualSizes( - tester.element(find.byKey(_k2)).size!, - const Size(200, 70.4), - ); - expectEqualSizes( - tester.element(find.byKey(_k3)).size!, - const Size(200, 70.4), - ); - }, - ); + await tester.pumpWidget( + wrap( + Center(child: SizedBox(width: 200.0, height: 200.0, child: split)), + ), + ); + expectEqualSizes( + tester.element(find.byKey(_k1)).size!, + const Size(200, 35.2), + ); + expectEqualSizes( + tester.element(find.byKey(_k2)).size!, + const Size(200, 70.4), + ); + expectEqualSizes( + tester.element(find.byKey(_k3)).size!, + const Size(200, 70.4), + ); + }); + }); + + group('rebuilds with a different number of children', () { + testWidgets('does not throw a RangeError when child count shrinks', ( + WidgetTester tester, + ) async { + final threeChildSplit = buildSplitPane( + Axis.horizontal, + children: const [_w1, _w2, _w3], + initialFractions: const [0.2, 0.4, 0.4], + minSizes: const [50.0, 50.0, 50.0], + ); + await tester.pumpWidget(wrap(threeChildSplit)); + expect(find.byKey(_k1), findsOneWidget); + expect(find.byKey(_k2), findsOneWidget); + expect(find.byKey(_k3), findsOneWidget); + + final twoChildSplit = buildSplitPane( + Axis.horizontal, + children: const [_w1, _w2], + initialFractions: const [0.5, 0.5], + minSizes: const [50.0, 50.0], + ); + await tester.pumpWidget(wrap(twoChildSplit)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(_k1), findsOneWidget); + expect(find.byKey(_k2), findsOneWidget); + expect(find.byKey(_k3), findsNothing); + }); + + testWidgets('does not throw a RangeError when child count grows', ( + WidgetTester tester, + ) async { + final twoChildSplit = buildSplitPane( + Axis.horizontal, + children: const [_w1, _w2], + initialFractions: const [0.5, 0.5], + minSizes: const [50.0, 50.0], + ); + await tester.pumpWidget(wrap(twoChildSplit)); + expect(find.byKey(_k1), findsOneWidget); + expect(find.byKey(_k2), findsOneWidget); + + final threeChildSplit = buildSplitPane( + Axis.horizontal, + children: const [_w1, _w2, _w3], + initialFractions: const [0.2, 0.4, 0.4], + minSizes: const [50.0, 50.0, 50.0], + ); + await tester.pumpWidget(wrap(threeChildSplit)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + expect(find.byKey(_k1), findsOneWidget); + expect(find.byKey(_k2), findsOneWidget); + expect(find.byKey(_k3), findsOneWidget); + }); }); group('axisFor', () { @@ -1163,8 +1204,12 @@ void main() { wrap( Builder( builder: (context) { - unawaited(expectLater( - SplitPane.axisFor(context, 1.0), Axis.horizontal)); + unawaited( + expectLater( + SplitPane.axisFor(context, 1.0), + Axis.horizontal, + ), + ); return Container(); }, ), @@ -1172,23 +1217,22 @@ void main() { ); }, ); - testWidgetsWithWindowSize( - 'return Axis.vertical', - const Size(500, 800), - (WidgetTester tester) async { - await tester.pumpWidget( - wrap( - Builder( - builder: (context) { - unawaited(expectLater( - SplitPane.axisFor(context, 1.0), Axis.vertical)); - return Container(); - }, - ), + testWidgetsWithWindowSize('return Axis.vertical', const Size(500, 800), ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + wrap( + Builder( + builder: (context) { + unawaited( + expectLater(SplitPane.axisFor(context, 1.0), Axis.vertical), + ); + return Container(); + }, ), - ); - }, - ); + ), + ); + }); }); }); } diff --git a/packages/devtools_app_shared/test/ui/theme_test.dart b/packages/devtools_app_shared/test/ui/theme_test.dart index fdea9c2230d..07660ed27a1 100644 --- a/packages/devtools_app_shared/test/ui/theme_test.dart +++ b/packages/devtools_app_shared/test/ui/theme_test.dart @@ -4,8 +4,8 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { setUpAll(() { @@ -19,40 +19,25 @@ void main() { theme = themeFor( isDarkTheme: true, ideTheme: IdeTheme(), - theme: ThemeData( - useMaterial3: true, - colorScheme: darkColorScheme, - ), + theme: ThemeData(useMaterial3: true, colorScheme: darkColorScheme), ); expect(theme.brightness, equals(Brightness.dark)); - expect( - theme.scaffoldBackgroundColor, - equals(darkColorScheme.surface), - ); + expect(theme.scaffoldBackgroundColor, equals(darkColorScheme.surface)); theme = themeFor( isDarkTheme: false, ideTheme: IdeTheme(), - theme: ThemeData( - useMaterial3: true, - colorScheme: lightColorScheme, - ), + theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme), ); expect(theme.brightness, equals(Brightness.light)); - expect( - theme.scaffoldBackgroundColor, - equals(lightColorScheme.surface), - ); + expect(theme.scaffoldBackgroundColor, equals(lightColorScheme.surface)); }); test('can be inferred from override background color', () { theme = themeFor( isDarkTheme: false, // Will be overridden by white BG ideTheme: IdeTheme(backgroundColor: Colors.white70), - theme: ThemeData( - useMaterial3: true, - colorScheme: lightColorScheme, - ), + theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme), ); expect(theme.brightness, equals(Brightness.light)); expect(theme.scaffoldBackgroundColor, equals(Colors.white70)); @@ -60,10 +45,7 @@ void main() { theme = themeFor( isDarkTheme: true, // Will be overridden by black BG ideTheme: IdeTheme(backgroundColor: Colors.black), - theme: ThemeData( - useMaterial3: true, - colorScheme: darkColorScheme, - ), + theme: ThemeData(useMaterial3: true, colorScheme: darkColorScheme), ); expect(theme.brightness, equals(Brightness.dark)); expect(theme.scaffoldBackgroundColor, equals(Colors.black)); @@ -73,30 +55,29 @@ void main() { theme = themeFor( isDarkTheme: false, // Will not be overridden - not dark enough ideTheme: IdeTheme(backgroundColor: Colors.orange), - theme: ThemeData( - useMaterial3: true, - colorScheme: lightColorScheme, - ), + theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme), ); expect(theme.brightness, equals(Brightness.light)); - expect( - theme.scaffoldBackgroundColor, - equals(lightColorScheme.surface), - ); + expect(theme.scaffoldBackgroundColor, equals(lightColorScheme.surface)); theme = themeFor( isDarkTheme: true, // Will not be overridden - not light enough ideTheme: IdeTheme(backgroundColor: Colors.orange), - theme: ThemeData( - useMaterial3: true, - colorScheme: darkColorScheme, - ), + theme: ThemeData(useMaterial3: true, colorScheme: darkColorScheme), ); expect(theme.brightness, equals(Brightness.dark)); - expect( - theme.scaffoldBackgroundColor, - equals(darkColorScheme.surface), - ); + expect(theme.scaffoldBackgroundColor, equals(darkColorScheme.surface)); + }); + }); + + group('search match colors', () { + // Regression test for https://github.com/flutter/devtools/issues/9010: the + // "opaque" search match colors are blended over the row background, so they + // must stay translucent. Otherwise the (theme-colored) row text is drawn on + // a fully opaque highlight and becomes unreadable. + test('are translucent so row text stays legible', () { + expect(searchMatchColorTranslucent.a, closeTo(0.5, 0.001)); + expect(activeSearchMatchColorTranslucent.a, closeTo(0.5, 0.001)); }); }); } diff --git a/packages/devtools_app_shared/test/utils/utils_test.dart b/packages/devtools_app_shared/test/utils/utils_test.dart index 25689846979..339077e6c68 100644 --- a/packages/devtools_app_shared/test/utils/utils_test.dart +++ b/packages/devtools_app_shared/test/utils/utils_test.dart @@ -3,8 +3,8 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; void main() { group('pluralize', () { @@ -46,17 +46,11 @@ void main() { test('parses 8 digit hex colors', () { expect(parseCssHexColor('#000000ff'), equals(Colors.black)); expect(parseCssHexColor('000000ff'), equals(Colors.black)); - expect( - parseCssHexColor('#00000000'), - equals(Colors.black.withAlpha(0)), - ); + expect(parseCssHexColor('#00000000'), equals(Colors.black.withAlpha(0))); expect(parseCssHexColor('00000000'), equals(Colors.black.withAlpha(0))); expect(parseCssHexColor('#ffffffff'), equals(Colors.white)); expect(parseCssHexColor('ffffffff'), equals(Colors.white)); - expect( - parseCssHexColor('#ffffff00'), - equals(Colors.white.withAlpha(0)), - ); + expect(parseCssHexColor('#ffffff00'), equals(Colors.white.withAlpha(0))); expect(parseCssHexColor('ffffff00'), equals(Colors.white.withAlpha(0))); expect( parseCssHexColor('#ff0000bb'), diff --git a/packages/devtools_extensions/CHANGELOG.md b/packages/devtools_extensions/CHANGELOG.md index 6f2f4bfae0e..dd4359a4d11 100644 --- a/packages/devtools_extensions/CHANGELOG.md +++ b/packages/devtools_extensions/CHANGELOG.md @@ -3,6 +3,17 @@ Copyright 2025 The Flutter Authors Use of this source code is governed by a BSD-style license that can be found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. --> +## 0.5.2-wip +* The minimum Dart SDK version is bumped to 3.11.0. +* The minimum Flutter SDK version is bumped to 3.41.0. +* Updates `devtools_shared` constraint to `^14.0.1`. +* Add validation for `pubspec.yaml` existence and valid characters in the `config.yaml` `name` field to the `validate` command. +* Remove the deprecated `--pwa-strategy` flag from the `build_and_copy` command. + +## 0.5.1 +* Updates `devtools_app_shared` constraint to `^0.5.1`. +* Updates `devtools_shared` constraint to `^13.0.0`. + ## 0.5.0 * Bump `devtools_app_shared` dependency to `0.5.0`. diff --git a/packages/devtools_extensions/bin/_build_and_copy.dart b/packages/devtools_extensions/bin/_build_and_copy.dart index a3d2ee670bb..9a28f0b9c45 100644 --- a/packages/devtools_extensions/bin/_build_and_copy.dart +++ b/packages/devtools_extensions/bin/_build_and_copy.dart @@ -21,14 +21,16 @@ class BuildExtensionCommand extends Command { argParser ..addOption( _sourceKey, - help: 'The source location for the extension flutter web app (can be ' + help: + 'The source location for the extension flutter web app (can be ' 'relative or absolute)', valueHelp: 'path/to/foo/packages/foo_devtools_extension', mandatory: true, ) ..addOption( _destinationKey, - help: 'The destination location for the extension build output (can be ' + help: + 'The destination location for the extension build output (can be ' 'relative or absolute)', valueHelp: 'path/to/foo/packages/foo/extension/devtools', mandatory: true, @@ -57,13 +59,7 @@ class BuildExtensionCommand extends Command { await _runProcess( processManager, Platform.isWindows ? 'flutter.bat' : 'flutter', - [ - 'build', - 'web', - '--pwa-strategy=offline-first', - '--release', - '--no-tree-shake-icons', - ], + ['build', 'web', '--release', '--no-tree-shake-icons'], workingDirectory: source, ); @@ -71,17 +67,12 @@ class BuildExtensionCommand extends Command { // `chmod` or if we even need to perform `chmod` for linux / mac anymore. if (!Platform.isWindows) { _log('Setting canvaskit permissions...'); - await _runProcess( - processManager, - 'chmod', - [ - '0755', - // Note: using a wildcard `canvaskit.*` throws. - 'build/web/canvaskit/canvaskit.js', - 'build/web/canvaskit/canvaskit.wasm', - ], - workingDirectory: source, - ); + await _runProcess(processManager, 'chmod', [ + '0755', + // Note: using a wildcard `canvaskit.*` throws. + 'build/web/canvaskit/canvaskit.js', + 'build/web/canvaskit/canvaskit.wasm', + ], workingDirectory: source); } _log('Copying built output to the extension destination...'); @@ -102,10 +93,7 @@ class BuildExtensionCommand extends Command { } Directory(destinationBuildPath).createSync(recursive: true); - await copyPath( - sourceBuildPath, - destinationBuildPath, - ); + await copyPath(sourceBuildPath, destinationBuildPath); _log( 'Successfully copied extension assets from ' diff --git a/packages/devtools_extensions/bin/_validate.dart b/packages/devtools_extensions/bin/_validate.dart index 89cda33b1e8..30f36cc1b66 100644 --- a/packages/devtools_extensions/bin/_validate.dart +++ b/packages/devtools_extensions/bin/_validate.dart @@ -49,17 +49,16 @@ class ValidateExtensionCommand extends Command { // Try to parse the config.yaml file. This will throw an exception if there // are parsing errors. - DevToolsExtensionConfig.parse( - { - ..._configAsMap(packagePath), - // These are generated on the DevTools server, so pass in stubbed - // values for the sake of validation. - DevToolsExtensionConfig.extensionAssetsPathKey: '', - DevToolsExtensionConfig.devtoolsOptionsUriKey: '', - DevToolsExtensionConfig.isPubliclyHostedKey: 'false', - DevToolsExtensionConfig.detectedFromStaticContextKey: 'false', - }, - ); + DevToolsExtensionConfig.parse({ + ..._configAsMap(packagePath), + // These are generated on the DevTools server, so pass in stubbed + // values for the sake of validation. + DevToolsExtensionConfig.packageNameKey: '', + DevToolsExtensionConfig.extensionAssetsPathKey: '', + DevToolsExtensionConfig.devtoolsOptionsUriKey: '', + DevToolsExtensionConfig.isPubliclyHostedKey: 'false', + DevToolsExtensionConfig.detectedFromStaticContextKey: 'false', + }); // If there are no exceptions at this point, the extension has successfully // been validated. @@ -80,43 +79,54 @@ void _validateDirectoryContents(String packagePath) { throw FileSystemException('${packageDirectory.path} directory not found'); } + final pubspecFile = File(path.join(packageDirectory.path, 'pubspec.yaml')); + if (!pubspecFile.existsSync()) { + throw const FileSystemException(''' +A pubspec.yaml file is required, but none was found. +See ${ValidateExtensionCommand.docUrl}. +'''); + } + final devtoolsExtensionDir = Directory( path.join(packageDirectory.path, 'extension', 'devtools'), ); if (!devtoolsExtensionDir.existsSync()) { - throw const FileSystemException( - ''' + throw const FileSystemException(''' An extension/devtools directory is required, but none was found. See ${ValidateExtensionCommand.docUrl}. -''', - ); +'''); } final buildDir = Directory(path.join(devtoolsExtensionDir.path, 'build')); if (!buildDir.existsSync()) { - throw const FileSystemException( - ''' + throw const FileSystemException(''' An extension/devtools/build directory is required, but none was found. See ${ValidateExtensionCommand.docUrl}. -''', - ); +'''); } if (buildDir.listSync().isEmpty) { - throw const FileSystemException( - ''' + throw const FileSystemException(''' A non-empty extension/devtools/build directory is required, but the directory is empty. See ${ValidateExtensionCommand.docUrl}. -''', - ); +'''); } final configFile = _lookupConfigFile(packagePath); if (!configFile.existsSync()) { - throw const FileSystemException( - ''' + throw const FileSystemException(''' An extension/devtools/config.yaml file is required, but none was found. See ${ValidateExtensionCommand.docUrl}. -''', +'''); + } + + // Ensure the extension's name is a valid identifier. + final configYaml = _configAsMap(packagePath); + final configName = configYaml['name']; + final underscoresAndLetters = RegExp(r'^[a-z0-9_]*$'); + if (configName is! String || !underscoresAndLetters.hasMatch(configName)) { + throw StateError( + 'The "name" field in config.yaml should only contain lowercase letters, ' + 'numbers, and underscores but instead was "$configName".', ); } } @@ -130,9 +140,7 @@ Map _configAsMap(String packagePath) { } File _lookupConfigFile(String packagePath) { - return File( - path.join(packagePath, 'extension', 'devtools', 'config.yaml'), - ); + return File(path.join(packagePath, 'extension', 'devtools', 'config.yaml')); } void _logError(String error) { diff --git a/packages/devtools_extensions/example/app_that_uses_foo/analysis_options.yaml b/packages/devtools_extensions/example/app_that_uses_foo/analysis_options.yaml index 0d2902135ca..bf8d421859d 100644 --- a/packages/devtools_extensions/example/app_that_uses_foo/analysis_options.yaml +++ b/packages/devtools_extensions/example/app_that_uses_foo/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/packages/devtools_extensions/example/app_that_uses_foo/lib/main.dart b/packages/devtools_extensions/example/app_that_uses_foo/lib/main.dart index 26fa27ccab6..ebc165b0bd7 100644 --- a/packages/devtools_extensions/example/app_that_uses_foo/lib/main.dart +++ b/packages/devtools_extensions/example/app_that_uses_foo/lib/main.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:dart_foo/dart_foo.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:foo/foo.dart'; void main() { diff --git a/packages/devtools_extensions/example/app_that_uses_foo/pubspec.yaml b/packages/devtools_extensions/example/app_that_uses_foo/pubspec.yaml index 71a7608314c..722e925db55 100644 --- a/packages/devtools_extensions/example/app_that_uses_foo/pubspec.yaml +++ b/packages/devtools_extensions/example/app_that_uses_foo/pubspec.yaml @@ -12,17 +12,20 @@ environment: flutter: '>=3.0.0' dependencies: + # ignore: dependencies, required for Flutter SDK. flutter: sdk: flutter dart_foo: path: ../packages_with_extensions/dart_foo/packages/dart_foo foo: path: ../packages_with_extensions/foo/packages/foo + material_ui: ^1.4.0 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^4.0.0 + # ignore: dependencies, intentional for example code. standalone_extension: path: ../packages_with_extensions/standalone_extension test: ^1.21.0 diff --git a/packages/devtools_extensions/example/packages_with_extensions/dart_foo/packages/dart_foo_devtools_extension/lib/main.dart b/packages/devtools_extensions/example/packages_with_extensions/dart_foo/packages/dart_foo_devtools_extension/lib/main.dart index 1ba9de66162..6bc10f184fe 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/dart_foo/packages/dart_foo_devtools_extension/lib/main.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/dart_foo/packages/dart_foo_devtools_extension/lib/main.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; void main() { runApp(const DartFooDevToolsExtension()); diff --git a/packages/devtools_extensions/example/packages_with_extensions/dart_foo/packages/dart_foo_devtools_extension/pubspec.yaml b/packages/devtools_extensions/example/packages_with_extensions/dart_foo/packages/dart_foo_devtools_extension/pubspec.yaml index 50b521875c4..8e10c363796 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/dart_foo/packages/dart_foo_devtools_extension/pubspec.yaml +++ b/packages/devtools_extensions/example/packages_with_extensions/dart_foo/packages/dart_foo_devtools_extension/pubspec.yaml @@ -12,12 +12,15 @@ environment: flutter: '>=3.0.0' dependencies: + # ignore: dependencies, intentional for example code. devtools_app_shared: path: ../../../../../../devtools_app_shared devtools_extensions: path: ../../../../../../devtools_extensions + # ignore: dependencies, required for Flutter SDK. flutter: sdk: flutter + material_ui: ^1.4.0 flutter: uses-material-design: true diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo/lib/src/foo_widget.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo/lib/src/foo_widget.dart index 670e62a6c0f..be9542a52ac 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo/lib/src/foo_widget.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo/lib/src/foo_widget.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'foo_controller.dart'; diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo/pubspec.yaml b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo/pubspec.yaml index d7a96fb4454..b23d888fe89 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo/pubspec.yaml +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo/pubspec.yaml @@ -12,6 +12,7 @@ environment: dependencies: flutter: sdk: flutter + material_ui: ^1.4.0 dev_dependencies: flutter_test: diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/main.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/main.dart index 91f63ec1fd3..c41d4493521 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/main.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/main.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'src/foo_devtools_extension.dart'; void main() { diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/common/ui.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/common/ui.dart index 73b58913930..52bdc761b29 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/common/ui.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/common/ui.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// Header widget for each example section. class SectionHeader extends StatelessWidget { diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/devtools_event_example.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/devtools_event_example.dart index 41f14c21572..0a9041863c9 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/devtools_event_example.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/devtools_event_example.dart @@ -4,7 +4,7 @@ import 'package:devtools_extensions/api.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// This widget shows an example of how you can register a custom event handler /// for any type of [DevToolsExtensionEventType]. diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/devtools_extension_api_example.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/devtools_extension_api_example.dart index cc7cd067510..31936080136 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/devtools_extension_api_example.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/devtools_extension_api_example.dart @@ -4,7 +4,7 @@ import 'package:devtools_extensions/api.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// This widget shows an example of how you can call public APIs exposed by /// [ExtensionManager]. diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/dtd_example.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/dtd_example.dart index 8ab07259f9f..34abddecc2e 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/dtd_example.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/dtd_example.dart @@ -8,7 +8,7 @@ import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; import 'package:dtd/dtd.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:path/path.dart' as p; /// This widget shows an example of how you can call public APIs exposed by @@ -169,6 +169,12 @@ class _ReadWriteTmpFileState extends State<_ReadWriteTmpFile> { textEditingController = TextEditingController(); } + @override + void dispose() { + textEditingController.dispose(); + super.dispose(); + } + Future _writeFilesAndUpdate() async { await dtdManager.writeFile(tmpFileUri, textEditingController.text); await _readTmpFile(); diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/expression_evaluation_example.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/expression_evaluation_example.dart index 837d4dc34ea..4ad272c6e60 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/expression_evaluation_example.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/expression_evaluation_example.dart @@ -8,7 +8,7 @@ import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// A widget that shows an example of how to perform expression evaluations over /// the VM Service protocol. diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/service_extension_example.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/service_extension_example.dart index 1ee30629707..e14e03743f4 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/service_extension_example.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/feature_examples/service_extension_example.dart @@ -11,7 +11,7 @@ import 'dart:async'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; /// A widget that shows an example of how to call a service extension over the /// VM Service protocol. @@ -108,6 +108,12 @@ class _TableOfThingsState extends State { unawaited(_refreshThings()); } + @override + void dispose() { + things.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Column( diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/foo_devtools_extension.dart b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/foo_devtools_extension.dart index 16f2eaa6501..852fbcd0cdf 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/foo_devtools_extension.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/lib/src/foo_devtools_extension.dart @@ -4,7 +4,7 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'common/ui.dart'; import 'feature_examples/devtools_event_example.dart'; diff --git a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/pubspec.yaml b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/pubspec.yaml index f7e2141944e..4d74da219a2 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/pubspec.yaml +++ b/packages/devtools_extensions/example/packages_with_extensions/foo/packages/foo_devtools_extension/pubspec.yaml @@ -18,8 +18,10 @@ dependencies: path: ../../../../../../devtools_extensions dtd: ^4.0.0 path: ^1.8.0 + # ignore: dependencies, required for Flutter SDK. flutter: sdk: flutter + material_ui: ^1.4.0 flutter: uses-material-design: true diff --git a/packages/devtools_extensions/example/packages_with_extensions/standalone_extension/lib/main.dart b/packages/devtools_extensions/example/packages_with_extensions/standalone_extension/lib/main.dart index c4f399a34ed..a59c303b023 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/standalone_extension/lib/main.dart +++ b/packages/devtools_extensions/example/packages_with_extensions/standalone_extension/lib/main.dart @@ -3,7 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'package:devtools_extensions/devtools_extensions.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; void main() { runApp(const DartFooDevToolsExtension()); diff --git a/packages/devtools_extensions/example/packages_with_extensions/standalone_extension/pubspec.yaml b/packages/devtools_extensions/example/packages_with_extensions/standalone_extension/pubspec.yaml index 83f45b746c4..bc3970283c4 100644 --- a/packages/devtools_extensions/example/packages_with_extensions/standalone_extension/pubspec.yaml +++ b/packages/devtools_extensions/example/packages_with_extensions/standalone_extension/pubspec.yaml @@ -12,13 +12,16 @@ environment: flutter: '>=3.0.0' dependencies: + # ignore: dependencies, intentional for example code. devtools_app_shared: path: ../../../../devtools_app_shared devtools_extensions: path: ../../../../devtools_extensions + # ignore: dependencies, required for Flutter SDK. flutter: sdk: flutter - + material_ui: ^1.4.0 + flutter: uses-material-design: true diff --git a/packages/devtools_extensions/integration_test/run_tests.dart b/packages/devtools_extensions/integration_test/run_tests.dart index 1eb213d81c4..64d38b32f22 100644 --- a/packages/devtools_extensions/integration_test/run_tests.dart +++ b/packages/devtools_extensions/integration_test/run_tests.dart @@ -38,6 +38,7 @@ Future _runIntegrationTest( testRunnerArgs.testTarget!, testDriver: 'test_driver/integration_test.dart', headless: testRunnerArgs.headless, + useWasm: testRunnerArgs.useWasm, dartDefineArgs: ['use_simulated_environment=true'], debugLogging: debugTestScript, ); diff --git a/packages/devtools_extensions/integration_test/test/simulated_environment_test.dart b/packages/devtools_extensions/integration_test/test/simulated_environment_test.dart index 8816ee21c37..dc64acaf967 100644 --- a/packages/devtools_extensions/integration_test/test/simulated_environment_test.dart +++ b/packages/devtools_extensions/integration_test/test/simulated_environment_test.dart @@ -6,24 +6,21 @@ import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_extensions/api.dart'; import 'package:devtools_extensions/devtools_extensions.dart'; import 'package:devtools_extensions/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:foo_devtools_extension/src/feature_examples/devtools_extension_api_example.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:material_ui/material_ui.dart'; // To run this test: // dart run integration_test/run_tests.dart --target=integration_test/test/simulated_environment_test.dart const safePumpDuration = Duration(seconds: 3); -const longPumpDuration = Duration(seconds: 6); void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); testWidgets('end to end simulated environment', (tester) async { - runApp( - const DevToolsExtension(child: TestDevToolsExtension()), - ); + runApp(const DevToolsExtension(child: TestDevToolsExtension())); await tester.pump(safePumpDuration); expect(find.byType(DevToolsExtension), findsOneWidget); expect(find.byType(TestDevToolsExtension), findsOneWidget); @@ -86,24 +83,22 @@ Future _testRegisterEventHandler( // Register a handler and verify it was called. int eventHandlerCalledCount = 0; - extensionManager.registerEventHandler( - DevToolsExtensionEventType.ping, - (event) { - eventHandlerCalledCount++; - }, - ); + extensionManager.registerEventHandler(DevToolsExtensionEventType.ping, ( + event, + ) { + eventHandlerCalledCount++; + }); await tester.tap(pingButtonFinder); await tester.pumpAndSettle(); expect(eventHandlerCalledCount, 1); // Register a different handler and verify it has replaced the original. int secondEventHandlerCalledCount = 0; - extensionManager.registerEventHandler( - DevToolsExtensionEventType.ping, - (event) { - secondEventHandlerCalledCount++; - }, - ); + extensionManager.registerEventHandler(DevToolsExtensionEventType.ping, ( + event, + ) { + secondEventHandlerCalledCount++; + }); await tester.tap(pingButtonFinder); await tester.pumpAndSettle(); @@ -205,8 +200,9 @@ Future _testCollapseEnvironmentPanel( matching: find.byType(SizedBox), ); - final environmentPanelSizedBoxWidth = - tester.firstWidget(environmentPanelSizedBox).width!; + final environmentPanelSizedBoxWidth = tester + .firstWidget(environmentPanelSizedBox) + .width!; // Check that the [environmentPanelSizedBoxWidth] is the expected width. expect( @@ -215,17 +211,12 @@ Future _testCollapseEnvironmentPanel( ); // Drag the divider to the right by [environmentPanelSizedBoxWidth]. - await tester.drag( - divider, - Offset( - environmentPanelSizedBoxWidth, - 0, - ), - ); + await tester.drag(divider, Offset(environmentPanelSizedBoxWidth, 0)); await tester.pumpAndSettle(); - final simulatedDevToolsWrapperRect = - tester.getRect(find.byType(SimulatedDevToolsWrapper)); + final simulatedDevToolsWrapperRect = tester.getRect( + find.byType(SimulatedDevToolsWrapper), + ); final environmentPanelRect = tester.getRect(find.byWidget(environmentPanel)); // Verify that the environment panel is off screen to the right of the @@ -238,13 +229,7 @@ Future _testCollapseEnvironmentPanel( // Drag the divider to the left by [environmentPanelSizedBoxWidth]. // // This is to bring the 'Clear logs' button into view so it can be tapped. - await tester.drag( - divider, - Offset( - -environmentPanelSizedBoxWidth, - 0, - ), - ); + await tester.drag(divider, Offset(-environmentPanelSizedBoxWidth, 0)); await tester.pumpAndSettle(); await _clearLogs(tester, simController); } @@ -276,9 +261,7 @@ class TestDevToolsExtension extends StatelessWidget { ), body: const Column( mainAxisSize: MainAxisSize.min, - children: [ - CallingDevToolsExtensionsAPIsExample(), - ], + children: [CallingDevToolsExtensionsAPIsExample()], ), ); } diff --git a/packages/devtools_extensions/lib/src/api/model.dart b/packages/devtools_extensions/lib/src/api/model.dart index 7dd9c1fc4f1..e62ce39d180 100644 --- a/packages/devtools_extensions/lib/src/api/model.dart +++ b/packages/devtools_extensions/lib/src/api/model.dart @@ -10,15 +10,12 @@ import 'api.dart'; /// See [DevToolsExtensionEventType] for different types of events that are /// supported over this communication channel. class DevToolsExtensionEvent { - DevToolsExtensionEvent( - this.type, { - this.data, - this.source, - }); + DevToolsExtensionEvent(this.type, {this.data, this.source}); factory DevToolsExtensionEvent.parse(Map json) { - final eventType = - DevToolsExtensionEventType.from(json[_typeKey]! as String); + final eventType = DevToolsExtensionEventType.from( + json[_typeKey]! as String, + ); final data = (json[_dataKey] as Map?)?.cast(); final source = json[sourceKey] as String?; return DevToolsExtensionEvent(eventType, data: data, source: source); @@ -44,10 +41,7 @@ class DevToolsExtensionEvent { final String? source; Map toJson() { - return { - _typeKey: type.name, - if (data != null) _dataKey: data!, - }; + return {_typeKey: type.name, _dataKey: ?data}; } @override @@ -65,10 +59,10 @@ typedef ExtensionEventHandler = void Function(DevToolsExtensionEvent event); /// notification the the DevTools notification framework. class ShowNotificationExtensionEvent extends DevToolsExtensionEvent { ShowNotificationExtensionEvent({required String message}) - : super( - DevToolsExtensionEventType.showNotification, - data: {_messageKey: message}, - ); + : super( + DevToolsExtensionEventType.showNotification, + data: {_messageKey: message}, + ); factory ShowNotificationExtensionEvent.from(DevToolsExtensionEvent event) { assert(event.type == DevToolsExtensionEventType.showNotification); @@ -93,18 +87,18 @@ class ShowBannerMessageExtensionEvent extends DevToolsExtensionEvent { required String extensionName, bool ignoreIfAlreadyDismissed = true, bool dismissOnConnectionChanges = true, - }) : assert(bannerMessageType == 'warning' || bannerMessageType == 'error'), - super( - DevToolsExtensionEventType.showBannerMessage, - data: { - _idKey: id, - _bannerMessageTypeKey: bannerMessageType, - _messageKey: message, - _extensionNameKey: extensionName, - _ignoreIfAlreadyDismissedKey: ignoreIfAlreadyDismissed, - _dismissOnConnectionChangesKey: dismissOnConnectionChanges, - }, - ); + }) : assert(bannerMessageType == 'warning' || bannerMessageType == 'error'), + super( + DevToolsExtensionEventType.showBannerMessage, + data: { + _idKey: id, + _bannerMessageTypeKey: bannerMessageType, + _messageKey: message, + _extensionNameKey: extensionName, + _ignoreIfAlreadyDismissedKey: ignoreIfAlreadyDismissed, + _dismissOnConnectionChangesKey: dismissOnConnectionChanges, + }, + ); factory ShowBannerMessageExtensionEvent.from(DevToolsExtensionEvent event) { assert(event.type == DevToolsExtensionEventType.showBannerMessage); @@ -154,12 +148,9 @@ class CopyToClipboardExtensionEvent extends DevToolsExtensionEvent { required String content, String successMessage = defaultSuccessMessage, }) : super( - DevToolsExtensionEventType.copyToClipboard, - data: { - _contentKey: content, - _successMessageKey: successMessage, - }, - ); + DevToolsExtensionEventType.copyToClipboard, + data: {_contentKey: content, _successMessageKey: successMessage}, + ); factory CopyToClipboardExtensionEvent.from(DevToolsExtensionEvent event) { assert(event.type == DevToolsExtensionEventType.copyToClipboard); diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart index 2853cbb7a1e..e1f20995815 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_controller.dart @@ -48,6 +48,7 @@ class SimulatedDevToolsController extends DisposableController void dispose() { window.removeEventListener('message', _handleMessageListener); _handleMessageListener = null; + messageLogs.dispose(); super.dispose(); } @@ -92,10 +93,7 @@ class SimulatedDevToolsController extends DisposableController void Function()? onUnknownEvent, }) { messageLogs.add( - MessageLogEntry( - source: MessageSource.extension, - data: event.toJson(), - ), + MessageLogEntry(source: MessageSource.extension, data: event.toJson()), ); } @@ -109,10 +107,7 @@ class SimulatedDevToolsController extends DisposableController window.origin.toJS, ); messageLogs.add( - MessageLogEntry( - source: MessageSource.devtools, - data: eventJson, - ), + MessageLogEntry(source: MessageSource.devtools, data: eventJson), ); } @@ -152,7 +147,7 @@ class SimulatedDevToolsController extends DisposableController @visibleForTesting class MessageLogEntry { MessageLogEntry({required this.source, this.data, this.message}) - : timestamp = DateTime.now(); + : timestamp = DateTime.now(); final MessageSource source; final Map? data; diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart index 1305aaba9f4..bd346daf816 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/_simulated_devtools_environment.dart @@ -9,8 +9,8 @@ import 'dart:math' as math; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:web/web.dart' hide Text, Clipboard; import '../../api/api.dart'; @@ -193,10 +193,7 @@ class _SimulatedApi extends StatelessWidget { children: [ Row( children: [ - DevToolsButton( - label: 'PING', - onPressed: simController.ping, - ), + DevToolsButton(label: 'PING', onPressed: simController.ping), const SizedBox(width: denseSpacing), DevToolsButton( label: 'TOGGLE THEME', @@ -245,10 +242,7 @@ class _LogsView extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( - 'Logs:', - style: Theme.of(context).textTheme.titleMedium, - ), + Text('Logs:', style: Theme.of(context).textTheme.titleMedium), DevToolsButton.iconOnly( icon: Icons.clear, outlined: false, @@ -258,11 +252,7 @@ class _LogsView extends StatelessWidget { ], ), const PaddedDivider.thin(), - Expanded( - child: _LogMessages( - simController: simController, - ), - ), + Expanded(child: _LogMessages(simController: simController)), ], ); } @@ -333,10 +323,7 @@ class LogListItem extends StatelessWidget { style: Theme.of(context).fixedFontStyle, ), if (log.message != null) Text(log.message!), - if (log.data != null) - FormattedJson( - json: log.data, - ), + if (log.data != null) FormattedJson(json: log.data), ], ), ); diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_connect.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_connect.dart index 2439d9d0b88..611834ba988 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_connect.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_connect.dart @@ -6,7 +6,6 @@ part of '../_simulated_devtools_environment.dart'; class _UriConnectionDisplay extends StatelessWidget { const _UriConnectionDisplay({ - required this.simController, required this.connected, required this.connectedLabel, required this.disconnectedLabel, @@ -20,7 +19,6 @@ class _UriConnectionDisplay extends StatelessWidget { @visibleForTesting static const totalControlsHeight = 45.0; - final SimulatedDevToolsController simController; final bool connected; final String connectedLabel; final String disconnectedLabel; @@ -36,14 +34,12 @@ class _UriConnectionDisplay extends StatelessWidget { height: totalControlsHeight, child: connected ? _ConnectedDisplay( - simController: simController, label: connectedLabel, onDisconnect: onDisconnect, currentConnection: currentConnection, help: help, ) : _DisconnectedDisplay( - simController: simController, label: disconnectedLabel, hint: disconnectedHint, onConnect: onConnect, @@ -55,14 +51,12 @@ class _UriConnectionDisplay extends StatelessWidget { class _ConnectedDisplay extends StatelessWidget { const _ConnectedDisplay({ - required this.simController, required this.label, required this.currentConnection, required this.onDisconnect, this.help, }); - final SimulatedDevToolsController simController; final String label; final String Function() currentConnection; final VoidCallback onDisconnect; @@ -77,19 +71,11 @@ class _ConnectedDisplay extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: theme.regularTextStyle, - ), - Text( - currentConnection(), - style: theme.boldTextStyle, - ), + Text(label, style: theme.regularTextStyle), + Text(currentConnection(), style: theme.boldTextStyle), ], ), - const Expanded( - child: SizedBox(width: denseSpacing), - ), + const Expanded(child: SizedBox(width: denseSpacing)), DevToolsButton( elevated: true, label: 'Disconnect', @@ -97,10 +83,7 @@ class _ConnectedDisplay extends StatelessWidget { ), if (help != null) ...[ const SizedBox(width: denseSpacing), - _ConnectionHelpButton( - dialogTitle: '$label help', - child: help!, - ), + _ConnectionHelpButton(dialogTitle: '$label help', child: help!), ], ], ); @@ -109,14 +92,12 @@ class _ConnectedDisplay extends StatelessWidget { class _DisconnectedDisplay extends StatefulWidget { const _DisconnectedDisplay({ - required this.simController, required this.label, required this.hint, required this.onConnect, this.help, }); - final SimulatedDevToolsController simController; final String label; final String hint; final void Function(String) onConnect; @@ -189,10 +170,7 @@ class _DisconnectedDisplayState extends State<_DisconnectedDisplay> { } class _ConnectionHelpButton extends StatelessWidget { - const _ConnectionHelpButton({ - required this.dialogTitle, - required this.child, - }); + const _ConnectionHelpButton({required this.dialogTitle, required this.child}); final String dialogTitle; @@ -209,10 +187,7 @@ class _ConnectionHelpButton extends StatelessWidget { showDevToolsDialog( context: context, title: dialogTitle, - content: SizedBox( - width: helpContentWidth, - child: child, - ), + content: SizedBox(width: helpContentWidth, child: child), ); }, ); diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_dtd_connect.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_dtd_connect.dart index fc2e64b9f53..ff202afbe9d 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_dtd_connect.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_dtd_connect.dart @@ -13,11 +13,6 @@ class DTDConnectionDisplay extends StatelessWidget { required this.onConnectionChange, }); - @visibleForTesting - static const totalControlsHeight = 45.0; - @visibleForTesting - static const totalControlsWidth = 415.0; - final SimulatedDevToolsController simController; final bool connected; final Future Function(String?) onConnectionChange; @@ -25,7 +20,6 @@ class DTDConnectionDisplay extends StatelessWidget { @override Widget build(BuildContext context) { return _UriConnectionDisplay( - simController: simController, connected: connected, connectedLabel: 'Dart Tooling Daemon connection: ', disconnectedLabel: 'Dart Tooling Daemon Connection', @@ -75,24 +69,20 @@ class DtdHelp extends StatelessWidget { 'If your DevTools extension interacts with the Dart Tooling ' 'Daemon (DTD) through ', children: [ - TextSpan( - text: 'dtdManager', - style: theme.boldTextStyle, - ), + TextSpan(text: 'dtdManager', style: theme.boldTextStyle), const TextSpan( - text: ', then you will need to connect to a local instance ' + text: + ', then you will need to connect to a local instance ' 'of DTD to debug these features in the simulated ' 'environment. There are multiple ways to access a local ' 'instance of DTD:\n\n' '1. If you are running a Dart or Flutter application ' 'from command line, add the ', ), - TextSpan( - text: printDtdFlag, - style: theme.boldTextStyle, - ), + TextSpan(text: printDtdFlag, style: theme.boldTextStyle), const TextSpan( - text: ' flag. This will output a Dart Tooling Daemon URI to ' + text: + ' flag. This will output a Dart Tooling Daemon URI to ' 'the command line that you can copy.', ), ], @@ -119,7 +109,8 @@ class DtdHelp extends StatelessWidget { RichText( text: TextSpan( style: theme.regularTextStyle, - text: '2. If you have a Dart or Flutter project open in your IDE ' + text: + '2. If you have a Dart or Flutter project open in your IDE ' '(VS Code, IntelliJ, or Android Studio), the IDE will have ' 'a running instance of DTD that you can use. Use the ' 'IDE\'s affordance to find an action (Command Pallette ' @@ -131,7 +122,8 @@ class DtdHelp extends StatelessWidget { style: theme.boldTextStyle, ), const TextSpan( - text: ' action.\n\n' + text: + ' action.\n\n' 'Now, you should have a DTD URI in your clipboard. Paste ' 'this into the "Dart Tooling Daemon Connection" text ' 'field to connect.', diff --git a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_vm_service_connect.dart b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_vm_service_connect.dart index e1fc42ac9c1..96a834d97f2 100644 --- a/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_vm_service_connect.dart +++ b/packages/devtools_extensions/lib/src/template/_simulated_devtools_environment/connection_ui/_vm_service_connect.dart @@ -12,8 +12,6 @@ class VmServiceConnectionDisplay extends StatelessWidget { required this.connected, }); - @visibleForTesting - static const totalControlsHeight = 45.0; @visibleForTesting static const totalControlsWidth = 415.0; @@ -23,7 +21,6 @@ class VmServiceConnectionDisplay extends StatelessWidget { @override Widget build(BuildContext context) { return _UriConnectionDisplay( - simController: simController, connected: connected, connectedLabel: 'Debugging: ', disconnectedLabel: 'Dart VM Service Connection', @@ -55,14 +52,12 @@ class VmServiceHelp extends StatelessWidget { RichText( text: TextSpan( style: theme.regularTextStyle, - text: '1. Run a Dart or Flutter application. If you also need to ' + text: + '1. Run a Dart or Flutter application. If you also need to ' 'connect your extension to the Dart Tooling Daemon, run your ' 'app with the ', children: [ - TextSpan( - text: '--print-dtd', - style: theme.boldTextStyle, - ), + TextSpan(text: '--print-dtd', style: theme.boldTextStyle), const TextSpan(text: ' flag.'), ], ), @@ -74,12 +69,14 @@ class VmServiceHelp extends StatelessWidget { text: '2. This will output text to the command line:', children: [ TextSpan( - text: ' "A Dart VM Service is available at: ' + text: + ' "A Dart VM Service is available at: ' 'http://127.0.0.1:53985/6RVz1q0e9ok=". ', style: theme.boldTextStyle, ), const TextSpan( - text: 'Copy the VM Service URI and paste it into the "Dart ' + text: + 'Copy the VM Service URI and paste it into the "Dart ' 'VM Service Connection" text field to connect.', ), ], diff --git a/packages/devtools_extensions/lib/src/template/devtools_extension.dart b/packages/devtools_extensions/lib/src/template/devtools_extension.dart index 61c922c9cbf..76bf9cba470 100644 --- a/packages/devtools_extensions/lib/src/template/devtools_extension.dart +++ b/packages/devtools_extensions/lib/src/template/devtools_extension.dart @@ -14,8 +14,8 @@ import 'package:devtools_app_shared/utils.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:devtools_shared/service.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:logging/logging.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vm_service/vm_service.dart' hide Event; import 'package:web/web.dart' hide Text; @@ -37,8 +37,9 @@ part 'extension_manager.dart'; /// "args": [ /// "--dart-define=use_simulated_environment=true" /// ] -const _simulatedEnvironmentEnabled = - bool.fromEnvironment('use_simulated_environment'); +const _simulatedEnvironmentEnabled = bool.fromEnvironment( + 'use_simulated_environment', +); bool get _useSimulatedEnvironment => !kReleaseMode && _simulatedEnvironmentEnabled; diff --git a/packages/devtools_extensions/lib/src/template/extension_manager.dart b/packages/devtools_extensions/lib/src/template/extension_manager.dart index 07012e4203a..48bcaebf436 100644 --- a/packages/devtools_extensions/lib/src/template/extension_manager.dart +++ b/packages/devtools_extensions/lib/src/template/extension_manager.dart @@ -66,9 +66,7 @@ class ExtensionManager { // respond with a [DevToolsPluginEventType.connectedVmService] event // containing the currently connected app's vm service URI. postMessageToDevTools( - DevToolsExtensionEvent( - DevToolsExtensionEventType.vmServiceConnection, - ), + DevToolsExtensionEvent(DevToolsExtensionEventType.vmServiceConnection), ); } else { unawaited(_connectToVmService(vmServiceUri)); @@ -97,8 +95,9 @@ class ExtensionManager { // Ignore events that are not supported for the DevTools => Extension // direction. - if (!extensionEvent.type - .supportedForDirection(ExtensionEventDirection.toExtension)) { + if (!extensionEvent.type.supportedForDirection( + ExtensionEventDirection.toExtension, + )) { return; } @@ -110,8 +109,10 @@ class ExtensionManager { ); break; case DevToolsExtensionEventType.vmServiceConnection: - final vmServiceUri = extensionEvent - .data?[ExtensionEventParameters.vmServiceConnectionUri] as String?; + final vmServiceUri = + extensionEvent.data?[ExtensionEventParameters + .vmServiceConnectionUri] + as String?; unawaited(_connectToVmService(vmServiceUri)); break; case DevToolsExtensionEventType.themeUpdate: @@ -206,10 +207,7 @@ class ExtensionManager { try { await dtdManager.connect(Uri.parse(dtdUri)); - updateQueryParameter( - _dtdQueryParameter, - dtdManager.uri.toString(), - ); + updateQueryParameter(_dtdQueryParameter, dtdManager.uri.toString()); } catch (e) { final errorMessage = 'Unable to connect extension to the Dart Tooling Daemon at $dtdUri: $e'; @@ -219,7 +217,8 @@ class ExtensionManager { } void _setThemeForValue(String? themeValue) { - final useDarkTheme = (themeValue == null && useDarkThemeAsDefault) || + final useDarkTheme = + (themeValue == null && useDarkThemeAsDefault) || themeValue == ExtensionEventParameters.themeValueDark; darkThemeEnabled.value = useDarkTheme; // Use a post frame callback so that we do not try to update this while a @@ -244,9 +243,7 @@ class ExtensionManager { /// /// See also [ShowNotificationExtensionEvent]. void showNotification(String message) { - postMessageToDevTools( - ShowNotificationExtensionEvent(message: message), - ); + postMessageToDevTools(ShowNotificationExtensionEvent(message: message)); } /// Show a banner message in DevTools. diff --git a/packages/devtools_extensions/pubspec.yaml b/packages/devtools_extensions/pubspec.yaml index 3b610bc993e..66338ab4c25 100644 --- a/packages/devtools_extensions/pubspec.yaml +++ b/packages/devtools_extensions/pubspec.yaml @@ -3,13 +3,13 @@ # found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. name: devtools_extensions description: A package for building and supporting extensions for Dart DevTools. -version: 0.5.0 +version: 0.5.2-wip repository: https://github.com/flutter/devtools/tree/master/packages/devtools_extensions environment: - sdk: ">=3.6.0 <4.0.0" - flutter: ">=3.27.1" + sdk: ^3.11.0 + flutter: ^3.41.0 resolution: workspace @@ -18,13 +18,14 @@ executables: dependencies: args: ^2.4.2 - devtools_shared: ^12.0.0 - devtools_app_shared: ^0.5.0 + devtools_shared: ^14.0.1 + devtools_app_shared: ^0.5.1 flutter: sdk: flutter io: ^1.0.4 path: ^1.8.0 logging: ^1.1.1 + material_ui: ^1.4.0 vm_service: '>=13.0.0 <16.0.0' web: ^1.0.0 yaml: ^3.1.2 @@ -32,10 +33,9 @@ dependencies: dev_dependencies: flutter_driver: sdk: flutter + flutter_lints: ^6.0.0 flutter_test: sdk: flutter - flutter_web_plugins: - sdk: flutter integration_test: sdk: flutter foo_devtools_extension: diff --git a/packages/devtools_extensions/test/api_test.dart b/packages/devtools_extensions/test/api_test.dart index f2b162d7ee1..acf8d79bb5d 100644 --- a/packages/devtools_extensions/test/api_test.dart +++ b/packages/devtools_extensions/test/api_test.dart @@ -22,9 +22,7 @@ void main() { expect(event.type, DevToolsExtensionEventType.pong); expect(event.data, {'baz': 'bob'}); - event = DevToolsExtensionEvent.parse({ - 'type': 'idk', - }); + event = DevToolsExtensionEvent.parse({'type': 'idk'}); expect(event.type, DevToolsExtensionEventType.unknown); expect(event.data, isNull); }); @@ -124,42 +122,51 @@ void main() { }); test('supportedForDirection', () { - verifyEventDirection( - DevToolsExtensionEventType.ping, - (bidirectional: false, toDevTools: false, toExtension: true), - ); - verifyEventDirection( - DevToolsExtensionEventType.pong, - (bidirectional: false, toDevTools: true, toExtension: false), - ); - verifyEventDirection( - DevToolsExtensionEventType.forceReload, - (bidirectional: false, toDevTools: false, toExtension: true), - ); - verifyEventDirection( - DevToolsExtensionEventType.vmServiceConnection, - (bidirectional: true, toDevTools: true, toExtension: true), - ); - verifyEventDirection( - DevToolsExtensionEventType.themeUpdate, - (bidirectional: false, toDevTools: false, toExtension: true), - ); - verifyEventDirection( - DevToolsExtensionEventType.showNotification, - (bidirectional: false, toDevTools: true, toExtension: false), - ); - verifyEventDirection( - DevToolsExtensionEventType.showBannerMessage, - (bidirectional: false, toDevTools: true, toExtension: false), - ); - verifyEventDirection( - DevToolsExtensionEventType.copyToClipboard, - (bidirectional: false, toDevTools: true, toExtension: false), - ); - verifyEventDirection( - DevToolsExtensionEventType.unknown, - (bidirectional: true, toDevTools: true, toExtension: true), - ); + verifyEventDirection(DevToolsExtensionEventType.ping, ( + bidirectional: false, + toDevTools: false, + toExtension: true, + )); + verifyEventDirection(DevToolsExtensionEventType.pong, ( + bidirectional: false, + toDevTools: true, + toExtension: false, + )); + verifyEventDirection(DevToolsExtensionEventType.forceReload, ( + bidirectional: false, + toDevTools: false, + toExtension: true, + )); + verifyEventDirection(DevToolsExtensionEventType.vmServiceConnection, ( + bidirectional: true, + toDevTools: true, + toExtension: true, + )); + verifyEventDirection(DevToolsExtensionEventType.themeUpdate, ( + bidirectional: false, + toDevTools: false, + toExtension: true, + )); + verifyEventDirection(DevToolsExtensionEventType.showNotification, ( + bidirectional: false, + toDevTools: true, + toExtension: false, + )); + verifyEventDirection(DevToolsExtensionEventType.showBannerMessage, ( + bidirectional: false, + toDevTools: true, + toExtension: false, + )); + verifyEventDirection(DevToolsExtensionEventType.copyToClipboard, ( + bidirectional: false, + toDevTools: true, + toExtension: false, + )); + verifyEventDirection(DevToolsExtensionEventType.unknown, ( + bidirectional: true, + toDevTools: true, + toExtension: true, + )); }); }); @@ -167,9 +174,7 @@ void main() { test('constructs for expected values', () { final event = DevToolsExtensionEvent.parse({ 'type': 'showNotification', - 'data': { - 'message': 'foo message', - }, + 'data': {'message': 'foo message'}, }); final showNotificationEvent = ShowNotificationExtensionEvent.from(event); expect(showNotificationEvent.message, 'foo message'); @@ -181,12 +186,9 @@ void main() { // Missing required fields. }, }); - expect( - () { - ShowNotificationExtensionEvent.from(event1); - }, - throwsFormatException, - ); + expect(() { + ShowNotificationExtensionEvent.from(event1); + }, throwsFormatException); final event2 = DevToolsExtensionEvent.parse({ 'type': 'showNotification', @@ -195,12 +197,9 @@ void main() { 'msg': 'foo message', }, }); - expect( - () { - ShowNotificationExtensionEvent.from(event2); - }, - throwsFormatException, - ); + expect(() { + ShowNotificationExtensionEvent.from(event2); + }, throwsFormatException); final event3 = DevToolsExtensionEvent.parse({ 'type': 'showNotification', @@ -209,26 +208,18 @@ void main() { 'message': false, }, }); - expect( - () { - ShowNotificationExtensionEvent.from(event3); - }, - throwsFormatException, - ); + expect(() { + ShowNotificationExtensionEvent.from(event3); + }, throwsFormatException); final event4 = DevToolsExtensionEvent.parse({ // Wrong type. 'type': 'showBannerMessage', - 'data': { - 'message': 'foo message', - }, + 'data': {'message': 'foo message'}, }); - expect( - () { - ShowNotificationExtensionEvent.from(event4); - }, - throwsAssertionError, - ); + expect(() { + ShowNotificationExtensionEvent.from(event4); + }, throwsAssertionError); }); }); @@ -277,12 +268,9 @@ void main() { 'extensionName': 'foo', }, }); - expect( - () { - ShowBannerMessageExtensionEvent.from(event1); - }, - throwsFormatException, - ); + expect(() { + ShowBannerMessageExtensionEvent.from(event1); + }, throwsFormatException); final event2 = DevToolsExtensionEvent.parse({ 'type': 'showBannerMessage', @@ -294,12 +282,9 @@ void main() { 'extension_name': 'foo', }, }); - expect( - () { - ShowBannerMessageExtensionEvent.from(event2); - }, - throwsFormatException, - ); + expect(() { + ShowBannerMessageExtensionEvent.from(event2); + }, throwsFormatException); final event3 = DevToolsExtensionEvent.parse({ 'type': 'showBannerMessage', @@ -311,12 +296,9 @@ void main() { 'extensionName': 'foo', }, }); - expect( - () { - ShowBannerMessageExtensionEvent.from(event3); - }, - throwsFormatException, - ); + expect(() { + ShowBannerMessageExtensionEvent.from(event3); + }, throwsFormatException); final event4 = DevToolsExtensionEvent.parse({ // Wrong type. @@ -328,12 +310,9 @@ void main() { 'extensionName': 'foo', }, }); - expect( - () { - ShowBannerMessageExtensionEvent.from(event4); - }, - throwsAssertionError, - ); + expect(() { + ShowBannerMessageExtensionEvent.from(event4); + }, throwsAssertionError); }); }); @@ -341,10 +320,7 @@ void main() { test('constructs for expected values', () { final event = DevToolsExtensionEvent.parse({ 'type': 'copyToClipboard', - 'data': { - 'content': 'foo content', - 'successMessage': 'foo success', - }, + 'data': {'content': 'foo content', 'successMessage': 'foo success'}, }); final copyToClipboardEvent = CopyToClipboardExtensionEvent.from(event); expect(copyToClipboardEvent.content, 'foo content'); @@ -357,12 +333,9 @@ void main() { // Missing required fields. }, }); - expect( - () { - CopyToClipboardExtensionEvent.from(event1); - }, - throwsFormatException, - ); + expect(() { + CopyToClipboardExtensionEvent.from(event1); + }, throwsFormatException); final event2 = DevToolsExtensionEvent.parse({ 'type': 'copyToClipboard', @@ -372,12 +345,9 @@ void main() { 'successMessage': 'foo success', }, }); - expect( - () { - CopyToClipboardExtensionEvent.from(event2); - }, - throwsFormatException, - ); + expect(() { + CopyToClipboardExtensionEvent.from(event2); + }, throwsFormatException); final event3 = DevToolsExtensionEvent.parse({ 'type': 'copyToClipboard', @@ -387,27 +357,18 @@ void main() { 'successMessage': 'foo success', }, }); - expect( - () { - CopyToClipboardExtensionEvent.from(event3); - }, - throwsFormatException, - ); + expect(() { + CopyToClipboardExtensionEvent.from(event3); + }, throwsFormatException); final event4 = DevToolsExtensionEvent.parse({ // Wrong type. 'type': 'showBannerMessage', - 'data': { - 'content': 'foo content', - 'successMessage': 'foo success', - }, + 'data': {'content': 'foo content', 'successMessage': 'foo success'}, }); - expect( - () { - CopyToClipboardExtensionEvent.from(event4); - }, - throwsAssertionError, - ); + expect(() { + CopyToClipboardExtensionEvent.from(event4); + }, throwsAssertionError); }); }); } diff --git a/packages/devtools_extensions/test/validate_test.dart b/packages/devtools_extensions/test/validate_test.dart index ec94dd1d932..3616638041d 100644 --- a/packages/devtools_extensions/test/validate_test.dart +++ b/packages/devtools_extensions/test/validate_test.dart @@ -10,10 +10,7 @@ import 'package:test/test.dart'; void main() { final examplesWithExtensions = [ ( - relativePublishLocation: p.join( - '..', - 'dart_foo', - ), + relativePublishLocation: p.join('..', 'dart_foo'), sourceCodeLocation: p.join( 'example', 'packages_with_extensions', @@ -23,10 +20,7 @@ void main() { ), ), ( - relativePublishLocation: p.join( - '..', - 'foo', - ), + relativePublishLocation: p.join('..', 'foo'), sourceCodeLocation: p.join( 'example', 'packages_with_extensions', @@ -48,19 +42,55 @@ void main() { group('devtools_extensions validate command succeeds', () { for (final example in examplesWithExtensions) { test(example.relativePublishLocation, () async { - final p = await Process.run( - 'dart', - [ - 'run', - 'devtools_extensions', - 'validate', - '-p', - example.relativePublishLocation, - ], - workingDirectory: example.sourceCodeLocation, - ); + final p = await Process.run('dart', [ + 'run', + 'devtools_extensions', + 'validate', + '-p', + example.relativePublishLocation, + ], workingDirectory: example.sourceCodeLocation); expect(p.stderr, isEmpty); }); } }); + + group('devtools_extensions validate command fails', () { + test('when config.yaml name contains invalid characters', () async { + final tempDir = Directory.systemTemp.createTempSync(); + try { + final extDir = Directory(p.join(tempDir.path, 'extension', 'devtools')) + ..createSync(recursive: true); + Directory(p.join(extDir.path, 'build')).createSync(recursive: true); + File(p.join(extDir.path, 'build', 'index.html')).writeAsStringSync(''); + File(p.join(extDir.path, 'config.yaml')).writeAsStringSync(''' +name: invalid-name-with-hyphens +issueTracker: https://www.google.com/ +version: 1.0.0 +materialIconCodePoint: "0xe50a" +'''); + File(p.join(tempDir.path, 'pubspec.yaml')).writeAsStringSync(''' +name: actual_package_name +environment: + sdk: ^3.2.0 +'''); + + final process = await Process.run('dart', [ + 'run', + 'devtools_extensions', + 'validate', + '-p', + tempDir.path, + ]); + expect( + process.stderr, + contains( + 'Validation error: The "name" field in config.yaml should only ' + 'contain lowercase letters, numbers, and underscores', + ), + ); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + }); } diff --git a/packages/devtools_extensions/test/web/devtools_extension_test.dart b/packages/devtools_extensions/test/web/devtools_extension_test.dart index 03fd4463607..15a4eda98a7 100644 --- a/packages/devtools_extensions/test/web/devtools_extension_test.dart +++ b/packages/devtools_extensions/test/web/devtools_extension_test.dart @@ -17,14 +17,13 @@ void main() { expect(() => dtdManager, throwsStateError); }); - testWidgets( - 'building $DevToolsExtension initializes globals', - (tester) async { - await tester.pumpWidget(const DevToolsExtension(child: SizedBox())); - expect(serviceManager, isNotNull); - expect(extensionManager, isNotNull); - expect(dtdManager, isNotNull); - }, - ); + testWidgets('building $DevToolsExtension initializes globals', ( + tester, + ) async { + await tester.pumpWidget(const DevToolsExtension(child: SizedBox())); + expect(serviceManager, isNotNull); + expect(extensionManager, isNotNull); + expect(dtdManager, isNotNull); + }); }); } diff --git a/packages/devtools_shared/CHANGELOG.md b/packages/devtools_shared/CHANGELOG.md index d0ac7b6c111..e8257206ff3 100644 --- a/packages/devtools_shared/CHANGELOG.md +++ b/packages/devtools_shared/CHANGELOG.md @@ -3,6 +3,80 @@ Copyright 2025 The Flutter Authors Use of this source code is governed by a BSD-style license that can be found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. --> +# 14.0.1 + +* Track providing package name for DevTools extensions to isolate extension enablement, + deduplication, and asset loading. +* Validate `rootPath`, `buildVariant`, `configuration`, and `target` parameters in + `DeeplinkManager`. +* Add `UniversalLinkSettings.fromErrorJson`, `UniversalLinkSettings.error`, and + `UniversalLinkSettings.error` getter to surface iOS universal link settings errors. +* `DeeplinkManager` now builds the environment for the `flutter` processes it + starts with `getEnvironment()` from `package:unified_analytics`, so + `DASH__TOOL` passes on this process's root tool. A known `ide` still overrides + it, and an unknown `ide` now falls back to the inherited root tool instead of + always using `devtools`. When `suppressAnalytics` is false, an inherited + `DASH__SUPPRESS_ANALYTICS=true` is now passed on instead of being reset to + `false`. +* Require `package:unified_analytics` `^8.4.0`. +* Require `POST` (with `devToolsRequestHeader`) or `OPTIONS` CORS preflight + requests and validate `Host`, `Origin`, and `Sec-Fetch-Site` headers in + `ServerApi.handle` to prevent cross-site and DNS rebinding requests, with + optional `disableOriginCheck` and `allowedOrigins` parameters. +* Add `devToolsRequestHeader` constant (`X-DevTools-Request`). +* Reject `file://` URIs with non-empty hosts (UNC paths), absolute, missing, or + non-directory `buildLocation` paths, and symbolic links resolving outside the + extension build or package root in `ExtensionsManager`, and store valid + resolved paths in `DevToolsExtensionConfig.extensionAssetsPath`. +* Confine `devtoolsOptionsUri` in `api/extensionEnabledState` to currently + served extensions, reject symlinked `devtools_options.yaml` files, and prevent + recursive directory creation when looking up `devtools_options.yaml`. + +# 14.0.0 + +* **Breaking changes**: `LocalFileSystem`, an extension which provided some handy + helpers, has been refactored into an extension on the `file` package's + `FileSystem` abstraction. In detail: + * `fileSystem` is a new top-level constant which represents the local (real) + file system. + * `LocalFileSystem.devToolsDir()` is now a static getter, + `FileSystemExtension.devToolsDir`. + * `LocalFileSystem.maybeMoveLegacyDevToolsStore()` is now an instance method, + `FileSystemExtension.maybeMoveLegacyDevToolsStore()`. + * `LocalFileSystem.devToolsStoreLocation()` is now a static getter, + `FileSystemExtension.devToolsStoreLocation`. + * `LocalFileSystem.ensureDevToolsDirectory()` is now private. + * `LocalFileSystem.devToolsFileFromPath()` is now an instance method, + `FileSystemExtension.devToolsFileFromPath()`. + * `LocalFileSystem.devToolsFileAsJson()` is now an instance method, + `FileSystemExtension.devToolsFileAsJson()`. + * `LocalFileSystem.flutterStoreExists()` is now an instance getter, + `FileSystemExtension.flutterStoreExists`. + * `IOPersistentProperties.new` accepts a new optional `FileSystem fs` + argument. +* Update `LocalFileSystem` and `IOPersistentProperties` to use `package:file` + instead of `dart:io` to allow mocking the file system. +* Increase ChromeDriver startup wait timeout to 30 seconds to support slower environments. + +# 13.0.2 + +* Validate the `devtoolsOptionsUri` query parameter in the extension enabled + state handler so it must be a `file:` URI named `devtools_options.yaml`, + preventing arbitrary file writes by the DevTools server process. +* The minimum Dart SDK version is bumped to 3.11.0. +* The minimum Flutter SDK version is bumped to 3.41.0. + +# 13.0.1 +* Handle null values for `FlutterStore.flutterClientId`. + +# 13.0.0 +* **Breaking change:** Removed `DevToolsStoreKeys.analyticsEnabled` and +`DevToolsStoreKeys.isFirstRun` since these were only used for legacy analytics. +* **Breaking change:** Removed legacy analytics APIs and state cleanup +(e.g. `apiGetFlutterGAEnabled`, `apiGetDevToolsEnabled`, `apiSetDevToolsEnabled`, `apiGetDevToolsFirstRun`, `apiResetDevTools`). +* **Breaking change:** Removed public constant `devToolsEnabledPropertyName`. +* **Breaking change:** Removed `isFirstRun`, `reset`, and `analyticsEnabled` from `DevToolsUsage`. + # 12.1.0 * Adds additional logging to `IntegrationTestRunner`. * Fixes assertion error when parsing Flutter channel versions using the format `X.XX.X-X.X.pre-XXX`. diff --git a/packages/devtools_shared/lib/src/common.dart b/packages/devtools_shared/lib/src/common.dart index bd99c667816..fc08b32d6d4 100644 --- a/packages/devtools_shared/lib/src/common.dart +++ b/packages/devtools_shared/lib/src/common.dart @@ -8,11 +8,8 @@ typedef DTDConnectionInfo = ({String? uri, String? secret}); /// Information about a Dart Tooling Daemon instance. class DtdInfo { - DtdInfo( - this.localUri, { - Uri? exposedUri, - this.secret, - }) : exposedUri = exposedUri ?? localUri; + DtdInfo(this.localUri, {Uri? exposedUri, this.secret}) + : exposedUri = exposedUri ?? localUri; /// The URI for connecting to DTD from the backend. /// diff --git a/packages/devtools_shared/lib/src/deeplink/app_link_settings.dart b/packages/devtools_shared/lib/src/deeplink/app_link_settings.dart index 87a62e1d024..9d9d21fe180 100644 --- a/packages/devtools_shared/lib/src/deeplink/app_link_settings.dart +++ b/packages/devtools_shared/lib/src/deeplink/app_link_settings.dart @@ -39,22 +39,12 @@ class AppLinkSettings { factory AppLinkSettings.fromErrorJson(String json) { final jsonObject = jsonDecode(json) as Map; final message = jsonObject[_kErrorKey]! as String; - return AppLinkSettings._( - '', - false, - [], - message, - ); + return AppLinkSettings._('', false, [], message); } /// Used when the server can't retrieve app link settings. factory AppLinkSettings.error(String message) { - return AppLinkSettings._( - '', - false, - [], - message, - ); + return AppLinkSettings._('', false, [], message); } static const _kApplicationIdKey = 'applicationId'; diff --git a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart index 2f10d997857..a376a2b8abb 100644 --- a/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart +++ b/packages/devtools_shared/lib/src/deeplink/deeplink_manager.dart @@ -7,6 +7,9 @@ import 'dart:io'; import 'package:meta/meta.dart'; import 'package:path/path.dart' as path; +import 'package:unified_analytics/unified_analytics.dart'; + +import 'xcode_build_options.dart'; class DeeplinkManager { /// A regex to retrieve the json part from the stdout of Android analyzer. @@ -32,6 +35,43 @@ class DeeplinkManager { /// APIs. static const kOutputJsonField = 'json'; + /// Cached Android build variants keyed by normalized project root path. + /// + /// Populated by [getAndroidBuildVariants] and used to validate `buildVariant` + /// in [getAndroidAppLinkSettings]. + static final _androidBuildVariantsCache = >{}; + + /// Cached iOS Xcode build options keyed by normalized project root path. + /// + /// Populated by [getIosBuildOptions] and used to validate `configuration` and + /// `target` in [getIosUniversalLinkSettings]. + static final _iosBuildOptionsCache = {}; + + /// Clears the cached Android build variants and iOS build options. + @visibleForTesting + static void clearBuildOptionsCache() { + _androidBuildVariantsCache.clear(); + _iosBuildOptionsCache.clear(); + } + + /// Mappings from case-insensitive IDE query parameter values to the + /// [DashTool] that should be reported as the root tool of the `flutter` + /// processes DevTools starts for that IDE. + /// + /// Contains multiple spelling and format variations (with/without hyphens + /// or suffixes) passed by different IDE integrations to ensure O(1) lookup. + static const _ideToDashTool = { + 'vs-code': DashTool.vscodePlugins, + 'vscode': DashTool.vscodePlugins, + 'vscodeplugins': DashTool.vscodePlugins, + 'intellij-idea': DashTool.intellijPlugins, + 'intellij': DashTool.intellijPlugins, + 'intellijplugins': DashTool.intellijPlugins, + 'android-studio': DashTool.androidStudioPlugins, + 'androidstudio': DashTool.androidStudioPlugins, + 'androidstudioplugins': DashTool.androidStudioPlugins, + }; + /// A regex to retrieve the file path from the stdout of iOS or Android /// analyzers. /// @@ -44,13 +84,51 @@ class DeeplinkManager { Future runProcess( String executable, { required List arguments, + required String workingDirectory, + required String? ide, + required bool suppressAnalytics, }) { return Process.run( executable, arguments, + workingDirectory: workingDirectory, + environment: buildEnvironment( + ide: ide, + suppressAnalytics: suppressAnalytics, + ), ); } + /// Returns the environment for a `flutter` process that DevTools starts. + /// + /// Uses `getEnvironment()` from `package:unified_analytics`, which sets + /// `DASH__SUPPRESS_ANALYTICS` and passes on the root tool from this + /// process's `DASH__TOOL` (falling back to `devtools`). If [ide] names a + /// known IDE, its [DashTool] overrides `DASH__TOOL`, because the IDE that + /// opened DevTools is the user's entry point for this request. + /// + /// If [suppressAnalytics] is true, analytics are always suppressed. + /// `getEnvironment()` alone would let an inherited + /// `DASH__SUPPRESS_ANALYTICS=false` (which `dart` sets for the processes it + /// starts) win over it. + // TODO(https://github.com/dart-lang/tools/issues/2803): Pass the IDE as a + // `fallbackRootTool` to `getEnvironment()` once it supports one. + @visibleForTesting + Map buildEnvironment({ + required String? ide, + required bool suppressAnalytics, + }) { + final ideTool = ide == null ? null : _ideToDashTool[ide.toLowerCase()]; + return { + ...getEnvironment( + currentTool: DashTool.devtools, + suppressAnalytics: suppressAnalytics, + ), + if (ideTool != null) DashEnvVar.tool.name: ideTool.label, + if (suppressAnalytics) DashEnvVar.suppressAnalytics.name: 'true', + }; + } + @visibleForTesting String getFlutterBinary() { // FLUTTER_ROOT can be set by Dart-Code VSCode extension or dart shell @@ -80,10 +158,19 @@ class DeeplinkManager { Future _runFlutterCommand( List arguments, { + required String workingDirectory, required RegExp outputMatcher, + String? ide, + bool suppressAnalytics = false, }) async { final flutterPath = getFlutterBinary(); - final result = await runProcess(flutterPath, arguments: arguments); + final result = await runProcess( + flutterPath, + arguments: arguments, + workingDirectory: workingDirectory, + ide: ide, + suppressAnalytics: suppressAnalytics, + ); if (result.exitCode != 0) { throw _FlutterProcessError( 'Flutter command exit with non-zero error code ${result.exitCode}\n${result.stderr}', @@ -97,58 +184,92 @@ class DeeplinkManager { } } - Map _handleRunFlutterError( - covariant _FlutterProcessError error, - ) { - return { - kErrorField: error.message, - }; + Map _handleRunFlutterError(Object error) { + final message = error is _FlutterProcessError + ? error.message + : error.toString(); + return {kErrorField: message}; } Future> _handleReadJsonFile(String filePath) { - return File(filePath) - .readAsString() - .then>(_handleJsonOutput); + return File( + filePath, + ).readAsString().then>(_handleJsonOutput); } Future> _handleJsonOutput(String jsonOutput) async { try { jsonEncode(jsonOutput); } on Error catch (e) { - return { - kErrorField: e.toString(), - }; + return {kErrorField: e.toString()}; } - return { - kOutputJsonField: jsonOutput, - }; + return {kOutputJsonField: jsonOutput}; } Future> getAndroidBuildVariants({ required String rootPath, + String? ide, + bool suppressAnalytics = false, }) { + final canonicalPath = path.canonicalize(rootPath); return _runFlutterCommand( - ['analyze', '--android', '--list-build-variants', rootPath], + ['analyze', '--android', '--list-build-variants'], + workingDirectory: rootPath, outputMatcher: _androidBuildVariantJsonRegex, - ).then>( - _handleJsonOutput, - onError: _handleRunFlutterError, - ); + ide: ide, + suppressAnalytics: suppressAnalytics, + ).then>((jsonOutput) { + try { + final variants = (jsonDecode(jsonOutput) as List) + .cast() + .toSet(); + _androidBuildVariantsCache[canonicalPath] = variants; + } on Object catch (e) { + return { + kErrorField: 'Failed to parse Android build variants: $e', + }; + } + return _handleJsonOutput(jsonOutput); + }, onError: _handleRunFlutterError); } + static const _fileIssueMessage = + 'This should not happen; please file an issue at ' + 'https://github.com/flutter/devtools/issues.'; + Future> getAndroidAppLinkSettings({ required String rootPath, required String buildVariant, - }) { + String? ide, + bool suppressAnalytics = false, + }) async { + final canonicalPath = path.canonicalize(rootPath); + final validVariants = _androidBuildVariantsCache[canonicalPath]; + if (validVariants == null) { + return { + kErrorField: + 'Android build variants for "$rootPath" have not been parsed yet. ' + '$_fileIssueMessage', + }; + } + if (!validVariants.contains(buildVariant)) { + return { + kErrorField: + 'Unknown Android build variant "$buildVariant" for "$rootPath". ' + '$_fileIssueMessage', + }; + } return _runFlutterCommand( [ 'analyze', '--android', '--output-app-link-settings', '--build-variant=$buildVariant', - rootPath, ], + workingDirectory: rootPath, outputMatcher: _outputFilePathRegex, + ide: ide, + suppressAnalytics: suppressAnalytics, ).then>( _handleReadJsonFile, onError: _handleRunFlutterError, @@ -157,21 +278,54 @@ class DeeplinkManager { Future> getIosBuildOptions({ required String rootPath, + String? ide, + bool suppressAnalytics = false, }) { + final canonicalPath = path.canonicalize(rootPath); return _runFlutterCommand( - ['analyze', '--ios', '--list-build-options', rootPath], + ['analyze', '--ios', '--list-build-options'], + workingDirectory: rootPath, outputMatcher: _iosBuildOptionsJsonRegex, - ).then>( - _handleJsonOutput, - onError: _handleRunFlutterError, - ); + ide: ide, + suppressAnalytics: suppressAnalytics, + ).then>((jsonOutput) { + try { + _iosBuildOptionsCache[canonicalPath] = XcodeBuildOptions.fromJson( + jsonOutput, + ); + } on Object catch (e) { + return { + kErrorField: 'Failed to parse iOS build options: $e', + }; + } + return _handleJsonOutput(jsonOutput); + }, onError: _handleRunFlutterError); } Future> getIosUniversalLinkSettings({ required String rootPath, required String configuration, required String target, - }) { + String? ide, + bool suppressAnalytics = false, + }) async { + final canonicalPath = path.canonicalize(rootPath); + final validOptions = _iosBuildOptionsCache[canonicalPath]; + if (validOptions == null) { + return { + kErrorField: + 'iOS build options for "$rootPath" have not been parsed yet. ' + '$_fileIssueMessage', + }; + } + if (!validOptions.configurations.contains(configuration) || + !validOptions.targets.contains(target)) { + return { + kErrorField: + 'Unknown iOS build configuration ($configuration) or target ($target) for "$rootPath". ' + '$_fileIssueMessage', + }; + } return _runFlutterCommand( [ 'analyze', @@ -179,9 +333,11 @@ class DeeplinkManager { '--output-universal-link-settings', '--configuration=$configuration', '--target=$target', - rootPath, ], + workingDirectory: rootPath, outputMatcher: _outputFilePathRegex, + ide: ide, + suppressAnalytics: suppressAnalytics, ).then>( _handleReadJsonFile, onError: _handleRunFlutterError, diff --git a/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart b/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart index 4e3a7b04c41..6d62b38c464 100644 --- a/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart +++ b/packages/devtools_shared/lib/src/deeplink/universal_link_settings.dart @@ -9,9 +9,28 @@ extension type const UniversalLinkSettings._(Map _json) { factory UniversalLinkSettings.fromJson(String json) => UniversalLinkSettings._(jsonDecode(json)); + /// Used when the server can't retrieve universal link settings. + /// + /// The input needs to be in json format from DevTools server response. + factory UniversalLinkSettings.fromErrorJson(String json) { + final jsonObject = jsonDecode(json) as Map; + final message = jsonObject[_kErrorKey]! as String; + return UniversalLinkSettings.error(message); + } + + /// Used when the server can't retrieve universal link settings. + factory UniversalLinkSettings.error(String message) => + UniversalLinkSettings._({ + _kBundleIdentifierKey: '', + _kTeamIdentifierKey: '', + _kAssociatedDomainsKey: const [], + _kErrorKey: message, + }); + static const _kBundleIdentifierKey = 'bundleIdentifier'; static const _kTeamIdentifierKey = 'teamIdentifier'; static const _kAssociatedDomainsKey = 'associatedDomains'; + static const _kErrorKey = 'error'; /// Used when the the server can't retrieve universal link settings. static const empty = UniversalLinkSettings._({ @@ -30,4 +49,7 @@ extension type const UniversalLinkSettings._(Map _json) { /// The associated domains of the iOS build of this Flutter project. List get associatedDomains => (_json[_kAssociatedDomainsKey] as List).cast().toList(); + + /// The error message when requesting universal link settings. + String? get error => _json[_kErrorKey] as String?; } diff --git a/packages/devtools_shared/lib/src/deeplink/xcode_build_options.dart b/packages/devtools_shared/lib/src/deeplink/xcode_build_options.dart index 1298652847e..2e3352f8399 100644 --- a/packages/devtools_shared/lib/src/deeplink/xcode_build_options.dart +++ b/packages/devtools_shared/lib/src/deeplink/xcode_build_options.dart @@ -10,8 +10,10 @@ extension type const XcodeBuildOptions._(Map _json) { XcodeBuildOptions._(jsonDecode(json)); /// Used when the the server can't retrieve ios build options. - static const empty = - XcodeBuildOptions._({_kConfigurationsKey: [], _kTargetsKey: []}); + static const empty = XcodeBuildOptions._({ + _kConfigurationsKey: [], + _kTargetsKey: [], + }); static const _kConfigurationsKey = 'configurations'; static const _kTargetsKey = 'targets'; diff --git a/packages/devtools_shared/lib/src/devtools_api.dart b/packages/devtools_shared/lib/src/devtools_api.dart index 37ea9c4b2ae..c3d9a7e393f 100644 --- a/packages/devtools_shared/lib/src/devtools_api.dart +++ b/packages/devtools_shared/lib/src/devtools_api.dart @@ -5,6 +5,14 @@ /// All server APIs prefix: const apiPrefix = 'api/'; +/// Custom HTTP header included on DevTools server API requests to force a CORS +/// preflight `OPTIONS` request in browsers and prevent simple cross-origin +/// `POST` requests. +/// +/// This value must match the header sent in +/// `packages/devtools_app/web/flutter_bootstrap.js`. +const devToolsRequestHeader = 'X-DevTools-Request'; + /// Key used for any request or response to specify a value argument. const apiParameterValueKey = 'value'; @@ -15,19 +23,8 @@ const apiNotifyForVmServiceConnection = const apiParameterVmServiceConnected = 'connected'; /// Flutter GA properties APIs: -const apiGetFlutterGAEnabled = '${apiPrefix}getFlutterGAEnabled'; const apiGetFlutterGAClientId = '${apiPrefix}getFlutterGAClientId'; -/// DevTools GA properties APIs: -const apiResetDevTools = '${apiPrefix}resetDevTools'; -const apiGetDevToolsFirstRun = '${apiPrefix}getDevToolsFirstRun'; -const apiGetDevToolsEnabled = '${apiPrefix}getDevToolsEnabled'; -const apiSetDevToolsEnabled = '${apiPrefix}setDevToolsEnabled'; - -/// Property name to apiSetDevToolsEnabled the DevToolsEnabled is the name used -/// in queryParameter: -const devToolsEnabledPropertyName = 'enabled'; - abstract class PreferencesApi { /// Returns the preference value in the DevTools store file for the key /// specified by the [preferenceKeyProperty] query parameter. @@ -147,6 +144,11 @@ abstract class ExtensionsApi { /// name of the extension whose state is being queried. static const extensionNamePropertyName = 'name'; + /// The property name for the query parameter optionally passed along with + /// [apiExtensionEnabledState] requests to the server that describes the + /// package name providing the extension. + static const extensionPackagePropertyName = 'package'; + /// The property name for the query parameter that is optionally passed along /// with [apiExtensionEnabledState] requests to the server to set the /// enabled state for the extension. diff --git a/packages/devtools_shared/lib/src/extensions/extension_enablement.dart b/packages/devtools_shared/lib/src/extensions/extension_enablement.dart index 4d0abaa747e..92251488584 100644 --- a/packages/devtools_shared/lib/src/extensions/extension_enablement.dart +++ b/packages/devtools_shared/lib/src/extensions/extension_enablement.dart @@ -16,14 +16,16 @@ class DevToolsOptions { static const _extensionsKey = 'extensions'; static const _descriptionKey = 'description'; static const _documentationKey = 'documentation'; - static const _defaultOptions = ''' + static const _defaultOptions = + ''' $_descriptionKey: This file stores settings for Dart & Flutter DevTools. $_documentationKey: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states $_extensionsKey: '''; - /// Returns the current enabled state for [extensionName] in the - /// 'devtools_options.yaml' file at [devtoolsOptionsUri]. + /// Returns the current enabled state of [packageName] (falls back to + /// [extensionName]) in the 'devtools_options.yaml' file at + /// [devtoolsOptionsUri]. /// /// If the 'devtools_options.yaml' file does not exist, it will be created /// with an empty set of extensions. @@ -32,27 +34,29 @@ $_extensionsKey: ExtensionEnabledState lookupExtensionEnabledState({ required Uri devtoolsOptionsUri, required String extensionName, + String? packageName, }) { final options = _optionsAsMap(optionsUri: devtoolsOptionsUri); if (options == null) return ExtensionEnabledState.error; - final extensions = - (options[_extensionsKey] as List?)?.cast>(); + final extensions = (options[_extensionsKey] as List?) + ?.cast>(); if (extensions == null) return ExtensionEnabledState.none; + final targetKey = packageName ?? extensionName; for (final e in extensions) { // Each entry should only have one key / value pair (e.g. '- foo: true'). assert(e.keys.length == 1); - if (e.keys.first == extensionName) { - return _extensionStateForValue(e[extensionName]); + if (e.keys.first == targetKey) { + return _extensionStateForValue(e[targetKey]); } } return ExtensionEnabledState.none; } - /// Sets the enabled state for [extensionName] in the - /// 'devtools_options.yaml' file at [devtoolsOptionsUri]. + /// Sets the enabled state of [packageName] (falls back to [extensionName]) + /// in the 'devtools_options.yaml' file at [devtoolsOptionsUri]. /// /// If the 'devtools_options.yaml' file does not exist, it will be created. /// @@ -60,26 +64,29 @@ $_extensionsKey: ExtensionEnabledState setExtensionEnabledState({ required Uri devtoolsOptionsUri, required String extensionName, + String? packageName, required bool enable, }) { final options = _optionsAsMap(optionsUri: devtoolsOptionsUri); if (options == null) return ExtensionEnabledState.error; - var extensions = - (options[_extensionsKey] as List?)?.cast>(); + var extensions = (options[_extensionsKey] as List?) + ?.cast>(); if (extensions == null) { options[_extensionsKey] = >[]; extensions = options[_extensionsKey] as List>; } + final targetKey = packageName ?? extensionName; + // Write the new enabled state to the map. final extension = extensions.firstWhereOrNull( - (e) => e.keys.first == extensionName, + (e) => e.keys.first == targetKey, ); if (extension == null) { - extensions.add({extensionName: enable}); + extensions.add({targetKey: enable}); } else { - extension[extensionName] = enable; + extension[targetKey] = enable; } _writeToOptionsFile(optionsUri: devtoolsOptionsUri, options: options); @@ -89,6 +96,7 @@ $_extensionsKey: return lookupExtensionEnabledState( devtoolsOptionsUri: devtoolsOptionsUri, extensionName: extensionName, + packageName: packageName, ); } @@ -111,21 +119,25 @@ $_extensionsKey: }) { final yamlEditor = YamlEditor(''); yamlEditor.update([], options); - _lookupOptionsFile(optionsUri)?.writeAsStringSync( - yamlEditor.toString(), - flush: true, - ); + _lookupOptionsFile( + optionsUri, + )?.writeAsStringSync(yamlEditor.toString(), flush: true); } - /// Returns the `devtools_options.yaml` file at [optionsUri]. + /// Returns the `devtools_options.yaml` file at [optionsUri], or `null` if its + /// parent directory does not exist or the file is a symbolic link. /// /// If the `devtools_options.yaml` does not already exist, it will be created /// and written with [_defaultOptions], and then returned. File? _lookupOptionsFile(Uri optionsUri) { final optionsFile = File.fromUri(optionsUri); + if (!optionsFile.parent.existsSync() || + FileSystemEntity.isLinkSync(optionsFile.path)) { + return null; + } if (!optionsFile.existsSync()) { optionsFile - ..createSync(recursive: true) + ..createSync() ..writeAsStringSync(_defaultOptions, flush: true); } return optionsFile; diff --git a/packages/devtools_shared/lib/src/extensions/extension_manager.dart b/packages/devtools_shared/lib/src/extensions/extension_manager.dart index 7df4cee7790..acc0fd55258 100644 --- a/packages/devtools_shared/lib/src/extensions/extension_manager.dart +++ b/packages/devtools_shared/lib/src/extensions/extension_manager.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:io'; + import 'package:collection/collection.dart'; import 'package:dtd/dtd.dart'; import 'package:extension_discovery/extension_discovery.dart'; @@ -148,28 +150,85 @@ class ExtensionsManager { for (final extension in extensions) { final config = extension.config; + + if (extension.rootUri.scheme != 'file' || + extension.rootUri.host.isNotEmpty) { + parsingErrors.writeln( + 'Ignoring extension from package "${extension.package}": invalid ' + 'package root URI "${extension.rootUri}" must be a file:// URI ' + 'with an empty host.', + ); + continue; + } + // TODO(https://github.com/dart-lang/pub/issues/4042): make this check // more robust. - final isPubliclyHosted = (extension.rootUri.path.contains('pub.dev') || - extension.rootUri.path.contains('pub.flutter-io.cn')) - .toString(); + final isPubliclyHosted = + (extension.rootUri.path.contains('pub.dev') || + extension.rootUri.path.contains('pub.flutter-io.cn')) + .toString(); // This should be relative to the 'extension/devtools/' directory and // defaults to 'build'; final relativeExtensionLocation = config['buildLocation'] as String? ?? 'build'; - final location = path.join( - extension.rootUri.toFilePath(), - 'extension', - 'devtools', - relativeExtensionLocation, + // Ensure that `relativeExtensionLocation is not absolute to prevent + // arbitrary access to the filesystem. + // + // For example, path.join(a, b, c, d) discards a, b, and c if d is + // absolute. + if (path.isAbsolute(relativeExtensionLocation) || + path.posix.isAbsolute(relativeExtensionLocation) || + path.windows.isAbsolute(relativeExtensionLocation)) { + parsingErrors.writeln( + 'Ignoring extension from package "${extension.package}": invalid ' + 'buildLocation "$relativeExtensionLocation" must be a relative path.', + ); + continue; + } + + final packageRoot = path.normalize(extension.rootUri.toFilePath()); + final location = path.normalize( + path.join( + packageRoot, + 'extension', + 'devtools', + relativeExtensionLocation, + ), ); + // Verify that this extension's build location is a subdirectory of the + // package providing the extension. Usually this is + // $HOME/.pub-cache/hosted/pub.dev/foo-1.0.0/extension/devtools/build. + // + // This prevents packages from declaring a build location outside of + // their package directory (e.g. ../../../../.ssh/) + if (!path.isWithin(packageRoot, location)) { + parsingErrors.writeln( + 'Ignoring extension from package "${extension.package}": invalid ' + 'buildLocation "$relativeExtensionLocation" outside package root.', + ); + continue; + } + + final (:resolvedLocation, :error) = _validateAndResolveExtensionLocation( + packageRootPath: packageRoot, + locationPath: location, + relativeExtensionLocation: relativeExtensionLocation, + ); + if (resolvedLocation == null) { + parsingErrors.writeln( + 'Ignoring extension from package "${extension.package}": $error', + ); + continue; + } + try { final extensionConfig = DevToolsExtensionConfig.parse({ ...config, - DevToolsExtensionConfig.extensionAssetsPathKey: location, + DevToolsExtensionConfig.packageNameKey: extension.package, + DevToolsExtensionConfig.extensionAssetsPathKey: resolvedLocation, // The [packageConfigPath] will look like // 'pkg/.dart_tool/package_config.json' so we will store the // devtools_options.yaml file at the 'pkg' root: @@ -179,8 +238,8 @@ class ExtensionsManager { devtoolsOptionsFileName, ), DevToolsExtensionConfig.isPubliclyHostedKey: isPubliclyHosted, - DevToolsExtensionConfig.detectedFromStaticContextKey: - staticContext.toString(), + DevToolsExtensionConfig.detectedFromStaticContextKey: staticContext + .toString(), }); devtoolsExtensions.add(extensionConfig); } on StateError catch (e) { @@ -190,9 +249,128 @@ class ExtensionsManager { } } + /// Validates that [packageRootPath] and [locationPath] exist as directories + /// and that [locationPath] and all entries within it do not resolve via + /// symbolic links outside [packageRootPath] or [locationPath]. + /// + /// This prevents a package from registering a non-existent or non-directory + /// build location, or using symlinks in its extension directory to expose + /// arbitrary files or directories on the host filesystem. + /// + /// Returns a record containing either the canonical `resolvedLocation` on + /// success, or an `error` description using [relativeExtensionLocation] if + /// validation fails. + ({String? resolvedLocation, String? error}) + _validateAndResolveExtensionLocation({ + required String packageRootPath, + required String locationPath, + required String relativeExtensionLocation, + }) { + if (FileSystemEntity.isLinkSync(locationPath) && + !Directory(locationPath).existsSync()) { + return ( + resolvedLocation: null, + error: + 'invalid buildLocation "$relativeExtensionLocation" is a broken ' + 'or non-directory symlink.', + ); + } + + if (!Directory(packageRootPath).existsSync()) { + return ( + resolvedLocation: null, + error: 'package root does not exist or is not a directory.', + ); + } + + if (!Directory(locationPath).existsSync()) { + return ( + resolvedLocation: null, + error: + 'invalid buildLocation "$relativeExtensionLocation" does not ' + 'exist or is not a directory.', + ); + } + + final String resolvedPackageRoot; + final String resolvedLocation; + try { + resolvedPackageRoot = path.normalize( + Directory(packageRootPath).resolveSymbolicLinksSync(), + ); + resolvedLocation = path.normalize( + Directory(locationPath).resolveSymbolicLinksSync(), + ); + } on FileSystemException catch (e) { + return ( + resolvedLocation: null, + error: + 'failed to resolve symbolic links for buildLocation ' + '"$relativeExtensionLocation" ($e).', + ); + } + + if (!path.isWithin(resolvedPackageRoot, resolvedLocation)) { + return ( + resolvedLocation: null, + error: + 'invalid buildLocation "$relativeExtensionLocation" resolves ' + 'outside package root.', + ); + } + + final List entries; + try { + entries = Directory( + resolvedLocation, + ).listSync(recursive: true, followLinks: false); + } on FileSystemException catch (e) { + return ( + resolvedLocation: null, + error: + 'failed to inspect extension build directory ' + '"$relativeExtensionLocation" ($e).', + ); + } + + for (final entry in entries) { + // Because listSync was called with followLinks: false on + // resolvedLocation, any non-Link entry physically resides within + // resolvedLocation. + if (entry is! Link) continue; + String? resolvedTargetPath; + try { + resolvedTargetPath = path.normalize(entry.resolveSymbolicLinksSync()); + } on FileSystemException { + // Leave resolvedTargetPath as null so broken or unresolvable symlinks + // are rejected below. + } + if (resolvedTargetPath == null || + (!path.isWithin(resolvedLocation, resolvedTargetPath) && + !path.equals(resolvedLocation, resolvedTargetPath))) { + return ( + resolvedLocation: null, + error: + 'extension build directory contains a symlink pointing ' + 'outside the build directory ("${entry.path}").', + ); + } + } + return (resolvedLocation: resolvedLocation, error: null); + } + void _assertUriFormat(String? uriString) { - if (uriString != null && !uriString.startsWith('file://')) { - throw ArgumentError.value(uriString, 'must be a file:// URI String'); + if (uriString == null) return; + final uri = Uri.tryParse(uriString); + if (!uriString.startsWith('file://') || + uri == null || + uri.scheme != 'file' || + uri.host.isNotEmpty) { + throw ArgumentError.value( + uriString, + 'uriString', + 'must be a file:// URI String with an empty host', + ); } } diff --git a/packages/devtools_shared/lib/src/extensions/extension_model.dart b/packages/devtools_shared/lib/src/extensions/extension_model.dart index 39d480638cd..1f14661da0a 100644 --- a/packages/devtools_shared/lib/src/extensions/extension_model.dart +++ b/packages/devtools_shared/lib/src/extensions/extension_model.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -/// @docImport 'package:flutter/material.dart'; +/// @docImport 'package:material_ui/material_ui.dart'; library; import 'package:collection/collection.dart'; @@ -16,6 +16,7 @@ import 'package:collection/collection.dart'; class DevToolsExtensionConfig implements Comparable { DevToolsExtensionConfig._({ required this.name, + required this.packageName, required this.issueTrackerLink, required this.version, required this.materialIconCodePoint, @@ -32,25 +33,24 @@ class DevToolsExtensionConfig implements Comparable { final requiresConnection = requiresConnectionValue != false && requiresConnectionValue != 'false'; - if (json - case { - // The exptected keys below are required fields in the extension's - // config.yaml file. - nameKey: final String name, - issueTrackerKey: final String issueTracker, - versionKey: final String version, - // ignore: avoid-unnecessary-type-assertions, this can be a String or an int - materialIconCodePointKey: final Object codePointFromJson, - // The expected keys below are not from the extension's config.yaml - // file; they are generated during the extension detection mechanism - // in the DevTools server. - extensionAssetsPathKey: final String extensionAssetsPath, - devtoolsOptionsUriKey: final String devtoolsOptionsUri, - isPubliclyHostedKey: final String isPubliclyHosted, - detectedFromStaticContextKey: final String detectedFromStaticContext, - // Note that the field [requiresConnectionKey] is not required for - // this check because it is optional. - }) { + if (json case { + // The exptected keys below are required fields in the extension's + // config.yaml file. + nameKey: final String name, + issueTrackerKey: final String issueTracker, + versionKey: final String version, + materialIconCodePointKey: final Object codePointFromJson, + // The expected keys below are not from the extension's config.yaml + // file; they are generated during the extension detection mechanism + // in the DevTools server. + packageNameKey: final String packageName, + extensionAssetsPathKey: final String extensionAssetsPath, + devtoolsOptionsUriKey: final String devtoolsOptionsUri, + isPubliclyHostedKey: final String isPubliclyHosted, + detectedFromStaticContextKey: final String detectedFromStaticContext, + // Note that the field [requiresConnectionKey] is not required for + // this check because it is optional. + }) { final underscoresAndLetters = RegExp(r'^[a-z0-9_]*$'); if (!underscoresAndLetters.hasMatch(name)) { throw StateError( @@ -81,6 +81,7 @@ class DevToolsExtensionConfig implements Comparable { // and will use default values if not specified. requiresConnection: requiresConnection, // These values are generated by the DevTools server. + packageName: packageName, extensionAssetsPath: extensionAssetsPath, devtoolsOptionsUri: devtoolsOptionsUri, isPubliclyHosted: bool.parse(isPubliclyHosted), @@ -89,13 +90,8 @@ class DevToolsExtensionConfig implements Comparable { } else { _assertGeneratedKeysPresent(json); final jsonKeysFromConfigFile = Set.of(json.keys.toSet()) - ..removeAll([ - ..._serverGeneratedKeys, - ..._optionalKeys, - ]); - final diff = _requiredKeys.toSet().difference( - jsonKeysFromConfigFile, - ); + ..removeAll([..._serverGeneratedKeys, ..._optionalKeys]); + final diff = _requiredKeys.toSet().difference(jsonKeysFromConfigFile); if (diff.isNotEmpty) { throw StateError( 'Missing required fields ${diff.toString()} in the extension ' @@ -137,22 +133,30 @@ class DevToolsExtensionConfig implements Comparable { // The following keys are never expected to be in the extension's config.yaml // file. They are generated during the extension detection mechanism in the // DevTools server. + static const packageNameKey = 'packageName'; static const extensionAssetsPathKey = 'extensionAssetsPath'; static const devtoolsOptionsUriKey = 'devtoolsOptionsUri'; static const isPubliclyHostedKey = 'isPubliclyHosted'; static const detectedFromStaticContextKey = 'detectedFromStaticContext'; static const _serverGeneratedKeys = [ + packageNameKey, extensionAssetsPathKey, devtoolsOptionsUriKey, isPubliclyHostedKey, detectedFromStaticContextKey, ]; - /// The package name that this extension is for. + /// The name that this extension is for. /// - /// This value should be defined by the extension's config.yaml file. + /// This value is defined by the extension's config.yaml file. final String name; + /// The Dart package that provides this DevTools extension. + /// + /// This value is parsed from the package name in + /// `.dart_tool/package_config.json`. + final String packageName; + // TODO(kenz): we might want to add validation to these issue tracker // links to ensure they don't point to the DevTools repo or flutter repo. // If an invalid issue tracker link is provided, we can default to @@ -237,29 +241,35 @@ class DevToolsExtensionConfig implements Comparable { String get displayName => name.toLowerCase(); - String get identifier => '${displayName}_$version'; + String get identifier => packageName == displayName + ? '${displayName}_$version' + : '${packageName}_${displayName}_$version'; String get analyticsSafeName => isPubliclyHosted ? name : 'private'; Map toJson() => { - nameKey: name, - issueTrackerKey: issueTrackerLink, - versionKey: version, - materialIconCodePointKey: materialIconCodePoint, - requiresConnectionKey: requiresConnection.toString(), - extensionAssetsPathKey: extensionAssetsPath, - devtoolsOptionsUriKey: devtoolsOptionsUri, - isPubliclyHostedKey: isPubliclyHosted.toString(), - detectedFromStaticContextKey: detectedFromStaticContext.toString(), - }; + nameKey: name, + packageNameKey: packageName, + issueTrackerKey: issueTrackerLink, + versionKey: version, + materialIconCodePointKey: materialIconCodePoint, + requiresConnectionKey: requiresConnection.toString(), + extensionAssetsPathKey: extensionAssetsPath, + devtoolsOptionsUriKey: devtoolsOptionsUri, + isPubliclyHostedKey: isPubliclyHosted.toString(), + detectedFromStaticContextKey: detectedFromStaticContext.toString(), + }; @override int compareTo(DevToolsExtensionConfig other) { - var compare = name.compareTo(other.name); + var compare = packageName.compareTo(other.packageName); if (compare == 0) { - compare = extensionAssetsPath.compareTo(other.extensionAssetsPath); + compare = name.compareTo(other.name); if (compare == 0) { - return devtoolsOptionsUri.compareTo(other.devtoolsOptionsUri); + compare = extensionAssetsPath.compareTo(other.extensionAssetsPath); + if (compare == 0) { + return devtoolsOptionsUri.compareTo(other.devtoolsOptionsUri); + } } } return compare; @@ -269,6 +279,7 @@ class DevToolsExtensionConfig implements Comparable { bool operator ==(Object other) { return other is DevToolsExtensionConfig && other.name == name && + other.packageName == packageName && other.issueTrackerLink == issueTrackerLink && other.version == version && other.materialIconCodePoint == materialIconCodePoint && @@ -281,16 +292,17 @@ class DevToolsExtensionConfig implements Comparable { @override int get hashCode => Object.hash( - name, - issueTrackerLink, - version, - materialIconCodePoint, - requiresConnection, - extensionAssetsPath, - devtoolsOptionsUri, - isPubliclyHosted, - detectedFromStaticContext, - ); + name, + packageName, + issueTrackerLink, + version, + materialIconCodePoint, + requiresConnection, + extensionAssetsPath, + devtoolsOptionsUri, + isPubliclyHosted, + detectedFromStaticContext, + ); static void _assertGeneratedKeysPresent(Map json) { final missingKeys = []; @@ -326,8 +338,9 @@ enum ExtensionEnabledState { /// Parses [value] and returns the matching [ExtensionEnabledState] if found. static ExtensionEnabledState from(String? value) { - return ExtensionEnabledState.values - .firstWhereOrNull((e) => e.name == value) ?? + return ExtensionEnabledState.values.firstWhereOrNull( + (e) => e.name == value, + ) ?? ExtensionEnabledState.none; } } diff --git a/packages/devtools_shared/lib/src/memory/adb_memory_info.dart b/packages/devtools_shared/lib/src/memory/adb_memory_info.dart index 3e65031a304..948752657b1 100644 --- a/packages/devtools_shared/lib/src/memory/adb_memory_info.dart +++ b/packages/devtools_shared/lib/src/memory/adb_memory_info.dart @@ -23,16 +23,16 @@ class AdbMemoryInfo with Serializable { /// then the data is in kilobytes. See the factory constructor /// [AdbMemoryInfo.fromJsonInKB]. factory AdbMemoryInfo.fromJson(Map json) => AdbMemoryInfo( - json[realTimeKey] as int, - json[javaHeapKey] as int, - json[nativeHeapKey] as int, - json[codeKey] as int, - json[stackKey] as int, - json[graphicsKey] as int, - json[otherKey] as int, - json[systemKey] as int, - json[totalKey] as int, - ); + json[realTimeKey] as int, + json[javaHeapKey] as int, + json[nativeHeapKey] as int, + json[codeKey] as int, + json[stackKey] as int, + json[graphicsKey] as int, + json[otherKey] as int, + json[systemKey] as int, + json[totalKey] as int, + ); /// Use when converting data received from the service extension, /// directly from ADB. All data received from ADB dumpsys meminfo is @@ -85,16 +85,16 @@ class AdbMemoryInfo with Serializable { @override Map toJson() => { - realTimeKey: realtime, - javaHeapKey: javaHeap, - nativeHeapKey: nativeHeap, - codeKey: code, - stackKey: stack, - graphicsKey: graphics, - otherKey: other, - systemKey: system, - totalKey: total, - }; + realTimeKey: realtime, + javaHeapKey: javaHeap, + nativeHeapKey: nativeHeap, + codeKey: code, + stackKey: stack, + graphicsKey: graphics, + otherKey: other, + systemKey: system, + totalKey: total, + }; /// Create an empty [AdbMemoryInfo], where all values are 0. static AdbMemoryInfo empty() => AdbMemoryInfo(0, 0, 0, 0, 0, 0, 0, 0, 0); @@ -130,7 +130,8 @@ class AdbMemoryInfo with Serializable { Duration get bootDuration => Duration(milliseconds: realtime); @override - String toString() => '[AdbMemoryInfo ' + String toString() => + '[AdbMemoryInfo ' '$realTimeKey: $realtime, ' 'realtimeDT: $realtimeDT, ' 'durationBoot: $bootDuration, ' diff --git a/packages/devtools_shared/lib/src/memory/class_heap_detail_stats.dart b/packages/devtools_shared/lib/src/memory/class_heap_detail_stats.dart index 7e15120bef7..69909590d48 100644 --- a/packages/devtools_shared/lib/src/memory/class_heap_detail_stats.dart +++ b/packages/devtools_shared/lib/src/memory/class_heap_detail_stats.dart @@ -13,11 +13,11 @@ class ClassHeapDetailStats { required int instances, int deltaInstances = 0, bool traceAllocations = false, - }) : bytesCurrent = bytes, - bytesDelta = deltaBytes, - instancesCurrent = instances, - instancesDelta = deltaInstances, - isStacktraced = traceAllocations; + }) : bytesCurrent = bytes, + bytesDelta = deltaBytes, + instancesCurrent = instances, + instancesDelta = deltaInstances, + isStacktraced = traceAllocations; factory ClassHeapDetailStats.fromJson(Map json) { final {'id': classId, 'name': className} = json['class'] as Map; @@ -33,16 +33,13 @@ class ClassHeapDetailStats { } Map toJson() => { - 'class': { - 'id': classRef.id, - 'name': classRef.name, - }, - 'bytesCurrent': bytesCurrent, - 'bytesDelta': bytesDelta, - 'instancesCurrent': instancesCurrent, - 'instancesDelta': instancesDelta, - 'isStackedTraced': isStacktraced, - }; + 'class': {'id': classRef.id, 'name': classRef.name}, + 'bytesCurrent': bytesCurrent, + 'bytesDelta': bytesDelta, + 'instancesCurrent': instancesCurrent, + 'instancesDelta': instancesDelta, + 'isStackedTraced': isStacktraced, + }; /// Version of [ClassHeapDetailStats] payload. static const version = 1; @@ -60,6 +57,7 @@ class ClassHeapDetailStats { bool isStacktraced; @override - String toString() => '[ClassHeapDetailStats class: ${classRef.name}, ' + String toString() => + '[ClassHeapDetailStats class: ${classRef.name}, ' 'count: $instancesCurrent, bytes: $bytesCurrent]'; } diff --git a/packages/devtools_shared/lib/src/memory/event_sample.dart b/packages/devtools_shared/lib/src/memory/event_sample.dart index 1234452b52e..b51a12b69fc 100644 --- a/packages/devtools_shared/lib/src/memory/event_sample.dart +++ b/packages/devtools_shared/lib/src/memory/event_sample.dart @@ -19,17 +19,17 @@ class AllocationAccumulator { AllocationAccumulator(this._start, this._continues, this._reset); AllocationAccumulator.start() - : _start = true, - _continues = false, - _reset = false; + : _start = true, + _continues = false, + _reset = false; AllocationAccumulator.continues() - : _start = false, - _continues = true, - _reset = false; + : _start = false, + _continues = true, + _reset = false; AllocationAccumulator.reset() - : _start = false, - _continues = false, - _reset = true; + : _start = false, + _continues = false, + _reset = true; factory AllocationAccumulator.fromJson(Map json) => AllocationAccumulator( @@ -39,10 +39,10 @@ class AllocationAccumulator { ); Map toJson() => { - 'start': _start, - 'continues': _continues, - 'reset': _reset, - }; + 'start': _start, + 'continues': _continues, + 'reset': _reset, + }; static AllocationAccumulator empty() => AllocationAccumulator(false, false, false); @@ -64,13 +64,14 @@ class AllocationAccumulator { bool get isReset => _reset; @override - String toString() => '[AllocationAccumulator ' + String toString() => + '[AllocationAccumulator ' '${const JsonEncoder.withIndent(' ').convert(toJson())}]'; } class ExtensionEvent { ExtensionEvent(this.timestamp, this.eventKind, this.data) - : customEventName = null; + : customEventName = null; ExtensionEvent.custom( this.timestamp, @@ -88,11 +89,11 @@ class ExtensionEvent { ); Map toJson() => { - 'timestamp': timestamp, - 'eventKind': eventKind, - 'data': data, - 'customEventName': customEventName, - }; + 'timestamp': timestamp, + 'eventKind': eventKind, + 'data': data, + 'customEventName': customEventName, + }; static ExtensionEvent empty() => ExtensionEvent.custom(null, null, null, null); @@ -112,7 +113,8 @@ class ExtensionEvent { final String? customEventName; @override - String toString() => '[ExtensionEvent ' + String toString() => + '[ExtensionEvent ' '${const JsonEncoder.withIndent(' ').convert(toJson())}]'; } @@ -152,7 +154,8 @@ class ExtensionEvents { void clear() => events.clear(); @override - String toString() => '[ExtensionEvents = ' + String toString() => + '[ExtensionEvents = ' '${const JsonEncoder.withIndent(' ').convert(toJson())}]'; } @@ -167,58 +170,52 @@ class EventSample with Serializable { ); EventSample.gcEvent(this.timestamp, {ExtensionEvents? events}) - : isEventGC = true, - isEventSnapshot = false, - isEventSnapshotAuto = false, - allocationAccumulator = null, - extensionEvents = events; + : isEventGC = true, + isEventSnapshot = false, + isEventSnapshotAuto = false, + allocationAccumulator = null, + extensionEvents = events; EventSample.snapshotEvent( this.timestamp, { bool snapshotAuto = false, ExtensionEvents? events, - }) : isEventGC = false, - isEventSnapshot = !snapshotAuto, - isEventSnapshotAuto = snapshotAuto, - allocationAccumulator = null, - extensionEvents = events; - - EventSample.accumulatorStart( - this.timestamp, { - ExtensionEvents? events, - }) : isEventGC = false, - isEventSnapshot = false, - isEventSnapshotAuto = false, - allocationAccumulator = AllocationAccumulator.start(), - extensionEvents = events; - - EventSample.accumulatorContinues( - this.timestamp, { - ExtensionEvents? events, - }) : isEventGC = false, - isEventSnapshot = false, - isEventSnapshotAuto = false, - allocationAccumulator = AllocationAccumulator.continues(), - extensionEvents = events; - - EventSample.accumulatorReset( - this.timestamp, { - ExtensionEvents? events, - }) : isEventGC = false, - isEventSnapshot = false, - isEventSnapshotAuto = false, - allocationAccumulator = AllocationAccumulator.reset(), - extensionEvents = events; + }) : isEventGC = false, + isEventSnapshot = !snapshotAuto, + isEventSnapshotAuto = snapshotAuto, + allocationAccumulator = null, + extensionEvents = events; + + EventSample.accumulatorStart(this.timestamp, {ExtensionEvents? events}) + : isEventGC = false, + isEventSnapshot = false, + isEventSnapshotAuto = false, + allocationAccumulator = AllocationAccumulator.start(), + extensionEvents = events; + + EventSample.accumulatorContinues(this.timestamp, {ExtensionEvents? events}) + : isEventGC = false, + isEventSnapshot = false, + isEventSnapshotAuto = false, + allocationAccumulator = AllocationAccumulator.continues(), + extensionEvents = events; + + EventSample.accumulatorReset(this.timestamp, {ExtensionEvents? events}) + : isEventGC = false, + isEventSnapshot = false, + isEventSnapshotAuto = false, + allocationAccumulator = AllocationAccumulator.reset(), + extensionEvents = events; EventSample.extensionEvent(this.timestamp, this.extensionEvents) - : isEventGC = false, - isEventSnapshot = false, - isEventSnapshotAuto = false, - allocationAccumulator = null; + : isEventGC = false, + isEventSnapshot = false, + isEventSnapshotAuto = false, + allocationAccumulator = null; factory EventSample.fromJson(Map json) { - final extensionEvents = - (json['extensionEvents'] as Map?)?.cast(); + final extensionEvents = (json['extensionEvents'] as Map?) + ?.cast(); return EventSample( json['timestamp'] as int, @@ -238,13 +235,13 @@ class EventSample with Serializable { @override Map toJson() => { - 'timestamp': timestamp, - 'gcEvent': isEventGC, - 'snapshotEvent': isEventSnapshot, - 'snapshotAutoEvent': isEventSnapshotAuto, - 'allocationAccumulatorEvent': allocationAccumulator?.toJson(), - 'extensionEvents': extensionEvents?.toJson(), - }; + 'timestamp': timestamp, + 'gcEvent': isEventGC, + 'snapshotEvent': isEventSnapshot, + 'snapshotAutoEvent': isEventSnapshotAuto, + 'allocationAccumulatorEvent': allocationAccumulator?.toJson(), + 'extensionEvents': extensionEvents?.toJson(), + }; EventSample clone(int timestamp, {ExtensionEvents? extensionEvents}) => EventSample( @@ -257,14 +254,8 @@ class EventSample with Serializable { ); /// Create an empty event (all values are nothing). - static EventSample empty() => EventSample( - -1, - false, - false, - false, - AllocationAccumulator.empty(), - null, - ); + static EventSample empty() => + EventSample(-1, false, false, false, AllocationAccumulator.empty(), null); bool get isEmpty => timestamp == -1; @@ -288,7 +279,8 @@ class EventSample with Serializable { final ExtensionEvents? extensionEvents; @override - String toString() => '[EventSample timestamp: $timestamp = ' + String toString() => + '[EventSample timestamp: $timestamp = ' '${const JsonEncoder.withIndent(' ').convert(toJson())}]'; } @@ -314,11 +306,12 @@ class RasterCache with Serializable { @override Map toJson() => { - 'layerBytes': layerBytes, - 'pictureBytes': pictureBytes, - }; + 'layerBytes': layerBytes, + 'pictureBytes': pictureBytes, + }; @override - String toString() => '[RasterCache ' + String toString() => + '[RasterCache ' '${const JsonEncoder.withIndent(' ').convert(toJson())}]'; } diff --git a/packages/devtools_shared/lib/src/memory/heap_sample.dart b/packages/devtools_shared/lib/src/memory/heap_sample.dart index 5248072fc24..8ba0e8540b3 100644 --- a/packages/devtools_shared/lib/src/memory/heap_sample.dart +++ b/packages/devtools_shared/lib/src/memory/heap_sample.dart @@ -41,9 +41,9 @@ class HeapSample with Serializable { AdbMemoryInfo? adbMemoryInfo, EventSample? memoryEventInfo, RasterCache? rasterCache, - ) : adbMemoryInfo = adbMemoryInfo ?? AdbMemoryInfo.empty(), - memoryEventInfo = memoryEventInfo ?? EventSample.empty(), - rasterCache = rasterCache ?? RasterCache.empty(); + ) : adbMemoryInfo = adbMemoryInfo ?? AdbMemoryInfo.empty(), + memoryEventInfo = memoryEventInfo ?? EventSample.empty(), + rasterCache = rasterCache ?? RasterCache.empty(); factory HeapSample.fromJson(Map json) { return HeapSample( @@ -72,16 +72,16 @@ class HeapSample with Serializable { @override Map toJson() => { - Json.timestamp.key: timestamp, - Json.rss.key: rss, - Json.capacity.key: capacity, - Json.used.key: used, - Json.external.key: external, - Json.gc.key: isGC, - Json.adbMemoryInfo.key: adbMemoryInfo, - Json.memoryEventInfo.key: memoryEventInfo, - Json.rasterCache.key: rasterCache, - }; + Json.timestamp.key: timestamp, + Json.rss.key: rss, + Json.capacity.key: capacity, + Json.used.key: used, + Json.external.key: external, + Json.gc.key: isGC, + Json.adbMemoryInfo.key: adbMemoryInfo, + Json.memoryEventInfo.key: memoryEventInfo, + Json.rasterCache.key: rasterCache, + }; /// Version of HeapSample JSON payload. static const version = 1; @@ -105,6 +105,7 @@ class HeapSample with Serializable { RasterCache rasterCache; @override - String toString() => '[HeapSample timestamp: $timestamp, ' + String toString() => + '[HeapSample timestamp: $timestamp, ' '${const JsonEncoder.withIndent(' ').convert(toJson())}]'; } diff --git a/packages/devtools_shared/lib/src/memory/heap_space.dart b/packages/devtools_shared/lib/src/memory/heap_space.dart index 9c455865d40..b0e0335440d 100644 --- a/packages/devtools_shared/lib/src/memory/heap_space.dart +++ b/packages/devtools_shared/lib/src/memory/heap_space.dart @@ -5,14 +5,13 @@ /// HeapSpace of Dart VM collected heap data. class HeapSpace { HeapSpace._fromJson(this.json) - : avgCollectionPeriodMillis = - json['avgCollectionPeriodMillis'] as double?, - capacity = json['capacity'] as int?, - collections = json['collections'] as int?, - external = json['external'] as int?, - name = json['name'] as String?, - time = json['time'] as double?, - used = json['used'] as int?; + : avgCollectionPeriodMillis = json['avgCollectionPeriodMillis'] as double?, + capacity = json['capacity'] as int?, + collections = json['collections'] as int?, + external = json['external'] as int?, + name = json['name'] as String?, + time = json['time'] as double?, + used = json['used'] as int?; static HeapSpace? parse(Map? json) => json == null ? null : HeapSpace._fromJson(json); @@ -34,15 +33,15 @@ class HeapSpace { final int? used; Map toJson() => { - 'type': 'HeapSpace', - 'avgCollectionPeriodMillis': avgCollectionPeriodMillis, - 'capacity': capacity, - 'collections': collections, - 'external': external, - 'name': name, - 'time': time, - 'used': used, - }; + 'type': 'HeapSpace', + 'avgCollectionPeriodMillis': avgCollectionPeriodMillis, + 'capacity': capacity, + 'collections': collections, + 'external': external, + 'name': name, + 'time': time, + 'used': used, + }; @override String toString() => '[HeapSpace]'; diff --git a/packages/devtools_shared/lib/src/memory/memory_json.dart b/packages/devtools_shared/lib/src/memory/memory_json.dart index 9607c658069..4b78fd7f3c0 100644 --- a/packages/devtools_shared/lib/src/memory/memory_json.dart +++ b/packages/devtools_shared/lib/src/memory/memory_json.dart @@ -97,10 +97,10 @@ class SamplesMemoryJson extends MemoryJson { required String argJsonString, Map? argDecodedMap, }) : super.decode( - _jsonMemoryPayloadField, - argJsonString: argJsonString, - argDecodedMap: argDecodedMap, - ); + _jsonMemoryPayloadField, + argJsonString: argJsonString, + argDecodedMap: argDecodedMap, + ); /// Exported JSON payload of collected memory statistics. static const _jsonMemoryPayloadField = 'samples'; @@ -180,10 +180,9 @@ class SamplesMemoryJson extends MemoryJson { Map upgradeToVersion( Map payload, int oldVersion, - ) => - throw UnimplementedError( - '${HeapSample.version} is the only valid HeapSample version', - ); + ) => throw UnimplementedError( + '${HeapSample.version} is the only valid HeapSample version', + ); /// Given a list of [HeapSample], encode as a Json string. static String encodeList(List data) { @@ -201,7 +200,8 @@ class SamplesMemoryJson extends MemoryJson { return '$header$result${MemoryJson.trailer}'; } - static String get header => '{"$_jsonMemoryPayloadField": {' + static String get header => + '{"$_jsonMemoryPayloadField": {' '"${MemoryJson.jsonVersionField}": ${HeapSample.version}, ' '"${MemoryJson.jsonDevToolsScreenField}": "${MemoryJson.devToolsScreenValueMemory}", ' '"${MemoryJson.jsonDataField}": [\n'; @@ -272,10 +272,10 @@ class AllocationMemoryJson extends MemoryJson { required String argJsonString, Map? argDecodedMap, }) : super.decode( - _jsonAllocationPayloadField, - argJsonString: argJsonString, - argDecodedMap: argDecodedMap, - ); + _jsonAllocationPayloadField, + argJsonString: argJsonString, + argDecodedMap: argDecodedMap, + ); /// Exported JSON payload of collected memory statistics. static const _jsonAllocationPayloadField = 'allocations'; @@ -308,10 +308,7 @@ class AllocationMemoryJson extends MemoryJson { for (final data in oldData) { 'type': 'ClassHeapStats', - 'class': { - 'type': '@Class', - ...data.class_, - }, + 'class': {'type': '@Class', ...data.class_}, 'bytesCurrent': data.bytesCurrent, 'accumulatedSize': data.bytesDelta, 'instancesCurrent': data.instancesCurrent, @@ -355,7 +352,8 @@ class AllocationMemoryJson extends MemoryJson { } /// Allocations Header portion: - static String get header => '{"$_jsonAllocationPayloadField": {' + static String get header => + '{"$_jsonAllocationPayloadField": {' '"${MemoryJson.jsonVersionField}": $allocationFormatVersion, ' '"${MemoryJson.jsonDevToolsScreenField}": "${MemoryJson.devToolsScreenValueMemory}", ' '"${MemoryJson.jsonDataField}": [\n'; diff --git a/packages/devtools_shared/lib/src/server/devtools_store.dart b/packages/devtools_shared/lib/src/server/devtools_store.dart index 4d468b5c393..5df2442f1d9 100644 --- a/packages/devtools_shared/lib/src/server/devtools_store.dart +++ b/packages/devtools_shared/lib/src/server/devtools_store.dart @@ -5,14 +5,6 @@ import 'file_system.dart'; enum DevToolsStoreKeys { - /// The key holding the value for whether Google Analytics (legacy) for - /// DevTools have been enabled. - analyticsEnabled, - - /// The key holding the value for whether this is a user's first run of - /// DevTools. - isFirstRun, - /// The key holding the value for the last DevTools version that the user /// viewed release notes for. lastReleaseNotesVersion, @@ -29,12 +21,13 @@ enum DevToolsStoreKeys { /// Provides access to the local DevTools store (~/.flutter-devtools/.devtools). class DevToolsUsage { DevToolsUsage() { - LocalFileSystem.maybeMoveLegacyDevToolsStore(); - + // TODO(srawlins): Accept a FileSystem parameter during tests. + fileSystem.maybeMoveLegacyDevToolsStore(); properties = IOPersistentProperties( storeName, - documentDirPath: LocalFileSystem.devToolsDir(), + documentDirPath: FileSystemExtension.devToolsDir, ); + _removeLegacyKeys(); } static const storeName = '.devtools'; @@ -55,23 +48,14 @@ class DevToolsUsage { late IOPersistentProperties properties; - void reset() { - properties.remove(DevToolsStoreKeys.isFirstRun.name); - properties[DevToolsStoreKeys.analyticsEnabled.name] = false; - } - - bool get isFirstRun { - return properties[DevToolsStoreKeys.isFirstRun.name] = - properties[DevToolsStoreKeys.isFirstRun.name] == null; - } - - bool get analyticsEnabled { - return properties[DevToolsStoreKeys.analyticsEnabled.name] = - properties[DevToolsStoreKeys.analyticsEnabled.name] == true; - } - - set analyticsEnabled(bool value) { - properties[DevToolsStoreKeys.analyticsEnabled.name] = value; + void _removeLegacyKeys() { + // TODO(https://github.com/flutter/devtools/issues/9775): remove this logic + // once legacy keys have been removed for ~1 year. We are intentionally + // using raw strings instead of values from [DevToolsStoreKeys] to avoid + // unnecessary breaking changes in the future. + properties + ..remove('analyticsEnabled') + ..remove('isFirstRun'); } bool surveyNameExists(String surveyName) => properties[surveyName] != null; @@ -105,8 +89,8 @@ class DevToolsUsage { /// The active survey in [properties], as a [_ActiveSurveyJson]. _ActiveSurveyJson get _activeSurveyFromProperties => _ActiveSurveyJson( - (properties[activeSurvey!] as Map).cast(), - ); + (properties[activeSurvey!] as Map).cast(), + ); int get surveyShownCount { assert(activeSurvey != null); @@ -121,10 +105,7 @@ class DevToolsUsage { assert(activeSurvey != null); surveyShownCount; // Ensure surveyShownCount has been initialized. final prop = _activeSurveyFromProperties; - rewriteActiveSurvey( - prop.surveyActionTaken, - prop.surveyShownCount! + 1, - ); + rewriteActiveSurvey(prop.surveyActionTaken, prop.surveyShownCount! + 1); } bool get surveyActionTaken { @@ -132,10 +113,7 @@ class DevToolsUsage { } set surveyActionTaken(bool value) { - rewriteActiveSurvey( - value, - _activeSurveyFromProperties.surveyShownCount!, - ); + rewriteActiveSurvey(value, _activeSurveyFromProperties.surveyShownCount!); } String get lastReleaseNotesVersion { diff --git a/packages/devtools_shared/lib/src/server/file_system.dart b/packages/devtools_shared/lib/src/server/file_system.dart index 80d24409b5f..63918084354 100644 --- a/packages/devtools_shared/lib/src/server/file_system.dart +++ b/packages/devtools_shared/lib/src/server/file_system.dart @@ -3,81 +3,92 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:convert'; -import 'dart:io'; +import 'dart:io' as io show Platform, File; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:path/path.dart' as path; import 'devtools_store.dart'; -/// A namespace local file system utlities. -extension LocalFileSystem on Never { - static String _userHomeDir() { - final envKey = Platform.operatingSystem == 'windows' ? 'APPDATA' : 'HOME'; - return Platform.environment[envKey] ?? '.'; +/// The real, local file system, which can be avoided in tests. +const fileSystem = LocalFileSystem(); + +extension FileSystemExtension on FileSystem { + static String get _userHomeDir { + final envKey = io.Platform.operatingSystem == 'windows' + ? 'APPDATA' + : 'HOME'; + return io.Platform.environment[envKey] ?? '.'; } - /// Returns the path to the DevTools storage directory. - static String devToolsDir() { - return path.join(_userHomeDir(), '.flutter-devtools'); + /// The path to the DevTools storage directory. + static String get devToolsDir { + return path.join(_userHomeDir, '.flutter-devtools'); } - /// Moves the .devtools file to ~/.flutter-devtools/.devtools if the .devtools - /// file exists in the user's home directory. - static void maybeMoveLegacyDevToolsStore() { - final file = File(path.join(_userHomeDir(), DevToolsUsage.storeName)); - if (file.existsSync()) { - ensureDevToolsDirectory(); - file.copySync(devToolsStoreLocation()); - file.deleteSync(); + /// Moves the `.devtools` file to `~/.flutter-devtools/.devtools` if the + /// `.devtools` file exists in the user's home directory. + void maybeMoveLegacyDevToolsStore() { + final storeFile = file(path.join(_userHomeDir, DevToolsUsage.storeName)); + if (storeFile.existsSync()) { + _ensureDevToolsDirectory(); + storeFile.copySync(devToolsStoreLocation); + storeFile.deleteSync(); } } - static String devToolsStoreLocation() { - return path.join(devToolsDir(), DevToolsUsage.storeName); + static String get devToolsStoreLocation { + return path.join(devToolsDir, DevToolsUsage.storeName); } - /// Creates the ~/.flutter-devtools directory if it does not already exist. - static void ensureDevToolsDirectory() { - Directory(devToolsDir()).createSync(); + /// Creates the `~/.flutter-devtools` directory if it does not already exist. + void _ensureDevToolsDirectory() { + directory(devToolsDir).createSync(); } /// Returns a DevTools file from the given path. /// /// Only files within ~/.flutter-devtools/ can be accessed. - static File? devToolsFileFromPath(String pathFromDevToolsDir) { - if (pathFromDevToolsDir.contains('..')) { + File? devToolsFileFromPath(String relativePath) { + if (relativePath.contains('..') || path.isAbsolute(relativePath)) { // The passed in path should not be able to walk up the directory tree - // outside of the ~/.flutter-devtools/ directory. + // outside of the `~/.flutter-devtools/` directory. It must also not be an + // absolute path: `path.join()` discards the base directory when its + // second argument is absolute, which would otherwise allow reading an + // arbitrary file on disk (e.g. an absolute path to a credentials `.json` + // file). return null; } - ensureDevToolsDirectory(); - final file = File(path.join(devToolsDir(), pathFromDevToolsDir)); - if (!file.existsSync()) { - return null; - } - return file; + _ensureDevToolsDirectory(); + final targetFile = file(path.join(devToolsDir, relativePath)); + // Defense in depth: ensure the resolved path is actually contained within + // the DevTools directory. + if (!path.isWithin(devToolsDir, targetFile.path)) return null; + if (!targetFile.existsSync()) return null; + return targetFile; } - /// Returns a DevTools file from the given path as encoded json. + /// Returns a DevTools file from the given path as encoded JSON. /// - /// Only files within ~/.flutter-devtools/ can be accessed. - static String? devToolsFileAsJson(String pathFromDevToolsDir) { - final file = devToolsFileFromPath(pathFromDevToolsDir); - if (file == null) return null; + /// Only files within `~/.flutter-devtools/` can be accessed. + String? devToolsFileAsJson(String relativePath) { + final targetFile = devToolsFileFromPath(relativePath); + if (targetFile == null) return null; - final fileName = path.basename(file.path); + final fileName = path.basename(targetFile.path); if (!fileName.endsWith('.json')) return null; - final content = file.readAsStringSync(); + final content = targetFile.readAsStringSync(); final json = jsonDecode(content) as Map; - json['lastModifiedTime'] = file.lastModifiedSync().toString(); + json['lastModifiedTime'] = targetFile.lastModifiedSync().toString(); return jsonEncode(json); } /// Whether the flutter store file exists. - static bool flutterStoreExists() { - final flutterStore = File(path.join(_userHomeDir(), '.flutter')); + bool get flutterStoreExists { + final flutterStore = file(path.join(_userHomeDir, '.flutter')); return flutterStore.existsSync(); } } @@ -86,18 +97,23 @@ class IOPersistentProperties { IOPersistentProperties( this.name, { String? documentDirPath, - }) { + FileSystem fs = fileSystem, + }) : _fs = fs { final fileName = name.replaceAll(' ', '_'); - documentDirPath ??= LocalFileSystem._userHomeDir(); - _file = File(path.join(documentDirPath, fileName)); + documentDirPath ??= FileSystemExtension._userHomeDir; + _file = _fs.file(path.join(documentDirPath, fileName)); if (!_file.existsSync()) { _file.createSync(recursive: true); } syncSettings(); } - IOPersistentProperties.fromFile(File file) : name = path.basename(file.path) { - _file = file; + // TODO(srawlins): This is unused in any devtools code. Even in tests. Either + // test it, if it is needed by users, or remove it. + IOPersistentProperties.fromFile(io.File file) + : name = path.basename(file.path), + _fs = file is File ? file.fileSystem : fileSystem { + _file = file is File ? file : _fs.file(file.path); if (!_file.existsSync()) { _file.createSync(recursive: true); } @@ -106,7 +122,9 @@ class IOPersistentProperties { final String name; - late File _file; + final FileSystem _fs; + + late final File _file; late Map _map; @@ -141,7 +159,7 @@ class IOPersistentProperties { } void remove(String propertyName) { - _map.remove(propertyName); + this[propertyName] = null; } } diff --git a/packages/devtools_shared/lib/src/server/flutter_store.dart b/packages/devtools_shared/lib/src/server/flutter_store.dart index 34410707b31..e316c566ddd 100644 --- a/packages/devtools_shared/lib/src/server/flutter_store.dart +++ b/packages/devtools_shared/lib/src/server/flutter_store.dart @@ -7,15 +7,10 @@ import 'file_system.dart'; /// Provides access to the local Flutter store (~/.flutter). class FlutterStore { static const storeName = '.flutter'; - static const firstRunKey = 'firstRun'; - static const gaEnabledKey = 'enabled'; static const flutterClientIdKey = 'clientId'; final properties = IOPersistentProperties(storeName); - bool get isFirstRun => properties[firstRunKey] == true; - - bool get gaEnabled => properties[gaEnabledKey] == true; - - String get flutterClientId => properties[flutterClientIdKey] as String; + String get flutterClientId => + (properties[flutterClientIdKey] as String?) ?? ''; } diff --git a/packages/devtools_shared/lib/src/server/handlers/_app_size.dart b/packages/devtools_shared/lib/src/server/handlers/_app_size.dart index f7f31a5be2b..f6e61dc579f 100644 --- a/packages/devtools_shared/lib/src/server/handlers/_app_size.dart +++ b/packages/devtools_shared/lib/src/server/handlers/_app_size.dart @@ -19,7 +19,7 @@ extension _AppSizeHandler on Never { if (missingRequiredParams != null) return missingRequiredParams; final filePath = queryParams[AppSizeApi.baseAppSizeFilePropertyName]!; - final fileJson = LocalFileSystem.devToolsFileAsJson(filePath); + final fileJson = fileSystem.devToolsFileAsJson(filePath); if (fileJson == null) { return api.badRequest('No JSON file available at $filePath.'); } @@ -39,7 +39,7 @@ extension _AppSizeHandler on Never { if (missingRequiredParams != null) return missingRequiredParams; final filePath = queryParams[AppSizeApi.testAppSizeFilePropertyName]!; - final fileJson = LocalFileSystem.devToolsFileAsJson(filePath); + final fileJson = fileSystem.devToolsFileAsJson(filePath); if (fileJson == null) { return api.badRequest('No JSON file available at $filePath.'); } diff --git a/packages/devtools_shared/lib/src/server/handlers/_deeplink.dart b/packages/devtools_shared/lib/src/server/handlers/_deeplink.dart index b5c86417bfe..b3d75e1c754 100644 --- a/packages/devtools_shared/lib/src/server/handlers/_deeplink.dart +++ b/packages/devtools_shared/lib/src/server/handlers/_deeplink.dart @@ -20,8 +20,11 @@ extension _DeeplinkApiHandler on Never { if (missingRequiredParams != null) return missingRequiredParams; final rootPath = queryParams[DeeplinkApi.deeplinkRootPathPropertyName]!; - final result = - await deeplinkManager.getAndroidBuildVariants(rootPath: rootPath); + final result = await deeplinkManager.getAndroidBuildVariants( + rootPath: rootPath, + ide: queryParams.ide, + suppressAnalytics: queryParams.suppressAnalytics, + ); return _resultOutputOrError(api, result); } @@ -47,6 +50,8 @@ extension _DeeplinkApiHandler on Never { final result = await deeplinkManager.getAndroidAppLinkSettings( rootPath: rootPath, buildVariant: buildVariant, + ide: queryParams.ide, + suppressAnalytics: queryParams.suppressAnalytics, ); return _resultOutputOrError(api, result); } @@ -65,7 +70,11 @@ extension _DeeplinkApiHandler on Never { if (missingRequiredParams != null) return missingRequiredParams; final rootPath = queryParams[DeeplinkApi.deeplinkRootPathPropertyName]!; - final result = await deeplinkManager.getIosBuildOptions(rootPath: rootPath); + final result = await deeplinkManager.getIosBuildOptions( + rootPath: rootPath, + ide: queryParams.ide, + suppressAnalytics: queryParams.suppressAnalytics, + ); return _resultOutputOrError(api, result); } @@ -90,6 +99,8 @@ extension _DeeplinkApiHandler on Never { rootPath: queryParams[DeeplinkApi.deeplinkRootPathPropertyName]!, configuration: queryParams[DeeplinkApi.xcodeConfigurationPropertyName]!, target: queryParams[DeeplinkApi.xcodeTargetPropertyName]!, + ide: queryParams.ide, + suppressAnalytics: queryParams.suppressAnalytics, ); return _resultOutputOrError(api, result); } @@ -102,8 +113,11 @@ extension _DeeplinkApiHandler on Never { if (error != null) { return api.serverError(error); } - return api.success( - result[DeeplinkManager.kOutputJsonField]! as String, - ); + return api.success(result[DeeplinkManager.kOutputJsonField]! as String); } } + +extension on Map { + String? get ide => this['ide']; + bool get suppressAnalytics => this['suppress_analytics'] == 'true'; +} diff --git a/packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart b/packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart index f7c63671630..af2b3f33957 100644 --- a/packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart +++ b/packages/devtools_shared/lib/src/server/handlers/_devtools_extensions.dart @@ -23,13 +23,26 @@ extension _ExtensionsApiHandler on Never { final logs = []; final rootFileUriString = queryParams[ExtensionsApi.packageRootUriPropertyName]; + if (rootFileUriString != null && rootFileUriString.isNotEmpty) { + final rootFileUri = Uri.tryParse(rootFileUriString); + if (!rootFileUriString.startsWith('file://') || + rootFileUri == null || + rootFileUri.scheme != 'file' || + rootFileUri.host.isNotEmpty) { + return api.badRequest( + 'Invalid ${ExtensionsApi.packageRootUriPropertyName}: must be a ' + 'file:// URI with an empty host.', + ); + } + } final result = {}; /// Helper to return a success response with all available extensions /// detected by [extensionsManager]. shelf.Response succeedWithAvailableExtensions({String? warning}) { - final extensions = - extensionsManager.devtoolsExtensions.map((p) => p.toJson()).toList(); + final extensions = extensionsManager.devtoolsExtensions + .map((p) => p.toJson()) + .toList(); result[ExtensionsApi.extensionsResultPropertyName] = extensions; if (warning != null) { result[ExtensionsApi.extensionsResultWarningPropertyName] = warning; @@ -65,6 +78,7 @@ extension _ExtensionsApiHandler on Never { static shelf.Response handleExtensionEnabledState( ServerApi api, Map queryParams, + ExtensionsManager extensionsManager, ) { final missingRequiredParams = ServerApi._checkRequiredParameters( [ @@ -80,22 +94,108 @@ extension _ExtensionsApiHandler on Never { final devtoolsOptionsFileUriString = queryParams[ExtensionsApi.devtoolsOptionsUriPropertyName]!; final devtoolsOptionsFileUri = Uri.parse(devtoolsOptionsFileUriString); + + // Validate that the URI is a local file URI whose file name is exactly + // 'devtools_options.yaml'. Accepting arbitrary URIs from the query string + // would allow an untrusted caller to create or overwrite any file writable + // by the DevTools server process. Resolving the name through + // `Uri.toFilePath()` + `p.basename` handles both '/' and '\' path + // separators, so the check holds for Windows file URIs as well. Requiring + // an empty host rejects UNC paths (e.g. `file://server/share/...`) and + // keeps `toFilePath()` from throwing on a non-local authority. + final isFileUri = + devtoolsOptionsFileUri.scheme == 'file' && + devtoolsOptionsFileUri.host.isEmpty; + final fileName = isFileUri + ? p.basename(devtoolsOptionsFileUri.toFilePath()) + : ''; + if (!isFileUri || fileName != devtoolsOptionsFileName) { + return api.badRequest( + 'Invalid devtoolsOptionsUri: must be a file: URI named ' + "'$devtoolsOptionsFileName'.", + ); + } + + final requestedOptionsPath = _resolveOptionsFilePath( + devtoolsOptionsFileUri, + ); + + // Make sure that this extension's `devtools_options.yaml` file is + // being served by [ExtensionsManager]. + // + // This prevents DevTools from creating missing directories on disk when + // this path points outside of the extension directory. + final isServedExtensionOptionsFile = + requestedOptionsPath != null && + extensionsManager.devtoolsExtensions.any((ext) { + final uri = Uri.tryParse(ext.devtoolsOptionsUri); + if (uri == null) return false; + if (uri.scheme == 'file' && uri.host.isEmpty) { + // If these are equal, there's no need to call + // _resolveOptionsFilePath again, so return early. + final servedPathUnresolved = p.normalize(uri.toFilePath()); + if (p.equals(servedPathUnresolved, requestedOptionsPath)) { + return true; + } + } + final servedPath = _resolveOptionsFilePath(uri); + return servedPath != null && + p.equals(servedPath, requestedOptionsPath); + }); + + if (!isServedExtensionOptionsFile) { + return api.badRequest( + 'Invalid devtoolsOptionsUri: must belong to a currently served ' + 'DevTools extension.', + ); + } + + final optionsFileUri = Uri.file(requestedOptionsPath); final extensionName = queryParams[ExtensionsApi.extensionNamePropertyName]!; + final extensionPackage = + queryParams[ExtensionsApi.extensionPackagePropertyName]; final activate = queryParams[ExtensionsApi.enabledStatePropertyName]; if (activate != null) { final newState = ServerApi._devToolsOptions.setExtensionEnabledState( - devtoolsOptionsUri: devtoolsOptionsFileUri, + devtoolsOptionsUri: optionsFileUri, extensionName: extensionName, + packageName: extensionPackage, enable: bool.parse(activate), ); return ServerApi._encodeResponse(newState.name, api: api); } - final activationState = - ServerApi._devToolsOptions.lookupExtensionEnabledState( - devtoolsOptionsUri: devtoolsOptionsFileUri, - extensionName: extensionName, - ); + final activationState = ServerApi._devToolsOptions + .lookupExtensionEnabledState( + devtoolsOptionsUri: optionsFileUri, + extensionName: extensionName, + packageName: extensionPackage, + ); return ServerApi._encodeResponse(activationState.name, api: api); } + + /// Resolves symbolic links on the parent directory of [uri] to return the + /// absolute path to `devtools_options.yaml`. + /// + /// Returns the actual file path to `devtools_options.yaml`, or `null` if + /// [uri] is not a local `file:` URI, its parent directory does not exist, or + /// the `devtools_options.yaml` file itself is a symbolic link. + static String? _resolveOptionsFilePath(Uri uri) { + if (uri.scheme != 'file' || uri.host.isNotEmpty) return null; + + final filePath = p.normalize(uri.toFilePath()); + if (p.basename(filePath) != devtoolsOptionsFileName) return null; + + final parentDir = Directory(p.dirname(filePath)); + if (!parentDir.existsSync()) return null; + + try { + final resolvedParent = p.normalize(parentDir.resolveSymbolicLinksSync()); + final resolvedFilePath = p.join(resolvedParent, devtoolsOptionsFileName); + if (FileSystemEntity.isLinkSync(resolvedFilePath)) return null; + return resolvedFilePath; + } on FileSystemException { + return null; + } + } } diff --git a/packages/devtools_shared/lib/src/server/handlers/_dtd.dart b/packages/devtools_shared/lib/src/server/handlers/_dtd.dart index b7f381b4b3e..f5e8c2f659a 100644 --- a/packages/devtools_shared/lib/src/server/handlers/_dtd.dart +++ b/packages/devtools_shared/lib/src/server/handlers/_dtd.dart @@ -6,16 +6,10 @@ part of '../server_api.dart'; /// A namespace for Dart Tooling Daemon (DTD) server request handlers. extension _DtdApiHandler on Never { - static shelf.Response handleGetDtdUri( - ServerApi api, - DtdInfo? dtd, - ) { - return ServerApi._encodeResponse( - { - // Always provide the exposed URI to callers of the web API. - DtdApi.uriPropertyName: dtd?.exposedUri.toString(), - }, - api: api, - ); + static shelf.Response handleGetDtdUri(ServerApi api, DtdInfo? dtd) { + return ServerApi._encodeResponse({ + // Always provide the exposed URI to callers of the web API. + DtdApi.uriPropertyName: dtd?.exposedUri.toString(), + }, api: api); } } diff --git a/packages/devtools_shared/lib/src/server/handlers/_vm_service.dart b/packages/devtools_shared/lib/src/server/handlers/_vm_service.dart index 2097bf9815a..94fd867e199 100644 --- a/packages/devtools_shared/lib/src/server/handlers/_vm_service.dart +++ b/packages/devtools_shared/lib/src/server/handlers/_vm_service.dart @@ -130,7 +130,8 @@ extension VmServiceHandler on Never { if (root == null) { return ( success: false, - message: 'No root library found for main isolate ' + message: + 'No root library found for main isolate ' '($vmServiceUriAsString).', uri: null, ); @@ -168,8 +169,8 @@ extension VmServiceHandler on Never { required bool connected, required ServerApi api, }) async { - final currentRoots = - (await dtd.getIDEWorkspaceRoots()).ideWorkspaceRoots.toSet(); + final currentRoots = (await dtd.getIDEWorkspaceRoots()).ideWorkspaceRoots + .toSet(); // Add or remove [rootFromVmService] depending on whether this was a // connect or disconnect notification. final newRoots = connected @@ -195,10 +196,9 @@ extension on VmService { final rootLib = mainIsolate.rootLib?.uri; if (rootLib == null) return null; - final fileUriAsString = - (await lookupResolvedPackageUris(mainIsolate.id!, [rootLib])) - .uris - ?.first; + final fileUriAsString = (await lookupResolvedPackageUris(mainIsolate.id!, [ + rootLib, + ])).uris?.first; return fileUriAsString; } @@ -218,8 +218,9 @@ extension on VmService { Isolate? mainIsolate; for (final isolate in isolateCandidates) { - final isFlutterIsolate = (isolate.isolate.extensionRPCs ?? []) - .any((ext) => ext.startsWith('ext.flutter')); + final isFlutterIsolate = (isolate.isolate.extensionRPCs ?? []).any( + (ext) => ext.startsWith('ext.flutter'), + ); if (isFlutterIsolate) { mainIsolate = isolate.isolate; break; @@ -237,8 +238,4 @@ extension on VmService { } @visibleForTesting -typedef DetectRootPackageResponse = ({ - bool success, - String? message, - Uri? uri, -}); +typedef DetectRootPackageResponse = ({bool success, String? message, Uri? uri}); diff --git a/packages/devtools_shared/lib/src/server/server_api.dart b/packages/devtools_shared/lib/src/server/server_api.dart index 6d27c66fda3..f284d7f281f 100644 --- a/packages/devtools_shared/lib/src/server/server_api.dart +++ b/packages/devtools_shared/lib/src/server/server_api.dart @@ -11,12 +11,14 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:dtd/dtd.dart'; import 'package:meta/meta.dart'; +import 'package:path/path.dart' as p; import 'package:shelf/shelf.dart' as shelf; import 'package:vm_service/vm_service.dart'; import '../common.dart'; import '../deeplink/deeplink_manager.dart'; import '../devtools_api.dart'; +import '../extensions/constants.dart'; import '../extensions/extension_enablement.dart'; import '../extensions/extension_manager.dart'; import '../service/service.dart'; @@ -30,11 +32,11 @@ part 'handlers/_app_size.dart'; part 'handlers/_deeplink.dart'; part 'handlers/_devtools_extensions.dart'; part 'handlers/_dtd.dart'; -part 'handlers/_vm_service.dart'; part 'handlers/_preferences.dart'; part 'handlers/_release_notes.dart'; part 'handlers/_storage.dart'; part 'handlers/_survey.dart'; +part 'handlers/_vm_service.dart'; /// The DevTools server API. /// @@ -43,22 +45,224 @@ class ServerApi { static const logsKey = 'logs'; static const errorKey = 'error'; + static const _postMethod = 'POST'; + static const _optionsMethod = 'OPTIONS'; + static const _originHeader = 'origin'; + static const _secFetchSiteHeader = 'sec-fetch-site'; + static const _allowedSecFetchSitesWithoutOrigin = {'same-origin', 'none'}; + static const _allowedSecFetchSitesWithOrigin = { + 'same-origin', + 'same-site', + 'cross-site', + 'none', + }; + /// Determines whether or not [request] is an API call. static bool canHandle(shelf.Request request) { return request.url.path.startsWith(apiPrefix); } + /// Validates [request] to mitigate CSRF, DNS rebinding, and cross-origin + /// attacks before dispatching to any [ServerApi] handler. + /// + /// When [disableOriginCheck] is `true`, `Host`, `Origin`, and + /// `Sec-Fetch-Site` validation is skipped, while HTTP method and + /// [devToolsRequestHeader] checks are still enforced. + /// + /// When [disableOriginCheck] is `false`, requests from loopback hosts/origins + /// and any URI in [allowedOrigins] are accepted. + /// + /// Returns a [shelf.Response] if the request is rejected, or `null` if the + /// request is valid. + static shelf.Response? _validateRequest( + shelf.Request request, + ServerApi api, { + required bool disableOriginCheck, + required List allowedOrigins, + }) { + final method = request.method.toUpperCase(); + if (method != _postMethod && method != _optionsMethod) { + return shelf.Response( + HttpStatus.methodNotAllowed, + body: 'Method Not Allowed: ${request.method}', + headers: const { + HttpHeaders.allowHeader: '$_postMethod, $_optionsMethod', + }, + ); + } + + if (method == _postMethod) { + final requestHeader = request.headers[devToolsRequestHeader]; + if (requestHeader == null || requestHeader.trim().isEmpty) { + return api.forbidden('Missing $devToolsRequestHeader header'); + } + } + + if (disableOriginCheck) { + return null; + } + + final hostHeaders = request.headersAll[HttpHeaders.hostHeader]; + if (hostHeaders != null && hostHeaders.length > 1) { + return api.forbidden('Invalid Host header'); + } + final hostHeader = request.headers[HttpHeaders.hostHeader]; + final host = hostHeader != null + ? _extractHostFromHeader(hostHeader) + : request.requestedUri.host; + if (host == null || + !_isAllowedHost(host, allowedOrigins) || + !_isAllowedHost(request.requestedUri.host, allowedOrigins)) { + return api.forbidden('Invalid Host header'); + } + + final originHeaders = request.headersAll[_originHeader]; + if (originHeaders != null && originHeaders.length > 1) { + return api.forbidden('Invalid Origin header'); + } + final originHeader = request.headers[_originHeader]; + if (originHeader != null) { + final originUri = Uri.tryParse(originHeader.trim()); + if (originUri == null || + !_isHttpOrHttps(originUri) || + originUri.userInfo.isNotEmpty || + originUri.host.isEmpty || + originUri.path.isNotEmpty || + originUri.hasQuery || + originUri.hasFragment || + !_isAllowedOrigin(originUri, allowedOrigins)) { + return api.forbidden('Invalid Origin header'); + } + } + + final secFetchSite = request.headers[_secFetchSiteHeader] + ?.trim() + .toLowerCase(); + if (secFetchSite != null) { + final allowedSites = originHeader != null + ? _allowedSecFetchSitesWithOrigin + : _allowedSecFetchSitesWithoutOrigin; + if (!allowedSites.contains(secFetchSite)) { + return api.forbidden('Cross-site requests are not allowed'); + } + } + + return null; + } + + /// Extracts the host component from an HTTP `Host` header value, or returns + /// `null` if the header is malformed. + static String? _extractHostFromHeader(String hostHeader) { + final trimmed = hostHeader.trim(); + if (trimmed.isEmpty) return null; + if (trimmed == '::1') return '::1'; + final parsed = Uri.tryParse('http://$trimmed'); + if (parsed == null || + parsed.userInfo.isNotEmpty || + parsed.path.isNotEmpty || + parsed.hasQuery || + parsed.hasFragment || + parsed.host.isEmpty) { + return null; + } + return parsed.host; + } + + /// Returns whether [uri] uses the `http` or `https` scheme. + static bool _isHttpOrHttps(Uri uri) { + return uri.isScheme('http') || uri.isScheme('https'); + } + + /// Returns whether [host] is a loopback host or matches the host of an entry + /// in [allowedOrigins]. + static bool _isAllowedHost(String host, List allowedOrigins) { + if (host.isEmpty) return false; + if (_isLoopbackHost(host)) return true; + + for (final allowedOrigin in allowedOrigins) { + if (allowedOrigin.host.isEmpty) continue; + if (_hostsMatch(allowedOrigin.host, host)) return true; + } + return false; + } + + /// Returns whether [originUri] is a loopback origin or matches the scheme, + /// host, and port of an HTTP(S) URI in [allowedOrigins]. + static bool _isAllowedOrigin(Uri originUri, List allowedOrigins) { + if (originUri.host.isEmpty) return false; + if (_isLoopbackHost(originUri.host)) return true; + + for (final allowedOrigin in allowedOrigins) { + if (!_isHttpOrHttps(allowedOrigin)) continue; + if (allowedOrigin.host.isEmpty) continue; + if (originUri.scheme != allowedOrigin.scheme) continue; + if (originUri.port != allowedOrigin.port) continue; + if (_hostsMatch(allowedOrigin.host, originUri.host)) return true; + } + return false; + } + + /// Returns whether [a] and [b] refer to the same hostname or IP address. + static bool _hostsMatch(String a, String b) { + if (a.toLowerCase() == b.toLowerCase()) return true; + final addrA = InternetAddress.tryParse(a); + final addrB = InternetAddress.tryParse(b); + if (addrA == null || addrB == null) return false; + return const ListEquality().equals(addrA.rawAddress, addrB.rawAddress); + } + + /// Returns whether [host] is a loopback host + /// (`localhost`, `127.x.x.x`, or `::1`). + static bool _isLoopbackHost(String host) { + if (host.toLowerCase() == 'localhost') return true; + final address = InternetAddress.tryParse(host); + return address?.isLoopback ?? false; + } + /// Handles all requests. /// /// To override an API call, pass in a subclass of [ServerApi]. + /// + /// Set [disableOriginCheck] to `true` to bypass `Host`, `Origin`, and + /// `Sec-Fetch-Site` validation while still enforcing HTTP method and + /// [devToolsRequestHeader] checks. + /// + /// Pass [allowedOrigins] to permit requests from specific non-loopback + /// origins (and their corresponding `Host` headers) in addition to loopback + /// hosts and origins. static FutureOr handle( shelf.Request request, { required ExtensionsManager extensionsManager, required DeeplinkManager deeplinkManager, ServerApi? api, DtdInfo? dtd, + bool disableOriginCheck = false, + List allowedOrigins = const [], }) { api ??= ServerApi(); + final validationErrorResponse = _validateRequest( + request, + api, + disableOriginCheck: disableOriginCheck, + allowedOrigins: allowedOrigins, + ); + if (validationErrorResponse != null) { + return validationErrorResponse; + } + if (request.method.toUpperCase() == _optionsMethod) { + // Respond to CORS preflight requests (e.g. from `debug_devtools_server` + // running on another loopback port). `Access-Control-Allow-Origin` is + // intentionally omitted here and controlled by the server's `--disable-cors` + // configuration. + return shelf.Response( + HttpStatus.noContent, + headers: const { + HttpHeaders.accessControlAllowMethodsHeader: + '$_postMethod, $_optionsMethod', + HttpHeaders.accessControlAllowHeadersHeader: devToolsRequestHeader, + }, + ); + } final queryParams = request.requestedUri.queryParameters; switch (request.url.path) { case apiNotifyForVmServiceConnection: @@ -68,51 +272,13 @@ class ServerApi { dtd, ); - // TODO(kenz): remove legacy analytics once the unified analytics rollout - // is complete and verified for robustness (est. Fall 2025). - // ----- Flutter Tool GA store. ----- - case apiGetFlutterGAEnabled: - // Is Analytics collection enabled? - return _encodeResponse( - LocalFileSystem.flutterStoreExists() - ? _flutterStore.gaEnabled - : false, - api: api, - ); case apiGetFlutterGAClientId: - // Flutter Tool GA clientId - ONLY get Flutter's clientId if enabled is - // true. return _encodeResponse( - LocalFileSystem.flutterStoreExists() - ? _flutterStore.flutterClientId - : '', + fileSystem.flutterStoreExists ? _flutterStore.flutterClientId : '', api: api, ); - // ----- DevTools GA store. ----- - - case apiResetDevTools: - _devToolsStore.reset(); - return _encodeResponse(true, api: api); - case apiGetDevToolsFirstRun: - // Has DevTools been run first time? To bring up analytics dialog. - final isFirstRun = _devToolsStore.isFirstRun; - return _encodeResponse(isFirstRun, api: api); - case apiGetDevToolsEnabled: - // Is DevTools Analytics collection enabled? - final isEnabled = _devToolsStore.analyticsEnabled; - return _encodeResponse(isEnabled, api: api); - case apiSetDevToolsEnabled: - // Enable or disable DevTools analytics collection. - if (queryParams.containsKey(devToolsEnabledPropertyName)) { - final analyticsEnabled = - json.decode(queryParams[devToolsEnabledPropertyName]!); - - _devToolsStore.analyticsEnabled = analyticsEnabled; - } - return _encodeResponse(_devToolsStore.analyticsEnabled, api: api); - // ----- Preferences api. ----- case PreferencesApi.getPreferenceValue: return _PreferencesApiHandler.getPreferenceValue( @@ -182,6 +348,7 @@ class ServerApi { return _ExtensionsApiHandler.handleExtensionEnabledState( api, queryParams, + extensionsManager, ); // ----- deeplink api. ----- @@ -258,16 +425,16 @@ class ServerApi { : null; } - /// Accessing DevTools store file e.g., ~/.flutter-devtools/.devtools + /// Accessing DevTools store file e.g., `~/.flutter-devtools/.devtools`. static final _devToolsStore = DevToolsUsage(); - /// Accessing Flutter store file e.g., ~/.flutter + /// Accessing Flutter store file e.g., `~/.flutter`. static final _flutterStore = FlutterStore(); static DevToolsUsage get devToolsPreferences => _devToolsStore; /// Provides read and write access to DevTools options files - /// (e.g. path/to/app/root/devtools_options.yaml). + /// (e.g. `path/to/app/root/devtools_options.yaml`). static final _devToolsOptions = DevToolsOptions(); /// Logs a page view in the DevTools server. @@ -304,10 +471,7 @@ class ServerApi { return shelf.Response( HttpStatus.internalServerError, body: error != null || logs != null - ? jsonEncode({ - if (error != null) errorKey: error, - if (logs != null) logsKey: logs, - }) + ? jsonEncode({errorKey: ?error, logsKey: ?logs}) : null, ); } diff --git a/packages/devtools_shared/lib/src/service/service.dart b/packages/devtools_shared/lib/src/service/service.dart index 91db01f0514..00fadb001e5 100644 --- a/packages/devtools_shared/lib/src/service/service.dart +++ b/packages/devtools_shared/lib/src/service/service.dart @@ -69,13 +69,10 @@ Future _connectWithWebSocket({ return service; } unawaited( - ws.sink.done.then( - (_) async { - finishedCompleter.complete(); - await service.dispose(); - }, - onError: onError, - ), + ws.sink.done.then((_) async { + finishedCompleter.complete(); + await service.dispose(); + }, onError: onError), ); return service; } @@ -113,15 +110,19 @@ Future connect({ return service; } - unawaited(connectHelper().then( - (service) => connectedCompleter.safeComplete(service), - onError: onError, - )); - unawaited(finishedCompleter.future.then((_) { - // It is an error if we finish before we are connected. - if (!connectedCompleter.isCompleted) { - onError(null); - } - })); + unawaited( + connectHelper().then( + (service) => connectedCompleter.safeComplete(service), + onError: onError, + ), + ); + unawaited( + finishedCompleter.future.then((_) { + // It is an error if we finish before we are connected. + if (!connectedCompleter.isCompleted) { + onError(null); + } + }), + ); return connectedCompleter.future; } diff --git a/packages/devtools_shared/lib/src/service_utils.dart b/packages/devtools_shared/lib/src/service_utils.dart index 8d8de24ebf7..5abe740b0f2 100644 --- a/packages/devtools_shared/lib/src/service_utils.dart +++ b/packages/devtools_shared/lib/src/service_utils.dart @@ -3,10 +3,7 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. class RegisteredService { - const RegisteredService({ - required this.service, - required this.title, - }); + const RegisteredService({required this.service, required this.title}); final String service; final String title; diff --git a/packages/devtools_shared/lib/src/test/chrome.dart b/packages/devtools_shared/lib/src/test/chrome.dart index ddc275b0890..294385baaf5 100644 --- a/packages/devtools_shared/lib/src/test/chrome.dart +++ b/packages/devtools_shared/lib/src/test/chrome.dart @@ -16,7 +16,8 @@ import 'package:path/path.dart' as path; import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' hide ChromeTab; import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' - as wip show ChromeTab; + as wip + show ChromeTab; import 'io_utils.dart'; @@ -94,10 +95,7 @@ class Chrome { '--remote-debugging-port=$debugPort', ]; if (useChromeHeadless && headlessModeIsSupported) { - args.addAll([ - '--headless', - '--disable-gpu', - ]); + args.addAll(['--headless', '--disable-gpu']); } if (url != null) { args.add(url); @@ -153,12 +151,9 @@ class ChromeProcess { required bool Function(wip.ChromeTab) tabFound, required Duration timeout, }) async { - final wipTab = await connection.getTab( - (wip.ChromeTab tab) { - return tabFound(tab); - }, - retryFor: timeout, - ); + final wipTab = await connection.getTab((wip.ChromeTab tab) { + return tabFound(tab); + }, retryFor: timeout); unawaited( process.exitCode.then((_) { @@ -214,8 +209,9 @@ class ChromeTab { }); unawaited( - _exceptionThrownController - .addStream(wipConnection.runtime.onExceptionThrown), + _exceptionThrownController.addStream( + wipConnection.runtime.onExceptionThrown, + ), ); unawaited(wipConnection.page.enable()); diff --git a/packages/devtools_shared/lib/src/test/chrome_driver.dart b/packages/devtools_shared/lib/src/test/chrome_driver.dart index ab84cba8eb1..0a5cca1d7f6 100644 --- a/packages/devtools_shared/lib/src/test/chrome_driver.dart +++ b/packages/devtools_shared/lib/src/test/chrome_driver.dart @@ -55,7 +55,7 @@ class ChromeDriver with IOMixin { Future _waitForPortOpen( int port, { - Duration timeout = const Duration(seconds: 10), + Duration timeout = const Duration(seconds: 30), }) async { final stopwatch = Stopwatch()..start(); diff --git a/packages/devtools_shared/lib/src/test/cli_test_driver.dart b/packages/devtools_shared/lib/src/test/cli_test_driver.dart index 5808cf40e52..1530e8057c3 100644 --- a/packages/devtools_shared/lib/src/test/cli_test_driver.dart +++ b/packages/devtools_shared/lib/src/test/cli_test_driver.dart @@ -28,8 +28,9 @@ class AppFixture { _onAppStarted = lines.first; unawaited(serviceConnection.streamListen(EventStreams.kIsolate)); - _isolateEventStreamSubscription = - serviceConnection.onIsolateEvent.listen((Event event) { + _isolateEventStreamSubscription = serviceConnection.onIsolateEvent.listen(( + Event event, + ) { if (event.kind == EventKind.kIsolateExit) { isolates.remove(event.isolate); } else { @@ -86,27 +87,30 @@ class CliAppFixture extends AppFixture { List isolates, Future Function()? onTeardown, ) : super._( - process, - lines, - serviceUri, - serviceConnection, - isolates, - onTeardown, - ); + process, + lines, + serviceUri, + serviceConnection, + isolates, + onTeardown, + ); final String appScriptPath; static Future create(String appScriptPath) async { - final dartVmServicePrefix = - RegExp('(Observatory|The Dart VM service is) listening on '); - - final process = await Process.start( - Platform.resolvedExecutable, - ['--observe=0', '--pause-isolates-on-start', appScriptPath], + final dartVmServicePrefix = RegExp( + '(Observatory|The Dart VM service is) listening on ', ); - final Stream lines = - process.stdout.transform(utf8.decoder).transform(const LineSplitter()); + final process = await Process.start(Platform.resolvedExecutable, [ + '--observe=0', + '--pause-isolates-on-start', + appScriptPath, + ]); + + final Stream lines = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()); final lineController = StreamController.broadcast(); final completer = Completer(); @@ -166,16 +170,18 @@ class CliAppFixture extends AppFixture { const skipId = 'skip'; final vm = await serviceConnection.getVM(); final isolates = await vm.isolates! - .map((ref) => serviceConnection - .getIsolate(ref.id!) - // Calling getIsolate() can sometimes return a collected sentinel - // for an isolate that hasn't started yet. We can just ignore these - // as on the next trip around the Isolate will be returned. - // https://github.com/dart-lang/sdk/issues/33747 - .catchError((Object error) { - print('getIsolate(${ref.id}) failed, skipping\n$error'); - return Future.value(Isolate(id: skipId)); - })) + .map( + (ref) => + serviceConnection.getIsolate(ref.id!) + // Calling getIsolate() can sometimes return a collected sentinel + // for an isolate that hasn't started yet. We can just ignore these + // as on the next trip around the Isolate will be returned. + // https://github.com/dart-lang/sdk/issues/33747 + .catchError((Object error) { + print('getIsolate(${ref.id}) failed, skipping\n$error'); + return Future.value(Isolate(id: skipId)); + }), + ) .wait; foundIsolate = isolates.firstWhereOrNull( (isolate) => diff --git a/packages/devtools_shared/lib/src/test/integration_test_runner.dart b/packages/devtools_shared/lib/src/test/integration_test_runner.dart index de88474efc3..0733851e165 100644 --- a/packages/devtools_shared/lib/src/test/integration_test_runner.dart +++ b/packages/devtools_shared/lib/src/test/integration_test_runner.dart @@ -27,6 +27,7 @@ class IntegrationTestRunner with IOMixin { String testTarget, { required String testDriver, bool headless = false, + bool useWasm = false, List dartDefineArgs = const [], bool debugLogging = false, }) async { @@ -51,7 +52,10 @@ class IntegrationTestRunner with IOMixin { headless ? 'web-server' : 'chrome', // --disable-gpu speeds up tests that use ChromeDriver when run on // GitHub Actions. See https://github.com/flutter/devtools/issues/8301. - '--web-browser-flag=--disable-gpu', + // However, it also breaks the tests when running with the wasm flag, + // because it prevents capturing browser logs. See: + // https://github.com/flutter/devtools/issues/9727 + useWasm ? '--wasm' : '--web-browser-flag=--disable-gpu', if (headless) ...[ // Flags to avoid breakage with chromedriver 138. See // https://github.com/flutter/devtools/issues/9357. @@ -63,7 +67,9 @@ class IntegrationTestRunner with IOMixin { debugLog('> flutter ${flutterDriveArgs.join(' ')}'); final process = await Process.start( - Platform.isWindows ? 'flutter.bat' : 'flutter', flutterDriveArgs); + Platform.isWindows ? 'flutter.bat' : 'flutter', + flutterDriveArgs, + ); bool stdOutWriteInProgress = false; bool stdErrWriteInProgress = false; @@ -115,12 +121,15 @@ class IntegrationTestRunner with IOMixin { ); bool testTimedOut = false; - await process.exitCode.timeout(const Duration(minutes: 8), onTimeout: () { - testTimedOut = true; - // TODO(srawlins): Refactor the retry situation to catch a - // TimeoutException, and not recursively call `runTest`. - return -1; - }); + await process.exitCode.timeout( + const Duration(minutes: 8), + onTimeout: () { + testTimedOut = true; + // TODO(srawlins): Refactor the retry situation to catch a + // TimeoutException, and not recursively call `runTest`. + return -1; + }, + ); debugLog( 'shutting down processes because ' @@ -168,7 +177,7 @@ class _IntegrationTestResult { final result = json[resultKey] == 'true'; final failureDetails = (json[failureDetailsKey] as List).cast().firstOrNull ?? - '{}'; + '{}'; final failureDetailsMap = jsonDecode(failureDetails) as Map; final methodName = failureDetailsMap[methodNameKey] as String?; @@ -200,8 +209,8 @@ class IntegrationTestRunnerArgs { List args, { bool verifyValidTarget = true, void Function(ArgParser)? addExtraArgs, - }) : rawArgs = args, - argResults = buildArgParser(addExtraArgs: addExtraArgs).parse(args) { + }) : rawArgs = args, + argResults = buildArgParser(addExtraArgs: addExtraArgs).parse(args) { if (verifyValidTarget) { final target = argResults[testTargetArg]; assert( @@ -223,6 +232,9 @@ class IntegrationTestRunnerArgs { /// instead of 'chrome'. bool get headless => argResults.flag(_headlessArg); + /// Whether this integration test should be run against dart2wasm-compiled DevTools. + bool get useWasm => argResults.flag(_wasmArg); + /// Sharding information for this test run. ({int shardNumber, int totalShards})? get shard { final shardValue = argResults.option(_shardArg); @@ -250,18 +262,13 @@ class IntegrationTestRunnerArgs { static const _helpArg = 'help'; static const testTargetArg = 'target'; static const _headlessArg = 'headless'; + static const _wasmArg = 'wasm'; static const _shardArg = 'shard'; /// Builds an arg parser for DevTools integration tests. - static ArgParser buildArgParser({ - void Function(ArgParser)? addExtraArgs, - }) { + static ArgParser buildArgParser({void Function(ArgParser)? addExtraArgs}) { final argParser = ArgParser() - ..addFlag( - _helpArg, - abbr: 'h', - help: 'Prints help output.', - ) + ..addFlag(_helpArg, abbr: 'h', help: 'Prints help output.') ..addOption( testTargetArg, abbr: 't', @@ -277,10 +284,16 @@ class IntegrationTestRunnerArgs { 'the \'chrome\' device. For headless test runs, you will not be ' 'able to see the integration test run visually in a Chrome browser.', ) + ..addFlag( + _wasmArg, + negatable: false, + help: 'Runs the integration test against dart2wasm-compiled DevTools.', + ) ..addOption( _shardArg, valueHelp: '1/3', - help: 'The shard number for this run out of the total number of shards ' + help: + 'The shard number for this run out of the total number of shards ' '(e.g. 1/3)', ); addExtraArgs?.call(argParser); diff --git a/packages/devtools_shared/lib/src/test/io_utils.dart b/packages/devtools_shared/lib/src/test/io_utils.dart index eb1b5c8353d..3c6e60b6e28 100644 --- a/packages/devtools_shared/lib/src/test/io_utils.dart +++ b/packages/devtools_shared/lib/src/test/io_utils.dart @@ -65,10 +65,7 @@ mixin IOMixin { Future cancelAllStreamSubscriptions() async { await streamSubscriptions.map((s) => s.cancel()).wait; - await [ - stdoutController.close(), - stderrController.close(), - ].wait; + await [stdoutController.close(), stderrController.close()].wait; streamSubscriptions.clear(); } @@ -98,10 +95,7 @@ mixin IOMixin { ); } - Future killForcefully( - Process process, { - bool debugLogging = false, - }) { + Future killForcefully(Process process, {bool debugLogging = false}) { final processId = process.pid; // Use sigint here instead of sigkill. See // https://github.com/flutter/flutter/issues/117415. diff --git a/packages/devtools_shared/lib/src/utils/file_utils.dart b/packages/devtools_shared/lib/src/utils/file_utils.dart index 553e6e65507..e9137974545 100644 --- a/packages/devtools_shared/lib/src/utils/file_utils.dart +++ b/packages/devtools_shared/lib/src/utils/file_utils.dart @@ -5,6 +5,7 @@ import 'package:dtd/dtd.dart'; import 'package:meta/meta.dart'; +// ignore: unused-code, false positive. const _fileUriPrefix = 'file://'; /// Attempts to detect the package root of [fileUriString], which is expected to @@ -78,8 +79,9 @@ Future packageRootFromFileUriString( // If we do not have access to DTD or if we failed to detect the package root // by walking the directory structure, default to using a regexp heuristic. - final directoryRegExp = - RegExp(r'\/(lib|bin|integration_test|test|benchmark|example)\/.+\.dart'); + final directoryRegExp = RegExp( + r'\/(lib|bin|integration_test|test|benchmark|example)\/.+\.dart', + ); final directoryIndex = fileUriString.lastIndexOf(directoryRegExp); if (directoryIndex != -1) { fileUriString = fileUriString.substring(0, directoryIndex); diff --git a/packages/devtools_shared/lib/src/utils/retry.dart b/packages/devtools_shared/lib/src/utils/retry.dart index 492596215f0..6ce76d4ff37 100644 --- a/packages/devtools_shared/lib/src/utils/retry.dart +++ b/packages/devtools_shared/lib/src/utils/retry.dart @@ -22,9 +22,11 @@ Future runWithRetry({ FutureOr Function()? stopCondition, FutureOr Function(int attempt)? onRetry, }) async { - for (var attempt = 1; - attempt <= maxRetries && (await stopCondition?.call() != true); - attempt++) { + for ( + var attempt = 1; + attempt <= maxRetries && (await stopCondition?.call() != true); + attempt++ + ) { try { await callback(); break; diff --git a/packages/devtools_shared/lib/src/utils/semantic_version.dart b/packages/devtools_shared/lib/src/utils/semantic_version.dart index 3e8046f5f5a..d4cfe221f75 100644 --- a/packages/devtools_shared/lib/src/utils/semantic_version.dart +++ b/packages/devtools_shared/lib/src/utils/semantic_version.dart @@ -25,22 +25,26 @@ class SemanticVersion with CompareMixin { final semVersion = splitOnDash.first; final versionParts = semVersion.split('.'); - final major = - versionParts.isNotEmpty ? int.tryParse(versionParts.first) ?? 0 : 0; - final minor = - versionParts.length > 1 ? int.tryParse(versionParts[1]) ?? 0 : 0; - final patch = - versionParts.length > 2 ? int.tryParse(versionParts[2]) ?? 0 : 0; + final major = versionParts.isNotEmpty + ? int.tryParse(versionParts.first) ?? 0 + : 0; + final minor = versionParts.length > 1 + ? int.tryParse(versionParts[1]) ?? 0 + : 0; + final patch = versionParts.length > 2 + ? int.tryParse(versionParts[2]) ?? 0 + : 0; int? preReleaseMajor; int? preReleaseMinor; if (splitOnDash.length == 2) { final preRelease = splitOnDash.last; - final preReleaseParts = preRelease - .split('.') - .map((part) => RegExp(r'\d+').stringMatch(part) ?? '') - .toList() - ..removeWhere((part) => part.isEmpty); + final preReleaseParts = + preRelease + .split('.') + .map((part) => RegExp(r'\d+').stringMatch(part) ?? '') + .toList() + ..removeWhere((part) => part.isEmpty); preReleaseMajor = preReleaseParts.isNotEmpty ? int.tryParse(preReleaseParts.first) ?? 0 : 0; @@ -83,11 +87,7 @@ class SemanticVersion with CompareMixin { if (downgradePatch) { patch = math.max(0, patch - 1); } - return SemanticVersion( - major: major, - minor: minor, - patch: patch, - ); + return SemanticVersion(major: major, minor: minor, patch: patch); } int major; @@ -134,8 +134,10 @@ class SemanticVersion with CompareMixin { /// /// e.g. 2.6.0-12.0.pre-443 -> 2.6.0-12.0.pre.443 static String _canonicalizeVersion(String semanticVersion) => - semanticVersion.replaceFirstMapped(_nonStandardPreReleaseVersionRegex, - (match) => '${match[1]}.${match[2]}'); + semanticVersion.replaceFirstMapped( + _nonStandardPreReleaseVersionRegex, + (match) => '${match[1]}.${match[2]}', + ); bool get isPreRelease => preReleaseMajor != null || preReleaseMinor != null; diff --git a/packages/devtools_shared/pubspec.yaml b/packages/devtools_shared/pubspec.yaml index d9b56803d77..c4ff623fdb4 100644 --- a/packages/devtools_shared/pubspec.yaml +++ b/packages/devtools_shared/pubspec.yaml @@ -4,12 +4,12 @@ name: devtools_shared description: Package of shared Dart structures between devtools_app, dds, and other tools. -version: 12.1.0 +version: 14.0.1 repository: https://github.com/flutter/devtools/tree/master/packages/devtools_shared environment: - sdk: ">=3.6.0 <4.0.0" + sdk: ^3.11.0 resolution: workspace @@ -18,6 +18,7 @@ dependencies: collection: ^1.15.0 dtd: ^4.0.0 extension_discovery: ^2.1.0 + file: ^7.0.0 meta: ^1.9.1 path: ^1.8.0 shelf: ^1.1.0 @@ -26,7 +27,9 @@ dependencies: web_socket_channel: '>=2.4.0 <4.0.0' webkit_inspection_protocol: ">=0.5.0 <2.0.0" yaml: ^3.1.2 + unified_analytics: ^8.4.0 yaml_edit: ^2.1.1 dev_dependencies: + flutter_lints: ^6.0.0 test: ^1.21.2 diff --git a/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart b/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart index ce77240debb..7d22907a91b 100644 --- a/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart +++ b/packages/devtools_shared/test/deeplink/deeplink_manager_test.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:devtools_shared/src/deeplink/deeplink_manager.dart'; import 'package:test/test.dart'; +import 'package:unified_analytics/unified_analytics.dart'; void main() { group('DeeplinkManager', () { @@ -14,18 +15,22 @@ void main() { late Directory tmpDir; setUp(() { + DeeplinkManager.clearBuildOptionsCache(); manager = StubbedDeeplinkManager(); tmpDir = Directory.current.createTempSync(); }); tearDown(() { - expect( - manager.expectedCommands.isEmpty, - true, - reason: - 'stub does not receive expected command ${manager.expectedCommands}', - ); - tmpDir.deleteSync(recursive: true); + try { + expect( + manager.expectedCommands.isEmpty, + true, + reason: + 'stub does not receive expected command ${manager.expectedCommands}', + ); + } finally { + tmpDir.deleteSync(recursive: true); + } }); test('getBuildVariants calls flutter command correctly', () async { @@ -33,21 +38,12 @@ void main() { manager.expectedCommands.add( TestCommand( executable: manager.mockedFlutterBinary, - arguments: [ - 'analyze', - '--android', - '--list-build-variants', - projectRoot, - ], - result: ProcessResult( - 0, - 0, - r''' + arguments: ['analyze', '--android', '--list-build-variants'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, r''' Running Gradle task 'printBuildVariants'... 10.4s ["debug","release","profile"] - ''', - '', - ), + ''', ''), ), ); final response = await manager.getAndroidBuildVariants( @@ -60,6 +56,37 @@ Running Gradle task 'printBuildVariants'... 10.4s ); }); + test( + 'getBuildVariants propagates parent IDE and analytics opt-out status', + () async { + const projectRoot = '/abc'; + manager.expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: [ + 'analyze', + '--android', + '--list-build-variants', + ], + workingDirectory: projectRoot, + ide: 'VS-Code', + suppressAnalytics: true, + result: ProcessResult(0, 0, r''' +Running Gradle task 'printBuildVariants'... 10.4s +["debug"] + ''', ''), + ), + ); + final response = await manager.getAndroidBuildVariants( + rootPath: projectRoot, + ide: 'VS-Code', + suppressAnalytics: true, + ); + expect(response[DeeplinkManager.kErrorField], isNull); + expect(response[DeeplinkManager.kOutputJsonField], '["debug"]'); + }, + ); + test( 'getBuildVariants return internal server error if command failed', () async { @@ -71,14 +98,9 @@ Running Gradle task 'printBuildVariants'... 10.4s 'analyze', '--android', '--list-build-variants', - projectRoot, ], - result: ProcessResult( - 0, - 1, - '', - 'unknown error', - ), + workingDirectory: projectRoot, + result: ProcessResult(0, 1, '', 'unknown error'), ), ); final response = await manager.getAndroidBuildVariants( @@ -97,7 +119,16 @@ Running Gradle task 'printBuildVariants'... 10.4s const buildVariant = 'someVariant'; final jsonFile = File('${tmpDir.path}/some-output.json'); jsonFile.writeAsStringSync(json); - manager.expectedCommands.add( + manager.expectedCommands.addAll([ + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: ['analyze', '--android', '--list-build-variants'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, ''' +Running Gradle task 'printBuildVariants'... 10.4s +["$buildVariant"] + ''', ''), + ), TestCommand( executable: manager.mockedFlutterBinary, arguments: [ @@ -105,30 +136,76 @@ Running Gradle task 'printBuildVariants'... 10.4s '--android', '--output-app-link-settings', '--build-variant=$buildVariant', - projectRoot, ], - result: ProcessResult( - 0, - 0, - ''' + workingDirectory: projectRoot, + result: ProcessResult(0, 0, ''' Running Gradle task 'printBuildVariants'... 10.4s result saved in ${jsonFile.absolute.path} - ''', - '', - ), + ''', ''), ), - ); + ]); + await manager.getAndroidBuildVariants(rootPath: projectRoot); final response = await manager.getAndroidAppLinkSettings( buildVariant: buildVariant, rootPath: projectRoot, ); expect(response[DeeplinkManager.kErrorField], isNull); - expect( - response[DeeplinkManager.kOutputJsonField], - json, - ); + expect(response[DeeplinkManager.kOutputJsonField], json); }); + test( + 'getAndroidAppLinkSettings reuses cached variants across instances', + () async { + const projectRoot = '/abc'; + const json = '"some json"'; + const buildVariant = 'release'; + final jsonFile = File('${tmpDir.path}/some-output.json'); + jsonFile.writeAsStringSync(json); + + manager.expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: [ + 'analyze', + '--android', + '--list-build-variants', + ], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, ''' +Running Gradle task 'printBuildVariants'... 10.4s +["debug","release","profile"] + ''', ''), + ), + ); + await manager.getAndroidBuildVariants(rootPath: projectRoot); + + final secondManager = StubbedDeeplinkManager() + ..expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: [ + 'analyze', + '--android', + '--output-app-link-settings', + '--build-variant=$buildVariant', + ], + workingDirectory: '$projectRoot/', + result: ProcessResult(0, 0, ''' +Running Gradle task 'printBuildVariants'... 10.4s +result saved in ${jsonFile.absolute.path} + ''', ''), + ), + ); + final response = await secondManager.getAndroidAppLinkSettings( + buildVariant: buildVariant, + rootPath: '$projectRoot/', + ); + expect(secondManager.expectedCommands, isEmpty); + expect(response[DeeplinkManager.kErrorField], isNull); + expect(response[DeeplinkManager.kOutputJsonField], json); + }, + ); + test( 'getIosUniversalLinkSettings calls flutter command correctly', () async { @@ -138,7 +215,15 @@ result saved in ${jsonFile.absolute.path} const target = 'someTarget'; final jsonFile = File('${tmpDir.path}/some-output.json'); jsonFile.writeAsStringSync(json); - manager.expectedCommands.add( + manager.expectedCommands.addAll([ + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: ['analyze', '--ios', '--list-build-options'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, ''' +{"configurations":["$configuration"],"targets":["$target"]} + ''', ''), + ), TestCommand( executable: manager.mockedFlutterBinary, arguments: [ @@ -147,29 +232,22 @@ result saved in ${jsonFile.absolute.path} '--output-universal-link-settings', '--configuration=$configuration', '--target=$target', - projectRoot, ], - result: ProcessResult( - 0, - 0, - ''' + workingDirectory: projectRoot, + result: ProcessResult(0, 0, ''' Running Gradle task 'printBuildVariants'... 10.4s result saved in ${jsonFile.absolute.path} - ''', - '', - ), + ''', ''), ), - ); + ]); + await manager.getIosBuildOptions(rootPath: projectRoot); final response = await manager.getIosUniversalLinkSettings( configuration: configuration, target: target, rootPath: projectRoot, ); expect(response[DeeplinkManager.kErrorField], isNull); - expect( - response[DeeplinkManager.kOutputJsonField], - json, - ); + expect(response[DeeplinkManager.kOutputJsonField], json); }, ); @@ -178,31 +256,273 @@ result saved in ${jsonFile.absolute.path} manager.expectedCommands.add( TestCommand( executable: manager.mockedFlutterBinary, - arguments: [ - 'analyze', - '--ios', - '--list-build-options', - projectRoot, - ], - result: ProcessResult( - 0, - 0, - r''' + arguments: ['analyze', '--ios', '--list-build-options'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, r''' {"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]} - ''', - '', - ), + ''', ''), ), ); - final response = await manager.getIosBuildOptions( - rootPath: projectRoot, - ); + final response = await manager.getIosBuildOptions(rootPath: projectRoot); expect(response[DeeplinkManager.kErrorField], isNull); expect( response[DeeplinkManager.kOutputJsonField], '{"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]}', ); }); + + test( + 'returns error when project build options have not been parsed yet', + () async { + const projectRoot = '/unparsed_project'; + final androidResponse = await manager.getAndroidAppLinkSettings( + rootPath: projectRoot, + buildVariant: 'debug', + ); + expect( + androidResponse[DeeplinkManager.kErrorField], + allOf( + contains('have not been parsed yet'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + + final iosResponse = await manager.getIosUniversalLinkSettings( + rootPath: projectRoot, + configuration: 'Debug', + target: 'Runner', + ); + expect( + iosResponse[DeeplinkManager.kErrorField], + allOf( + contains('have not been parsed yet'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + }, + ); + + test( + 'rejects buildVariant not in available Android build variants', + () async { + const projectRoot = '/abc'; + manager.expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: [ + 'analyze', + '--android', + '--list-build-variants', + ], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, r''' +Running Gradle task 'printBuildVariants'... 10.4s +["debug","release","profile"] + ''', ''), + ), + ); + await manager.getAndroidBuildVariants(rootPath: projectRoot); + + for (final invalidVariant in [ + 'nonExistentVariant', + '"& calc & echo pwned > MARKER.txt &"', + 'debug --help', + ]) { + final response = await manager.getAndroidAppLinkSettings( + rootPath: projectRoot, + buildVariant: invalidVariant, + ); + expect( + response[DeeplinkManager.kErrorField], + allOf( + contains('Unknown Android build variant'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + } + }, + ); + + test( + 'rejects configuration or target not in available iOS build options', + () async { + const projectRoot = '/abc'; + manager.expectedCommands.add( + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: ['analyze', '--ios', '--list-build-options'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, r''' +{"configurations":["Debug","Release","Profile"],"targets":["Runner","RunnerTests"]} + ''', ''), + ), + ); + await manager.getIosBuildOptions(rootPath: projectRoot); + + final badConfigResponse = await manager.getIosUniversalLinkSettings( + rootPath: projectRoot, + configuration: '"& calc & echo pwned > MARKER.txt &"', + target: 'Runner', + ); + expect( + badConfigResponse[DeeplinkManager.kErrorField], + allOf( + contains('Unknown iOS build configuration'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + + final badTargetResponse = await manager.getIosUniversalLinkSettings( + rootPath: projectRoot, + configuration: 'Debug', + target: 'Runner --help', + ); + expect( + badTargetResponse[DeeplinkManager.kErrorField], + allOf( + contains('Unknown iOS build configuration'), + contains('https://github.com/flutter/devtools/issues'), + ), + ); + }, + ); + + test('returns error when workingDirectory does not exist', () async { + final realManager = DeeplinkManager(); + final response = await realManager.getAndroidBuildVariants( + rootPath: '${tmpDir.path}/non_existent_dir_"&calc', + ); + expect(response[DeeplinkManager.kErrorField], isNotNull); + }); + + test( + 'returns error when parsing Android build variants or iOS build options fails', + () async { + const projectRoot = '/abc'; + manager.expectedCommands.addAll([ + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: [ + 'analyze', + '--android', + '--list-build-variants', + ], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, '[invalid json]', ''), + ), + TestCommand( + executable: manager.mockedFlutterBinary, + arguments: ['analyze', '--ios', '--list-build-options'], + workingDirectory: projectRoot, + result: ProcessResult(0, 0, '{invalid json}', ''), + ), + ]); + + final androidResponse = await manager.getAndroidBuildVariants( + rootPath: projectRoot, + ); + expect( + androidResponse[DeeplinkManager.kErrorField], + contains('Failed to parse Android build variants'), + ); + + final iosResponse = await manager.getIosBuildOptions( + rootPath: projectRoot, + ); + expect( + iosResponse[DeeplinkManager.kErrorField], + contains('Failed to parse iOS build options'), + ); + }, + ); + }); + + group('DeeplinkManager.buildEnvironment', () { + final manager = DeeplinkManager(); + // What `getEnvironment()` passes on when DevTools starts a sub-tool and no + // IDE override applies: this process's root tool, or `devtools`. + final inheritedRootTool = getEnvironment( + currentTool: DashTool.devtools, + )[DashEnvVar.tool.name]; + + test('sets DASH__TOOL to the IDE that opened DevTools', () { + for (final MapEntry(key: ide, value: tool) in { + 'VS-Code': DashTool.vscodePlugins, + 'vscode': DashTool.vscodePlugins, + 'IntelliJ-IDEA': DashTool.intellijPlugins, + 'intellijplugins': DashTool.intellijPlugins, + 'Android-Studio': DashTool.androidStudioPlugins, + 'androidstudio': DashTool.androidStudioPlugins, + }.entries) { + final environment = manager.buildEnvironment( + ide: ide, + suppressAnalytics: false, + ); + expect(environment[DashEnvVar.tool.name], tool.label, reason: ide); + } + }); + + test('uses the inherited root tool when there is no known IDE', () { + expect(inheritedRootTool, isNotNull); + for (final ide in [null, 'unknown-ide']) { + final environment = manager.buildEnvironment( + ide: ide, + suppressAnalytics: false, + ); + expect(environment[DashEnvVar.tool.name], inheritedRootTool); + } + }); + + test('never passes on a shared component or an invalid label', () { + final environment = manager.buildEnvironment( + ide: 'VS-Code', + suppressAnalytics: false, + ); + expect( + () => DashTool.fromLabel(environment[DashEnvVar.tool.name]!), + returnsNormally, + ); + expect( + DashTool.fromLabel(environment[DashEnvVar.tool.name]!).canBeRootTool, + isTrue, + ); + }); + + test('suppresses analytics when requested, even if inherited is false', () { + // `dart test` runs under `dart`, which sets + // DASH__SUPPRESS_ANALYTICS=false for its child processes. + expect( + manager.buildEnvironment( + ide: null, + suppressAnalytics: true, + )[DashEnvVar.suppressAnalytics.name], + 'true', + ); + }); + + test('otherwise passes on the inherited suppression value', () { + expect( + manager.buildEnvironment( + ide: null, + suppressAnalytics: false, + )[DashEnvVar.suppressAnalytics.name], + areAnalyticsSuppressed().toString(), + ); + }); + + test('keeps the rest of the process environment', () { + final environment = manager.buildEnvironment( + ide: null, + suppressAnalytics: false, + ); + for (final MapEntry(:key, :value) in Platform.environment.entries) { + if (key == DashEnvVar.tool.name || + key == DashEnvVar.suppressAnalytics.name) { + continue; + } + expect(environment[key], value, reason: key); + } + }); }); } @@ -217,15 +537,23 @@ class StubbedDeeplinkManager extends DeeplinkManager { Future runProcess( String executable, { required List arguments, + required String workingDirectory, + required String? ide, + required bool suppressAnalytics, }) async { if (expectedCommands.isNotEmpty) { final expectedCommand = expectedCommands.removeAt(0); - expect(expectedCommand.executable, executable); + expect(executable, expectedCommand.executable); expect( - const ListEquality() - .equals(expectedCommand.arguments, arguments), + const ListEquality().equals( + arguments, + expectedCommand.arguments, + ), isTrue, ); + expect(workingDirectory, expectedCommand.workingDirectory); + expect(ide, expectedCommand.ide); + expect(suppressAnalytics, expectedCommand.suppressAnalytics); return expectedCommand.result; } throw 'Received unexpected command: $executable ${arguments.join(' ')}'; @@ -236,14 +564,20 @@ class TestCommand { const TestCommand({ required this.executable, required this.arguments, + required this.workingDirectory, + this.ide, + this.suppressAnalytics = false, required this.result, }); final String executable; final List arguments; + final String workingDirectory; + final String? ide; + final bool suppressAnalytics; final ProcessResult result; @override String toString() { - return '"$executable ${arguments.join(' ')}"'; + return '"$executable ${arguments.join(' ')}" (workingDirectory: $workingDirectory)'; } } diff --git a/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart b/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart index ec15bcd1e68..d53955cbe2d 100644 --- a/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart +++ b/packages/devtools_shared/test/deeplink/universal_link_settings_test.dart @@ -45,6 +45,24 @@ void main() { expect(settings.bundleIdentifier, 'com.example.app'); expect(settings.teamIdentifier, isEmpty); expect(settings.associatedDomains, ['applinks:example.com']); + expect(settings.error, isNull); + }); + + test('parses error json correctly', () { + const json = '{"error": "Failed to load iOS settings"}'; + final settings = UniversalLinkSettings.fromErrorJson(json); + expect(settings.bundleIdentifier, isEmpty); + expect(settings.teamIdentifier, isEmpty); + expect(settings.associatedDomains, isEmpty); + expect(settings.error, 'Failed to load iOS settings'); + }); + + test('creates error settings correctly', () { + final settings = UniversalLinkSettings.error('Some error'); + expect(settings.bundleIdentifier, isEmpty); + expect(settings.teamIdentifier, isEmpty); + expect(settings.associatedDomains, isEmpty); + expect(settings.error, 'Some error'); }); }); } diff --git a/packages/devtools_shared/test/extensions/extension_enablement_test.dart b/packages/devtools_shared/test/extensions/extension_enablement_test.dart index f0740e45832..34472b6ebde 100644 --- a/packages/devtools_shared/test/extensions/extension_enablement_test.dart +++ b/packages/devtools_shared/test/extensions/extension_enablement_test.dart @@ -41,14 +41,11 @@ void main() { extensionName: 'foo', ); final file = optionsFileFromTmp(); - expect( - file.readAsStringSync(), - ''' + expect(file.readAsStringSync(), ''' description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: -''', - ); +'''); }); test('can write to options file', () { @@ -58,14 +55,11 @@ extensions: enable: true, ); final file = optionsFileFromTmp(); - expect( - file.readAsStringSync(), - ''' + expect(file.readAsStringSync(), ''' description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: - - foo: true''', - ); + - foo: true'''); }); test('can read from options file', () { @@ -80,15 +74,12 @@ extensions: enable: false, ); final file = optionsFileFromTmp(); - expect( - file.readAsStringSync(), - ''' + expect(file.readAsStringSync(), ''' description: This file stores settings for Dart & Flutter DevTools. documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states extensions: - foo: true - - bar: false''', - ); + - bar: false'''); expect( options.lookupExtensionEnabledState( @@ -112,5 +103,138 @@ extensions: ExtensionEnabledState.none, ); }); + + test('isolates enablement state by packageName', () { + options.setExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'provider', + packageName: 'provider', + enable: true, + ); + + // Legitimate provider matches + expect( + options.lookupExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'provider', + packageName: 'provider', + ), + ExtensionEnabledState.enabled, + ); + + // Spoofed package does not inherit provider's enablement + expect( + options.lookupExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'provider', + packageName: 'bad_pkg', + ), + ExtensionEnabledState.none, + ); + + // Custom package with custom tool name writes and reads cleanly using packageName + options.setExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'custom_tool', + packageName: 'custom_pkg', + enable: true, + ); + expect( + options.lookupExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'custom_tool', + packageName: 'custom_pkg', + ), + ExtensionEnabledState.enabled, + ); + + final file = optionsFileFromTmp(); + expect(file.readAsStringSync(), ''' +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: + - provider: true + - custom_pkg: true'''); + }); + + test( + 'returns error and does not create parent directory when parent does not ' + 'exist', + () { + final missingParentDir = Directory( + p.join(tmpDir.path, 'missing_parent'), + ); + final missingOptionsUri = Uri.file( + p.join(missingParentDir.path, devtoolsOptionsFileName), + ); + expect( + options.lookupExtensionEnabledState( + devtoolsOptionsUri: missingOptionsUri, + extensionName: 'foo', + ), + ExtensionEnabledState.error, + ); + expect( + options.setExtensionEnabledState( + devtoolsOptionsUri: missingOptionsUri, + extensionName: 'foo', + enable: true, + ), + ExtensionEnabledState.error, + ); + expect(missingParentDir.existsSync(), isFalse); + }, + ); + + test( + 'returns error and does not follow symlink when devtools_options.yaml is ' + 'a symlink', + () { + final targetFile = File(p.join(tmpDir.path, 'target.yaml')) + ..writeAsStringSync('original'); + final brokenTargetFile = File( + p.join(tmpDir.path, 'broken_target.yaml'), + ); + + final optionsLink = Link(p.join(tmpDir.path, devtoolsOptionsFileName)) + ..createSync(targetFile.path); + expect( + options.lookupExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'foo', + ), + ExtensionEnabledState.error, + ); + expect( + options.setExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'foo', + enable: true, + ), + ExtensionEnabledState.error, + ); + expect(targetFile.readAsStringSync(), 'original'); + + optionsLink + ..deleteSync() + ..createSync(brokenTargetFile.path); + expect( + options.lookupExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'foo', + ), + ExtensionEnabledState.error, + ); + expect( + options.setExtensionEnabledState( + devtoolsOptionsUri: optionsUri, + extensionName: 'foo', + enable: true, + ), + ExtensionEnabledState.error, + ); + expect(brokenTargetFile.existsSync(), isFalse); + }, + ); }); } diff --git a/packages/devtools_shared/test/extensions/extension_model_test.dart b/packages/devtools_shared/test/extensions/extension_model_test.dart index 8380b16f1ae..3a98e0445b8 100644 --- a/packages/devtools_shared/test/extensions/extension_model_test.dart +++ b/packages/devtools_shared/test/extensions/extension_model_test.dart @@ -10,6 +10,7 @@ void main() { test('parses with a String materialIconCodePoint field', () { final config = DevToolsExtensionConfig.parse({ 'name': 'foo', + 'packageName': 'foo', 'issueTracker': 'www.google.com', 'version': '1.0.0', 'materialIconCodePoint': '0xf012', @@ -21,6 +22,7 @@ void main() { }); expect(config.name, 'foo'); + expect(config.packageName, 'foo'); expect(config.extensionAssetsPath, '/absolute/path/to/foo/extension'); expect(config.issueTrackerLink, 'www.google.com'); expect(config.version, '1.0.0'); @@ -31,6 +33,7 @@ void main() { test('parses with an int materialIconCodePoint field', () { final config = DevToolsExtensionConfig.parse({ 'name': 'foo', + 'packageName': 'foo', 'issueTracker': 'www.google.com', 'version': '1.0.0', 'materialIconCodePoint': 0xf012, @@ -42,6 +45,7 @@ void main() { }); expect(config.name, 'foo'); + expect(config.packageName, 'foo'); expect(config.extensionAssetsPath, '/absolute/path/to/foo/extension'); expect(config.issueTrackerLink, 'www.google.com'); expect(config.version, '1.0.0'); @@ -52,6 +56,7 @@ void main() { test('parses with a String requiresConnection field', () { final config = DevToolsExtensionConfig.parse({ 'name': 'foo', + 'packageName': 'foo', 'issueTracker': 'www.google.com', 'version': '1.0.0', 'materialIconCodePoint': '0xf012', @@ -63,6 +68,7 @@ void main() { }); expect(config.name, 'foo'); + expect(config.packageName, 'foo'); expect(config.extensionAssetsPath, '/absolute/path/to/foo/extension'); expect(config.issueTrackerLink, 'www.google.com'); expect(config.version, '1.0.0'); @@ -73,6 +79,7 @@ void main() { test('parses with a bool requiresConnection field', () { final config = DevToolsExtensionConfig.parse({ 'name': 'foo', + 'packageName': 'foo', 'issueTracker': 'www.google.com', 'version': '1.0.0', 'materialIconCodePoint': 0xf012, @@ -84,6 +91,7 @@ void main() { }); expect(config.name, 'foo'); + expect(config.packageName, 'foo'); expect(config.extensionAssetsPath, '/absolute/path/to/foo/extension'); expect(config.issueTrackerLink, 'www.google.com'); expect(config.version, '1.0.0'); @@ -91,6 +99,36 @@ void main() { expect(config.requiresConnection, false); }); + test('parses with a packageName field and computes identifier', () { + final configWithMatchingPackage = DevToolsExtensionConfig.parse({ + 'name': 'foo', + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + expect(configWithMatchingPackage.packageName, 'foo'); + expect(configWithMatchingPackage.identifier, 'foo_1.0.0'); + + final configWithDistinctPackage = DevToolsExtensionConfig.parse({ + 'name': 'bar', + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + expect(configWithDistinctPackage.packageName, 'foo'); + expect(configWithDistinctPackage.identifier, 'foo_bar_1.0.0'); + }); + group('parse throws when missing required field', () { Matcher throwsMissingRequiredFieldsError() { return throwsA( @@ -113,178 +151,71 @@ void main() { } test('name', () { - expect( - () { - DevToolsExtensionConfig.parse({ - 'issueTracker': 'www.google.com', - 'version': '1.0.0', - 'materialIconCodePoint': 0xf012, - 'extensionAssetsPath': '/absolute/path/to/foo/extension', - 'devtoolsOptionsUri': - 'file:///path/to/package/devtools_options.yaml', - 'isPubliclyHosted': 'false', - 'detectedFromStaticContext': 'false', - }); - }, - throwsMissingRequiredFieldsError(), - ); + expect(() { + DevToolsExtensionConfig.parse({ + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': + 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + }, throwsMissingRequiredFieldsError()); }); test('issueTracker', () { - expect( - () { - DevToolsExtensionConfig.parse({ - 'name': 'foo', - 'version': '1.0.0', - 'materialIconCodePoint': 0xf012, - 'extensionAssetsPath': '/absolute/path/to/foo/extension', - 'devtoolsOptionsUri': - 'file:///path/to/package/devtools_options.yaml', - 'isPubliclyHosted': 'false', - 'detectedFromStaticContext': 'false', - }); - }, - throwsMissingRequiredFieldsError(), - ); + expect(() { + DevToolsExtensionConfig.parse({ + 'name': 'foo', + 'packageName': 'foo', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': + 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + }, throwsMissingRequiredFieldsError()); }); test('version', () { - expect( - () { - DevToolsExtensionConfig.parse({ - 'name': 'foo', - 'issueTracker': 'www.google.com', - 'materialIconCodePoint': 0xf012, - 'extensionAssetsPath': '/absolute/path/to/foo/extension', - 'devtoolsOptionsUri': - 'file:///path/to/package/devtools_options.yaml', - 'isPubliclyHosted': 'false', - 'detectedFromStaticContext': 'false', - }); - }, - throwsMissingRequiredFieldsError(), - ); + expect(() { + DevToolsExtensionConfig.parse({ + 'name': 'foo', + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': + 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + }, throwsMissingRequiredFieldsError()); }); test('materialIconCodePoint', () { - expect( - () { - DevToolsExtensionConfig.parse({ - 'name': 'foo', - 'issueTracker': 'www.google.com', - 'version': '1.0.0', - 'extensionAssetsPath': '/absolute/path/to/foo/extension', - 'devtoolsOptionsUri': - 'file:///path/to/package/devtools_options.yaml', - 'isPubliclyHosted': 'false', - 'detectedFromStaticContext': 'false', - }); - }, - throwsMissingRequiredFieldsError(), - ); - }); - test('extensionAssetsPath', () { - expect( - () { - DevToolsExtensionConfig.parse({ - 'name': 'foo', - 'issueTracker': 'www.google.com', - 'version': '1.0.0', - 'materialIconCodePoint': 0xf012, - 'devtoolsOptionsUri': - 'file:///path/to/package/devtools_options.yaml', - 'isPubliclyHosted': 'false', - 'detectedFromStaticContext': 'false', - }); - }, - throwsMissingGeneratedKeysError(), - ); - }); - - test('devtoolsOptionsUri', () { - expect( - () { - DevToolsExtensionConfig.parse({ - 'name': 'foo', - 'issueTracker': 'www.google.com', - 'version': '1.0.0', - 'materialIconCodePoint': 0xf012, - 'extensionAssetsPath': '/absolute/path/to/foo/extension', - 'isPubliclyHosted': 'false', - 'detectedFromStaticContext': 'false', - }); - }, - throwsMissingGeneratedKeysError(), - ); - }); - - test('isPubliclyHosted', () { - expect( - () { - DevToolsExtensionConfig.parse({ - 'name': 'foo', - 'issueTracker': 'www.google.com', - 'version': '1.0.0', - 'materialIconCodePoint': 0xf012, - 'extensionAssetsPath': '/absolute/path/to/foo/extension', - 'devtoolsOptionsUri': 'path/to/package/devtools_options.yaml', - 'detectedFromStaticContext': 'false', - }); - }, - throwsMissingGeneratedKeysError(), - ); - }); - - test('detectedFromStaticContext', () { - expect( - () { - DevToolsExtensionConfig.parse({ - 'name': 'foo', - 'issueTracker': 'www.google.com', - 'version': '1.0.0', - 'materialIconCodePoint': 0xf012, - 'extensionAssetsPath': '/absolute/path/to/foo/extension', - 'devtoolsOptionsUri': - 'file:///path/to/package/devtools_options.yaml', - 'isPubliclyHosted': 'false', - }); - }, - throwsMissingGeneratedKeysError(), - ); - }); - }); - - test('parse throws when value has unexpected type', () { - Matcher throwsUnexpectedValueTypesError() { - return throwsA( - isA().having( - (e) => e.message, - 'unexpected value types StateError', - startsWith('Unexpected value types'), - ), - ); - } - - expect( - () { + expect(() { DevToolsExtensionConfig.parse({ - // Expects a String here. - 'name': 23, + 'name': 'foo', + 'packageName': 'foo', 'issueTracker': 'www.google.com', 'version': '1.0.0', - 'materialIconCodePoint': 0xf012, 'extensionAssetsPath': '/absolute/path/to/foo/extension', 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', 'isPubliclyHosted': 'false', 'detectedFromStaticContext': 'false', }); - }, - throwsUnexpectedValueTypesError(), - ); + }, throwsMissingRequiredFieldsError()); + }); - expect( - () { + test('packageName', () { + expect(() { DevToolsExtensionConfig.parse({ 'name': 'foo', 'issueTracker': 'www.google.com', @@ -293,64 +224,63 @@ void main() { 'extensionAssetsPath': '/absolute/path/to/foo/extension', 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', - // Expects a String here. - 'isPubliclyHosted': false, + 'isPubliclyHosted': 'false', 'detectedFromStaticContext': 'false', }); - }, - throwsUnexpectedValueTypesError(), - ); - }); - - test('parse throws for invalid name', () { - Matcher throwsInvalidNameError() { - return throwsA( - isA().having( - (e) => e.message, - 'unexpected value types StateError', - startsWith('The "name" field in the extension config.yaml should'), - ), - ); - } + }, throwsMissingGeneratedKeysError()); + }); - expect( - () { + test('extensionAssetsPath', () { + expect(() { DevToolsExtensionConfig.parse({ - 'name': 'name with spaces', + 'name': 'foo', + 'packageName': 'foo', 'issueTracker': 'www.google.com', 'version': '1.0.0', 'materialIconCodePoint': 0xf012, - 'extensionAssetsPath': '/absolute/path/to/foo/extension', 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', 'isPubliclyHosted': 'false', 'detectedFromStaticContext': 'false', }); - }, - throwsInvalidNameError(), - ); + }, throwsMissingGeneratedKeysError()); + }); - expect( - () { + test('devtoolsOptionsUri', () { + expect(() { DevToolsExtensionConfig.parse({ - 'name': 'Name_With_Capital_Letters', + 'name': 'foo', + 'packageName': 'foo', 'issueTracker': 'www.google.com', 'version': '1.0.0', 'materialIconCodePoint': 0xf012, 'extensionAssetsPath': '/absolute/path/to/foo/extension', - 'devtoolsOptionsUri': - 'file:///path/to/package/devtools_options.yaml', 'isPubliclyHosted': 'false', 'detectedFromStaticContext': 'false', }); - }, - throwsInvalidNameError(), - ); + }, throwsMissingGeneratedKeysError()); + }); + + test('isPubliclyHosted', () { + expect(() { + DevToolsExtensionConfig.parse({ + 'name': 'foo', + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': 'path/to/package/devtools_options.yaml', + 'detectedFromStaticContext': 'false', + }); + }, throwsMissingGeneratedKeysError()); + }); - expect( - () { + test('detectedFromStaticContext', () { + expect(() { DevToolsExtensionConfig.parse({ - 'name': 'name.with\'specialchars/', + 'name': 'foo', + 'packageName': 'foo', 'issueTracker': 'www.google.com', 'version': '1.0.0', 'materialIconCodePoint': 0xf012, @@ -358,11 +288,105 @@ void main() { 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', 'isPubliclyHosted': 'false', - 'detectedFromStaticContext': 'false', }); - }, - throwsInvalidNameError(), - ); + }, throwsMissingGeneratedKeysError()); + }); + }); + + test('parse throws when value has unexpected type', () { + Matcher throwsUnexpectedValueTypesError() { + return throwsA( + isA().having( + (e) => e.message, + 'unexpected value types StateError', + startsWith('Unexpected value types'), + ), + ); + } + + expect(() { + DevToolsExtensionConfig.parse({ + // Expects a String here. + 'name': 23, + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + }, throwsUnexpectedValueTypesError()); + + expect(() { + DevToolsExtensionConfig.parse({ + 'name': 'foo', + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', + // Expects a String here. + 'isPubliclyHosted': false, + 'detectedFromStaticContext': 'false', + }); + }, throwsUnexpectedValueTypesError()); + }); + + test('parse throws for invalid name', () { + Matcher throwsInvalidNameError() { + return throwsA( + isA().having( + (e) => e.message, + 'unexpected value types StateError', + startsWith('The "name" field in the extension config.yaml should'), + ), + ); + } + + expect(() { + DevToolsExtensionConfig.parse({ + 'name': 'name with spaces', + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + }, throwsInvalidNameError()); + + expect(() { + DevToolsExtensionConfig.parse({ + 'name': 'Name_With_Capital_Letters', + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + }, throwsInvalidNameError()); + + expect(() { + DevToolsExtensionConfig.parse({ + 'name': 'name.with\'specialchars/', + 'packageName': 'foo', + 'issueTracker': 'www.google.com', + 'version': '1.0.0', + 'materialIconCodePoint': 0xf012, + 'extensionAssetsPath': '/absolute/path/to/foo/extension', + 'devtoolsOptionsUri': 'file:///path/to/package/devtools_options.yaml', + 'isPubliclyHosted': 'false', + 'detectedFromStaticContext': 'false', + }); + }, throwsInvalidNameError()); }); }); } diff --git a/packages/devtools_shared/test/fakes.dart b/packages/devtools_shared/test/fakes.dart index dd79a9c0358..7c8c7fbf853 100644 --- a/packages/devtools_shared/test/fakes.dart +++ b/packages/devtools_shared/test/fakes.dart @@ -11,23 +11,27 @@ class FakeDeeplinkManager extends DeeplinkManager { String? receivedBuildVariant; String? receivedConfiguration; String? receivedTarget; - late Map responseForGetAndroidBuildVariants; - late Map responseForGetAndroidAppLinkSettings; - late Map responseForGetIosBuildOptions; - late Map responseForGetIosUniversalLinkSettings; + late Map responseForGetAndroidBuildVariants; + late Map responseForGetAndroidAppLinkSettings; + late Map responseForGetIosBuildOptions; + late Map responseForGetIosUniversalLinkSettings; @override - Future> getAndroidBuildVariants({ + Future> getAndroidBuildVariants({ required String rootPath, + String? ide, + bool suppressAnalytics = false, }) async { receivedPath = rootPath; return responseForGetAndroidBuildVariants; } @override - Future> getAndroidAppLinkSettings({ + Future> getAndroidAppLinkSettings({ required String rootPath, required String buildVariant, + String? ide, + bool suppressAnalytics = false, }) async { receivedPath = rootPath; receivedBuildVariant = buildVariant; @@ -35,18 +39,22 @@ class FakeDeeplinkManager extends DeeplinkManager { } @override - Future> getIosBuildOptions({ + Future> getIosBuildOptions({ required String rootPath, + String? ide, + bool suppressAnalytics = false, }) async { receivedPath = rootPath; return responseForGetIosBuildOptions; } @override - Future> getIosUniversalLinkSettings({ + Future> getIosUniversalLinkSettings({ required String rootPath, required String configuration, required String target, + String? ide, + bool suppressAnalytics = false, }) async { receivedPath = rootPath; receivedConfiguration = configuration; diff --git a/packages/devtools_shared/test/helpers/extension_test_manager.dart b/packages/devtools_shared/test/helpers/extension_test_manager.dart index 0355e728ea0..0ff7c63d55c 100644 --- a/packages/devtools_shared/test/helpers/extension_test_manager.dart +++ b/packages/devtools_shared/test/helpers/extension_test_manager.dart @@ -28,8 +28,10 @@ class ExtensionTestManager { Future reset() async { // Run with retry to ensure this deletes properly on Windows. - await deleteDirectoryWithRetry(testDirectory); - _testDirectory = null; + if (_testDirectory case final testDirectory?) { + await deleteDirectoryWithRetry(testDirectory); + _testDirectory = null; + } _packagesRootUri = null; _runtimeAppRoot = null; } @@ -82,20 +84,26 @@ class ExtensionTestManager { Future setupTestDirectoryStructure({ bool includeDependenciesWithExtensions = true, bool includeBadExtension = false, + bool includeSpoofedExtension = false, }) async { _testDirectory = Directory.systemTemp.createTempSync(); _setupPackages( includeDependenciesWithExtensions: includeDependenciesWithExtensions, includeBadExtension: includeBadExtension, + includeSpoofedExtension: includeSpoofedExtension, + ); + _setupExtensions( + includeBadExtension: includeBadExtension, + includeSpoofedExtension: includeSpoofedExtension, ); - _setupExtensions(includeBadExtension: includeBadExtension); // Generate the .dart_tool/package_config.json file for each Dart package. final testDirectoryContents = testDirectory.listSync(); expect(testDirectoryContents.length, 2); - final packageRoots = - Directory.fromUri(packagesRootUri).listSync().whereType(); + final packageRoots = Directory.fromUri( + packagesRootUri, + ).listSync().whereType(); expect(packageRoots.length, 4); for (final packageRoot in packageRoots) { @@ -103,11 +111,10 @@ class ExtensionTestManager { // Run `dart pub get` on this package to generate the // `.dart_tool/package_config.json` file. - final process = await Process.run( - Platform.resolvedExecutable, - ['pub', 'get'], - workingDirectory: packageRoot.path, - ); + final process = await Process.run(Platform.resolvedExecutable, [ + 'pub', + 'get', + ], workingDirectory: packageRoot.path); if (process.exitCode != 0) { throw Exception( 'Encountered error while running pub get. Exit code: ' @@ -144,10 +151,19 @@ class ExtensionTestManager { void _setupPackages({ required bool includeDependenciesWithExtensions, required bool includeBadExtension, + bool includeSpoofedExtension = false, }) { + final TestPackage myApp; + if (includeSpoofedExtension) { + myApp = myAppPackageWithSpoofedExtension; + } else if (includeBadExtension) { + myApp = myAppPackageWithBadExtension; + } else { + myApp = myAppPackage; + } _setupPackage( createTestPackageFrom( - includeBadExtension ? myAppPackageWithBadExtension : myAppPackage, + myApp, includeDependenciesWithExtensions: includeDependenciesWithExtensions, ), isRuntimeRoot: true, @@ -174,13 +190,13 @@ class ExtensionTestManager { void setupWorkspace(TestPackage package) { File( - p.join( - testDirectory.path, - 'packages', - 'workspace_root', - 'pubspec.yaml', - ), - ) + p.join( + testDirectory.path, + 'packages', + 'workspace_root', + 'pubspec.yaml', + ), + ) ..createSync(recursive: true) ..writeAsStringSync(''' name: _ @@ -190,14 +206,14 @@ workspace: - ${package.name} '''); File( - p.join( - testDirectory.path, - 'packages', - 'workspace_root', - package.name, - 'pubspec.yaml', - ), - ) + p.join( + testDirectory.path, + 'packages', + 'workspace_root', + package.name, + 'pubspec.yaml', + ), + ) ..createSync(recursive: true) ..writeAsStringSync(''' ${package.pubspecContent} @@ -228,7 +244,10 @@ resolution: workspace /// devtools/ /// build/ /// config.yaml - void _setupExtensions({required bool includeBadExtension}) { + void _setupExtensions({ + required bool includeBadExtension, + bool includeSpoofedExtension = false, + }) { _setupExtension(staticExtension1Package); _setupExtension(staticExtension2Package); @@ -237,6 +256,7 @@ resolution: workspace _setupExtension(newerStaticExtension1Package); if (includeBadExtension) _setupExtension(badExtensionPackage); + if (includeSpoofedExtension) _setupExtension(spoofedExtensionPackage); } void _setupPackage(TestPackage package, {bool isRuntimeRoot = false}) { @@ -244,12 +264,13 @@ resolution: workspace ..createSync(recursive: true); _packagesRootUri = Uri.file(packagesDirectory.uri.toFilePath()); - final projectRootDirectory = - Directory(p.join(packagesDirectory.path, package.name)) - ..createSync(recursive: true); + final projectRootDirectory = Directory( + p.join(packagesDirectory.path, package.name), + )..createSync(recursive: true); if (isRuntimeRoot) { - final directoryPath = - Uri.file(projectRootDirectory.uri.toFilePath()).toString(); + final directoryPath = Uri.file( + projectRootDirectory.uri.toFilePath(), + ).toString(); // Remove the trailing slash and set the value of [packagesRoot]. _runtimeAppRoot = directoryPath.substring(0, directoryPath.length - 1); } @@ -267,9 +288,9 @@ resolution: workspace package.relativePathFromExtensions, ), )..createSync(recursive: true); - final extensionDir = - Directory(p.join(extensionDirectory.path, 'extension', 'devtools')) - ..createSync(recursive: true); + final extensionDir = Directory( + p.join(extensionDirectory.path, 'extension', 'devtools'), + )..createSync(recursive: true); Directory(p.join(extensionDir.path, 'build')).createSync(recursive: true); File(p.join(extensionDir.path, 'config.yaml')) @@ -288,8 +309,9 @@ TestPackage createTestPackageFrom( }) { return TestPackage( name: originalPackage.name, - dependencies: - includeDependenciesWithExtensions ? originalPackage.dependencies : [], + dependencies: includeDependenciesWithExtensions + ? originalPackage.dependencies + : [], pathToExtensions: originalPackage.pathToExtensions, ); } @@ -302,6 +324,10 @@ final myAppPackageWithBadExtension = TestPackage( name: myAppPackage.name, dependencies: [...myAppPackage.dependencies, badExtensionPackage], ); +final myAppPackageWithSpoofedExtension = TestPackage( + name: myAppPackage.name, + dependencies: [...myAppPackage.dependencies, spoofedExtensionPackage], +); final otherRoot1Package = TestPackage( name: 'other_root_1', dependencies: [staticExtension1Package, staticExtension2Package], @@ -318,6 +344,7 @@ final workspaceMember = TestPackage( final driftPackage = TestPackageWithExtension( name: 'drift', + packageName: 'drift', issueTracker: 'https://github.com/simolus3/drift/issues', version: '0.0.1', materialIconCodePoint: 62494, @@ -327,6 +354,7 @@ final driftPackage = TestPackageWithExtension( ); final providerPackage = TestPackageWithExtension( name: 'provider', + packageName: 'provider', issueTracker: 'https://github.com/rrousselGit/provider/issues', version: '0.0.1', materialIconCodePoint: 57521, @@ -336,6 +364,7 @@ final providerPackage = TestPackageWithExtension( ); final staticExtension1Package = TestPackageWithExtension( name: 'static_extension_1', + packageName: 'static_extension_1', issueTracker: 'https://www.google.com/', version: '1.0.0', materialIconCodePoint: 0xe50a, @@ -345,6 +374,7 @@ final staticExtension1Package = TestPackageWithExtension( ); final staticExtension2Package = TestPackageWithExtension( name: 'static_extension_2', + packageName: 'static_extension_2', issueTracker: 'https://www.google.com/', version: '2.0.0', materialIconCodePoint: 0xe50a, @@ -354,6 +384,7 @@ final staticExtension2Package = TestPackageWithExtension( ); final newerStaticExtension1Package = TestPackageWithExtension( name: 'static_extension_1', + packageName: 'static_extension_1', issueTracker: 'https://www.google.com/', version: '2.0.0', materialIconCodePoint: 0xe50a, @@ -365,6 +396,7 @@ final newerStaticExtension1Package = TestPackageWithExtension( final badExtensionPackage = TestPackageWithExtension( // Extension names must be only lowercase letters and underscores. name: 'BAD_EXTENSION', + packageName: 'bad_extension', issueTracker: 'https://www.google.com/', version: '1.0.0', materialIconCodePoint: 0xe50a, @@ -372,10 +404,21 @@ final badExtensionPackage = TestPackageWithExtension( isPubliclyHosted: false, packageVersion: null, ); +final spoofedExtensionPackage = TestPackageWithExtension( + name: 'provider', + packageName: 'bad_pkg', + issueTracker: 'https://www.google.com/', + version: '999.0.0', + materialIconCodePoint: 0xe50a, + requiresConnection: true, + isPubliclyHosted: false, + packageVersion: null, +); class TestPackageWithExtension { TestPackageWithExtension({ required this.name, + required this.packageName, required this.issueTracker, required this.version, required this.materialIconCodePoint, @@ -383,11 +426,11 @@ class TestPackageWithExtension { required this.isPubliclyHosted, required this.packageVersion, String? relativePathFromExtensions, - }) : assert(isPubliclyHosted == (packageVersion != null)), - relativePathFromExtensions = - relativePathFromExtensions ?? name.toLowerCase(); + }) : assert(isPubliclyHosted == (packageVersion != null)), + relativePathFromExtensions = relativePathFromExtensions ?? packageName; final String name; + final String packageName; final String issueTracker; final String version; final Object? materialIconCodePoint; @@ -400,7 +443,8 @@ class TestPackageWithExtension { /// Uses the paths separator for the current platform. final String relativePathFromExtensions; - String get configYamlContent => ''' + String get configYamlContent => + ''' name: $name issueTracker: $issueTracker version: $version @@ -408,8 +452,9 @@ materialIconCodePoint: $materialIconCodePoint ${!requiresConnection ? 'requiresConnection: false' : ''} '''; - String get pubspecContent => ''' -name: ${name.toLowerCase()} + String get pubspecContent => + ''' +name: $packageName environment: sdk: ">=3.4.0-282.1.beta <4.0.0" '''; @@ -426,7 +471,8 @@ class TestPackage { final List dependencies; final String pathToExtensions; - String get pubspecContent => ''' + String get pubspecContent => + ''' name: $name environment: sdk: "^3.5.0" @@ -437,7 +483,7 @@ ${_dependenciesAsString()} String _dependenciesAsString() { final sb = StringBuffer(); for (final dep in dependencies) { - sb.write(' ${dep.name.toLowerCase()}:'); + sb.write(' ${dep.packageName}:'); if (dep.isPubliclyHosted) { sb.writeln(' ${dep.packageVersion!}'); } else { diff --git a/packages/devtools_shared/test/helpers/extension_test_manager_test.dart b/packages/devtools_shared/test/helpers/extension_test_manager_test.dart index 9bf976d8b5b..c49d6e7b4c1 100644 --- a/packages/devtools_shared/test/helpers/extension_test_manager_test.dart +++ b/packages/devtools_shared/test/helpers/extension_test_manager_test.dart @@ -13,6 +13,7 @@ void main() { group('$TestPackageWithExtension', () { test('$driftPackage', () { expect(driftPackage.name, 'drift'); + expect(driftPackage.packageName, 'drift'); expect( driftPackage.issueTracker, 'https://github.com/simolus3/drift/issues', @@ -27,6 +28,7 @@ void main() { test('$providerPackage', () { expect(providerPackage.name, 'provider'); + expect(providerPackage.packageName, 'provider'); expect( providerPackage.issueTracker, 'https://github.com/rrousselGit/provider/issues', @@ -41,6 +43,7 @@ void main() { test('$staticExtension1Package', () { expect(staticExtension1Package.name, 'static_extension_1'); + expect(staticExtension1Package.packageName, 'static_extension_1'); expect(staticExtension1Package.issueTracker, 'https://www.google.com/'); expect(staticExtension1Package.version, '1.0.0'); expect(staticExtension1Package.materialIconCodePoint, 0xe50a); @@ -51,28 +54,23 @@ void main() { staticExtension1Package.relativePathFromExtensions, staticExtension1Package.name, ); - expect( - staticExtension1Package.pubspecContent, - ''' + expect(staticExtension1Package.pubspecContent, ''' name: static_extension_1 environment: sdk: ">=3.4.0-282.1.beta <4.0.0" -''', - ); - expect( - staticExtension1Package.configYamlContent, - ''' +'''); + expect(staticExtension1Package.configYamlContent, ''' name: static_extension_1 issueTracker: https://www.google.com/ version: 1.0.0 materialIconCodePoint: 58634 requiresConnection: false -''', - ); +'''); }); test('$staticExtension2Package', () { expect(staticExtension2Package.name, 'static_extension_2'); + expect(staticExtension2Package.packageName, 'static_extension_2'); expect(staticExtension2Package.issueTracker, 'https://www.google.com/'); expect(staticExtension2Package.version, '2.0.0'); expect(staticExtension2Package.materialIconCodePoint, 0xe50a); @@ -83,28 +81,23 @@ requiresConnection: false staticExtension2Package.relativePathFromExtensions, staticExtension2Package.name, ); - expect( - staticExtension2Package.pubspecContent, - ''' + expect(staticExtension2Package.pubspecContent, ''' name: static_extension_2 environment: sdk: ">=3.4.0-282.1.beta <4.0.0" -''', - ); - expect( - staticExtension2Package.configYamlContent, - ''' +'''); + expect(staticExtension2Package.configYamlContent, ''' name: static_extension_2 issueTracker: https://www.google.com/ version: 2.0.0 materialIconCodePoint: 58634 requiresConnection: false -''', - ); +'''); }); test('$newerStaticExtension1Package', () { expect(newerStaticExtension1Package.name, 'static_extension_1'); + expect(newerStaticExtension1Package.packageName, 'static_extension_1'); expect( newerStaticExtension1Package.issueTracker, 'https://www.google.com/', @@ -118,28 +111,23 @@ requiresConnection: false newerStaticExtension1Package.relativePathFromExtensions, p.join('newer', 'static_extension_1'), ); - expect( - newerStaticExtension1Package.pubspecContent, - ''' + expect(newerStaticExtension1Package.pubspecContent, ''' name: static_extension_1 environment: sdk: ">=3.4.0-282.1.beta <4.0.0" -''', - ); - expect( - newerStaticExtension1Package.configYamlContent, - ''' +'''); + expect(newerStaticExtension1Package.configYamlContent, ''' name: static_extension_1 issueTracker: https://www.google.com/ version: 2.0.0 materialIconCodePoint: 58634 requiresConnection: false -''', - ); +'''); }); test('$badExtensionPackage', () { expect(badExtensionPackage.name, 'BAD_EXTENSION'); + expect(badExtensionPackage.packageName, 'bad_extension'); expect(badExtensionPackage.issueTracker, 'https://www.google.com/'); expect(badExtensionPackage.version, '1.0.0'); expect(badExtensionPackage.materialIconCodePoint, 0xe50a); @@ -148,26 +136,47 @@ requiresConnection: false expect(badExtensionPackage.packageVersion, null); expect( badExtensionPackage.relativePathFromExtensions, - badExtensionPackage.name.toLowerCase(), + badExtensionPackage.packageName, ); - expect( - badExtensionPackage.pubspecContent, - ''' + expect(badExtensionPackage.pubspecContent, ''' name: bad_extension environment: sdk: ">=3.4.0-282.1.beta <4.0.0" -''', - ); - expect( - badExtensionPackage.configYamlContent, - ''' +'''); + expect(badExtensionPackage.configYamlContent, ''' name: BAD_EXTENSION issueTracker: https://www.google.com/ version: 1.0.0 materialIconCodePoint: 58634 -''', +'''); + }); + + test('$spoofedExtensionPackage', () { + expect(spoofedExtensionPackage.name, 'provider'); + expect(spoofedExtensionPackage.packageName, 'bad_pkg'); + expect(spoofedExtensionPackage.issueTracker, 'https://www.google.com/'); + expect(spoofedExtensionPackage.version, '999.0.0'); + expect(spoofedExtensionPackage.materialIconCodePoint, 0xe50a); + expect(spoofedExtensionPackage.requiresConnection, true); + expect(spoofedExtensionPackage.isPubliclyHosted, false); + expect(spoofedExtensionPackage.packageVersion, null); + expect( + spoofedExtensionPackage.relativePathFromExtensions, + spoofedExtensionPackage.packageName, ); + expect(spoofedExtensionPackage.pubspecContent, ''' +name: bad_pkg +environment: + sdk: ">=3.4.0-282.1.beta <4.0.0" +'''); + expect(spoofedExtensionPackage.configYamlContent, ''' +name: provider +issueTracker: https://www.google.com/ +version: 999.0.0 +materialIconCodePoint: 58634 + +'''); }); }); @@ -175,9 +184,7 @@ materialIconCodePoint: 58634 test('$myAppPackage', () { expect(myAppPackage.name, 'my_app'); expect(myAppPackage.dependencies.length, 3); - expect( - myAppPackage.pubspecContent, - ''' + expect(myAppPackage.pubspecContent, ''' name: my_app environment: sdk: "^3.5.0" @@ -187,16 +194,13 @@ dependencies: static_extension_1: path: ../../extensions/static_extension_1 -''', - ); +'''); }); test('$myAppPackageWithBadExtension', () { expect(myAppPackageWithBadExtension.name, 'my_app'); expect(myAppPackageWithBadExtension.dependencies.length, 4); - expect( - myAppPackageWithBadExtension.pubspecContent, - ''' + expect(myAppPackageWithBadExtension.pubspecContent, ''' name: my_app environment: sdk: "^3.5.0" @@ -208,16 +212,13 @@ dependencies: bad_extension: path: ../../extensions/bad_extension -''', - ); +'''); }); test('$otherRoot1Package', () { expect(otherRoot1Package.name, 'other_root_1'); expect(otherRoot1Package.dependencies.length, 2); - expect( - otherRoot1Package.pubspecContent, - ''' + expect(otherRoot1Package.pubspecContent, ''' name: other_root_1 environment: sdk: "^3.5.0" @@ -227,16 +228,13 @@ dependencies: static_extension_2: path: ../../extensions/static_extension_2 -''', - ); +'''); }); test('$otherRoot2Package', () { expect(otherRoot2Package.name, 'other_root_2'); expect(otherRoot2Package.dependencies.length, 1); - expect( - otherRoot2Package.pubspecContent, - ''' + expect(otherRoot2Package.pubspecContent, ''' name: other_root_2 environment: sdk: "^3.5.0" @@ -244,8 +242,7 @@ dependencies: static_extension_1: path: ../../extensions/newer/static_extension_1 -''', - ); +'''); }); test('createTestPackageFrom when excluding dependencies', () { @@ -253,58 +250,46 @@ dependencies: myAppPackage, includeDependenciesWithExtensions: false, ); - expect( - pkg.pubspecContent, - ''' + expect(pkg.pubspecContent, ''' name: my_app environment: sdk: "^3.5.0" dependencies: -''', - ); +'''); pkg = createTestPackageFrom( myAppPackageWithBadExtension, includeDependenciesWithExtensions: false, ); - expect( - pkg.pubspecContent, - ''' + expect(pkg.pubspecContent, ''' name: my_app environment: sdk: "^3.5.0" dependencies: -''', - ); +'''); pkg = createTestPackageFrom( otherRoot1Package, includeDependenciesWithExtensions: false, ); - expect( - pkg.pubspecContent, - ''' + expect(pkg.pubspecContent, ''' name: other_root_1 environment: sdk: "^3.5.0" dependencies: -''', - ); +'''); pkg = createTestPackageFrom( otherRoot2Package, includeDependenciesWithExtensions: false, ); - expect( - pkg.pubspecContent, - ''' + expect(pkg.pubspecContent, ''' name: other_root_2 environment: sdk: "^3.5.0" dependencies: -''', - ); +'''); }); }); } diff --git a/packages/devtools_shared/test/helpers/helpers.dart b/packages/devtools_shared/test/helpers/helpers.dart index 523795b66fd..0f9dbeee66c 100644 --- a/packages/devtools_shared/test/helpers/helpers.dart +++ b/packages/devtools_shared/test/helpers/helpers.dart @@ -9,10 +9,7 @@ import 'dart:io'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:path/path.dart' as path; -typedef TestDtdConnectionInfo = ({ - DtdInfo? info, - Process? process, -}); +typedef TestDtdConnectionInfo = ({DtdInfo? info, Process? process}); /// Helper method to start DTD for the purpose of testing. Future startDtd() async { @@ -25,28 +22,25 @@ Future startDtd() async { TestDtdConnectionInfo onFailure() => (info: null, process: dtdProcess); try { - dtdProcess = await Process.start( - Platform.resolvedExecutable, - ['tooling-daemon', '--machine'], - ); + dtdProcess = await Process.start(Platform.resolvedExecutable, [ + 'tooling-daemon', + '--machine', + ]); dtdStoutSubscription = dtdProcess.stdout.listen((List data) { try { final decoded = utf8.decode(data); final json = jsonDecode(decoded) as Map; - if (json - case { - 'tooling_daemon_details': { - 'uri': final String uri, - 'trusted_client_secret': final String secret, - } - }) { - completer.complete( - ( - info: DtdInfo(Uri.parse(uri), secret: secret), - process: dtdProcess, - ), - ); + if (json case { + 'tooling_daemon_details': { + 'uri': final String uri, + 'trusted_client_secret': final String secret, + }, + }) { + completer.complete(( + info: DtdInfo(Uri.parse(uri), secret: secret), + process: dtdProcess, + )); } else { completer.complete(onFailure()); } @@ -58,9 +52,9 @@ Future startDtd() async { return await completer.future .timeout(dtdConnectTimeout, onTimeout: onFailure) .then((value) async { - await dtdStoutSubscription?.cancel(); - return value; - }); + await dtdStoutSubscription?.cancel(); + return value; + }); } catch (e) { await dtdStoutSubscription?.cancel(); return onFailure(); @@ -68,54 +62,68 @@ Future startDtd() async { } class TestDartApp { + TestDartApp() { + directory = Directory.systemTemp.createTempSync('test_app_'); + } static final dartVMServiceRegExp = RegExp( r'The Dart VM service is listening on (http://127.0.0.1:.*)', ); - final directory = Directory('tmp/test_app'); + late final Directory directory; Process? process; + StreamSubscription? _stdoutSub; + StreamSubscription? _stderrSub; Future start() async { - await _initTestApp(); - process = await Process.start( - Platform.resolvedExecutable, - ['--observe=0', 'run', 'bin/main.dart'], - workingDirectory: directory.path, - ); + _initTestApp(); + process = await Process.start(Platform.resolvedExecutable, [ + '--observe=0', + 'bin/main.dart', + ], workingDirectory: directory.path); final serviceUriCompleter = Completer(); - late StreamSubscription sub; - sub = process!.stdout + _stdoutSub = process!.stdout .transform(utf8.decoder) .transform(const LineSplitter()) - .listen((line) async { - if (line.contains(dartVMServiceRegExp)) { - await sub.cancel(); - serviceUriCompleter.complete( - dartVMServiceRegExp.firstMatch(line)!.group(1), - ); - } - }); + .listen((line) { + if (!serviceUriCompleter.isCompleted && + line.contains(dartVMServiceRegExp)) { + serviceUriCompleter.complete( + dartVMServiceRegExp.firstMatch(line)!.group(1), + ); + } + }); + + _stderrSub = process!.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + // ignore: avoid_print, deliberate print to monitor errors. + print('TestDartApp stderr: $line'); + }); + return await serviceUriCompleter.future.timeout( const Duration(seconds: 5), - onTimeout: () async { - await sub.cancel(); + onTimeout: () { return ''; }, ); } Future kill() async { + await _stdoutSub?.cancel(); + await _stderrSub?.cancel(); process?.kill(); await process?.exitCode; process = null; await deleteDirectoryWithRetry(directory); } - Future _initTestApp() async { - await deleteDirectoryWithRetry(directory); - directory.createSync(recursive: true); + void _initTestApp() { + Directory( + path.join(directory.path, '.dart_tool'), + ).createSync(recursive: true); final mainFile = File(path.join(directory.path, 'bin', 'main.dart')) ..createSync(recursive: true); diff --git a/packages/devtools_shared/test/semantic_version_test.dart b/packages/devtools_shared/test/semantic_version_test.dart index 8e22b57a4f3..06b637b5da8 100644 --- a/packages/devtools_shared/test/semantic_version_test.dart +++ b/packages/devtools_shared/test/semantic_version_test.dart @@ -44,16 +44,10 @@ void main() { preReleaseMajor: 1, preReleaseMinor: 2, ); - expect( - version.downgrade().toString(), - equals('3.2.1'), - ); + expect(version.downgrade().toString(), equals('3.2.1')); version = SemanticVersion(major: 3, minor: 2, patch: 1); - expect( - version.downgrade().toString(), - equals('3.2.1'), - ); + expect(version.downgrade().toString(), equals('3.2.1')); expect( version.downgrade(downgradeMajor: true).toString(), equals('2.2.1'), @@ -87,18 +81,27 @@ void main() { isTrue, ); expect( - SemanticVersion(major: 1, minor: 1, patch: 2) - .isSupported(minSupportedVersion: supportedVersion), + SemanticVersion( + major: 1, + minor: 1, + patch: 2, + ).isSupported(minSupportedVersion: supportedVersion), isTrue, ); expect( - SemanticVersion(major: 1, minor: 2, patch: 1) - .isSupported(minSupportedVersion: supportedVersion), + SemanticVersion( + major: 1, + minor: 2, + patch: 1, + ).isSupported(minSupportedVersion: supportedVersion), isTrue, ); expect( - SemanticVersion(major: 2, minor: 1, patch: 1) - .isSupported(minSupportedVersion: supportedVersion), + SemanticVersion( + major: 2, + minor: 1, + patch: 1, + ).isSupported(minSupportedVersion: supportedVersion), isTrue, ); expect( @@ -123,10 +126,7 @@ void main() { version.compareTo(SemanticVersion(major: 2, minor: 1, patch: 1)), equals(-1), ); - expect( - version.compareTo(SemanticVersion(major: 1, minor: 1)), - equals(1), - ); + expect(version.compareTo(SemanticVersion(major: 1, minor: 1)), equals(1)); expect( version.compareTo(SemanticVersion(major: 1, minor: 1, patch: 1)), equals(0), @@ -184,8 +184,12 @@ void main() { equals('1.1.1'), ); expect( - SemanticVersion(major: 1, minor: 1, patch: 1, preReleaseMajor: 17) - .toString(), + SemanticVersion( + major: 1, + minor: 1, + patch: 1, + preReleaseMajor: 17, + ).toString(), equals('1.1.1-17'), ); expect( diff --git a/packages/devtools_shared/test/server/deeplink_api_test.dart b/packages/devtools_shared/test/server/deeplink_api_test.dart index e3d39f26dee..1ce9a91ad4a 100644 --- a/packages/devtools_shared/test/server/deeplink_api_test.dart +++ b/packages/devtools_shared/test/server/deeplink_api_test.dart @@ -18,7 +18,7 @@ void main() { test('handle deeplink api ${DeeplinkApi.androidBuildVariants}', () async { const expectedRootPath = '/abc'; final request = Request( - 'get', + 'post', Uri( scheme: 'https', host: 'localhost', @@ -27,6 +27,7 @@ void main() { DeeplinkApi.deeplinkRootPathPropertyName: expectedRootPath, }, ), + headers: const {devToolsRequestHeader: '1'}, ); final fakeManager = FakeDeeplinkManager(); fakeManager.responseForGetAndroidBuildVariants = { @@ -46,12 +47,13 @@ void main() { 'handle deeplink api ${DeeplinkApi.androidBuildVariants} returns bad request if no root path', () async { final request = Request( - 'get', + 'post', Uri( scheme: 'https', host: 'localhost', path: DeeplinkApi.androidBuildVariants, ), + headers: const {devToolsRequestHeader: '1'}, ); final response = await ServerApi.handle( request, @@ -67,7 +69,7 @@ void main() { const buildVariant = 'buildVariant'; const someMessage = 'some message'; final request = Request( - 'get', + 'post', Uri( scheme: 'https', host: 'localhost', @@ -77,6 +79,7 @@ void main() { DeeplinkApi.androidBuildVariantPropertyName: buildVariant, }, ), + headers: const {devToolsRequestHeader: '1'}, ); final fakeManager = FakeDeeplinkManager(); fakeManager.responseForGetAndroidAppLinkSettings = { @@ -97,7 +100,7 @@ void main() { const expectedRootPath = '/abc'; const someMessage = 'some message'; final request = Request( - 'get', + 'post', Uri( scheme: 'https', host: 'localhost', @@ -106,6 +109,7 @@ void main() { DeeplinkApi.deeplinkRootPathPropertyName: expectedRootPath, }, ), + headers: const {devToolsRequestHeader: '1'}, ); final fakeManager = FakeDeeplinkManager(); fakeManager.responseForGetIosBuildOptions = { @@ -129,7 +133,7 @@ void main() { const target = 'target'; const someMessage = 'some message'; final request = Request( - 'get', + 'post', Uri( scheme: 'https', host: 'localhost', @@ -140,6 +144,7 @@ void main() { DeeplinkApi.xcodeTargetPropertyName: target, }, ), + headers: const {devToolsRequestHeader: '1'}, ); final fakeManager = FakeDeeplinkManager(); fakeManager.responseForGetIosUniversalLinkSettings = { diff --git a/packages/devtools_shared/test/server/devtools_extensions_api_test.dart b/packages/devtools_shared/test/server/devtools_extensions_api_test.dart index f075b4717e1..ec672bff0dc 100644 --- a/packages/devtools_shared/test/server/devtools_extensions_api_test.dart +++ b/packages/devtools_shared/test/server/devtools_extensions_api_test.dart @@ -5,6 +5,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:collection/collection.dart'; import 'package:devtools_shared/devtools_extensions.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:devtools_shared/src/extensions/extension_manager.dart'; @@ -44,20 +45,22 @@ void main() { Future initializeTestDirectory({ bool includeDependenciesWithExtensions = true, bool includeBadExtension = false, + bool includeSpoofedExtension = false, }) async { await extensionTestManager.setupTestDirectoryStructure( includeDependenciesWithExtensions: includeDependenciesWithExtensions, includeBadExtension: includeBadExtension, + includeSpoofedExtension: includeSpoofedExtension, ); - await testDtdConnection!.setIDEWorkspaceRoots( - dtd!.info!.secret!, - [extensionTestManager.packagesRootUri], - ); + await testDtdConnection!.setIDEWorkspaceRoots(dtd!.info!.secret!, [ + extensionTestManager.packagesRootUri, + ]); } Future serveExtensions( ExtensionsManager manager, { bool includeRuntimeRoot = true, + String? packageRootUriOverride, }) async { final request = Request( 'post', @@ -67,9 +70,11 @@ void main() { path: ExtensionsApi.apiServeAvailableExtensions, queryParameters: { ExtensionsApi.packageRootUriPropertyName: - includeRuntimeRoot ? extensionTestManager.runtimeAppRoot : null, + packageRootUriOverride ?? + (includeRuntimeRoot ? extensionTestManager.runtimeAppRoot : null), }, ), + headers: const {devToolsRequestHeader: '1'}, ); return await ServerApi.handle( request, @@ -112,6 +117,40 @@ void main() { ); }); + test( + 'spoofed extension is isolated by packageName and cannot overwrite legitimate extension', + () async { + await initializeTestDirectory(includeSpoofedExtension: true); + final response = await serveExtensions(extensionsManager); + expect(response.statusCode, HttpStatus.ok); + + // Verify that the legitimate provider extension is present. + final providerExtension = extensionsManager.devtoolsExtensions + .firstWhereOrNull((e) => e.packageName == 'provider'); + expect(providerExtension, isNotNull); + expect(providerExtension!.name, 'provider'); + expect(providerExtension.version, providerPackage.version); + + // Verify that the spoofed extension from bad_pkg is isolated under packageName 'bad_pkg'. + final spoofedExtension = extensionsManager.devtoolsExtensions + .firstWhereOrNull((e) => e.packageName == 'bad_pkg'); + expect(spoofedExtension, isNotNull); + expect(spoofedExtension!.name, 'provider'); + expect(spoofedExtension.version, '999.0.0'); + expect(spoofedExtension.identifier, 'bad_pkg_provider_999.0.0'); + + // Verify lookupLocationFor returns the respective paths without collision. + expect( + extensionsManager.lookupLocationFor(providerExtension.identifier), + providerExtension.extensionAssetsPath, + ); + expect( + extensionsManager.lookupLocationFor(spoofedExtension.identifier), + spoofedExtension.extensionAssetsPath, + ); + }, + ); + test('succeeds for valid extensions when an exception is thrown', () async { await initializeTestDirectory(); extensionsManager = _TestExtensionsManager(); @@ -128,9 +167,7 @@ void main() { test( 'fails when an exception is thrown and there are no valid extensions', () async { - await initializeTestDirectory( - includeDependenciesWithExtensions: false, - ); + await initializeTestDirectory(includeDependenciesWithExtensions: false); extensionsManager = _TestExtensionsManager(); final response = await serveExtensions(extensionsManager); expect(response.statusCode, HttpStatus.internalServerError); @@ -142,23 +179,494 @@ void main() { expect(error, contains('Fake exception for test')); }, ); + + test( + 'rejects packageRootUri with non-empty host (Windows UNC path)', + () async { + await initializeTestDirectory(); + const uncUri = 'file://remotehost/share/app'; + final response = await serveExtensions( + extensionsManager, + packageRootUriOverride: uncUri, + ); + expect(response.statusCode, HttpStatus.badRequest); + expect(extensionsManager.devtoolsExtensions, isEmpty); + + await expectLater( + extensionsManager.serveAvailableExtensions(uncUri, [], null), + throwsArgumentError, + ); + }, + ); + + test('rejects extension config.yaml with absolute buildLocation', () async { + await initializeTestDirectory(); + final posixAbsConfigFile = File( + p.join( + extensionTestManager.testDirectory.path, + 'extensions', + staticExtension2Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'config.yaml', + ), + ); + posixAbsConfigFile.writeAsStringSync( + '${staticExtension2Package.configYamlContent}\nbuildLocation: /tmp\n', + flush: true, + ); + + final windowsAbsConfigFile = File( + p.join( + extensionTestManager.testDirectory.path, + 'extensions', + newerStaticExtension1Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'config.yaml', + ), + ); + windowsAbsConfigFile.writeAsStringSync( + '${newerStaticExtension1Package.configYamlContent}\n' + r'buildLocation: C:\' + '\n', + flush: true, + ); + + final response = await serveExtensions(extensionsManager); + expect(response.statusCode, HttpStatus.ok); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => e.packageName == staticExtension2Package.packageName, + ), + isEmpty, + ); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => + e.packageName == newerStaticExtension1Package.packageName && + e.version == newerStaticExtension1Package.version, + ), + isEmpty, + ); + + final parsedResponse = json.decode(await response.readAsString()) as Map; + final warning = + parsedResponse[ExtensionsApi.extensionsResultWarningPropertyName] + as String; + expect( + warning, + contains('Encountered errors while parsing extension config.yaml'), + ); + expect(warning, contains('must be a relative path')); + }); + + test( + 'rejects extension with symlink pointing outside build directory', + () async { + await initializeTestDirectory(); + final secretFile = File( + p.join(extensionTestManager.testDirectory.path, 'secret.txt'), + )..writeAsStringSync('top secret', flush: true); + + final extensionBuildDir = Directory( + p.join( + extensionTestManager.testDirectory.path, + 'extensions', + staticExtension2Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'build', + ), + ); + Link( + p.join(extensionBuildDir.path, 'secret_link.txt'), + ).createSync(secretFile.path); + + final outsideBuildDir = Directory( + p.join(extensionTestManager.testDirectory.path, 'outside_build'), + )..createSync(recursive: true); + final newerBuildDir = Directory( + p.join( + extensionTestManager.testDirectory.path, + 'extensions', + newerStaticExtension1Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'build', + ), + ); + newerBuildDir.deleteSync(recursive: true); + Link(newerBuildDir.path).createSync(outsideBuildDir.path); + + final response = await serveExtensions(extensionsManager); + expect(response.statusCode, HttpStatus.ok); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => e.packageName == staticExtension2Package.packageName, + ), + isEmpty, + ); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => + e.packageName == newerStaticExtension1Package.packageName && + e.version == newerStaticExtension1Package.version, + ), + isEmpty, + ); + + final parsedResponse = + json.decode(await response.readAsString()) as Map; + final warning = + parsedResponse[ExtensionsApi.extensionsResultWarningPropertyName] + as String; + expect( + warning, + contains('Encountered errors while parsing extension config.yaml'), + ); + expect( + warning, + contains('contains a symlink pointing outside the build directory'), + ); + expect(warning, contains('resolves outside package root')); + }, + ); + + test('rejects extension with broken or outside directory symlink inside ' + 'build directory', () async { + await initializeTestDirectory(); + final extensionBuildDir = Directory( + p.join( + extensionTestManager.testDirectory.path, + 'extensions', + staticExtension2Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'build', + ), + ); + Link( + p.join(extensionBuildDir.path, 'broken_link.txt'), + ).createSync(p.join(extensionBuildDir.path, 'does_not_exist.txt')); + + final outsideDir = Directory( + p.join(extensionTestManager.testDirectory.path, 'outside_dir'), + )..createSync(recursive: true); + final newerBuildDir = Directory( + p.join( + extensionTestManager.testDirectory.path, + 'extensions', + newerStaticExtension1Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'build', + ), + ); + Link( + p.join(newerBuildDir.path, 'outside_dir_link'), + ).createSync(outsideDir.path); + + final response = await serveExtensions(extensionsManager); + expect(response.statusCode, HttpStatus.ok); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => e.packageName == staticExtension2Package.packageName, + ), + isEmpty, + ); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => + e.packageName == newerStaticExtension1Package.packageName && + e.version == newerStaticExtension1Package.version, + ), + isEmpty, + ); + + final parsedResponse = json.decode(await response.readAsString()) as Map; + final warning = + parsedResponse[ExtensionsApi.extensionsResultWarningPropertyName] + as String; + expect( + warning, + contains('contains a symlink pointing outside the build directory'), + ); + }); + + test( + 'allows extension with symlinks pointing within build directory', + () async { + await initializeTestDirectory(); + final extensionBuildDir = Directory( + p.join( + extensionTestManager.testDirectory.path, + 'extensions', + staticExtension2Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'build', + ), + ); + final indexFile = File(p.join(extensionBuildDir.path, 'index.html')) + ..writeAsStringSync('', flush: true); + final assetsDir = Directory(p.join(extensionBuildDir.path, 'assets')) + ..createSync(recursive: true); + File( + p.join(assetsDir.path, 'data.json'), + ).writeAsStringSync('{}', flush: true); + + Link( + p.join(extensionBuildDir.path, 'alias.html'), + ).createSync(indexFile.path); + Link( + p.join(extensionBuildDir.path, 'assets_alias'), + ).createSync(assetsDir.path); + Link( + p.join(extensionBuildDir.path, 'self_link'), + ).createSync(extensionBuildDir.path); + + final response = await serveExtensions(extensionsManager); + expect(response.statusCode, HttpStatus.ok); + final parsedResponse = + json.decode(await response.readAsString()) as Map; + expect( + parsedResponse[ExtensionsApi.extensionsResultWarningPropertyName], + isNull, + ); + _verifyAllExtensions(extensionsManager); + }, + ); + + test( + 'rejects extension with non-existent or non-directory buildLocation', + () async { + await initializeTestDirectory(); + final missingBuildDir = Directory( + p.join( + extensionTestManager.testDirectory.path, + 'extensions', + staticExtension2Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'build', + ), + ); + missingBuildDir.deleteSync(recursive: true); + + final regularFileBuildPath = p.join( + extensionTestManager.testDirectory.path, + 'extensions', + newerStaticExtension1Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'build', + ); + Directory(regularFileBuildPath).deleteSync(recursive: true); + File( + regularFileBuildPath, + ).writeAsStringSync('not a directory', flush: true); + + final response = await serveExtensions(extensionsManager); + expect(response.statusCode, HttpStatus.ok); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => e.packageName == staticExtension2Package.packageName, + ), + isEmpty, + ); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => + e.packageName == newerStaticExtension1Package.packageName && + e.version == newerStaticExtension1Package.version, + ), + isEmpty, + ); + + final parsedResponse = + json.decode(await response.readAsString()) as Map; + final warning = + parsedResponse[ExtensionsApi.extensionsResultWarningPropertyName] + as String; + expect( + warning, + contains('Encountered errors while parsing extension config.yaml'), + ); + expect( + warning, + contains( + 'Ignoring extension from package ' + '"${staticExtension2Package.packageName}": invalid buildLocation ' + '"build" does not exist or is not a directory.', + ), + ); + expect( + warning, + contains( + 'Ignoring extension from package ' + '"${newerStaticExtension1Package.packageName}": invalid ' + 'buildLocation "build" does not exist or is not a directory.', + ), + ); + }, + ); + + test( + 'rejects extension with broken or non-directory symlink buildLocation', + () async { + await initializeTestDirectory(); + final brokenSymlinkBuildPath = p.join( + extensionTestManager.testDirectory.path, + 'extensions', + staticExtension2Package.relativePathFromExtensions, + 'extension', + 'devtools', + 'build', + ); + Directory(brokenSymlinkBuildPath).deleteSync(recursive: true); + Link(brokenSymlinkBuildPath).createSync( + p.join(extensionTestManager.testDirectory.path, 'does_not_exist'), + ); + + final newerDevToolsDir = p.join( + extensionTestManager.testDirectory.path, + 'extensions', + newerStaticExtension1Package.relativePathFromExtensions, + 'extension', + 'devtools', + ); + final fileSymlinkBuildPath = p.join(newerDevToolsDir, 'build'); + Directory(fileSymlinkBuildPath).deleteSync(recursive: true); + Link( + fileSymlinkBuildPath, + ).createSync(p.join(newerDevToolsDir, 'config.yaml')); + + final response = await serveExtensions(extensionsManager); + expect(response.statusCode, HttpStatus.ok); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => e.packageName == staticExtension2Package.packageName, + ), + isEmpty, + ); + expect( + extensionsManager.devtoolsExtensions.where( + (e) => + e.packageName == newerStaticExtension1Package.packageName && + e.version == newerStaticExtension1Package.version, + ), + isEmpty, + ); + + final parsedResponse = + json.decode(await response.readAsString()) as Map; + final warning = + parsedResponse[ExtensionsApi.extensionsResultWarningPropertyName] + as String; + expect( + warning, + contains('Encountered errors while parsing extension config.yaml'), + ); + expect( + warning, + contains( + 'Ignoring extension from package ' + '"${staticExtension2Package.packageName}": invalid buildLocation ' + '"build" is a broken or non-directory symlink.', + ), + ); + expect( + warning, + contains( + 'Ignoring extension from package ' + '"${newerStaticExtension1Package.packageName}": invalid ' + 'buildLocation "build" is a broken or non-directory symlink.', + ), + ); + }, + ); + + test( + 'stores canonical resolvedLocation and prevents post-discovery symlink ' + 'manipulation', + () async { + await initializeTestDirectory(); + final packageRoot = p.join( + extensionTestManager.testDirectory.path, + 'extensions', + staticExtension2Package.relativePathFromExtensions, + ); + final customBuildDir = Directory(p.join(packageRoot, 'custom_build')) + ..createSync(recursive: true); + File( + p.join(customBuildDir.path, 'index.html'), + ).writeAsStringSync('', flush: true); + + final symlinkBuildPath = p.join( + packageRoot, + 'extension', + 'devtools', + 'build', + ); + Directory(symlinkBuildPath).deleteSync(recursive: true); + Link(symlinkBuildPath).createSync(customBuildDir.path); + + final response = await serveExtensions(extensionsManager); + expect(response.statusCode, HttpStatus.ok); + + final registered = extensionsManager.devtoolsExtensions + .firstWhereOrNull( + (e) => e.packageName == staticExtension2Package.packageName, + ); + expect(registered, isNotNull); + + final canonicalBuildPath = p.normalize( + customBuildDir.resolveSymbolicLinksSync(), + ); + expect(registered!.extensionAssetsPath, canonicalBuildPath); + expect( + extensionsManager.lookupLocationFor(registered.identifier), + canonicalBuildPath, + ); + + // Mutating the symlink after discovery should not affect the stored or + // looked-up canonical assets path. + final outsideDir = Directory( + p.join(extensionTestManager.testDirectory.path, 'outside_build'), + )..createSync(recursive: true); + Link(symlinkBuildPath).deleteSync(); + Link(symlinkBuildPath).createSync(outsideDir.path); + + expect(registered.extensionAssetsPath, canonicalBuildPath); + expect( + extensionsManager.lookupLocationFor(registered.identifier), + canonicalBuildPath, + ); + }, + ); }); group(ExtensionsApi.apiExtensionEnabledState, () { late File optionsFile; - late final optionsFileUriString = p.posix.join( - extensionTestManager.runtimeAppRoot, - devtoolsOptionsFileName, - ); + late String optionsFileUriString; setUp(() async { await initializeTestDirectory(); + // Recompute per test: each test gets a fresh temp directory, so this + // must not be cached across tests (otherwise it would point at a + // previous test's directory). + optionsFileUriString = p.posix.join( + extensionTestManager.runtimeAppRoot, + devtoolsOptionsFileName, + ); optionsFile = File.fromUri(Uri.parse(optionsFileUriString)); }); Future sendEnabledStateRequest({ required String extensionName, bool? enable, + String? optionsUriOverride, }) async { final request = Request( 'post', @@ -167,12 +675,14 @@ void main() { host: 'localhost', path: ExtensionsApi.apiExtensionEnabledState, queryParameters: { - ExtensionsApi.devtoolsOptionsUriPropertyName: optionsFileUriString, + ExtensionsApi.devtoolsOptionsUriPropertyName: + optionsUriOverride ?? optionsFileUriString, ExtensionsApi.extensionNamePropertyName: extensionName, if (enable != null) ExtensionsApi.enabledStatePropertyName: enable.toString(), }, ), + headers: const {devToolsRequestHeader: '1'}, ); return await ServerApi.handle( request, @@ -181,6 +691,132 @@ void main() { ); } + test('rejects a devtoolsOptionsUri that is not a devtools_options.yaml ' + 'file', () async { + await serveExtensions(extensionsManager); + const invalidUris = [ + // Wrong file name. + 'file:///tmp/evil.txt', + 'file:///tmp/devtools_options.yaml.bak', + // Non-file scheme. + 'https://evil.example.com/devtools_options.yaml', + // UNC path / non-empty host. + 'file://remotehost/share/devtools_options.yaml', + ]; + for (final invalidUri in invalidUris) { + final response = await sendEnabledStateRequest( + extensionName: 'drift', + optionsUriOverride: invalidUri, + ); + expect( + response.statusCode, + HttpStatus.badRequest, + reason: 'expected $invalidUri to be rejected', + ); + } + }); + + test('rejects devtoolsOptionsUri in a non-existent directory and does not ' + 'create the directory', () async { + await serveExtensions(extensionsManager); + final nonExistentDir = Directory( + p.join(extensionTestManager.testDirectory.path, 'non_existent_dir'), + ); + expect(nonExistentDir.existsSync(), isFalse); + + final invalidOptionsUri = Uri.file( + p.join(nonExistentDir.path, devtoolsOptionsFileName), + ).toString(); + final response = await sendEnabledStateRequest( + extensionName: 'drift', + enable: true, + optionsUriOverride: invalidOptionsUri, + ); + + expect(response.statusCode, HttpStatus.badRequest); + expect(nonExistentDir.existsSync(), isFalse); + }); + + test('rejects devtoolsOptionsUri in an existing directory or unserved Dart ' + 'package directory', () async { + await serveExtensions(extensionsManager); + + final nonPackageDir = Directory( + p.join(extensionTestManager.testDirectory.path, 'non_package_dir'), + )..createSync(recursive: true); + final unservedPackageDir = Directory( + p.join(extensionTestManager.testDirectory.path, 'unserved_pkg'), + )..createSync(recursive: true); + + const unservedPubspec = ''' +name: unserved_pkg +environment: + sdk: "^3.5.0" +'''; + File( + p.join(unservedPackageDir.path, 'pubspec.yaml'), + ).writeAsStringSync(unservedPubspec); + + for (final dir in [nonPackageDir, unservedPackageDir]) { + final invalidOptionsFile = File( + p.join(dir.path, devtoolsOptionsFileName), + ); + final response = await sendEnabledStateRequest( + extensionName: 'drift', + enable: true, + optionsUriOverride: invalidOptionsFile.uri.toString(), + ); + + expect(response.statusCode, HttpStatus.badRequest); + expect(invalidOptionsFile.existsSync(), isFalse); + } + }); + + test( + 'rejects devtoolsOptionsUri when devtools_options.yaml is a symlink', + () async { + await serveExtensions(extensionsManager); + + final outsideTarget = File( + p.join(extensionTestManager.testDirectory.path, 'outside_target.txt'), + )..writeAsStringSync('unchanged'); + final brokenTarget = File( + p.join(extensionTestManager.testDirectory.path, 'broken_target.txt'), + ); + + final optionsLink = Link(optionsFile.path) + ..createSync(outsideTarget.path); + addTearDown(() { + if (FileSystemEntity.isLinkSync(optionsLink.path)) { + optionsLink.deleteSync(); + } + }); + + var response = await sendEnabledStateRequest( + extensionName: 'drift', + enable: true, + ); + expect(response.statusCode, HttpStatus.badRequest); + expect(outsideTarget.readAsStringSync(), 'unchanged'); + + optionsLink + ..deleteSync() + ..createSync(brokenTarget.path); + response = await sendEnabledStateRequest( + extensionName: 'drift', + enable: true, + ); + expect(response.statusCode, HttpStatus.badRequest); + expect(brokenTarget.existsSync(), isFalse); + }, + ); + + test('accepts a valid devtools_options.yaml file: URI', () async { + await serveExtensions(extensionsManager); + final response = await sendEnabledStateRequest(extensionName: 'drift'); + expect(response.statusCode, HttpStatus.ok); + }); + test('options file does not exist until first acesss', () async { await serveExtensions(extensionsManager); expect(optionsFile.existsSync(), isFalse); @@ -202,16 +838,16 @@ void main() { ExtensionEnabledState.none.name, ); -// TODO(kenz): why is existsSync() returning false when I can verify the file -// contents on the file system at [optionsFileUriString]? -// expect(optionsFile.existsSync(), isTrue); -// expect( -// optionsFile.readAsStringSync(), -// ''' -// description: This file stores settings for Dart & Flutter DevTools. -// documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -// extensions:''', -// ); + // TODO(kenz): why is existsSync() returning false when I can verify the file + // contents on the file system at [optionsFileUriString]? + // expect(optionsFile.existsSync(), isTrue); + // expect( + // optionsFile.readAsStringSync(), + // ''' + // description: This file stores settings for Dart & Flutter DevTools. + // documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states + // extensions:''', + // ); response = await sendEnabledStateRequest( extensionName: 'drift', @@ -233,16 +869,16 @@ void main() { ExtensionEnabledState.disabled.name, ); -// expect(optionsFile.existsSync(), isTrue); -// expect( -// optionsFile.readAsStringSync(), -// ''' -// description: This file stores settings for Dart & Flutter DevTools. -// documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states -// extensions: -// - drift: true -// - provider: false''', -// ); + // expect(optionsFile.existsSync(), isTrue); + // expect( + // optionsFile.readAsStringSync(), + // ''' + // description: This file stores settings for Dart & Flutter DevTools. + // documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states + // extensions: + // - drift: true + // - provider: false''', + // ); }); }); } @@ -364,6 +1000,7 @@ void _verifyExtension( required bool fromStaticContext, }) { expect(ext.name, extensionPackage.name); + expect(ext.packageName, extensionPackage.packageName); expect(ext.issueTrackerLink, extensionPackage.issueTracker); expect(ext.version, extensionPackage.version); expect(ext.materialIconCodePoint, extensionPackage.materialIconCodePoint); @@ -398,6 +1035,10 @@ void _verifyExtension( ), ); } + expect( + ext.extensionAssetsPath, + p.normalize(Directory(ext.extensionAssetsPath).resolveSymbolicLinksSync()), + ); expect( ext.devtoolsOptionsUri, endsWith( diff --git a/packages/devtools_shared/test/server/dtd_api_test.dart b/packages/devtools_shared/test/server/dtd_api_test.dart index c54ae54cfde..873692381b1 100644 --- a/packages/devtools_shared/test/server/dtd_api_test.dart +++ b/packages/devtools_shared/test/server/dtd_api_test.dart @@ -18,12 +18,9 @@ void main() { test('handle ${DtdApi.apiGetDtdUri} succeeds', () async { final dtdUri = Uri.parse('ws://dtd/uri'); final request = Request( - 'get', - Uri( - scheme: 'https', - host: 'localhost', - path: DtdApi.apiGetDtdUri, - ), + 'post', + Uri(scheme: 'https', host: 'localhost', path: DtdApi.apiGetDtdUri), + headers: const {devToolsRequestHeader: '1'}, ); final response = await ServerApi.handle( request, diff --git a/packages/devtools_shared/test/server/file_system_test.dart b/packages/devtools_shared/test/server/file_system_test.dart new file mode 100644 index 00000000000..b7c2a0d57b0 --- /dev/null +++ b/packages/devtools_shared/test/server/file_system_test.dart @@ -0,0 +1,111 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:convert'; + +import 'package:devtools_shared/src/server/file_system.dart' hide fileSystem; +import 'package:file/memory.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +void main() { + group('LocalFileSystem.devToolsFileFromPath', () { + late MemoryFileSystem fs; + + setUp(() { + fs = MemoryFileSystem(); + fs + .directory(FileSystemExtension.devToolsDir) + .parent + .createSync(recursive: true); + }); + + group('path validation', () { + // These inputs must be rejected before any filesystem access so that reads + // stay confined to the ~/.flutter-devtools/ directory. + + test('rejects absolute paths', () { + final root = path.style == .windows ? 'C:\\' : '/'; + // `path.join()` discards the base directory when its second argument is + // absolute, so an absolute path would otherwise escape the DevTools + // directory and read an arbitrary file on disk. + expect( + fs.devToolsFileFromPath(path.join('${root}etc', 'passwd')), + isNull, + ); + expect( + fs.devToolsFileFromPath( + path.join('${root}absolute', 'path', 'to', 'file.json'), + ), + isNull, + ); + }); + + test('rejects paths containing ".."', () { + expect(fs.devToolsFileFromPath('..'), isNull); + expect( + fs.devToolsFileFromPath(path.join('..', '..', '..', 'etc', 'passwd')), + isNull, + ); + expect( + fs.devToolsFileFromPath( + path.join('subdir', '..', '..', 'escape.json'), + ), + isNull, + ); + }); + }); + + test('returns file when path is valid and file exists', () { + final testFile = fs.file( + path.join(FileSystemExtension.devToolsDir, 'sub', 'file.json'), + )..createSync(recursive: true); + + final file = fs.devToolsFileFromPath(path.join('sub', 'file.json'))!; + expect(file.path, testFile.path); + }); + + test('returns null when path is valid but file does not exist', () { + final file = fs.devToolsFileFromPath( + path.join('sub', 'non_existent.json'), + ); + expect(file, isNull); + }); + }); + + group('LocalFileSystem.devToolsFileAsJson', () { + late MemoryFileSystem fs; + + setUp(() { + fs = MemoryFileSystem(); + fs + .directory(FileSystemExtension.devToolsDir) + .parent + .createSync(recursive: true); + }); + + test('returns null if file does not exist', () { + expect(fs.devToolsFileAsJson('test.json'), isNull); + }); + + test('returns null if file is not json', () { + fs.file(path.join(FileSystemExtension.devToolsDir, 'test.txt')) + ..createSync(recursive: true) + ..writeAsStringSync('hello'); + expect(fs.devToolsFileAsJson('test.txt'), isNull); + }); + + test('returns json content with lastModifiedTime', () { + final file = fs.file( + path.join(FileSystemExtension.devToolsDir, 'test.json'), + )..createSync(recursive: true); + file.writeAsStringSync('{"key": "value"}'); + + final jsonStr = fs.devToolsFileAsJson('test.json')!; + final json = jsonDecode(jsonStr) as Map; + expect(json['key'], 'value'); + expect(json['lastModifiedTime'], file.lastModifiedSync().toString()); + }); + }); +} diff --git a/packages/devtools_shared/test/server/general_api_test.dart b/packages/devtools_shared/test/server/general_api_test.dart index d931d53b3cf..f0902307d63 100644 --- a/packages/devtools_shared/test/server/general_api_test.dart +++ b/packages/devtools_shared/test/server/general_api_test.dart @@ -6,7 +6,6 @@ import 'dart:io'; import 'package:devtools_shared/devtools_server.dart'; import 'package:devtools_shared/devtools_shared.dart'; -import 'package:devtools_shared/devtools_test_utils.dart'; import 'package:devtools_shared/src/extensions/extension_manager.dart'; import 'package:devtools_shared/src/server/server_api.dart' as server; import 'package:dtd/dtd.dart'; @@ -18,61 +17,606 @@ import '../helpers/helpers.dart'; void main() { group('General DevTools server API', () { - group(apiNotifyForVmServiceConnection, () { - Future sendNotifyRequest({ - required DtdInfo? dtd, - Map? queryParameters, + group('request validation', () { + Future sendServerRequest({ + String method = 'POST', + String uriHost = 'localhost', + Map? headers, + bool includeDevToolsRequestHeader = true, + bool disableOriginCheck = false, + List allowedOrigins = const [], // ignore: avoid-redundant-async, returning FutureOr. }) async { + final requestHeaders = { + if (includeDevToolsRequestHeader && method.toUpperCase() == 'POST') + devToolsRequestHeader: '1', + ...?headers, + }; final request = Request( - 'get', - Uri( - scheme: 'https', - host: 'localhost', - path: apiNotifyForVmServiceConnection, - queryParameters: queryParameters, - ), + method, + Uri(scheme: 'http', host: uriHost, path: DtdApi.apiGetDtdUri), + headers: requestHeaders, ); return server.ServerApi.handle( request, extensionsManager: ExtensionsManager(), deeplinkManager: FakeDeeplinkManager(), - dtd: dtd, + disableOriginCheck: disableOriginCheck, + allowedOrigins: allowedOrigins, ); } test( - 'succeeds when DTD is not available', + 'rejects unsupported HTTP methods with 405 Method Not Allowed', () async { - final response = await sendNotifyRequest( - dtd: null, - queryParameters: { - apiParameterValueKey: 'fake_uri', - apiParameterVmServiceConnected: 'true', + for (final method in [ + 'GET', + 'get', + 'PUT', + 'DELETE', + 'PATCH', + 'HEAD', + ]) { + final response = await sendServerRequest(method: method); + expect(response.statusCode, HttpStatus.methodNotAllowed); + expect(response.headers[HttpHeaders.allowHeader], 'POST, OPTIONS'); + expect( + await response.readAsString(), + 'Method Not Allowed: $method', + ); + } + }, + ); + + test( + 'rejects POST requests missing or blank $devToolsRequestHeader header', + () async { + final missingResponse = await sendServerRequest( + includeDevToolsRequestHeader: false, + ); + expect(missingResponse.statusCode, HttpStatus.forbidden); + expect( + await missingResponse.readAsString(), + 'Missing $devToolsRequestHeader header', + ); + + for (final blankValue in ['', ' ']) { + final blankResponse = await sendServerRequest( + headers: {devToolsRequestHeader: blankValue}, + ); + expect(blankResponse.statusCode, HttpStatus.forbidden); + expect( + await blankResponse.readAsString(), + 'Missing $devToolsRequestHeader header', + ); + } + + final lowercaseResponse = await sendServerRequest( + includeDevToolsRequestHeader: false, + headers: {devToolsRequestHeader.toLowerCase(): '1'}, + ); + expect(lowercaseResponse.statusCode, HttpStatus.ok); + }, + ); + + test( + 'handles OPTIONS preflight requests from loopback origins', + () async { + for (final method in ['OPTIONS', 'options']) { + for (final headers in [ + { + HttpHeaders.hostHeader: '127.0.0.1:9104', + 'origin': 'http://localhost:54321', + 'sec-fetch-site': 'cross-site', + HttpHeaders.accessControlRequestMethodHeader: 'POST', + HttpHeaders.accessControlRequestHeadersHeader: + devToolsRequestHeader.toLowerCase(), + }, + { + HttpHeaders.hostHeader: 'localhost:9104', + 'origin': 'http://localhost:54321', + 'sec-fetch-site': 'same-site', + HttpHeaders.accessControlRequestMethodHeader: 'POST', + HttpHeaders.accessControlRequestHeadersHeader: + devToolsRequestHeader.toLowerCase(), + }, + ]) { + final response = await sendServerRequest( + method: method, + headers: headers, + ); + expect(response.statusCode, HttpStatus.noContent); + expect(await response.readAsString(), isEmpty); + expect( + response.headers[HttpHeaders.accessControlAllowMethodsHeader], + 'POST, OPTIONS', + ); + expect( + response.headers[HttpHeaders.accessControlAllowHeadersHeader], + devToolsRequestHeader, + ); + expect( + response.headers.containsKey( + HttpHeaders.accessControlAllowOriginHeader, + ), + isFalse, + ); + } + } + }, + ); + + test('rejects OPTIONS preflight requests with invalid headers', () async { + final invalidPreflights = [ + { + HttpHeaders.hostHeader: 'evil.com:9104', + 'origin': 'http://localhost:54321', + }, + { + HttpHeaders.hostHeader: '127.0.0.1:9104', + 'origin': 'https://evil.com', + 'sec-fetch-site': 'cross-site', + }, + { + HttpHeaders.hostHeader: '127.0.0.1:9104', + 'sec-fetch-site': 'cross-site', + }, + ]; + for (final headers in invalidPreflights) { + final response = await sendServerRequest( + method: 'OPTIONS', + headers: headers, + ); + expect(response.statusCode, HttpStatus.forbidden); + expect( + response.headers.containsKey( + HttpHeaders.accessControlAllowMethodsHeader, + ), + isFalse, + ); + expect( + response.headers.containsKey( + HttpHeaders.accessControlAllowHeadersHeader, + ), + isFalse, + ); + } + }); + + test('rejects non-loopback Host headers with 403 Forbidden', () async { + const invalidHosts = [ + 'evil.com', + 'evil.com:9100', + 'localhost.evil.com', + '127.0.0.1.evil.com', + 'localhost@evil.com', + '8.8.8.8', + '0.0.0.0', + '', + ]; + for (final invalidHost in invalidHosts) { + final response = await sendServerRequest( + headers: {HttpHeaders.hostHeader: invalidHost}, + ); + expect( + response.statusCode, + HttpStatus.forbidden, + reason: 'Expected Host "$invalidHost" to be rejected', + ); + expect(await response.readAsString(), 'Invalid Host header'); + } + + final nonLoopbackUriResponse = await sendServerRequest( + uriHost: 'evil.com', + ); + expect(nonLoopbackUriResponse.statusCode, HttpStatus.forbidden); + expect( + await nonLoopbackUriResponse.readAsString(), + 'Invalid Host header', + ); + + final duplicateHostResponse = await sendServerRequest( + headers: { + HttpHeaders.hostHeader: ['localhost:9104', 'localhost:9104'], + }, + ); + expect(duplicateHostResponse.statusCode, HttpStatus.forbidden); + expect( + await duplicateHostResponse.readAsString(), + 'Invalid Host header', + ); + }); + + test('accepts loopback Host headers', () async { + const validHosts = [ + 'localhost', + 'localhost:9100', + '127.0.0.1', + '127.0.0.1:9100', + '127.0.0.2:9100', + '[::1]', + '[::1]:9100', + '::1', + ]; + for (final validHost in validHosts) { + final response = await sendServerRequest( + headers: {HttpHeaders.hostHeader: validHost}, + ); + expect( + response.statusCode, + HttpStatus.ok, + reason: 'Expected Host "$validHost" to be accepted', + ); + } + }); + + test('rejects invalid Origin headers with 403 Forbidden', () async { + const invalidOrigins = [ + 'https://evil.com', + 'http://evil.com:9100', + 'http://localhost.evil.com', + 'http://127.0.0.1.evil.com', + 'http://localhost@evil.com', + 'file://localhost/foo', + 'custom://localhost', + 'ws://localhost:9104', + 'http://localhost:9104/', + 'http://localhost:9104/foo', + 'http://localhost:9104?a=1', + 'http://localhost:9104#frag', + 'http://', + 'http://:9104', + 'null', + '', + ]; + for (final invalidOrigin in invalidOrigins) { + final response = await sendServerRequest( + headers: {'origin': invalidOrigin}, + ); + expect( + response.statusCode, + HttpStatus.forbidden, + reason: 'Expected Origin "$invalidOrigin" to be rejected', + ); + expect(await response.readAsString(), 'Invalid Origin header'); + } + + final duplicateOriginResponse = await sendServerRequest( + headers: { + 'origin': ['http://localhost:54321', 'http://localhost:54321'], + }, + ); + expect(duplicateOriginResponse.statusCode, HttpStatus.forbidden); + expect( + await duplicateOriginResponse.readAsString(), + 'Invalid Origin header', + ); + }); + + test('accepts loopback Origin headers', () async { + const validOrigins = [ + 'http://localhost:9100', + 'https://localhost:9100', + 'http://127.0.0.1:9100', + 'http://127.0.0.2:9100', + 'http://[::1]:9100', + ]; + for (final validOrigin in validOrigins) { + final response = await sendServerRequest( + headers: {'origin': validOrigin}, + ); + expect( + response.statusCode, + HttpStatus.ok, + reason: 'Expected Origin "$validOrigin" to be accepted', + ); + } + }); + + test( + 'validates Sec-Fetch-Site based on presence of validated Origin', + () async { + for (final disallowedWithoutOrigin in [ + 'cross-site', + 'same-site', + 'unexpected', + ]) { + final response = await sendServerRequest( + headers: {'sec-fetch-site': disallowedWithoutOrigin}, + ); + expect( + response.statusCode, + HttpStatus.forbidden, + reason: + 'Expected Sec-Fetch-Site "$disallowedWithoutOrigin" without Origin to be rejected', + ); + expect( + await response.readAsString(), + 'Cross-site requests are not allowed', + ); + } + + final evilOriginResponse = await sendServerRequest( + headers: { + 'origin': 'https://evil.com', + 'sec-fetch-site': 'cross-site', }, ); - expect(response.statusCode, HttpStatus.ok); - expect(await response.readAsString(), isEmpty); + expect(evilOriginResponse.statusCode, HttpStatus.forbidden); + + final invalidValueWithLoopbackOrigin = await sendServerRequest( + headers: { + 'origin': 'http://localhost:54321', + 'sec-fetch-site': 'unexpected', + }, + ); + expect( + invalidValueWithLoopbackOrigin.statusCode, + HttpStatus.forbidden, + ); + + for (final allowedWithLoopbackOrigin in [ + 'same-origin', + 'same-site', + 'cross-site', + 'none', + ]) { + final response = await sendServerRequest( + headers: { + HttpHeaders.hostHeader: '127.0.0.1:9104', + 'origin': 'http://localhost:54321', + 'sec-fetch-site': allowedWithLoopbackOrigin, + }, + ); + expect( + response.statusCode, + HttpStatus.ok, + reason: + 'Expected Sec-Fetch-Site "$allowedWithLoopbackOrigin" with loopback Origin to be accepted', + ); + } + + for (final allowedWithoutOrigin in ['same-origin', 'none']) { + final okResponse = await sendServerRequest( + headers: {'sec-fetch-site': allowedWithoutOrigin}, + ); + expect( + okResponse.statusCode, + HttpStatus.ok, + reason: + 'Expected Sec-Fetch-Site "$allowedWithoutOrigin" without Origin to be accepted', + ); + } }, ); test( - 'returns badRequest for invalid VM service argument', + 'disableOriginCheck skips origin/host checks while keeping method and header checks', () async { - final response = await sendNotifyRequest( - dtd: DtdInfo(Uri.parse('ws://dtd/uri'), secret: 'fake_secret'), - queryParameters: { - apiParameterValueKey: 'fake_uri', - apiParameterVmServiceConnected: 'true', + final allowedPostResponse = await sendServerRequest( + uriHost: 'remote.example.com', + headers: { + HttpHeaders.hostHeader: 'remote.example.com:9104', + 'origin': 'https://evil.com', + 'sec-fetch-site': 'cross-site', }, + disableOriginCheck: true, + ); + expect(allowedPostResponse.statusCode, HttpStatus.ok); + + final allowedNoOriginCrossSite = await sendServerRequest( + uriHost: 'remote.example.com', + headers: { + HttpHeaders.hostHeader: 'remote.example.com:9104', + 'sec-fetch-site': 'cross-site', + }, + disableOriginCheck: true, + ); + expect(allowedNoOriginCrossSite.statusCode, HttpStatus.ok); + + final allowedOptionsResponse = await sendServerRequest( + method: 'OPTIONS', + uriHost: 'remote.example.com', + headers: { + HttpHeaders.hostHeader: 'remote.example.com:9104', + 'origin': 'https://evil.com', + 'sec-fetch-site': 'cross-site', + }, + disableOriginCheck: true, + ); + expect(allowedOptionsResponse.statusCode, HttpStatus.noContent); + + final disallowedMethodResponse = await sendServerRequest( + method: 'GET', + uriHost: 'remote.example.com', + disableOriginCheck: true, ); - expect(response.statusCode, HttpStatus.badRequest); expect( - await response.readAsString(), - contains('Cannot normalize VM service URI'), + disallowedMethodResponse.statusCode, + HttpStatus.methodNotAllowed, + ); + expect( + disallowedMethodResponse.headers[HttpHeaders.allowHeader], + 'POST, OPTIONS', + ); + + final missingHeaderResponse = await sendServerRequest( + uriHost: 'remote.example.com', + includeDevToolsRequestHeader: false, + disableOriginCheck: true, + ); + expect(missingHeaderResponse.statusCode, HttpStatus.forbidden); + expect( + await missingHeaderResponse.readAsString(), + 'Missing $devToolsRequestHeader header', + ); + }, + ); + + test( + 'supports non-loopback hosts and origins via allowedOrigins', + () async { + final allowedOrigins = [ + Uri.parse('http://192.168.1.50:9104/auth-token/'), + Uri.parse('https://dev.example.com'), + Uri.parse('http://[fe80::1]:9104'), + ]; + + final matchingPost = await sendServerRequest( + uriHost: '192.168.1.50', + headers: { + HttpHeaders.hostHeader: '192.168.1.50:9104', + 'origin': 'http://192.168.1.50:9104', + 'sec-fetch-site': 'same-origin', + }, + allowedOrigins: allowedOrigins, + ); + expect(matchingPost.statusCode, HttpStatus.ok); + + final matchingHttpsDefaultPort = await sendServerRequest( + uriHost: 'dev.example.com', + headers: { + HttpHeaders.hostHeader: 'dev.example.com', + 'origin': 'https://dev.example.com:443', + 'sec-fetch-site': 'same-site', + }, + allowedOrigins: allowedOrigins, + ); + expect(matchingHttpsDefaultPort.statusCode, HttpStatus.ok); + + final matchingIpv6 = await sendServerRequest( + uriHost: 'fe80::1', + headers: { + HttpHeaders.hostHeader: '[fe80::1]:9104', + 'origin': 'http://[fe80:0:0:0:0:0:0:1]:9104', + 'sec-fetch-site': 'same-origin', + }, + allowedOrigins: allowedOrigins, + ); + expect(matchingIpv6.statusCode, HttpStatus.ok); + + final matchingOptions = await sendServerRequest( + method: 'OPTIONS', + uriHost: '192.168.1.50', + headers: { + HttpHeaders.hostHeader: '192.168.1.50:9104', + 'origin': 'https://dev.example.com', + 'sec-fetch-site': 'cross-site', + }, + allowedOrigins: allowedOrigins, + ); + expect(matchingOptions.statusCode, HttpStatus.noContent); + + final loopbackStillAllowed = await sendServerRequest( + headers: { + HttpHeaders.hostHeader: '127.0.0.1:9104', + 'origin': 'http://localhost:54321', + 'sec-fetch-site': 'cross-site', + }, + allowedOrigins: allowedOrigins, + ); + expect(loopbackStillAllowed.statusCode, HttpStatus.ok); + + final wrongHost = await sendServerRequest( + uriHost: 'evil.com', + headers: {HttpHeaders.hostHeader: 'evil.com'}, + allowedOrigins: allowedOrigins, + ); + expect(wrongHost.statusCode, HttpStatus.forbidden); + expect(await wrongHost.readAsString(), 'Invalid Host header'); + + final wrongPortOrigin = await sendServerRequest( + uriHost: '192.168.1.50', + headers: { + HttpHeaders.hostHeader: '192.168.1.50:9104', + 'origin': 'http://192.168.1.50:5555', + }, + allowedOrigins: allowedOrigins, + ); + expect(wrongPortOrigin.statusCode, HttpStatus.forbidden); + expect(await wrongPortOrigin.readAsString(), 'Invalid Origin header'); + + final wrongSchemeOrigin = await sendServerRequest( + uriHost: 'dev.example.com', + headers: { + HttpHeaders.hostHeader: 'dev.example.com', + 'origin': 'http://dev.example.com', + }, + allowedOrigins: allowedOrigins, + ); + expect(wrongSchemeOrigin.statusCode, HttpStatus.forbidden); + expect( + await wrongSchemeOrigin.readAsString(), + 'Invalid Origin header', + ); + + final sameSiteWithoutOrigin = await sendServerRequest( + uriHost: 'dev.example.com', + headers: { + HttpHeaders.hostHeader: 'dev.example.com', + 'sec-fetch-site': 'same-site', + }, + allowedOrigins: allowedOrigins, + ); + expect(sameSiteWithoutOrigin.statusCode, HttpStatus.forbidden); + expect( + await sameSiteWithoutOrigin.readAsString(), + 'Cross-site requests are not allowed', ); }, ); + }); + + group(apiNotifyForVmServiceConnection, () { + Future sendNotifyRequest({ + required DtdInfo? dtd, + Map? queryParameters, + // ignore: avoid-redundant-async, returning FutureOr. + }) async { + final request = Request( + 'post', + Uri( + scheme: 'https', + host: 'localhost', + path: apiNotifyForVmServiceConnection, + queryParameters: queryParameters, + ), + headers: const {devToolsRequestHeader: '1'}, + ); + return server.ServerApi.handle( + request, + extensionsManager: ExtensionsManager(), + deeplinkManager: FakeDeeplinkManager(), + dtd: dtd, + ); + } + + test('succeeds when DTD is not available', () async { + final response = await sendNotifyRequest( + dtd: null, + queryParameters: { + apiParameterValueKey: 'fake_uri', + apiParameterVmServiceConnected: 'true', + }, + ); + expect(response.statusCode, HttpStatus.ok); + expect(await response.readAsString(), isEmpty); + }); + + test('returns badRequest for invalid VM service argument', () async { + final response = await sendNotifyRequest( + dtd: DtdInfo(Uri.parse('ws://dtd/uri'), secret: 'fake_secret'), + queryParameters: { + apiParameterValueKey: 'fake_uri', + apiParameterVmServiceConnected: 'true', + }, + ); + expect(response.statusCode, HttpStatus.badRequest); + expect( + await response.readAsString(), + contains('Cannot normalize VM service URI'), + ); + }); test( 'returns badRequest for invalid $apiParameterVmServiceConnected argument', () async { @@ -99,8 +643,9 @@ void main() { setUp(() async { dtd = await startDtd(); expect(dtd!.info, isNotNull, reason: 'Error starting DTD for test'); - testDtdConnection = - await DartToolingDaemon.connect(dtd!.info!.localUri); + testDtdConnection = await DartToolingDaemon.connect( + dtd!.info!.localUri, + ); }); tearDown(() async { @@ -131,32 +676,28 @@ void main() { expect(currentRoots, containsAll(roots)); } - test( - 'adds and removes workspace roots', - () async { - await verifyWorkspaceRoots({}); - final rootUri1 = Uri.parse('file:///Users/me/package_root_1'); - final rootUri2 = Uri.parse('file:///Users/me/package_root_2'); - - await updateWorkspaceRoots(root: rootUri1, connected: true); - await verifyWorkspaceRoots({rootUri1}); - - // Add a second root and verify the roots are unioned. - await updateWorkspaceRoots(root: rootUri2, connected: true); - await verifyWorkspaceRoots({rootUri1, rootUri2}); - - // Verify duplicates cannot be added. - await updateWorkspaceRoots(root: rootUri2, connected: true); - await verifyWorkspaceRoots({rootUri1, rootUri2}); - - // Verify roots are removed for disconnect events. - await updateWorkspaceRoots(root: rootUri2, connected: false); - await verifyWorkspaceRoots({rootUri1}); - await updateWorkspaceRoots(root: rootUri1, connected: false); - await verifyWorkspaceRoots({}); - }, - timeout: const Timeout.factor(4), - ); + test('adds and removes workspace roots', () async { + await verifyWorkspaceRoots({}); + final rootUri1 = Uri.parse('file:///Users/me/package_root_1'); + final rootUri2 = Uri.parse('file:///Users/me/package_root_2'); + + await updateWorkspaceRoots(root: rootUri1, connected: true); + await verifyWorkspaceRoots({rootUri1}); + + // Add a second root and verify the roots are unioned. + await updateWorkspaceRoots(root: rootUri2, connected: true); + await verifyWorkspaceRoots({rootUri1, rootUri2}); + + // Verify duplicates cannot be added. + await updateWorkspaceRoots(root: rootUri2, connected: true); + await verifyWorkspaceRoots({rootUri1, rootUri2}); + + // Verify roots are removed for disconnect events. + await updateWorkspaceRoots(root: rootUri2, connected: false); + await verifyWorkspaceRoots({rootUri1}); + await updateWorkspaceRoots(root: rootUri1, connected: false); + await verifyWorkspaceRoots({}); + }, timeout: const Timeout.factor(4)); }); group('detectRootPackageForVmService', () { @@ -166,8 +707,6 @@ void main() { setUp(() async { app = TestDartApp(); vmServiceUriString = await app!.start(); - // Await a short delay to give the VM a chance to initialize. - await delay(duration: const Duration(milliseconds: 2500)); expect(vmServiceUriString, isNotEmpty); }); @@ -180,27 +719,35 @@ void main() { test('succeeds for a connect event', () async { final vmServiceUri = normalizeVmServiceUri(vmServiceUriString!); expect(vmServiceUri, isNotNull); - final response = - await server.VmServiceHandler.detectRootPackageForVmService( - vmServiceUriAsString: vmServiceUriString!, - vmServiceUri: vmServiceUri!, - connected: true, - dtd: testDtdConnection!, + late server.DetectRootPackageResponse response; + await runWithRetry( + callback: () async { + response = + await server.VmServiceHandler.detectRootPackageForVmService( + vmServiceUriAsString: vmServiceUriString!, + vmServiceUri: vmServiceUri!, + connected: true, + dtd: testDtdConnection!, + ); + if (!response.success) throw Exception('VM not ready'); + }, + maxRetries: 20, + retryDelay: const Duration(milliseconds: 500), ); expect(response.success, true); expect(response.message, isNull); expect(response.uri, isNotNull); - expect(response.uri!.toString(), endsWith(app!.directory.path)); + expect(response.uri!.toFilePath(), endsWith(app!.directory.path)); }); test('succeeds for a disconnect event when cache is empty', () async { final response = await server.VmServiceHandler.detectRootPackageForVmService( - vmServiceUriAsString: vmServiceUriString!, - vmServiceUri: Uri.parse('ws://127.0.0.1:63555/fake-uri=/ws'), - connected: false, - dtd: testDtdConnection!, - ); + vmServiceUriAsString: vmServiceUriString!, + vmServiceUri: Uri.parse('ws://127.0.0.1:63555/fake-uri=/ws'), + connected: false, + dtd: testDtdConnection!, + ); expect(response, (success: true, message: null, uri: null)); }); @@ -209,30 +756,38 @@ void main() { () async { final vmServiceUri = normalizeVmServiceUri(vmServiceUriString!); expect(vmServiceUri, isNotNull); - final response = - await server.VmServiceHandler.detectRootPackageForVmService( - vmServiceUriAsString: vmServiceUriString!, - vmServiceUri: vmServiceUri!, - connected: true, - dtd: testDtdConnection!, + late server.DetectRootPackageResponse response; + await runWithRetry( + callback: () async { + response = + await server.VmServiceHandler.detectRootPackageForVmService( + vmServiceUriAsString: vmServiceUriString!, + vmServiceUri: vmServiceUri!, + connected: true, + dtd: testDtdConnection!, + ); + if (!response.success) throw Exception('VM not ready'); + }, + maxRetries: 20, + retryDelay: const Duration(milliseconds: 500), ); expect(response.success, true); expect(response.message, isNull); expect(response.uri, isNotNull); - expect(response.uri!.toString(), endsWith(app!.directory.path)); + expect(response.uri!.toFilePath(), endsWith(app!.directory.path)); final disconnectResponse = await server.VmServiceHandler.detectRootPackageForVmService( - vmServiceUriAsString: vmServiceUriString!, - vmServiceUri: vmServiceUri, - connected: false, - dtd: testDtdConnection!, - ); + vmServiceUriAsString: vmServiceUriString!, + vmServiceUri: vmServiceUri!, + connected: false, + dtd: testDtdConnection!, + ); expect(disconnectResponse.success, true); expect(disconnectResponse.message, isNull); expect(disconnectResponse.uri, isNotNull); expect( - disconnectResponse.uri!.toString(), + disconnectResponse.uri!.toFilePath(), endsWith(app!.directory.path), ); }, diff --git a/packages/devtools_shared/test/server/persistent_properties_test.dart b/packages/devtools_shared/test/server/persistent_properties_test.dart new file mode 100644 index 00000000000..fd337ef77e9 --- /dev/null +++ b/packages/devtools_shared/test/server/persistent_properties_test.dart @@ -0,0 +1,52 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:convert'; + +import 'package:devtools_shared/src/server/file_system.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +void main() { + group('IOPersistentProperties', () { + late MemoryFileSystem fs; + late Directory tempDir; + late IOPersistentProperties properties; + const storeName = 'test_store'; + + setUp(() { + fs = MemoryFileSystem(); + tempDir = fs.systemTempDirectory.createTempSync( + 'persistent_properties_test', + ); + properties = IOPersistentProperties( + storeName, + documentDirPath: tempDir.path, + fs: fs, + ); + }); + + test('remove persists changes to disk', () { + properties['key1'] = 'value1'; + properties['key2'] = 'value2'; + + final file = fs.file(path.join(tempDir.path, storeName)); + expect(file.existsSync(), isTrue); + + var content = file.readAsStringSync(); + var json = (jsonDecode(content) as Map).cast(); + expect(json['key1'], 'value1'); + expect(json['key2'], 'value2'); + + properties.remove('key1'); + + content = file.readAsStringSync(); + json = (jsonDecode(content) as Map).cast(); + expect(json.containsKey('key1'), isFalse); + expect(json['key2'], 'value2'); + }); + }); +} diff --git a/packages/devtools_shared/test/service_utils_test.dart b/packages/devtools_shared/test/service_utils_test.dart index fe3a8df5bf3..351a6fbd304 100644 --- a/packages/devtools_shared/test/service_utils_test.dart +++ b/packages/devtools_shared/test/service_utils_test.dart @@ -13,8 +13,9 @@ void main() { equals('http://127.0.0.1:60667/72K34Xmq0X0='), ); expect( - normalizeVmServiceUri('http://127.0.0.1:60667/72K34Xmq0X0=/ ') - .toString(), + normalizeVmServiceUri( + 'http://127.0.0.1:60667/72K34Xmq0X0=/ ', + ).toString(), equals('http://127.0.0.1:60667/72K34Xmq0X0=/'), ); expect( @@ -29,21 +30,24 @@ void main() { test('properly strips leading whitespace and trailing URI fragments', () { expect( - normalizeVmServiceUri(' http://127.0.0.1:60667/72K34Xmq0X0=/#/vm') - .toString(), + normalizeVmServiceUri( + ' http://127.0.0.1:60667/72K34Xmq0X0=/#/vm', + ).toString(), equals('http://127.0.0.1:60667/72K34Xmq0X0=/'), ); expect( - normalizeVmServiceUri(' http://127.0.0.1:60667/72K34Xmq0X0=/#/vm ') - .toString(), + normalizeVmServiceUri( + ' http://127.0.0.1:60667/72K34Xmq0X0=/#/vm ', + ).toString(), equals('http://127.0.0.1:60667/72K34Xmq0X0=/'), ); }); test('properly handles encoded urls', () { expect( - normalizeVmServiceUri('http%3A%2F%2F127.0.0.1%3A58824%2FCnvgRrQJG7w%3D') - .toString(), + normalizeVmServiceUri( + 'http%3A%2F%2F127.0.0.1%3A58824%2FCnvgRrQJG7w%3D', + ).toString(), equals('http://127.0.0.1:58824/CnvgRrQJG7w='), ); diff --git a/packages/devtools_shared/test/utils/file_utils_test.dart b/packages/devtools_shared/test/utils/file_utils_test.dart index fd755041ec1..f8b5bdc2f0b 100644 --- a/packages/devtools_shared/test/utils/file_utils_test.dart +++ b/packages/devtools_shared/test/utils/file_utils_test.dart @@ -46,10 +46,9 @@ void main() { _setupTestDirectoryStructure(); - await testDtdConnection!.setIDEWorkspaceRoots( - dtd!.info!.secret!, - [Uri.parse(projectRootUriString)], - ); + await testDtdConnection!.setIDEWorkspaceRoots(dtd!.info!.secret!, [ + Uri.parse(projectRootUriString), + ]); }); tearDown(() async { @@ -57,9 +56,7 @@ void main() { dtd?.process?.kill(); await dtd?.process?.exitCode; dtd = null; - }); - tearDownAll(() async { // Run with retry to ensure this deletes properly on Windows. await deleteDirectoryWithRetry(testDirectory); }); @@ -78,12 +75,9 @@ void main() { } test('packageRootFromFileUriString throw exception for invalid input', () { - expect( - () async { - await packageRootFromFileUriString('/not/a/valid/file/uri'); - }, - throwsA(isA()), - ); + expect(() async { + await packageRootFromFileUriString('/not/a/valid/file/uri'); + }, throwsA(isA())); }); for (final useDtd in const [true, false]) { @@ -113,10 +107,7 @@ void main() { ); // Dart file under 'benchmark' - await verifyPackageRoot( - benchmarkFile.uri.toString(), - useDtd: useDtd, - ); + await verifyPackageRoot(benchmarkFile.uri.toString(), useDtd: useDtd); await verifyPackageRoot( benchmarkSubFile.uri.toString(), useDtd: useDtd, @@ -132,14 +123,20 @@ void main() { // Dart file in a nested project. await verifyPackageRoot( nestedProjectLibFile.uri.toString(), - expected: - p.posix.join(projectRootUriString, 'example', 'nested_project'), + expected: p.posix.join( + projectRootUriString, + 'example', + 'nested_project', + ), useDtd: useDtd, ); await verifyPackageRoot( nestedProjectTestFile.uri.toString(), - expected: - p.posix.join(projectRootUriString, 'example', 'nested_project'), + expected: p.posix.join( + projectRootUriString, + 'example', + 'nested_project', + ), useDtd: useDtd, ); @@ -201,19 +198,23 @@ void main() { /// foo_test.dart void _setupTestDirectoryStructure() { testDirectory = Directory.systemTemp.createTempSync(); - final projectRootDirectory = - Directory(p.joinAll([testDirectory.path, ...projectRootParts])) - ..createSync(recursive: true); - final projectRootDirectoryUriString = - Uri.file(projectRootDirectory.uri.toFilePath()).toString(); + final projectRootDirectory = Directory( + p.joinAll([testDirectory.path, ...projectRootParts]), + )..createSync(recursive: true); + final projectRootDirectoryUriString = Uri.file( + projectRootDirectory.uri.toFilePath(), + ).toString(); // Remove the trailing slash and set the value of [projectRoot]. projectRootUriString = projectRootDirectoryUriString.substring( - 0, projectRootDirectoryUriString.length - 1); + 0, + projectRootDirectoryUriString.length - 1, + ); // Set up the project root contents. - Directory(p.join(projectRootDirectory.path, '.dart_tool')) - .createSync(recursive: true); + Directory( + p.join(projectRootDirectory.path, '.dart_tool'), + ).createSync(recursive: true); libFile = File(p.join(projectRootDirectory.path, 'lib', 'foo.dart')) ..createSync(recursive: true); libSubFile = File(p.join(projectRootDirectory.path, 'lib', 'sub', 'foo.dart')) @@ -238,17 +239,17 @@ void _setupTestDirectoryStructure() { 'foo_test.dart', ), )..createSync(recursive: true); - benchmarkFile = - File(p.join(projectRootDirectory.path, 'benchmark', 'foo.dart')) - ..createSync(recursive: true); + benchmarkFile = File( + p.join(projectRootDirectory.path, 'benchmark', 'foo.dart'), + )..createSync(recursive: true); benchmarkSubFile = File( p.join(projectRootDirectory.path, 'benchmark', 'sub', 'foo.dart'), )..createSync(recursive: true); exampleFile = File(p.join(projectRootDirectory.path, 'example', 'foo.dart')) ..createSync(recursive: true); - exampleSubFile = - File(p.join(projectRootDirectory.path, 'example', 'sub', 'foo.dart')) - ..createSync(recursive: true); + exampleSubFile = File( + p.join(projectRootDirectory.path, 'example', 'sub', 'foo.dart'), + )..createSync(recursive: true); // Setup a nested Dart project under the 'example' directory. Directory( p.join( @@ -278,7 +279,7 @@ void _setupTestDirectoryStructure() { )..createSync(recursive: true); anyFile = File(p.join(projectRootDirectory.path, 'any_name', 'foo.dart')) ..createSync(recursive: true); - anySubFile = - File(p.join(projectRootDirectory.path, 'any_name', 'sub', 'foo.dart')) - ..createSync(recursive: true); + anySubFile = File( + p.join(projectRootDirectory.path, 'any_name', 'sub', 'foo.dart'), + )..createSync(recursive: true); } diff --git a/packages/devtools_shared/test/utils/retry_test.dart b/packages/devtools_shared/test/utils/retry_test.dart index 1bad59f0e57..1968f726ad0 100644 --- a/packages/devtools_shared/test/utils/retry_test.dart +++ b/packages/devtools_shared/test/utils/retry_test.dart @@ -52,15 +52,12 @@ void main() { test('throws after max retries reached', () async { expect(counter, 0); - await expectLater( - () async { - await runWithRetry( - callback: () => callback(succeedOnAttempt: 11), - maxRetries: 10, - ); - }, - throwsException, - ); + await expectLater(() async { + await runWithRetry( + callback: () => callback(succeedOnAttempt: 11), + maxRetries: 10, + ); + }, throwsException); expect(counter, 10); }); diff --git a/packages/devtools_test/lib/src/helpers/actions.dart b/packages/devtools_test/lib/src/helpers/actions.dart index f772168a9d4..6bcc527e644 100644 --- a/packages/devtools_test/lib/src/helpers/actions.dart +++ b/packages/devtools_test/lib/src/helpers/actions.dart @@ -6,8 +6,8 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'utils.dart'; @@ -36,8 +36,9 @@ Future navigateThroughDevToolsScreens( findsOneWidget, shouldExpect: runWithExpectations, ); - final menuChildren = - controller.widget(tabOverflowMenuFinder).menuChildren; + final menuChildren = controller + .widget(tabOverflowMenuFinder) + .menuChildren; numTabs += menuChildren.length; } @@ -47,12 +48,12 @@ Future navigateThroughDevToolsScreens( shouldExpect: runWithExpectations, ); - final expectedConnectedControllersCount = - devtoolsScreens!.where((s) => s.providesController).length; - final expectedDisconnectedControllersCount = - devtoolsScreens! - .where((s) => s.providesController && !s.screen.requiresConnection) - .length; + final expectedConnectedControllersCount = devtoolsScreens! + .where((s) => s.providesController) + .length; + final expectedDisconnectedControllersCount = devtoolsScreens! + .where((s) => s.providesController && !s.screen.requiresConnection) + .length; _maybeExpect( screenControllers.controllers.length, connectedToApp @@ -66,9 +67,8 @@ Future navigateThroughDevToolsScreens( shouldExpect: runWithExpectations, ); - final screens = - (ScreenMetaData.values.toList() - ..removeWhere((data) => !visibleScreenIds.contains(data.id))); + final screens = (ScreenMetaData.values.toList() + ..removeWhere((data) => !visibleScreenIds.contains(data.id))); for (final screen in screens) { await switchToScreen( controller, diff --git a/packages/devtools_test/lib/src/helpers/finders.dart b/packages/devtools_test/lib/src/helpers/finders.dart index 1b22b61f0a6..440ae86aaff 100644 --- a/packages/devtools_test/lib/src/helpers/finders.dart +++ b/packages/devtools_test/lib/src/helpers/finders.dart @@ -2,8 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; Finder findSubstring(String text) { return find.byWidgetPredicate((widget) { diff --git a/packages/devtools_test/lib/src/helpers/utils.dart b/packages/devtools_test/lib/src/helpers/utils.dart index a11e3657368..510471bb1b9 100644 --- a/packages/devtools_test/lib/src/helpers/utils.dart +++ b/packages/devtools_test/lib/src/helpers/utils.dart @@ -35,21 +35,6 @@ const safePumpDuration = Duration(seconds: 3); const longPumpDuration = Duration(seconds: 6); const veryLongPumpDuration = Duration(seconds: 9); -final screenIds = [ - AppSizeScreen.id, - DebuggerScreen.id, - DeepLinksScreen.id, - InspectorScreen.id, - LoggingScreen.id, - MemoryScreen.id, - NetworkScreen.id, - PerformanceScreen.id, - ProfilerScreen.id, - ProviderScreen.id, - VMDeveloperToolsScreen.id, - DTDToolsScreen.id, -]; - /// Scoping method which registers `listener` as a listener for `listenable`, /// invokes `callback`, and then removes the `listener`. /// diff --git a/packages/devtools_test/lib/src/helpers/wrappers.dart b/packages/devtools_test/lib/src/helpers/wrappers.dart index 20f16ad8817..fc7f002abee 100644 --- a/packages/devtools_test/lib/src/helpers/wrappers.dart +++ b/packages/devtools_test/lib/src/helpers/wrappers.dart @@ -11,9 +11,9 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/shared/primitives/query_parameters.dart'; import 'package:devtools_app_shared/ui.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:meta/meta.dart'; import 'package:provider/provider.dart'; @@ -21,20 +21,32 @@ import 'package:provider/provider.dart'; /// for widget state to be preserved. final _testNavigatorKey = GlobalKey(); +/// Returns the [ThemeData] used by [wrap] and [wrapSimple] test helper widgets. +ThemeData _testTheme({required bool isDarkTheme}) { + return themeFor( + isDarkTheme: isDarkTheme, + ideTheme: IdeTheme(), + theme: ThemeData( + useMaterial3: true, + colorScheme: isDarkTheme ? darkColorScheme : lightColorScheme, + ), + ); +} + /// Wraps [widget] with the build context it needs to load in a test as well as /// the [DevToolsRouterDelegate]. /// /// This includes a [MaterialApp] to provide context like [Theme.of], a /// [Material] to support elements like [TextField] that draw ink effects, and a /// [Directionality] to support [RenderFlex] widgets like [Row] and [Column]. -Widget wrap(Widget widget, {DevToolsQueryParams? queryParams}) { +Widget wrap( + Widget widget, { + DevToolsQueryParams? queryParams, + bool isDarkTheme = false, +}) { setGlobal(GlobalKey, _testNavigatorKey); return MaterialApp.router( - theme: themeFor( - isDarkTheme: false, - ideTheme: IdeTheme(), - theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme), - ), + theme: _testTheme(isDarkTheme: isDarkTheme), routerDelegate: DevToolsRouterDelegate.test( (context, page, args, state) => MaterialPage( child: Material( @@ -62,13 +74,9 @@ Widget wrap(Widget widget, {DevToolsQueryParams? queryParams}) { /// This includes a [MaterialApp] to provide context like [Theme.of], a /// [Material] to support elements like [TextField] that draw ink effects, and a /// [Directionality] to support [RenderFlex] widgets like [Row] and [Column]. -Widget wrapSimple(Widget widget) { +Widget wrapSimple(Widget widget, {bool isDarkTheme = false}) { return MaterialApp( - theme: themeFor( - isDarkTheme: false, - ideTheme: IdeTheme(), - theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme), - ), + theme: _testTheme(isDarkTheme: isDarkTheme), home: Material( child: Directionality( textDirection: TextDirection.ltr, @@ -96,8 +104,10 @@ Widget wrapWithControllers( ReleaseNotesController? releaseNotes, VMDeveloperToolsController? vmDeveloperTools, DTDToolsController? dtdTools, + AccessibilityController? accessibility, bool includeRouter = true, DevToolsQueryParams? queryParams, + bool isDarkTheme = false, }) { // The [ScreenControllers] class uses the [OfflineDataController] class to // determine which type of controller should be returned from @@ -173,6 +183,12 @@ Widget wrapWithControllers( offline: offline, ); } + if (accessibility != null) { + screenControllers.register( + () => accessibility, + offline: offline, + ); + } var child = wrapWithNotifications(widget); final providers = [ @@ -185,8 +201,8 @@ Widget wrapWithControllers( child = MultiProvider(providers: providers, child: child); } return includeRouter - ? wrap(child, queryParams: queryParams) - : wrapSimple(child); + ? wrap(child, queryParams: queryParams, isDarkTheme: isDarkTheme) + : wrapSimple(child, isDarkTheme: isDarkTheme); } Widget wrapWithNotifications(Widget child) { diff --git a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart index 605df1e5e47..6cd60ebfff3 100644 --- a/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart +++ b/packages/devtools_test/lib/src/integration_test/integration_test_utils.dart @@ -8,9 +8,9 @@ import 'dart:ui' as ui; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/main.dart' as app; import 'package:devtools_app_shared/ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../helpers/actions.dart'; import '../helpers/utils.dart'; diff --git a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart index 787e75adae0..5e9823fdd07 100644 --- a/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart +++ b/packages/devtools_test/lib/src/mocks/fake_isolate_manager.dart @@ -4,6 +4,8 @@ // ignore_for_file: invalid_use_of_visible_for_testing_member, devtools_test is only used in test code. +import 'dart:async'; + import 'package:devtools_app_shared/service.dart'; // ignore: implementation_imports, intentional import from src/ import 'package:devtools_app_shared/src/service/isolate_manager.dart'; @@ -30,11 +32,23 @@ base class FakeIsolateManager extends Fake with TestIsolateManager { IsolateRef.parse({'id': 'fake_main_isolate_id'}), ); + final _isolateCreatedController = StreamController.broadcast(); + + @override + Stream get onIsolateCreated => _isolateCreatedController.stream; + + /// Notifies listeners that a new isolate was created. + /// + /// This can be used in tests to simulate a hot restart spawning a new + /// isolate. + @visibleForTesting + void notifyIsolateCreated(IsolateRef isolate) { + _isolateCreatedController.add(isolate); + } + @override ValueNotifier> get isolates { - final value = _selectedIsolate.value; - _isolates ??= ValueNotifier([if (value != null) value]); - return _isolates!; + return _isolates ??= ValueNotifier([?_selectedIsolate.value]); } final _pausedState = ValueNotifier(false); diff --git a/packages/devtools_test/lib/src/mocks/fake_service_extension_manager.dart b/packages/devtools_test/lib/src/mocks/fake_service_extension_manager.dart index 5be3b19956a..fc47c3adacd 100644 --- a/packages/devtools_test/lib/src/mocks/fake_service_extension_manager.dart +++ b/packages/devtools_test/lib/src/mocks/fake_service_extension_manager.dart @@ -79,20 +79,19 @@ base class FakeServiceExtensionManager extends Fake /// Hook for tests to call to fake changing the state of a service /// extension. void fakeServiceExtensionStateChanged( - final String name, + String name, String valueFromJson, ) async { final extension = serviceExtensionsAllowlist[name]; if (extension != null) { final value = _getExtensionValueFromJson(name, valueFromJson); - final enabled = - extension is ToggleableServiceExtension - ? value == extension.enabledValue - // For extensions that have more than two states - // (enabled / disabled), we will always consider them to be - // enabled with the current value. - : true; + final enabled = extension is ToggleableServiceExtension + ? value == extension.enabledValue + // For extensions that have more than two states + // (enabled / disabled), we will always consider them to be + // enabled with the current value. + : true; await setServiceExtensionState( name, diff --git a/packages/devtools_test/lib/src/mocks/fake_service_manager.dart b/packages/devtools_test/lib/src/mocks/fake_service_manager.dart index 35efaa85615..bb2b821ce0d 100644 --- a/packages/devtools_test/lib/src/mocks/fake_service_manager.dart +++ b/packages/devtools_test/lib/src/mocks/fake_service_manager.dart @@ -3,7 +3,6 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:async'; -import 'dart:collection'; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/service.dart'; @@ -13,7 +12,6 @@ import 'package:flutter/foundation.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart'; -import '../helpers/utils.dart'; import 'fake_isolate_manager.dart'; import 'fake_service_extension_manager.dart'; import 'fake_vm_service_wrapper.dart'; @@ -30,6 +28,7 @@ class FakeServiceConnectionManager extends Fake List availableServices = const [], List availableLibraries = const [], String? rootLibrary, + RootInfo? rootInfo, }) { _serviceManager = FakeServiceManager( service: service, @@ -38,11 +37,10 @@ class FakeServiceConnectionManager extends Fake availableLibraries: availableLibraries, availableServices: availableServices, rootLibrary: rootLibrary, + rootInfo: rootInfo, ); - for (final screenId in screenIds) { - when(errorBadgeManager.erroredItemsForPage(screenId)).thenReturn( - FixedValueListenable(LinkedHashMap()), - ); + for (final screen in ScreenMetaData.values) { + final screenId = screen.id; when( errorBadgeManager.errorCountNotifier(screenId), ).thenReturn(ValueNotifier(0)); @@ -96,9 +94,11 @@ class FakeServiceManager extends Fake this.onVmServiceOpened, Map? serviceExtensionResponses, String? rootLibrary, + RootInfo? rootInfo, bool hasConnection = true, }) : serviceExtensionResponses = serviceExtensionResponses ?? _defaultServiceExtensionResponses, + _rootInfo = rootInfo ?? RootInfo('package:myPackage/myPackage.dart'), _isolateManager = FakeIsolateManager(rootLibrary: rootLibrary) { this.service = service ?? createFakeService(); serviceUri = this.service!.wsUri; @@ -183,8 +183,15 @@ class FakeServiceManager extends Fake @override ConnectedApp? connectedApp = MockConnectedApp(); + /// The [RootInfo] returned by [rootInfoNow]. + /// + /// This is independent of the `rootLibrary` constructor argument, which only + /// configures the root library of the fake main isolate. Some tests depend on + /// this defaulting to a fixed `package:` root library. + final RootInfo _rootInfo; + @override - RootInfo rootInfoNow() => RootInfo('package:myPackage/myPackage.dart'); + RootInfo rootInfoNow() => _rootInfo; @override Future tryToDetectMainRootInfo() => Future.value(rootInfoNow()); diff --git a/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart b/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart index 353bb7651cc..c687bb6d4cb 100644 --- a/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart +++ b/packages/devtools_test/lib/src/mocks/fake_vm_service_wrapper.dart @@ -45,17 +45,16 @@ class FakeVmServiceWrapper extends Fake implements VmServiceWrapper { } } - static final _defaultProfile = - CpuSamples.parse({ - 'samplePeriod': 50, - 'maxStackDepth': 12, - 'sampleCount': 0, - 'timeOriginMicros': 47377796685, - 'timeExtentMicros': 3000, - 'pid': 54321, - 'functions': [], - 'samples': [], - })!; + static final _defaultProfile = CpuSamples.parse({ + 'samplePeriod': 50, + 'maxStackDepth': 12, + 'sampleCount': 0, + 'timeOriginMicros': 47377796685, + 'timeExtentMicros': 3000, + 'pid': 54321, + 'functions': [], + 'samples': [], + })!; CpuSamples cpuSamples; @@ -129,32 +128,27 @@ class FakeVmServiceWrapper extends Fake implements VmServiceWrapper { } @override - Future lookupPackageUris(String isolateId, List uris) { - return Future.value( - UriList( - uris: - _resolvedUriMap != null - ? (uris.map((e) => _resolvedUriMap[e]).toList()) - : null, - ), - ); - } + Future lookupPackageUris(String isolateId, List uris) => + Future.syncValue( + UriList( + uris: _resolvedUriMap != null + ? (uris.map((e) => _resolvedUriMap[e]).toList()) + : null, + ), + ); @override Future lookupResolvedPackageUris( String isolateId, List uris, { bool? local, - }) { - return Future.value( - UriList( - uris: - _reverseResolvedUriMap != null - ? (uris.map((e) => _reverseResolvedUriMap[e]).toList()) - : null, - ), - ); - } + }) => Future.syncValue( + UriList( + uris: _reverseResolvedUriMap != null + ? (uris.map((e) => _reverseResolvedUriMap[e]).toList()) + : null, + ), + ); @override String get wsUri => 'ws://127.0.0.1:56137/ISsyt6ki0no=/ws'; @@ -192,21 +186,21 @@ class FakeVmServiceWrapper extends Fake implements VmServiceWrapper { allocationProfile.json = allocationProfile.toJson(); // Fake GC statistics - allocationProfile.json![AllocationProfilePrivateViewExtension - .heapsKey] = { - AllocationProfilePrivateViewExtension.newSpaceKey: { - GCStats.usedKey: 1234, - GCStats.capacityKey: 12345, - GCStats.collectionsKey: 42, - GCStats.timeKey: 69, - }, - AllocationProfilePrivateViewExtension.oldSpaceKey: { - GCStats.usedKey: 4321, - GCStats.capacityKey: 54321, - GCStats.collectionsKey: 24, - GCStats.timeKey: 96, - }, - }; + allocationProfile.json![AllocationProfilePrivateViewExtension.heapsKey] = + { + AllocationProfilePrivateViewExtension.newSpaceKey: { + GCStats.usedKey: 1234, + GCStats.capacityKey: 12345, + GCStats.collectionsKey: 42, + GCStats.timeKey: 69, + }, + AllocationProfilePrivateViewExtension.oldSpaceKey: { + GCStats.usedKey: 4321, + GCStats.capacityKey: 54321, + GCStats.collectionsKey: 24, + GCStats.timeKey: 96, + }, + }; return Future.value(allocationProfile); } @@ -525,8 +519,8 @@ class FakeVmServiceWrapper extends Fake implements VmServiceWrapper { @override Stream get onGCEvent => _gcEventStream.stream; - void emitGCEvent() { - _gcEventStream.sink.add(Event(kind: EventKind.kGC)); + void emitGCEvent({Event? event}) { + _gcEventStream.sink.add(event ?? Event(kind: EventKind.kGC)); } @override diff --git a/packages/devtools_test/lib/src/mocks/generated_mocks_factories.dart b/packages/devtools_test/lib/src/mocks/generated_mocks_factories.dart index f16f27330c4..2da5cea47b3 100644 --- a/packages/devtools_test/lib/src/mocks/generated_mocks_factories.dart +++ b/packages/devtools_test/lib/src/mocks/generated_mocks_factories.dart @@ -9,7 +9,7 @@ import 'dart:async'; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_app_shared/utils.dart'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:mockito/mockito.dart'; import 'package:vm_service/vm_service.dart' hide TimelineEvent; diff --git a/packages/devtools_test/lib/src/mocks/mocks.dart b/packages/devtools_test/lib/src/mocks/mocks.dart index fee470e121e..c803c7588b5 100644 --- a/packages/devtools_test/lib/src/mocks/mocks.dart +++ b/packages/devtools_test/lib/src/mocks/mocks.dart @@ -7,12 +7,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:devtools_app/devtools_app.dart'; -// ignore: implementation_imports, required to separate V2 inspector imports. -import 'package:devtools_app/src/screens/inspector_v2/inspector_controller.dart' - as inspector_v2; -// ignore: implementation_imports, required to separate V2 inspector imports. -import 'package:devtools_app/src/shared/console/eval/inspector_tree_v2.dart' - as inspector_v2; import 'package:devtools_app_shared/service.dart'; import 'package:devtools_shared/devtools_shared.dart'; import 'package:flutter/foundation.dart'; @@ -77,39 +71,21 @@ class TestInspectorController extends Fake implements InspectorController { ValueListenable get selectedNode => _selectedNode; final _selectedNode = ValueNotifier(null); - @override - void setSelectedNode(InspectorTreeNode? newSelection) { - _selectedNode.value = newSelection; - } - - @override - InspectorService get inspectorService => service; -} - -class TestInspectorV2Controller extends Fake - implements inspector_v2.InspectorController { - InspectorService service = FakeInspectorService(); - - @override - ValueListenable get selectedNode => - _selectedNode; - final _selectedNode = ValueNotifier(null); - @override RemoteDiagnosticsNode? get selectedDiagnostic => _selectedDiagnostic; RemoteDiagnosticsNode? _selectedDiagnostic; @override - ValueListenable - get selectedNodeProperties => - ValueNotifier(( + ValueListenable get selectedNodeProperties => + ValueNotifier(( widgetProperties: [], renderProperties: [], layoutProperties: null, + creationLocation: null, )); @override - void setSelectedNode(inspector_v2.InspectorTreeNode? newSelection) { + void setSelectedNode(InspectorTreeNode? newSelection) { _selectedNode.value = newSelection; } @@ -262,6 +238,7 @@ Script? _loadScript(String scriptName) { return Script.parse(jsonDecode(script.readAsStringSync())); } +// ignore: invalid_use_of_visible_for_testing_member, devtools_test is only used in tests. final mockSyntaxHighlighter = SyntaxHighlighter.withGrammar( grammar: mockGrammar, source: mockScript!.source, @@ -274,10 +251,10 @@ const coverageMissLines = {2, 5}; const executableLines = {...coverageHitLines, ...coverageMissLines}; const profilerEntries = { - 1: ProfileReportEntry(sampleCount: 5, line: 1, inclusive: 2, exclusive: 2), - 3: ProfileReportEntry(sampleCount: 5, line: 3, inclusive: 1, exclusive: 1), - 4: ProfileReportEntry(sampleCount: 5, line: 4, inclusive: 1, exclusive: 1), - 7: ProfileReportEntry(sampleCount: 5, line: 7, inclusive: 1, exclusive: 1), + 1: ProfileReportEntry(sampleCount: 5, inclusive: 2, exclusive: 2), + 3: ProfileReportEntry(sampleCount: 5, inclusive: 1, exclusive: 1), + 4: ProfileReportEntry(sampleCount: 5, inclusive: 1, exclusive: 1), + 7: ProfileReportEntry(sampleCount: 5, inclusive: 1, exclusive: 1), }; final mockParsedScript = ParsedScript( diff --git a/packages/devtools_test/lib/src/test_data/_performance_data.dart b/packages/devtools_test/lib/src/test_data/_performance_data.dart index 88403e32399..d2f0f490377 100644 --- a/packages/devtools_test/lib/src/test_data/_performance_data.dart +++ b/packages/devtools_test/lib/src/test_data/_performance_data.dart @@ -108244,7 +108244,8 @@ final Map samplePerformanceData = json.decode(''' } ], "displayRefreshRate": 60, - "rebuildCountModel": null + "rebuildCountModel": null, + "selectedTab": 0 } } '''); diff --git a/packages/devtools_test/pubspec.yaml b/packages/devtools_test/pubspec.yaml index 78fc6d84586..1162a7d1bb6 100644 --- a/packages/devtools_test/pubspec.yaml +++ b/packages/devtools_test/pubspec.yaml @@ -8,31 +8,34 @@ publish_to: none repository: https://github.com/flutter/devtools/tree/master/packages/devtools_test environment: - sdk: ">=3.7.0-160.0.dev <4.0.0" - flutter: ">=3.27.0-1.0.pre.563" + sdk: ^3.11.0 + flutter: ^3.41.0 resolution: workspace dependencies: - async: ^2.0.0 collection: ^1.15.0 + # ignore: dependencies, required transitively by material_ui. + cupertino_ui: ^1.1.1 dap: ^1.1.0 devtools_app: devtools_app_shared: devtools_shared: dtd: ^4.0.0 + fixnum: ^1.1.1 flutter: sdk: flutter flutter_test: sdk: flutter integration_test: sdk: flutter + material_ui: ^1.4.0 meta: ^1.9.1 mockito: ^5.4.1 path: ^1.8.0 provider: ^6.0.2 vm_service: '>=13.0.0 <16.0.0' - webkit_inspection_protocol: '>=0.5.0 <2.0.0' + vm_service_protos: ^2.0.1 dev_dependencies: - build_runner: ^2.3.3 + flutter_lints: ^6.0.0 diff --git a/pubspec.lock b/pubspec.lock index bb9ece344d4..a5a55b68152 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d + sha256: "9bfb8122e2505aca17219018593c384a87166cfe860d11c7ae927e7f978eb05f" url: "https://pub.dev" source: hosted - version: "91.0.0" + version: "110.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: "74c36179c4de542efea24c9dff78324375e4b118a2c1e14e8a7659f457947f8d" url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "14.6.0" archive: dependency: transitive description: name: archive - sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" url: "https://pub.dev" source: hosted - version: "4.0.9" + version: "4.5.2" args: dependency: transitive description: @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" benchmark_harness: dependency: transitive description: @@ -61,50 +61,50 @@ packages: dependency: transitive description: name: build - sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" + sha256: "8a5c5761af8e31748bba3c82f68925ace40f3225c3eea25be9beb57eca7cd7a8" url: "https://pub.dev" source: hosted - version: "4.0.4" + version: "4.0.11" build_config: dependency: transitive description: name: build_config - sha256: "4070d2a59f8eec34c97c86ceb44403834899075f66e8a9d59706f8e7834f6f71" + sha256: d466ed2dc9c6cd1d169948879b84ee061eb5e22c64a7c6089879c6296d272a8d url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.3" build_daemon: dependency: transitive description: name: build_daemon - sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + sha256: "39ab65f7c60c19af64b9ef96e6a5d3ab48355f7dddfd98a9079d55a8cb6628fc" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.1.7" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "3552e5c2874ed47cf9ed9d6813ac71b2276ee07622f48530468b8013f1767e3f" + sha256: "6feadd32d90a68253c1f7b4fcff76adb4b41ad32a53f8bad835dc65f01ac9dc5" url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.16.2" built_collection: dependency: transitive description: name: built_collection - sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "5.1.2" built_value: dependency: transitive description: name: built_value - sha256: "6ae8a6435a8c6520c7077b107e77f1fb4ba7009633259a4d49a8afd8e7efc5e9" + sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7 url: "https://pub.dev" source: hosted - version: "8.12.4" + version: "8.13.0" characters: dependency: transitive description: @@ -141,18 +141,18 @@ packages: dependency: transitive description: name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "1.1.3" code_builder: dependency: transitive description: name: code_builder - sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + sha256: "811709c1badd02fc054039a13f66e0db1961865f9a314175f68c3eaeebec786f" url: "https://pub.dev" source: hosted - version: "4.11.1" + version: "4.13.0" collection: dependency: transitive description: @@ -173,26 +173,34 @@ packages: dependency: transitive description: name: coverage - sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.15.1" cross_file: dependency: transitive description: name: cross_file - sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 url: "https://pub.dev" source: hosted - version: "0.3.5+2" + version: "0.3.5+5" crypto: dependency: transitive description: name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + sha256: e3626ae6270383c9e3a549f34c9e4cd26c09ae6a9adaab2bf4737a00340efdc4 + url: "https://pub.dev" + source: hosted + version: "3.0.8" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: a47c4e7c66fd7444a063a602597fceb238f705c3f6b1322bc0053d0a2638ee6e url: "https://pub.dev" source: hosted - version: "3.0.7" + version: "1.1.4" dap: dependency: transitive description: @@ -213,10 +221,10 @@ packages: dependency: transitive description: name: dart_style - sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b + sha256: a0e4f65339b31eb6a9e6a12e14b4682a48d46fbc54a82f4a702396fbdbac63a7 url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.14" dds_service_extensions: dependency: transitive description: @@ -277,34 +285,34 @@ packages: dependency: transitive description: name: file_selector_android - sha256: "51e8fd0446de75e4b62c065b76db2210c704562d072339d333bd89c57a7f8a7c" + sha256: d670cd0ce77a2e785b18d8b4d0a8d6a222d6a813ec9b7ddf2790a1b4fb6fa92c url: "https://pub.dev" source: hosted - version: "0.5.2+4" + version: "0.5.2+11" file_selector_ios: dependency: transitive description: name: file_selector_ios - sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + sha256: "5b42ec0ef7626124b4624ea244341e9df780406793c204e89fa68a2e71af115c" url: "https://pub.dev" source: hosted - version: "0.5.3+5" + version: "0.5.3+7" file_selector_linux: dependency: transitive description: name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab url: "https://pub.dev" source: hosted - version: "0.9.4" + version: "0.9.4+1" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba url: "https://pub.dev" source: hosted - version: "0.9.5" + version: "0.9.5+2" file_selector_platform_interface: dependency: transitive description: @@ -317,18 +325,18 @@ packages: dependency: transitive description: name: file_selector_web - sha256: c4c0ea4224d97a60a7067eca0c8fd419e708ff830e0c83b11a48faf566cec3e7 + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" url: "https://pub.dev" source: hosted - version: "0.9.4+2" + version: "0.9.5" file_selector_windows: dependency: transitive description: name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec url: "https://pub.dev" source: hosted - version: "0.9.3+5" + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -351,10 +359,15 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "6.0.0" + flutter_localizations: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_markdown: dependency: transitive description: @@ -384,10 +397,10 @@ packages: dependency: transitive description: name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + sha256: "2b56423afc7651a81da53ed3a4f9f51e92572293f54d0d54e19b759f13714849" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.2.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -397,10 +410,10 @@ packages: dependency: transitive description: name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.2.0" graphs: dependency: transitive description: @@ -421,10 +434,10 @@ packages: dependency: transitive description: name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + sha256: "361668bdd71f0ae2dc6d17b75a593520f3c414af6534f9eed1aa72049dd71d18" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" http_parser: dependency: transitive description: @@ -437,10 +450,10 @@ packages: dependency: transitive description: name: image - sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" url: "https://pub.dev" source: hosted - version: "4.8.0" + version: "4.12.0" integration_test: dependency: transitive description: flutter @@ -450,26 +463,26 @@ packages: dependency: transitive description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.3" io: dependency: transitive description: name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.1.0" json_annotation: dependency: transitive description: name: json_annotation - sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80" url: "https://pub.dev" source: hosted - version: "4.11.0" + version: "4.12.0" json_rpc_2: dependency: transitive description: @@ -506,10 +519,18 @@ packages: dependency: transitive description: name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + listen: + dependency: transitive + description: + name: listen + sha256: "47501a08016a43fcad79252439d723f50f14f88fa7bfd8a177e0a417e5c9e1f2" url: "https://pub.dev" source: hosted - version: "5.1.1" + version: "1.0.1" logging: dependency: transitive description: @@ -530,10 +551,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -542,30 +563,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: e4031746f96c8316864d9b92bce375f1320a36ed56e166307e1e1bda9d90b4ab + url: "https://pub.dev" + source: hosted + version: "1.8.0" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" mockito: dependency: transitive description: name: mockito - sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 + sha256: "6c1970612452260366064d4df80fdb3eece1ec5e52610ae34a0e51f2f3d8e64f" url: "https://pub.dev" source: hosted - version: "5.6.3" + version: "5.8.1" nested: dependency: transitive description: @@ -586,10 +615,10 @@ packages: dependency: transitive description: name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "3.0.0" path: dependency: transitive description: @@ -605,22 +634,14 @@ packages: relative: true source: path version: "0.0.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" - source: hosted - version: "7.0.2" platform: dependency: transitive description: name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + sha256: a36d119c13416516a7b5913fbe8af8531e11633d784c550b2125f76c758524ec url: "https://pub.dev" source: hosted - version: "3.1.6" + version: "3.2.0" plugin_platform_interface: dependency: transitive description: @@ -633,10 +654,10 @@ packages: dependency: transitive description: name: pointer_interceptor - sha256: "57210410680379aea8b1b7ed6ae0c3ad349bfd56fe845b8ea934a53344b9d523" + sha256: "9ad8889f36a417649ad7578fdb6df967157208cb90f69facd043660424020710" url: "https://pub.dev" source: hosted - version: "0.10.1+2" + version: "0.10.1+3" pointer_interceptor_ios: dependency: transitive description: @@ -665,26 +686,26 @@ packages: dependency: transitive description: name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.3" posix: dependency: transitive description: name: posix - sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + sha256: "6312deb534ce3812a1b454143740ffe721a17dc3a33bfc978f3a6d7bc5833edf" url: "https://pub.dev" source: hosted - version: "6.5.0" + version: "6.5.3" process: dependency: transitive description: name: process - sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + sha256: "4242ba3508d37e01808bdf71ad1d5bb93a8d671bf2e7450e6b1b353fb0808891" url: "https://pub.dev" source: hosted - version: "5.0.5" + version: "5.0.6" protobuf: dependency: transitive description: @@ -705,26 +726,26 @@ packages: dependency: transitive description: name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" pubspec_parse: dependency: transitive description: name: pubspec_parse - sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + sha256: "9ac81c01ffd6a9cf9b2713288834fbaaf6aebde05ffdf02d380e68b8e06f27d8" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.7.0" shelf: dependency: transitive description: name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + sha256: e75e9cf311d7fd712048038cc17c25199c38536c20330d53e7db058b543d5a48 url: "https://pub.dev" source: hosted - version: "1.4.2" + version: "1.4.3" shelf_packages_handler: dependency: transitive description: @@ -737,10 +758,10 @@ packages: dependency: transitive description: name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + sha256: "480504af9e5323899c5be26a4f6c2fc4437d50954d39842f0290f4ae733d57b4" url: "https://pub.dev" source: hosted - version: "1.1.3" + version: "1.2.0" shelf_web_socket: dependency: transitive description: @@ -749,6 +770,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + skills_lint: + dependency: transitive + description: + name: skills_lint + sha256: b7af62b95189d9f80c397c13133bb9a5dfae079cc18a7e3236ab3ea2d79edc41 + url: "https://pub.dev" + source: hosted + version: "0.5.3" sky_engine: dependency: transitive description: flutter @@ -758,10 +787,10 @@ packages: dependency: transitive description: name: source_gen - sha256: adc962c96fffb2de1728ef396a995aaedcafbe635abdca13d2a987ce17e57751 + sha256: "833ea7d15b5be79949d3d6c077da0267a0bd93c16897b36a771395a91e63086e" url: "https://pub.dev" source: hosted - version: "4.2.1" + version: "4.3.1" source_map_stack_trace: dependency: transitive description: @@ -774,10 +803,10 @@ packages: dependency: transitive description: name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + sha256: "14c2945847669b44089bb1222f66873d7ff7103c58911917f2a63c5a62327898" url: "https://pub.dev" source: hosted - version: "0.10.13" + version: "0.10.14" source_span: dependency: transitive description: @@ -790,26 +819,26 @@ packages: dependency: transitive description: name: sse - sha256: fcc97470240bb37377f298e2bd816f09fd7216c07928641c0560719f50603643 + sha256: a9a804dbde8bfd369da3b4aa241d44d63a6486a97388c54ec166073d88c52302 url: "https://pub.dev" source: hosted - version: "4.1.8" + version: "4.2.0" stack_trace: dependency: transitive description: name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" url: "https://pub.dev" source: hosted - version: "1.12.1" + version: "1.12.2" stager: dependency: transitive description: name: stager - sha256: "2782efb6057d9c391d96ef0e9714a0a3e02d311e7dd376448e3a87e4e87ac8f1" + sha256: c3b8bf30a2b02152a1125bb6fedf0d6f52fb0b154bd97854cb3e9cc59be975ad url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" stream_channel: dependency: transitive description: @@ -822,10 +851,10 @@ packages: dependency: transitive description: name: stream_transform - sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" string_scanner: dependency: transitive description: @@ -842,6 +871,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + tar: + dependency: transitive + description: + name: tar + sha256: af61a6382fb7a702b93a7b400492405793e0ebf3d2bea5d698d5ca39823ca46f + url: "https://pub.dev" + source: hosted + version: "2.0.3" term_glyph: dependency: transitive description: @@ -854,26 +891,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "44b2fccf4407bcdb5e105c064c5d6b5e2a640c87761f8b03cdce8cf532d98cfa" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.33.0" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "47481c58bb92d0b1d218f0510ff5647e768f8e1256d07077a151fb520923b51d" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.15" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "9dec4c178bd61493e1cd2060ffe701fcb61c43da2493e99590fb1b0cc82eb4af" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.21" typed_data: dependency: transitive description: @@ -886,50 +923,50 @@ packages: dependency: transitive description: name: unified_analytics - sha256: d581cd7007f5a60594195ee5d35337edea7d53757e4cff5a2f5168b82ec31d05 + sha256: "4013958a461aafcd9b80baf96b85e1537ae10991a05e7b05f34e4bee6202dfb0" url: "https://pub.dev" source: hosted - version: "7.0.2" + version: "8.4.0" url_launcher: dependency: transitive description: name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "611e87fb320b70d1dd721dc46af89c98aceccea9b31fde49e084591414e0c610" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.33" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + sha256: "8faa1aab294f1ab4040b43660c887b0418d5fa4f0cffef76a484e6aa1092eb4a" url: "https://pub.dev" source: hosted - version: "6.4.1" + version: "6.4.2" url_launcher_linux: dependency: transitive description: name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + sha256: "10f86fef4c2c43563fa6c211ff9cf757adf4d3ab762c56bd430664a947d70cd0" url: "https://pub.dev" source: hosted - version: "3.2.2" + version: "3.2.3" url_launcher_macos: dependency: transitive description: name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + sha256: "5e835a3b869c2d70325349c81c5a45c28e20791265b67b2669da6b08c5cd5201" url: "https://pub.dev" source: hosted - version: "3.2.5" + version: "3.2.6" url_launcher_platform_interface: dependency: transitive description: @@ -942,34 +979,34 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + sha256: "6c5ad3f22cd4c38e089b81963b3cd7bb83b111b2df5dce008bb066162f42e429" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: fa49e1674cb84acb900141714e6c6e1f9ae099827c24d141430ff6819db4e1bc url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.4" vm_service: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "89b11d62e1040ee681af3b4f1c97a803ecbf010c6e8b9c5896ee5b08c3bf7b78" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.4.0" vm_service_protos: dependency: transitive description: @@ -998,10 +1035,10 @@ packages: dependency: transitive description: name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + sha256: e119e82ac4795c7925103b7cf3768191f33941b99e7ccffbd6c645201d9f606b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" web_benchmarks: dependency: transitive description: @@ -1030,10 +1067,10 @@ packages: dependency: transitive description: name: webdriver - sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + sha256: "28b82ec894fed45dd71c23ba62d1af973ed97dd59a4f5790a4d38b0b13e5657e" url: "https://pub.dev" source: hosted - version: "3.1.0" + version: "3.2.0" webkit_inspection_protocol: dependency: transitive description: @@ -1042,22 +1079,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" - xml: - dependency: transitive - description: - name: xml - sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" - source: hosted - version: "6.6.1" yaml: dependency: transitive description: name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.1.4" yaml_edit: dependency: transitive description: @@ -1067,5 +1096,5 @@ packages: source: hosted version: "2.2.4" sdks: - dart: ">=3.10.0 <4.0.0" - flutter: ">=3.38.0" + dart: ">=3.13.0 <4.0.0" + flutter: ">=3.47.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index d5920002f05..0822c330ee5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ # found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. name: _devtools environment: - sdk: ^3.6.0 + sdk: ^3.11.0 workspace: - packages/devtools_app - packages/devtools_app_shared @@ -14,4 +14,4 @@ workspace: dev_dependencies: build_runner: ^2.5.4 - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 diff --git a/third_party/packages/widget_icons/analysis_options.yaml b/third_party/packages/widget_icons/analysis_options.yaml index f9b303465f1..743e05ad835 100644 --- a/third_party/packages/widget_icons/analysis_options.yaml +++ b/third_party/packages/widget_icons/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/third_party/packages/widget_icons/example/analysis_options.yaml b/third_party/packages/widget_icons/example/analysis_options.yaml index f9b303465f1..743e05ad835 100644 --- a/third_party/packages/widget_icons/example/analysis_options.yaml +++ b/third_party/packages/widget_icons/example/analysis_options.yaml @@ -1 +1,10 @@ +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml diff --git a/third_party/packages/widget_icons/example/lib/main.dart b/third_party/packages/widget_icons/example/lib/main.dart index cc9c60e4f0c..3cc92dd24e0 100644 --- a/third_party/packages/widget_icons/example/lib/main.dart +++ b/third_party/packages/widget_icons/example/lib/main.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:widget_icons/widget_icons.dart'; void main() { diff --git a/third_party/packages/widget_icons/example/pubspec.yaml b/third_party/packages/widget_icons/example/pubspec.yaml index 64a33c7751e..cef3b2b1e01 100644 --- a/third_party/packages/widget_icons/example/pubspec.yaml +++ b/third_party/packages/widget_icons/example/pubspec.yaml @@ -9,10 +9,9 @@ environment: dependencies: flutter: sdk: flutter - + material_ui: ^1.4.0 widget_icons: path: ../ - dev_dependencies: flutter_test: sdk: flutter diff --git a/third_party/packages/widget_icons/lib/widget_icons.dart b/third_party/packages/widget_icons/lib/widget_icons.dart index 71cbfc8b505..d618ff2896c 100644 --- a/third_party/packages/widget_icons/lib/widget_icons.dart +++ b/third_party/packages/widget_icons/lib/widget_icons.dart @@ -4,7 +4,7 @@ // ignore_for_file: constant_identifier_names -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:flutter/widgets.dart'; class WidgetIcons { diff --git a/third_party/packages/widget_icons/lib/widget_theme.dart b/third_party/packages/widget_icons/lib/widget_theme.dart index 6980e7321b0..f80f368b541 100644 --- a/third_party/packages/widget_icons/lib/widget_theme.dart +++ b/third_party/packages/widget_icons/lib/widget_theme.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:widget_icons/widget_icons.dart'; class WidgetTheme { diff --git a/third_party/packages/widget_icons/pubspec.yaml b/third_party/packages/widget_icons/pubspec.yaml index 0d7d600da9c..6851a24f485 100644 --- a/third_party/packages/widget_icons/pubspec.yaml +++ b/third_party/packages/widget_icons/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: flutter: sdk: flutter + material_ui: ^1.4.0 dev_dependencies: flutter_test: diff --git a/tool/analysis_options.yaml b/tool/analysis_options.yaml index 81588d9a14a..f4ded48e561 100644 --- a/tool/analysis_options.yaml +++ b/tool/analysis_options.yaml @@ -6,6 +6,13 @@ include: package:lints/recommended.yaml analyzer: exclude: - flutter-sdk/ + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** linter: rules: diff --git a/tool/bin/devtools_tool b/tool/bin/devtools_tool deleted file mode 100755 index 14278617c57..00000000000 --- a/tool/bin/devtools_tool +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -e - -# This file serves as an alias for the 'dt' executable so that -# the legacy `dt` can be used. -# TODO(kenz): remove this file in ~6 months (April 2025). - -echo Warning: devtools_tool has been replaced by dt. Please use dt instead. -dt $@ diff --git a/tool/bin/devtools_tool.bat b/tool/bin/devtools_tool.bat deleted file mode 100644 index 685b02e6367..00000000000 --- a/tool/bin/devtools_tool.bat +++ /dev/null @@ -1,7 +0,0 @@ -REM Copyright 2025 The Flutter Authors -REM Use of this source code is governed by a BSD-style license that can be -REM found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. -@echo off - -echo Warning: devtools_tool has been replaced by dt. Please use dt instead. -%~dp0/dt.bat %* diff --git a/tool/bin/dt b/tool/bin/dt index 9955ea20134..5d9c2edce98 100755 --- a/tool/bin/dt +++ b/tool/bin/dt @@ -2,9 +2,19 @@ SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) -if [ ! -z "$DEVTOOLS_TOOL_FLUTTER_FROM_PATH" ] +USE_PATH=false +if [ "$DEVTOOLS_TOOL_FLUTTER_FROM_PATH" = "true" ]; then + USE_PATH=true +fi +for arg in "$@"; do + if [ "$arg" = "-p" ] || [ "$arg" = "--flutter-from-path" ]; then + USE_PATH=true + fi +done + +if [ "$USE_PATH" = true ] then - echo Running dt using Dart/Flutter from PATH because DEVTOOLS_TOOL_FLUTTER_FROM_PATH is set + echo Running dt using Dart/Flutter from PATH dart run "$SCRIPT_DIR/dt.dart" "$@" else if [ ! -d $SCRIPT_DIR/../flutter-sdk ] diff --git a/tool/bin/dt.bat b/tool/bin/dt.bat index 10b73d303d0..c1007eaaa15 100755 --- a/tool/bin/dt.bat +++ b/tool/bin/dt.bat @@ -3,8 +3,15 @@ REM Use of this source code is governed by a BSD-style license that can be REM found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. @echo off -IF DEFINED DEVTOOLS_TOOL_FLUTTER_FROM_PATH ( - echo Running dt using Dart/Flutter from PATH because DEVTOOLS_TOOL_FLUTTER_FROM_PATH is set +set USE_PATH= +IF /I "%DEVTOOLS_TOOL_FLUTTER_FROM_PATH%"=="true" set USE_PATH=1 +for %%a in (%*) do ( + if "%%a"=="-p" set USE_PATH=1 + if "%%a"=="--flutter-from-path" set USE_PATH=1 +) + +IF DEFINED USE_PATH ( + echo Running dt using Dart/Flutter from PATH dart run %~dp0/dt.dart %* ) ELSE ( diff --git a/tool/build_release.sh b/tool/build_release.sh index 951fbf56dc8..172de354a4f 100755 --- a/tool/build_release.sh +++ b/tool/build_release.sh @@ -79,7 +79,6 @@ flutter pub get flutter build web \ --source-maps \ --wasm \ - --pwa-strategy=offline-first \ --release \ --no-tree-shake-icons \ --no-minify-wasm diff --git a/tool/ci/bots.sh b/tool/ci/bots.sh index 60ed7763025..6f19b770e1f 100755 --- a/tool/ci/bots.sh +++ b/tool/ci/bots.sh @@ -15,10 +15,9 @@ echo `pwd` if [ "$BOT" = "main" ]; then - # Verify that dart format has been run. echo "Checking formatting..." - # Here, we use the dart instance from the flutter sdk. - $(dirname $(which flutter))/dart format --output=none --set-exit-if-changed . + # Here, we use the dart instance from the flutter SDK. + dart format --output=none --set-exit-if-changed . # Make sure the app versions are in sync. dt repo-check @@ -31,8 +30,7 @@ if [ "$BOT" = "main" ]; then elif [ "$BOT" = "build_ddc" ]; then - # TODO(https://github.com/flutter/flutter/issues/43538): Remove workaround. - flutter build web --pwa-strategy=none --no-tree-shake-icons + flutter build web --no-tree-shake-icons elif [ "$BOT" = "build_dart2js" ]; then @@ -72,7 +70,13 @@ elif [ "$BOT" = "test_webdriver" ]; then # elif [ "$BOT" = "integration_ddc" ]; then # TODO(https://github.com/flutter/devtools/issues/1987): rewrite legacy integration tests. -elif [ "$BOT" = "integration_dart2js" ]; then +elif [[ "$BOT" == "integration_dart2js" || "$BOT" == "integration_dart2wasm" ]]; then + if [ "$BOT" == "integration_dart2wasm" ]; then + WASM_FLAG="--wasm" + else + WASM_FLAG="" + fi + if [ "$DEVTOOLS_PACKAGE" = "devtools_app" ]; then flutter pub get @@ -85,15 +89,15 @@ run to see if golden image failures have been uploaded (this only happens once a completed). Download these goldens and update them in the codebase to apply the updates." if [ "$DEVICE" = "flutter" ]; then - dart run integration_test/run_tests.dart --headless --shard="$SHARD" + dart run integration_test/run_tests.dart $WASM_FLAG --headless --shard="$SHARD" elif [ "$DEVICE" = "flutter-web" ]; then - dart run integration_test/run_tests.dart --test-app-device=chrome --headless --shard="$SHARD" + dart run integration_test/run_tests.dart --test-app-device=chrome $WASM_FLAG --headless --shard="$SHARD" elif [ "$DEVICE" = "dart-cli" ]; then - dart run integration_test/run_tests.dart --test-app-device=cli --headless --shard="$SHARD" + dart run integration_test/run_tests.dart --test-app-device=cli $WASM_FLAG --headless --shard="$SHARD" fi elif [ "$DEVTOOLS_PACKAGE" = "devtools_extensions" ]; then pushd $DEVTOOLS_DIR/packages/devtools_extensions - dart run integration_test/run_tests.dart --headless + dart run integration_test/run_tests.dart $WASM_FLAG --headless popd fi fi diff --git a/tool/ci/package_tests.sh b/tool/ci/package_tests.sh index 29e45671952..6e086837e1c 100755 --- a/tool/ci/package_tests.sh +++ b/tool/ci/package_tests.sh @@ -13,6 +13,11 @@ if [ "$PACKAGE" = "devtools_app_shared" ]; then pushd $DEVTOOLS_DIR/packages/devtools_app_shared echo `pwd` + + echo "Checking formatting..." + # Here, we use the dart instance from the flutter SDK. + dart format --output=none --set-exit-if-changed . + flutter test test/ popd @@ -20,6 +25,13 @@ elif [ "$PACKAGE" = "devtools_extensions" ]; then pushd $DEVTOOLS_DIR/packages/devtools_extensions echo `pwd` + + echo "Checking formatting..." + # Here, we use the dart instance from the flutter SDK. + dart format --output=none --set-exit-if-changed . + + # Note that this will _not_ test any tests in nested directories, if we add + # any. flutter test test/*_test.dart # Skip this on Windows because `flutter test --platform chrome` # appears to hang there. @@ -33,6 +45,11 @@ elif [ "$PACKAGE" = "devtools_shared" ]; then pushd $DEVTOOLS_DIR/packages/devtools_shared echo `pwd` + + echo "Checking formatting..." + # Here, we use the dart instance from the flutter SDK. + dart format --output=none --set-exit-if-changed . + dart test test/ popd diff --git a/tool/ci/setup.sh b/tool/ci/setup.sh index 57df3a5cdf0..fab26a724e7 100755 --- a/tool/ci/setup.sh +++ b/tool/ci/setup.sh @@ -10,10 +10,41 @@ set -ex export SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) export DEVTOOLS_DIR=$SCRIPT_DIR/../.. +# Determine the Flutter SDK to use: +# * If `DEVTOOLS_TOOL_FLUTTER_FROM_PATH` is "true", then discover from `PATH`. +# * If `./tool/flutter-sdk` (a directory) exists, then use that. +if [ "$DEVTOOLS_TOOL_FLUTTER_FROM_PATH" = "true" ]; then + # Use `type -P` instead of `command -v` so we resolve an executable file on + # PATH rather than a shell function or alias. + if type -P flutter &> /dev/null; then + FLUTTER_EXE="$(type -P flutter)" + elif type -P flutter.bat &> /dev/null; then + FLUTTER_EXE="$(type -P flutter.bat)" + else + echo "DEVTOOLS_TOOL_FLUTTER_FROM_PATH is set to true, but flutter was not found on PATH" + exit 1 + fi + FLUTTER_BIN="$(cd "$(dirname "$FLUTTER_EXE")" && pwd -P)" + FLUTTER_DIR="$(cd "$FLUTTER_BIN/.." && pwd -P)" + echo "Using Flutter from PATH at: $FLUTTER_DIR" + export DEVTOOLS_TOOL_FLUTTER_FROM_PATH=true +elif [ -d "./tool/flutter-sdk" ]; then + FLUTTER_DIR="$(pwd)/tool/flutter-sdk" +else + echo "Expected ./tool/flutter-sdk to exist, or DEVTOOLS_TOOL_FLUTTER_FROM_PATH to be set to true" + exit 1 +fi + +# Look in the dart bin/ directory first, then the flutter one, then the one for +# the devtools repo. We don't use the dart script from 'flutter/bin' as that +# script can and does print 'Waiting for another flutter command...' at +# inopportune times. +export PATH="$FLUTTER_DIR/bin/cache/dart-sdk/bin:$FLUTTER_DIR/bin:`pwd`/bin:$PATH" + # In GitBash on Windows, we have to call flutter.bat so we alias them in this # script to call the correct one based on the OS. function flutter { - if [[ $RUNNER_OS == "Windows" ]]; then + if [[ "$RUNNER_OS" == "Windows" || "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then command flutter.bat "$@" else command flutter "$@" @@ -21,18 +52,6 @@ function flutter { } export -f flutter -# Make sure Flutter sdk has been provided -if [ ! -d "./tool/flutter-sdk" ]; then - echo "Expected ./tool/flutter-sdk to exist" - exit 1; -fi - -# Look in the dart bin dir first, then the flutter one, then the one for the -# devtools repo. We don't use the dart script from flutter/bin as that script -# can and does print 'Waiting for another flutter command...' at inopportune -# times. -export PATH=`pwd`/tool/flutter-sdk/bin/cache/dart-sdk/bin:`pwd`/tool/flutter-sdk/bin:`pwd`/bin:$PATH - # Look up the latest flutter candidate (this is the latest flutter version in g3) # TODO(https://github.com/flutter/devtools/issues/4591): re-write this script as a # shell script so we won't have to incurr the cost of building flutter tool twice. @@ -40,7 +59,7 @@ export PATH=`pwd`/tool/flutter-sdk/bin/cache/dart-sdk/bin:`pwd`/tool/flutter-sdk flutter config --no-analytics flutter doctor -# We should be using dart from ../flutter-sdk/bin/cache/dart-sdk/dart. +# We should be using dart from the Flutter SDK's cache/dart-sdk/bin/ directory. echo "which flutter: " `which flutter` echo "which dart: " `which dart` diff --git a/tool/ci/tool_tests.sh b/tool/ci/tool_tests.sh index 58feb6ea690..29aa5dd85cb 100755 --- a/tool/ci/tool_tests.sh +++ b/tool/ci/tool_tests.sh @@ -11,5 +11,10 @@ source ./tool/ci/setup.sh pushd $DEVTOOLS_DIR/tool echo `pwd` + +echo "Checking formatting..." +# Here, we use the dart instance from the flutter SDK. +dart format --output=none --set-exit-if-changed lib/ test/ + flutter test test/ -popd \ No newline at end of file +popd diff --git a/tool/cpu_sample_intervals.dart b/tool/cpu_sample_intervals.dart index f6b2f07b556..10d50bcbc2f 100644 --- a/tool/cpu_sample_intervals.dart +++ b/tool/cpu_sample_intervals.dart @@ -26,10 +26,10 @@ void main(List arguments) async { final List deltas = []; for (int i = 0; i < cpuSampleTraceEvents.length - 1; i++) { - final Map current = - (cpuSampleTraceEvents[i] as Map).cast(); - final Map next = - (cpuSampleTraceEvents[i + 1] as Map).cast(); + final Map current = (cpuSampleTraceEvents[i] as Map) + .cast(); + final Map next = (cpuSampleTraceEvents[i + 1] as Map) + .cast(); deltas.add((next['ts'] as int) - (current['ts'] as int)); } print(deltas); diff --git a/tool/flutter_customer_tests/test.sh b/tool/flutter_customer_tests/test.sh index 3ab28912ab4..b8b1080dcc0 100755 --- a/tool/flutter_customer_tests/test.sh +++ b/tool/flutter_customer_tests/test.sh @@ -22,5 +22,4 @@ cd ../devtools_app flutter pub get flutter test --tags=include-for-flutter-customer-tests test/ flutter test --exclude-tags=skip-for-flutter-customer-tests test/screens/inspector/ -flutter test --exclude-tags=skip-for-flutter-customer-tests test/screens/inspector_v2/ flutter test --exclude-tags=skip-for-flutter-customer-tests test/shared/ diff --git a/tool/json_to_map.dart b/tool/json_to_map.dart index dffb32204c0..d3b134174b4 100644 --- a/tool/json_to_map.dart +++ b/tool/json_to_map.dart @@ -30,8 +30,9 @@ void main(List args) { } final jsonFileName = jsonFilePath.split('/').last; - final fileNameWithoutExtension = (jsonFileName.split('.') - ..removeLast()).join('.'); + final fileNameWithoutExtension = (jsonFileName.split( + '.', + )..removeLast()).join('.'); final jsonFileDirectoryPath = Uri.parse( (jsonFilePath.split('/')..removeLast()).join('/'), ); @@ -45,10 +46,9 @@ void main(List args) { // String interpolation. jsonFormattedString = jsonFormattedString.replaceAll('\$', '\\\$'); - final dartFile = - File('$jsonFileDirectoryPath/$fileNameWithoutExtension.dart') - ..createSync() - ..writeAsStringSync(''' + final dartFile = File('$jsonFileDirectoryPath/$fileNameWithoutExtension.dart') + ..createSync() + ..writeAsStringSync(''' // Copyright 2023 The Flutter Authors // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. diff --git a/tool/lib/commands/build.dart b/tool/lib/commands/build.dart index 3f7078088c5..48f2501e296 100644 --- a/tool/lib/commands/build.dart +++ b/tool/lib/commands/build.dart @@ -114,7 +114,6 @@ class BuildCommand extends Command { if (buildMode == 'debug') '--dart2js-optimization=O1', if (buildMode != 'debug') '--$buildMode', ], - '--pwa-strategy=offline-first', '--no-tree-shake-icons', ]), ], diff --git a/tool/lib/commands/presubmit.dart b/tool/lib/commands/presubmit.dart new file mode 100644 index 00000000000..66648807486 --- /dev/null +++ b/tool/lib/commands/presubmit.dart @@ -0,0 +1,161 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:cli_util/cli_logging.dart'; +import 'package:io/io.dart'; +import 'package:meta/meta.dart'; +import 'package:path/path.dart' as path; + +import '../model.dart'; +import '../utils.dart'; + +class PresubmitCommand extends Command { + PresubmitCommand({@visibleForTesting this.processManager}) { + argParser.addFlag( + 'fix', + help: 'Apply dart fixes and formatting.', + defaultsTo: false, + negatable: false, + ); + } + + ProcessManager? processManager; + + @override + String get name => 'presubmit'; + + @override + String get description => + 'Run repo checks, analysis, fix, and format on all packages.'; + + @override + Future run() async { + final log = Logger.standard(); + final repo = DevToolsRepo.getInstance(); + final pm = processManager ?? ProcessManager(); + final fix = argResults!['fix'] as bool; + + log.stdout('Running pub get...'); + final pubGetResult = await runner?.run(['pub-get']); + if (pubGetResult is int && pubGetResult != 0) { + log.stderr('Pub get failed. Exiting early.'); + return 1; + } + + final packages = repo.getPackages(includeSubdirectories: false); + int failureCount = 0; + + if (fix) { + log.stdout('Running Dart Fix and Format...'); + for (final p in packages) { + if (!p.hasAnyDartCode) continue; + + final progress = log.progress(' ${p.relativePath}'); + + final fixProcess = await pm.runProcess( + CliCommand.dart(['fix', '--apply'], throwOnException: false), + workingDirectory: p.packagePath, + ); + + final pathsToFormat = _getPathsToFormat(p); + + final formatProcess = await pm.runProcess( + CliCommand.dart([ + 'format', + ...pathsToFormat, + ], throwOnException: false), + workingDirectory: p.packagePath, + ); + + if (fixProcess.exitCode == 0 && formatProcess.exitCode == 0) { + progress.finish(showTiming: true); + } else { + failureCount++; + progress.finish(message: 'failed'); + } + } + + if (failureCount > 0) { + log.stderr('Presubmit failed.'); + log.stderr(' Fix or Format failed on $failureCount packages.'); + return 1; + } + } + + log.stdout('Running Repo Check...'); + final repoCheckResult = await runner?.run(['repo-check']); + if (repoCheckResult is int && repoCheckResult != 0) { + log.stderr('Repo checks failed. Exiting early.'); + return 1; + } + + log.stdout('Running Analyze...'); + final analyzeResult = await runner?.run(['analyze']); + if (analyzeResult is int && analyzeResult != 0) { + log.stderr('Analysis failed. Exiting early.'); + return 1; + } + + if (!fix) { + log.stdout('Running Dart Format Check...'); + for (final p in packages) { + if (!p.hasAnyDartCode) continue; + + final progress = log.progress(' ${p.relativePath}'); + + final pathsToFormat = _getPathsToFormat(p); + + final formatProcess = await pm.runProcess( + CliCommand.dart([ + 'format', + '--output=none', + '--set-exit-if-changed', + ...pathsToFormat, + ], throwOnException: false), + workingDirectory: p.packagePath, + ); + + if (formatProcess.exitCode == 0) { + progress.finish(showTiming: true); + } else { + failureCount++; + progress.finish(message: 'failed'); + } + } + + if (failureCount > 0) { + log.stderr('Presubmit failed.'); + log.stderr(' Formatting issues found in $failureCount packages.'); + return 1; + } + } + + log.stdout('Presubmit passed!'); + return 0; + } + + List _getPathsToFormat(Package p) { + final pathsToFormat = []; + if (p.relativePath == 'tool') { + final children = Directory(p.packagePath).listSync(); + for (final entity in children) { + final name = path.basename(entity.path); + if (name.startsWith('.')) continue; + if (name == 'flutter-sdk') continue; + if (entity is Directory) { + pathsToFormat.add(name); + } else if (entity is File && name.endsWith('.dart')) { + pathsToFormat.add(name); + } + } + } else { + pathsToFormat.add('.'); + } + return pathsToFormat; + } +} diff --git a/tool/lib/commands/release_helper.dart b/tool/lib/commands/release_helper.dart index 7e9ded065e4..fa2ffeb87a9 100644 --- a/tool/lib/commands/release_helper.dart +++ b/tool/lib/commands/release_helper.dart @@ -59,15 +59,14 @@ class ReleaseHelperCommand extends Command { if (debug) { // Temporarily commit any local changes to this script to the current // branch. This commit will be reset at the end of the script. - final pathToReleaseHelperScript = - Uri.parse( - p.posix.join( - DevToolsRepo.getInstance().toolDirectoryPath, - 'lib', - 'commands', - 'release_helper.dart', - ), - ).toFilePath(); + final pathToReleaseHelperScript = Uri.parse( + p.posix.join( + DevToolsRepo.getInstance().toolDirectoryPath, + 'lib', + 'commands', + 'release_helper.dart', + ), + ).toFilePath(); await processManager.runProcess( CliCommand.git(['add', pathToReleaseHelperScript]), ); @@ -135,6 +134,8 @@ class ReleaseHelperCommand extends Command { 'create', '--repo', 'flutter/devtools', + '--head', + releaseBranch, '--draft', '--title', commitMessage, diff --git a/tool/lib/commands/release_notes_helper.dart b/tool/lib/commands/release_notes_helper.dart index f98b984b05a..624483629b2 100644 --- a/tool/lib/commands/release_notes_helper.dart +++ b/tool/lib/commands/release_notes_helper.dart @@ -91,6 +91,8 @@ class ReleaseNotesCommand extends Command { final websiteReleaseNotesDir = Directory( p.join( websiteRepoPath, + 'sites', + 'docs', 'src', 'content', 'tools', @@ -141,7 +143,8 @@ class ReleaseNotesCommand extends Command { } } - final metadataHeader = '''--- + final metadataHeader = + '''--- title: DevTools $releaseNotesVersion release notes shortTitle: $releaseNotesVersion release notes breadcrumb: $releaseNotesVersion diff --git a/tool/lib/commands/rollback.dart b/tool/lib/commands/rollback.dart index 04f39b90c43..0a0680af60a 100644 --- a/tool/lib/commands/rollback.dart +++ b/tool/lib/commands/rollback.dart @@ -27,54 +27,62 @@ class RollbackCommand extends Command { final repo = DevToolsRepo.getInstance(); print('DevTools repo at ${repo.repoPath}.'); - final tempDir = - (await io.Directory.systemTemp.createTemp( - 'devtools-rollback', - )).absolute; - print('file://${tempDir.path}'); - final tarball = io.File('${tempDir.path}/devtools.tar.gz'); - final extractDir = - await io.Directory('${tempDir.path}/extract/').absolute.create(); - final client = io.HttpClient(); - final version = argResults![_toVersionArg] as String; - print('downloading tarball to ${tarball.path}'); - final tarballRequest = await client.getUrl( - Uri.http( - 'storage.googleapis.com', - 'pub-packages/packages/devtools-$version.tar.gz', - ), - ); - final tarballResponse = await tarballRequest.close(); - await tarballResponse.pipe(tarball.openWrite()); - print('Tarball written; unzipping.'); + final tempDir = (await io.Directory.systemTemp.createTemp( + 'devtools-rollback', + )).absolute; + try { + print('file://${tempDir.path}'); + final tarball = io.File('${tempDir.path}/devtools.tar.gz'); + final extractDir = await io.Directory( + '${tempDir.path}/extract/', + ).absolute.create(); + final client = io.HttpClient(); + final version = argResults![_toVersionArg] as String; + print('downloading tarball to ${tarball.path}'); + final tarballRequest = await client.getUrl( + Uri.http( + 'storage.googleapis.com', + 'pub-packages/packages/devtools-$version.tar.gz', + ), + ); + final tarballResponse = await tarballRequest.close(); + await tarballResponse.pipe(tarball.openWrite()); + print('Tarball written; unzipping.'); - await io.Process.run('tar', [ - '-x', - '-z', - '-f', - tarball.path.split('/').last, - '-C', - extractDir.path, - ], workingDirectory: tempDir.path); - print('file://${tempDir.path}'); + await io.Process.run('tar', [ + '-x', + '-z', + '-f', + tarball.path.split('/').last, + '-C', + extractDir.path, + ], workingDirectory: tempDir.path); + print('file://${tempDir.path}'); - final buildDir = io.Directory('${repo.repoPath}/packages/devtools/build/'); - await buildDir.delete(recursive: true); - await io.Directory( - '${extractDir.path}build/', - ).rename('${repo.repoPath}/packages/devtools/build/'); + final buildDir = io.Directory( + '${repo.repoPath}/packages/devtools/build/', + ); + await buildDir.delete(recursive: true); + await io.Directory( + '${extractDir.path}build/', + ).rename('${repo.repoPath}/packages/devtools/build/'); - print( - 'Build outputs from Devtools version $version checked out and moved ' - 'to ${buildDir.path}', - ); - print( - 'To complete the rollback, go to ${repo.repoPath}/packages/devtools, ' - 'rev pubspec.yaml, update the changelog, unhide build/ from the ' - 'packages/devtools/.gitignore file, then run pub publish.', - ); - // TODO(djshuckerow): automatically rev pubspec.yaml and update the - // changelog so that the user can just run pub publish from - // packages/devtools. + print( + 'Build outputs from Devtools version $version checked out and moved ' + 'to ${buildDir.path}', + ); + print( + 'To complete the rollback, go to ${repo.repoPath}/packages/devtools, ' + 'rev pubspec.yaml, update the changelog, unhide build/ from the ' + 'packages/devtools/.gitignore file, then run pub publish.', + ); + // TODO(djshuckerow): automatically rev pubspec.yaml and update the + // changelog so that the user can just run pub publish from + // packages/devtools. + } finally { + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + } } } diff --git a/tool/lib/commands/serve.dart b/tool/lib/commands/serve.dart index 6e3243efe82..9eb7cff86c1 100644 --- a/tool/lib/commands/serve.dart +++ b/tool/lib/commands/serve.dart @@ -174,29 +174,27 @@ class ServeCommand extends Command { } // Any flag that we aren't removing here is intended to be passed through. - final remainingArguments = - List.of(results.arguments) - ..remove(SharedCommandArgs.updateFlutter.asArg()) - ..remove(SharedCommandArgs.updateFlutter.asArg(negated: true)) - ..remove(SharedCommandArgs.updatePerfetto.asArg()) - ..remove(SharedCommandArgs.wasm.asArg()) - ..remove(SharedCommandArgs.noStripWasm.asArg()) - ..remove(SharedCommandArgs.noMinifyWasm.asArg()) - ..remove(valueAsArg(_buildAppFlag)) - ..remove(valueAsArg(_buildAppFlag, negated: true)) - ..remove(SharedCommandArgs.runApp.asArg()) - ..remove(SharedCommandArgs.debugServer.asArg()) - ..remove(SharedCommandArgs.pubGet.asArg()) - ..remove(SharedCommandArgs.pubGet.asArg(negated: true)) - ..removeWhere( - (element) => - element.startsWith(SharedCommandArgs.buildMode.asArg()), - ) - ..removeWhere( - (element) => element.startsWith( - valueAsArg(SharedCommandArgs.serveWithDartSdk.flagName), - ), - ); + final remainingArguments = List.of(results.arguments) + ..remove(SharedCommandArgs.updateFlutter.asArg()) + ..remove(SharedCommandArgs.updateFlutter.asArg(negated: true)) + ..remove(SharedCommandArgs.updatePerfetto.asArg()) + ..remove(SharedCommandArgs.wasm.asArg()) + ..remove(SharedCommandArgs.noStripWasm.asArg()) + ..remove(SharedCommandArgs.noMinifyWasm.asArg()) + ..remove(valueAsArg(_buildAppFlag)) + ..remove(valueAsArg(_buildAppFlag, negated: true)) + ..remove(SharedCommandArgs.runApp.asArg()) + ..remove(SharedCommandArgs.debugServer.asArg()) + ..remove(SharedCommandArgs.pubGet.asArg()) + ..remove(SharedCommandArgs.pubGet.asArg(negated: true)) + ..removeWhere( + (element) => element.startsWith(SharedCommandArgs.buildMode.asArg()), + ) + ..removeWhere( + (element) => element.startsWith( + valueAsArg(SharedCommandArgs.serveWithDartSdk.flagName), + ), + ); final localDartSdkLocation = Platform.environment['LOCAL_DART_SDK']; if (localDartSdkLocation == null) { @@ -271,11 +269,13 @@ class ServeCommand extends Command { void processServeLocalOutput(String line) { if (line.startsWith(_debugServerVmServiceLine)) { - debugServerVmServiceUri = - line.substring(_debugServerVmServiceLine.length).trim(); + debugServerVmServiceUri = line + .substring(_debugServerVmServiceLine.length) + .trim(); } else if (line.startsWith(_debugServerDartDevToolsLine)) { - debugServerDevToolsConnection = - line.substring(_debugServerDartDevToolsLine.length).trim(); + debugServerDevToolsConnection = line + .substring(_debugServerDartDevToolsLine.length) + .trim(); } else if (line.startsWith(_devToolsServerAddressLine)) { // This will pull the server address from a String like: // "Serving DevTools at http://127.0.0.1:9104.". @@ -344,12 +344,14 @@ class ServeCommand extends Command { void processFlutterRunOutput(String line) { if (line.contains(_runAppVmServiceLine)) { final index = line.indexOf(_runAppVmServiceLine); - devToolsWebAppVmServiceUri = - line.substring(index + _runAppVmServiceLine.length).trim(); + devToolsWebAppVmServiceUri = line + .substring(index + _runAppVmServiceLine.length) + .trim(); } else if (line.contains(_runAppFlutterDevToolsLine)) { final index = line.indexOf(_runAppFlutterDevToolsLine); - devToolsWebAppDevToolsConnection = - line.substring(index + _runAppFlutterDevToolsLine.length).trim(); + devToolsWebAppDevToolsConnection = line + .substring(index + _runAppFlutterDevToolsLine.length) + .trim(); } } @@ -371,13 +373,12 @@ class ServeCommand extends Command { ); // Consolidate important stdout content for easy access. - final debugServerContent = - debugServer - ? ''' + final debugServerContent = debugServer + ? ''' - VM Service URI: $debugServerVmServiceUri - DevTools URI for debugging the DevTools server: $debugServerDevToolsConnection ''' - : ''; + : ''; print(''' ------------------------------------------------------------------- diff --git a/tool/lib/commands/update_flutter_sdk.dart b/tool/lib/commands/update_flutter_sdk.dart index 81d5b0848a1..73a95f5070c 100644 --- a/tool/lib/commands/update_flutter_sdk.dart +++ b/tool/lib/commands/update_flutter_sdk.dart @@ -49,10 +49,9 @@ class UpdateFlutterSdkCommand extends Command { final versionStr = repo.readFile(Uri.parse('flutter-candidate.txt')).trim(); // If the version string doesn't match the expected pattern for a // pre-release tag, then assume it's a commit hash: - flutterVersion = - _flutterPreReleaseTagRegExp.hasMatch(versionStr) - ? 'tags/$versionStr' - : versionStr; + flutterVersion = _flutterPreReleaseTagRegExp.hasMatch(versionStr) + ? 'tags/$versionStr' + : versionStr; log.stdout('Updating to Flutter version from cache: $flutterVersion'); @@ -87,10 +86,23 @@ class UpdateFlutterSdkCommand extends Command { } // Next, update (or clone) the tool/flutter-sdk copy. + + // On Windows LCUI trybots, some of the flutter repo paths cause + // "Filename too long" errors. Filenames such as: + // engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ + // ScenariosUITests/ + // golden_platform_view_clippath_with_transform_multiple_clips_impeller_iPhone SE (3rd generation)_26.2_simulator.png'. + final gitLongFilesCommand = CliCommand.git([ + 'config', + 'core.longpaths', + 'true', + ]); + if (Directory(toolSdkPath).existsSync()) { log.stdout('Updating Flutter at $toolSdkPath'); await processManager.runAll( commands: [ + gitLongFilesCommand, CliCommand.git(['fetch']), CliCommand.git(['checkout', flutterVersion, '-f']), CliCommand.flutter(['--version']), @@ -102,6 +114,7 @@ class UpdateFlutterSdkCommand extends Command { await processManager.runProcess( CliCommand.git([ 'clone', + '--no-checkout', 'https://github.com/flutter/flutter', flutterSdkDirName, ]), @@ -109,6 +122,7 @@ class UpdateFlutterSdkCommand extends Command { ); await processManager.runAll( commands: [ + gitLongFilesCommand, CliCommand.git(['checkout', flutterVersion, '-f']), CliCommand.flutter(['--version']), ], diff --git a/tool/lib/commands/update_version.dart b/tool/lib/commands/update_version.dart index 2891ba30e9d..02c829ad2bb 100644 --- a/tool/lib/commands/update_version.dart +++ b/tool/lib/commands/update_version.dart @@ -142,14 +142,13 @@ void writeVersionToPubspec(File pubspec, String version) { } if (currentSection == pubspecVersionPrefix && line.startsWith(pubspecVersionPrefix)) { - line = - [ - line.substring( - 0, - line.indexOf(pubspecVersionPrefix) + pubspecVersionPrefix.length, - ), - ' $version', - ].join(); + line = [ + line.substring( + 0, + line.indexOf(pubspecVersionPrefix) + pubspecVersionPrefix.length, + ), + ' $version', + ].join(); } revisedLines.add(line); } @@ -208,6 +207,25 @@ bool isDevVersion(String version) { return RegExp(r'-dev\.\d+').hasMatch(version); } +String calculateNewVersion(String currentVersion, String type) { + switch (type) { + case 'release': + if (isDevVersion(currentVersion)) { + return stripPreReleases(currentVersion); + } else { + return incrementVersionByType(currentVersion, 'minor')!; + } + case 'dev': + return incrementDevVersion(currentVersion); + default: + final version = incrementVersionByType(currentVersion, type); + if (version == null) { + throw 'Failed to determine the newVersion.'; + } + return version; + } +} + const pubspecVersionPrefix = 'version:'; class ManualUpdateCommand extends Command { @@ -326,23 +344,10 @@ class AutoUpdateCommand extends Command { final type = argResults!['type'] as String; final isDryRun = argResults!['dry-run'] as bool; final currentVersion = versionFromPubspecFile(); - String? newVersion; if (currentVersion == null) { throw 'Could not automatically determine current version.'; } - switch (type) { - case 'release': - newVersion = stripPreReleases(currentVersion); - break; - case 'dev': - newVersion = incrementDevVersion(currentVersion); - break; - default: - newVersion = incrementVersionByType(currentVersion, type); - if (newVersion == null) { - throw 'Failed to determine the newVersion.'; - } - } + final newVersion = calculateNewVersion(currentVersion, type); print('Bump version from $currentVersion to $newVersion'); if (isDryRun) { diff --git a/tool/lib/devtools_command_runner.dart b/tool/lib/devtools_command_runner.dart index 05f8243524a..307d79a6cbe 100644 --- a/tool/lib/devtools_command_runner.dart +++ b/tool/lib/devtools_command_runner.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +import 'dart:io'; + import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:devtools_tool/commands/build.dart'; @@ -18,6 +20,7 @@ import 'package:devtools_tool/model.dart'; import 'commands/analyze.dart'; import 'commands/list.dart'; +import 'commands/presubmit.dart'; import 'commands/pub_get.dart'; import 'commands/release_helper.dart'; import 'commands/repo_check.dart'; @@ -27,6 +30,8 @@ import 'commands/update_version.dart'; const _flutterFromPathFlag = 'flutter-from-path'; +const _flutterFromPathEnvVar = 'DEVTOOLS_TOOL_FLUTTER_FROM_PATH'; + const _flutterSdkPathFlag = 'flutter-sdk-path'; class DevToolsCommandRunner extends CommandRunner { @@ -37,6 +42,7 @@ class DevToolsCommandRunner extends CommandRunner { addCommand(FixGoldensCommand()); addCommand(GenerateCodeCommand()); addCommand(ListCommand()); + addCommand(PresubmitCommand()); addCommand(PubGetCommand()); addCommand(ReleaseHelperCommand()); addCommand(ReleaseNotesCommand()); @@ -60,7 +66,9 @@ class DevToolsCommandRunner extends CommandRunner { 'Use the Flutter SDK on PATH for any `flutter`, `dart` and ' '`dt` commands spawned by this process, instead of the ' 'Flutter SDK from tool/flutter-sdk which is used by default. ' - 'This is incompatible with the `$_flutterSdkPathFlag` flag.', + 'This is incompatible with the `$_flutterSdkPathFlag` flag. ' + 'Can also be enabled via the `$_flutterFromPathEnvVar` ' + 'environment variable.', ) ..addOption( _flutterSdkPathFlag, @@ -74,6 +82,10 @@ class DevToolsCommandRunner extends CommandRunner { @override Future runCommand(ArgResults topLevelResults) { + final flutterFromPathEnvValue = + Platform.environment[_flutterFromPathEnvVar] ?? ''; + final flutterFromPathEnv = + bool.tryParse(flutterFromPathEnvValue, caseSensitive: false) == true; if (topLevelResults.flag(_flutterFromPathFlag) && topLevelResults.wasParsed(_flutterSdkPathFlag)) { throw ArgParserException( @@ -82,7 +94,8 @@ class DevToolsCommandRunner extends CommandRunner { } if (topLevelResults.wasParsed(_flutterSdkPathFlag)) { FlutterSdk.useFromPath(topLevelResults.option(_flutterSdkPathFlag)!); - } else if (topLevelResults.flag(_flutterFromPathFlag)) { + } else if (topLevelResults.flag(_flutterFromPathFlag) || + flutterFromPathEnv) { FlutterSdk.useFromPathEnvironmentVariable(); } else { FlutterSdk.useFromCurrentVm(); diff --git a/tool/lib/license_utils.dart b/tool/lib/license_utils.dart index 652e90e866c..d1d4fb16f1b 100644 --- a/tool/lib/license_utils.dart +++ b/tool/lib/license_utils.dart @@ -202,10 +202,9 @@ class LicenseHeader { .openRead(0, byteCount) .transform(utf8.decoder) .handleError( - (e) => - throw StateError( - 'License header expected, but error reading file - $e', - ), + (e) => throw StateError( + 'License header expected, but error reading file - $e', + ), ); await for (final content in stream) { // Return just the license headers for the simple case with no stored @@ -270,8 +269,10 @@ class LicenseHeader { }) async { final includedPathsList = []; final updatedPathsList = []; - final files = - directory.listSync(recursive: true).whereType().toList(); + final files = directory + .listSync(recursive: true) + .whereType() + .toList(); for (final file in files) { if (!config.shouldExclude(file)) { includedPathsList.add(file.path); @@ -332,10 +333,9 @@ class LicenseHeader { final storedNameIndex = matchStr.indexOf('<$storedName>'); if (storedNameIndex != -1) { final beforeStoredName = matchStr.substring(0, storedNameIndex); - final afterStoredName = - matchStr - .substring(storedNameIndex + storedName.length + 2) - .trimRight(); + final afterStoredName = matchStr + .substring(storedNameIndex + storedName.length + 2) + .trimRight(); final storedMatcher = RegExp( r'' + beforeStoredName + diff --git a/tool/lib/model.dart b/tool/lib/model.dart index ecba1f5de3c..b31e4a14e80 100644 --- a/tool/lib/model.dart +++ b/tool/lib/model.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; +import 'package:devtools_tool/utils.dart'; import 'package:path/path.dart' as path; class DevToolsRepo { @@ -114,21 +115,20 @@ class DevToolsRepo { // Do not include the top level devtools/packages directory in the results // even though it has a pubspec.yaml file. Also skip any directories // specified by [skip]. - final reason = - isTopLevelPackagesDir - ? 'each DevTools package is analyzed individually' - : '${skip.toString()} directories are intentionally skipped'; + final reason = isTopLevelPackagesDir + ? 'each DevTools package is analyzed individually' + : '${skip.toString()} directories are intentionally skipped'; print('Skipping ${dir.path} in _collectPackages because $reason.'); } else { final ancestor = result.firstWhereOrNull( (p) => - // Remove the last segment of [dir]'s pathSegments to ensure we - // are only checking ancestors and not sibling directories with - // similar names. - (List.from(dir.uri.pathSegments)..safeRemoveLast()) - // TODO(kenz): this may cause issues for Windows paths. - .join('/') - .startsWith(p.packagePath), + // Remove the last segment of [dir]'s pathSegments to ensure we + // are only checking ancestors and not sibling directories with + // similar names. + (List.from(dir.uri.pathSegments)..safeRemoveLast()) + // TODO(kenz): this may cause issues for Windows paths. + .join('/') + .startsWith(p.packagePath), ); final ancestorDirectoryAdded = ancestor != null; if (!includeSubdirectories && ancestorDirectoryAdded) { @@ -243,8 +243,20 @@ class FlutterSdk { } static FlutterSdk findFromPath(String sdkPath) { - if (path.basename(path.dirname(sdkPath)) == 'bin') { - return FlutterSdk._(path.dirname(path.dirname(sdkPath))); + var resolvedPath = sdkPath; + try { + resolvedPath = File(sdkPath).resolveSymbolicLinksSync(); + } catch (_) { + // Fallback to the unresolved path if resolution fails. + } + + final resolvedPathParent = path.dirname(resolvedPath); + if (path.basename(resolvedPathParent) == 'bin') { + return FlutterSdk._(path.dirname(resolvedPathParent)); + } else if (path.basename(resolvedPath) == 'bin') { + return FlutterSdk._(resolvedPathParent); + } else if (Directory(path.join(resolvedPath, 'bin')).existsSync()) { + return FlutterSdk._(resolvedPath); } throw Exception('Unable to locate the Flutter SDK at "$sdkPath"'); @@ -259,10 +271,7 @@ class FlutterSdk { final result = Process.runSync(whichCommand, ['flutter']); if (result.exitCode == 0) { final sdkPath = result.stdout.toString().split('\n').first.trim(); - // 'flutter/bin' - if (path.basename(path.dirname(sdkPath)) == 'bin') { - return FlutterSdk._(path.dirname(path.dirname(sdkPath))); - } + return findFromPath(sdkPath); } throw Exception('Unable to locate the Flutter SDK on PATH'); @@ -270,14 +279,16 @@ class FlutterSdk { final String sdkPath; - static String get flutterExecutableName => - Platform.isWindows ? 'flutter.bat' : 'flutter'; + /// The name of the 'flutter' shell script for the current platform. + static String get flutterExecutableName => shellScriptName('flutter'); + /// The name of the 'dart' wrapper shell script in Flutter for the current + /// platform. + /// /// On windows, 'dart' is fine for running the .exe from the Dart SDK directly /// but the wrapper in the Flutter bin folder is a .bat and needs an explicit /// extension. - static String get dartWrapperExecutableName => - Platform.isWindows ? 'dart.bat' : 'dart'; + static String get dartWrapperExecutableName => shellScriptName('dart'); String get flutterExePath => path.join(sdkPath, 'bin', flutterExecutableName); diff --git a/tool/lib/utils.dart b/tool/lib/utils.dart index 467bea8665c..45ecdc8b280 100644 --- a/tool/lib/utils.dart +++ b/tool/lib/utils.dart @@ -31,6 +31,14 @@ abstract class DartSdkHelper { } } +/// Returns the name of the shell script [name] for the current platform. +/// +/// On Windows, shell scripts have `.bat` extensions and must be invoked +/// using them. +String shellScriptName(String name) { + return Platform.isWindows ? '$name.bat' : name; +} + String localDartSdkLocation() { final localDartSdkLocation = Platform.environment['LOCAL_DART_SDK']; if (localDartSdkLocation == null) { @@ -86,7 +94,11 @@ class CliCommand { List args, { bool throwOnException = true, }) { - return CliCommand('gclient', args, throwOnException: throwOnException); + return CliCommand( + shellScriptName('gclient'), + args, + throwOnException: throwOnException, + ); } factory CliCommand.tool(List args, {bool throwOnException = true}) { @@ -163,6 +175,7 @@ extension DevToolsProcessManagerExtension on ProcessManager { ); } + /// Runs [commands] in serial, from [workingDirectory]. Future runAll({ required List commands, String? workingDirectory, @@ -257,8 +270,8 @@ Future findRemote( try { upstreamRemoteResult = remoteRegexpResults.firstWhere( (element) => - // ignore: prefer_interpolation_to_compose_strings - RegExp(r'' + remoteId + '\$').hasMatch(element.namedGroup('path')!), + // ignore: prefer_interpolation_to_compose_strings + RegExp(r'' + remoteId + '\$').hasMatch(element.namedGroup('path')!), ); } on StateError { throw StateError( diff --git a/tool/pubspec.yaml b/tool/pubspec.yaml index e93e984aa5c..135401d6b32 100644 --- a/tool/pubspec.yaml +++ b/tool/pubspec.yaml @@ -6,7 +6,7 @@ description: A repo management tool for DevTools. publish_to: none environment: - sdk: ">=3.7.0-160.0.dev <4.0.0" + sdk: ^3.11.0 resolution: workspace @@ -18,8 +18,11 @@ dependencies: cli_util: ^0.4.1 collection: ^1.19.0 io: ^1.0.4 + meta: ^1.18.0 path: ^1.9.0 yaml: ^3.1.2 - + dev_dependencies: + logging: ^1.1.1 + skills_lint: ^0.5.3 test: ^1.25.8 diff --git a/tool/skills_lint.yaml b/tool/skills_lint.yaml new file mode 100644 index 00000000000..14fbb78e053 --- /dev/null +++ b/tool/skills_lint.yaml @@ -0,0 +1,10 @@ +# Copyright 2026 The Flutter Authors +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. +skills_lint: + rules: + check-relative-paths: error + check-absolute-paths: error + check-trailing-whitespace: error + directories: + - path: "../.agents/skills" diff --git a/tool/test/command_test_utils.dart b/tool/test/command_test_utils.dart new file mode 100644 index 00000000000..fd9a362ab9c --- /dev/null +++ b/tool/test/command_test_utils.dart @@ -0,0 +1,128 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:io/io.dart'; + +class MockProcessManager implements ProcessManager { + MockProcessManager({this.onSpawn}); + + final Future Function( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment, + bool runInShell, + ProcessStartMode mode, + })? + onSpawn; + + @override + Future spawn( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + if (onSpawn != null) { + return onSpawn!( + executable, + arguments, + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + mode: mode, + ); + } + return MockProcess(); + } + + @override + Future spawnBackground( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + throw UnimplementedError(); + } + + @override + Future spawnDetached( + String executable, + Iterable arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + throw UnimplementedError(); + } +} + +class MockProcess implements Process { + MockProcess({ + this.exitCodeValue = 0, + this.stdoutString = '', + this.stderrString = '', + }); + + final int exitCodeValue; + final String stdoutString; + final String stderrString; + + @override + Future get exitCode => Future.value(exitCodeValue); + + @override + Stream> get stdout => Stream.value(utf8.encode(stdoutString)); + + @override + Stream> get stderr => Stream.value(utf8.encode(stderrString)); + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) => true; + + @override + int get pid => 0; + + @override + IOSink get stdin => throw UnimplementedError(); +} + +class TestCommandRunner extends CommandRunner { + TestCommandRunner() : super('test', 'test description'); + + void addDummyCommand(String name, [int exitCode = 0]) { + addCommand(DummyCommand(name, exitCode)); + } +} + +class DummyCommand extends Command { + DummyCommand(this.name, this.exitCodeValue); + + @override + final String name; + + @override + String get description => 'Dummy command for testing'; + + final int exitCodeValue; + + @override + Future run() async => exitCodeValue; +} diff --git a/tool/test/license_utils_test.dart b/tool/test/license_utils_test.dart index 5cbd21725c9..2a4ee5564f1 100644 --- a/tool/test/license_utils_test.dart +++ b/tool/test/license_utils_test.dart @@ -60,7 +60,7 @@ void main() { await _setupTestConfigFile(); }); - tearDownAll(() async { + tearDown(() async { await deleteDirectoryWithRetry(testDirectory); }); @@ -171,7 +171,7 @@ text that should be removed from the file. */ await _setupTestDirectoryStructure(); }); - tearDownAll(() async { + tearDown(() async { await deleteDirectoryWithRetry(testDirectory); }); @@ -420,8 +420,10 @@ text that should be added to the file. */''', true, reason: '$checkedDirectory does not exist.', ); - final files = - checkedDirectory.listSync(recursive: true).whereType().toList(); + final files = checkedDirectory + .listSync(recursive: true) + .whereType() + .toList(); final header = LicenseHeader(); const goodReplacementLicenseText = '''// Copyright The Flutter Authors @@ -480,7 +482,8 @@ Future _setupTestConfigFile() async { configFile = File(p.join(testDirectory.path, 'test_config.yaml')) ..createSync(recursive: true); - final contents = '''--- + final contents = + '''--- # sequence of license text strings that should be matched against at the top of a file and removed. , which normally represents a date, will be stored. remove_licenses: - | diff --git a/tool/test/model_test.dart b/tool/test/model_test.dart new file mode 100644 index 00000000000..85682c08114 --- /dev/null +++ b/tool/test/model_test.dart @@ -0,0 +1,79 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:io'; + +import 'package:devtools_tool/devtools_command_runner.dart'; +import 'package:devtools_tool/model.dart'; +import 'package:path/path.dart' as path; +import 'package:test/test.dart'; + +void main() { + group('DevToolsCommandRunner', () { + test('parses --flutter-from-path flag', () { + final runner = DevToolsCommandRunner(); + final results = runner.argParser.parse(['-p']); + expect(results['flutter-from-path'], isTrue); + }); + + test('parses --flutter-sdk-path option', () { + final runner = DevToolsCommandRunner(); + final results = runner.argParser.parse([ + '--flutter-sdk-path', + '/custom/path', + ]); + expect(results['flutter-sdk-path'], equals('/custom/path')); + }); + }); + + group('FlutterSdk.findFromPath', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('flutter_sdk_test_'); + }); + + tearDown(() { + tempDir.deleteSync(recursive: true); + }); + + test('finds SDK root from bin/flutter executable path', () { + final binDir = Directory(path.join(tempDir.path, 'bin'))..createSync(); + final flutterExe = File(path.join(binDir.path, 'flutter'))..createSync(); + + final sdk = FlutterSdk.findFromPath(flutterExe.path); + expect(sdk.sdkPath, equals(tempDir.resolveSymbolicLinksSync())); + }); + + test('resolves symbolic links to find actual SDK root', () { + final sdkDir = Directory(path.join(tempDir.path, 'real_sdk')) + ..createSync(); + final binDir = Directory(path.join(sdkDir.path, 'bin'))..createSync(); + final flutterExe = File(path.join(binDir.path, 'flutter'))..createSync(); + + final linkDir = Directory(path.join(tempDir.path, 'symlink_bin')) + ..createSync(); + final symlink = Link(path.join(linkDir.path, 'flutter')); + symlink.createSync(flutterExe.path); + + final sdk = FlutterSdk.findFromPath(symlink.path); + expect(sdk.sdkPath, equals(sdkDir.resolveSymbolicLinksSync())); + }); + + test('finds SDK root when given SDK directory path directly', () { + final binDir = Directory(path.join(tempDir.path, 'bin'))..createSync(); + File(path.join(binDir.path, 'flutter')).createSync(); + + final sdk = FlutterSdk.findFromPath(tempDir.path); + expect(sdk.sdkPath, equals(tempDir.resolveSymbolicLinksSync())); + }); + + test('throws Exception when unable to locate Flutter SDK', () { + expect( + () => FlutterSdk.findFromPath('/non/existent/path/to/flutter'), + throwsA(isA()), + ); + }); + }); +} diff --git a/tool/test/presubmit_test.dart b/tool/test/presubmit_test.dart new file mode 100644 index 00000000000..1f00f284e33 --- /dev/null +++ b/tool/test/presubmit_test.dart @@ -0,0 +1,179 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:io'; + +import 'package:devtools_tool/commands/presubmit.dart'; +import 'package:devtools_tool/model.dart'; +import 'package:test/test.dart'; + +import 'command_test_utils.dart'; + +void main() { + group('PresubmitCommand', () { + setUp(() { + try { + FlutterSdk.useFromCurrentVm(); + } catch (_) { + FlutterSdk.useFromPathEnvironmentVariable(); + } + }); + + test('succeeds when all steps pass', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + runner.addCommand(PresubmitCommand(processManager: MockProcessManager())); + + final result = await runner.run(['presubmit']); + expect(result, equals(0)); + }); + + test('runs fix and format when --fix is passed', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + + final capturedArgs = >[]; + final mockPm = MockProcessManager( + onSpawn: + ( + executable, + arguments, { + workingDirectory, + environment, + includeParentEnvironment = true, + runInShell = false, + mode = ProcessStartMode.normal, + }) async { + capturedArgs.add(arguments.toList()); + return MockProcess(); + }, + ); + + runner.addCommand(PresubmitCommand(processManager: mockPm)); + + final result = await runner.run(['presubmit', '--fix']); + expect(result, equals(0)); + + final hasFix = capturedArgs.any((args) => args.contains('fix')); + expect(hasFix, isTrue); + + final formatArgs = capturedArgs.firstWhere( + (args) => args.contains('format'), + orElse: () => [], + ); + expect(formatArgs, isNotEmpty); + expect(formatArgs.contains('--output=none'), isFalse); + expect(formatArgs.contains('--set-exit-if-changed'), isFalse); + }); + + test('fails fast if pub-get fails', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get', 1); // fails + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + runner.addCommand(PresubmitCommand(processManager: MockProcessManager())); + + final result = await runner.run(['presubmit']); + expect(result, equals(1)); + }); + + test('fails fast if repo-check fails', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check', 1); // fails + runner.addDummyCommand('analyze'); + runner.addCommand(PresubmitCommand(processManager: MockProcessManager())); + + final result = await runner.run(['presubmit']); + expect(result, equals(1)); + }); + + test('fails fast if analyze fails', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze', 1); // fails + runner.addCommand(PresubmitCommand(processManager: MockProcessManager())); + + final result = await runner.run(['presubmit']); + expect(result, equals(1)); + }); + + test('fails if dart format check fails without --fix', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + + final mockPm = MockProcessManager( + onSpawn: + ( + executable, + arguments, { + workingDirectory, + environment, + includeParentEnvironment = true, + runInShell = false, + mode = ProcessStartMode.normal, + }) async { + if (arguments.contains('format') && + arguments.contains('--set-exit-if-changed')) { + return MockProcess(exitCodeValue: 1); + } + return MockProcess(); + }, + ); + + runner.addCommand(PresubmitCommand(processManager: mockPm)); + + final result = await runner.run(['presubmit']); + expect(result, equals(1)); + }); + + test('filters files for tool package formatting', () async { + final runner = TestCommandRunner(); + runner.addDummyCommand('pub-get'); + runner.addDummyCommand('repo-check'); + runner.addDummyCommand('analyze'); + + final capturedArgs = >[]; + final mockPm = MockProcessManager( + onSpawn: + ( + executable, + arguments, { + workingDirectory, + environment, + includeParentEnvironment = true, + runInShell = false, + mode = ProcessStartMode.normal, + }) async { + capturedArgs.add(arguments.toList()); + return MockProcess(); + }, + ); + + runner.addCommand(PresubmitCommand(processManager: mockPm)); + + await runner.run(['presubmit']); + + // Find the format command for the tool package. + // It should contain 'lib' (since 'lib' is one of its children) and NOT + // '.' (which is used for other packages). Or it should contain multiple + // paths. + final toolFormatArgs = capturedArgs.firstWhere( + // 'dart', 'format', and at least two paths + (args) => args.contains('format') && args.length > 3, + orElse: () => [], + ); + + expect(toolFormatArgs, isNotEmpty); + expect(toolFormatArgs.contains('flutter-sdk'), isFalse); + }); + }); +} diff --git a/tool/test/update_version_test.dart b/tool/test/update_version_test.dart new file mode 100644 index 00000000000..68be52f0137 --- /dev/null +++ b/tool/test/update_version_test.dart @@ -0,0 +1,40 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_tool/commands/update_version.dart'; +import 'package:test/test.dart'; + +void main() { + group('calculateNewVersion', () { + test('release type strips pre-release when on dev version', () { + expect(calculateNewVersion('2.28.0-dev.0', 'release'), '2.28.0'); + expect(calculateNewVersion('2.28.0-dev.5', 'release'), '2.28.0'); + }); + + test('release type increments minor version when not on dev version', () { + expect(calculateNewVersion('2.28.0', 'release'), '2.29.0'); + expect(calculateNewVersion('2.28.1', 'release'), '2.29.0'); + }); + + test('dev type increments dev version', () { + expect(calculateNewVersion('1.2.3', 'dev'), '1.2.3-dev.0'); + expect(calculateNewVersion('1.2.3-dev.4', 'dev'), '1.2.3-dev.5'); + }); + + test('patch type increments patch version', () { + expect(calculateNewVersion('1.2.3', 'patch'), '1.2.4'); + expect(calculateNewVersion('1.2.3-dev.4', 'patch'), '1.2.4'); + }); + + test('minor type increments minor version', () { + expect(calculateNewVersion('1.2.3', 'minor'), '1.3.0'); + expect(calculateNewVersion('1.2.3-dev.4', 'minor'), '1.3.0'); + }); + + test('major type increments major version', () { + expect(calculateNewVersion('1.2.3', 'major'), '2.0.0'); + expect(calculateNewVersion('1.2.3-dev.4', 'major'), '2.0.0'); + }); + }); +} diff --git a/tool/test/validate_skills_test.dart b/tool/test/validate_skills_test.dart new file mode 100644 index 00000000000..b456dcea23b --- /dev/null +++ b/tool/test/validate_skills_test.dart @@ -0,0 +1,42 @@ +// Copyright 2026 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'dart:async'; + +import 'package:logging/logging.dart'; +import 'package:skills_lint/skills_lint.dart'; +import 'package:test/test.dart'; + +const String _configFilePath = 'skills_lint.yaml'; + +void main() { + test('Validate DevTools Skills', () async { + final Level oldLevel = Logger.root.level; + Logger.root.level = Level.ALL; + final StreamSubscription subscription = Logger.root.onRecord + .listen((record) { + print(record.message); + }); + + try { + final Configuration config = await ConfigParser.loadConfig( + path: _configFilePath, + ); + expect( + config.parsingErrors, + isEmpty, + reason: 'Configuration parsing failed: ${config.parsingErrors}', + ); + final bool isValid = await validateSkills(config: config); + expect( + isValid, + isTrue, + reason: 'Skills validation failed. See above for details.', + ); + } finally { + Logger.root.level = oldLevel; + await subscription.cancel(); + } + }); +}