diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index 7e23721..5c6fe67 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -131,6 +131,25 @@ jobs: exit $EXIT + # Sub-packages the root excludes: each one only resolves against its + # own pubspec, so analyze it in place after its own pub get. + - name: flutter analyze sub-packages + if: steps.filter.outputs.any_dart == 'true' + run: | + FAILED="" + for pkg in hyper_render_html hyper_render_markdown hyper_render_highlight \ + hyper_render_math hyper_render_clipboard hyper_render_devtools \ + hyper_render_epub; do + echo "::group::$pkg" + (cd "packages/$pkg" && flutter pub get >/dev/null && \ + flutter analyze --no-pub --fatal-warnings --fatal-infos) || FAILED="$FAILED $pkg" + echo "::endgroup::" + done + if [ -n "$FAILED" ]; then + echo "::error::analyze failed in:$FAILED" + exit 1 + fi + - name: Upload analyze report if: always() && steps.filter.outputs.any_dart == 'true' uses: actions/upload-artifact@v4 diff --git a/.github/workflows/deploy_web_demo.yml b/.github/workflows/deploy_web_demo.yml index 0e4f67d..6212768 100644 --- a/.github/workflows/deploy_web_demo.yml +++ b/.github/workflows/deploy_web_demo.yml @@ -5,10 +5,12 @@ on: branches: [main] workflow_dispatch: +# Pages is served from the gh-pages branch ("Deploy from a branch"), so the +# build is pushed there. The previous actions/deploy-pages flow needed the +# github-pages environment to allow `main`, which it doesn't — every run +# failed and the live demo stayed on v1.7.1. permissions: - contents: read - pages: write - id-token: write + contents: write concurrency: group: 'pages' @@ -16,9 +18,6 @@ concurrency: jobs: deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - name: Checkout repository @@ -40,11 +39,11 @@ jobs: cd example flutter build web --release --base-href "/hyper_render/" - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + - name: Publish to gh-pages + uses: peaceiris/actions-gh-pages@v4 with: - path: example/build/web - - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: example/build/web + publish_branch: gh-pages + force_orphan: true # keep gh-pages to a single commit, no history bloat + commit_message: "deploy: live web demo" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7915405..ad4d6a1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,6 +40,8 @@ jobs: changed_highlight: ${{ steps.filter.outputs.highlight }} changed_clipboard: ${{ steps.filter.outputs.clipboard }} changed_devtools: ${{ steps.filter.outputs.devtools }} + changed_math: ${{ steps.filter.outputs.math }} + changed_epub: ${{ steps.filter.outputs.epub }} changed_root: ${{ steps.filter.outputs.root }} changed_any_dart: ${{ steps.filter.outputs.any_dart }} steps: @@ -60,6 +62,10 @@ jobs: - 'packages/hyper_render_clipboard/**' devtools: - 'packages/hyper_render_devtools/**' + math: + - 'packages/hyper_render_math/**' + epub: + - 'packages/hyper_render_epub/**' root: - 'lib/**' - 'test/**' @@ -99,45 +105,12 @@ jobs: working-directory: example/android run: ./gradlew assembleDebug - emulator-tests: - name: Integration Tests (Emulator) - runs-on: macos-latest - needs: path-filter - if: >- - github.event_name == 'pull_request' && - needs.path-filter.outputs.changed_any_dart == 'true' - strategy: - fail-fast: false - matrix: - platform: [android, ios] - steps: - - uses: actions/checkout@v4 - - name: Setup Java - if: matrix.platform == 'android' - uses: actions/setup-java@v3 - with: - distribution: 'zulu' - java-version: '17' - - name: Setup Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ env.FLUTTER_VERSION }} - channel: stable - cache: true - - name: flutter pub get - run: flutter pub get - - name: Run Android Emulator Tests - if: matrix.platform == 'android' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 29 - arch: arm64-v8a - script: flutter test test/integration/ - - name: Run iOS Simulator Tests - if: matrix.platform == 'ios' - run: | - xcrun simctl list devicetypes - flutter test test/integration/ -d "iPhone 15" || flutter test test/integration/ + # NOTE: the former "Integration Tests (Emulator)" job was removed. It ran + # `flutter test test/integration/` with no device target, i.e. on the host VM + # — the same tests test-pr runs below — but on macOS runners (10x the + # per-minute price), and its Android leg could not boot an x86 emulator on + # Apple Silicon (HVF_UNSUPPORTED). Real on-device coverage lives in + # example/integration_test/all_demos_test.dart (run locally with -d macos). test-pr: name: Tests (PR · ubuntu-22.04 · stable) @@ -190,7 +163,7 @@ jobs: needs.path-filter.outputs.changed_html == 'true' || needs.path-filter.outputs.changed_core == 'true' working-directory: packages/hyper_render_html - run: flutter test --no-pub + run: flutter pub get && flutter test --no-pub - name: Test hyper_render_markdown if: >- @@ -198,7 +171,7 @@ jobs: needs.path-filter.outputs.changed_core == 'true' working-directory: packages/hyper_render_markdown run: | - if [ -d test ]; then flutter test --no-pub; else echo "No tests in this package — skipping."; fi + if [ -d test ]; then flutter pub get && flutter test --no-pub; else echo "No tests in this package — skipping."; fi - name: Test hyper_render_highlight if: >- @@ -206,7 +179,7 @@ jobs: needs.path-filter.outputs.changed_core == 'true' working-directory: packages/hyper_render_highlight run: | - if [ -d test ]; then flutter test --no-pub; else echo "No tests in this package — skipping."; fi + if [ -d test ]; then flutter pub get && flutter test --no-pub; else echo "No tests in this package — skipping."; fi - name: Test hyper_render_clipboard if: >- @@ -214,7 +187,36 @@ jobs: needs.path-filter.outputs.changed_core == 'true' working-directory: packages/hyper_render_clipboard run: | - if [ -d test ]; then flutter test --no-pub; else echo "No tests in this package — skipping."; fi + if [ -d test ]; then flutter pub get && flutter test --no-pub; else echo "No tests in this package — skipping."; fi + + - name: Test hyper_render_math + if: >- + needs.path-filter.outputs.changed_math == 'true' || + needs.path-filter.outputs.changed_core == 'true' + working-directory: packages/hyper_render_math + run: flutter pub get && flutter test --no-pub + + - name: Test hyper_render_epub + if: >- + needs.path-filter.outputs.changed_epub == 'true' || + needs.path-filter.outputs.changed_root == 'true' || + needs.path-filter.outputs.changed_core == 'true' + working-directory: packages/hyper_render_epub + run: flutter pub get && flutter test --no-pub + + - name: Test hyper_render_devtools + if: >- + needs.path-filter.outputs.changed_devtools == 'true' || + needs.path-filter.outputs.changed_core == 'true' + working-directory: packages/hyper_render_devtools + run: flutter pub get && flutter test --no-pub + + # The panel imports dart:js_interop: its widget test only compiles for + # the web (@TestOn('browser')). ubuntu runners ship Chrome. + - name: Test hyper_render_devtools panel (Chrome) + if: needs.path-filter.outputs.changed_devtools == 'true' + working-directory: packages/hyper_render_devtools/devtools_ui + run: flutter pub get && flutter test --no-pub --platform chrome - name: Upload test results if: failure() diff --git a/CHANGELOG.md b/CHANGELOG.md index 100454e..818b8be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.10.0 + +- **`var()` in `

Brand text

'; + + tearDown(() => HyperRenderDebugHooks.cssVariableOverrides.value = const {}); + + Color paragraphColor(WidgetTester tester) { + for (final w in tester + .widgetList(find.byType(HyperRenderWidget))) { + UDTNode? p; + void walk(UDTNode n) { + if (n.tagName == 'p') p ??= n; + n.children.forEach(walk); + } + + walk(w.document); + if (p != null) return p!.style.color; + } + fail('no

rendered'); + } + + testWidgets('sync mode re-renders with an overridden variable', + (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: HyperViewer(html: html, mode: HyperRenderMode.sync)), + )); + await tester.pumpAndSettle(); + expect(paragraphColor(tester), const Color(0xFFFF0000)); + + HyperRenderDebugHooks.cssVariableOverrides.value = {'--brand': '#0000ff'}; + await tester.pumpAndSettle(); + expect(paragraphColor(tester), const Color(0xFF0000FF)); + + HyperRenderDebugHooks.cssVariableOverrides.value = const {}; + await tester.pumpAndSettle(); + expect(paragraphColor(tester), const Color(0xFFFF0000)); + }); + + testWidgets('virtualized (async parse) mode picks up overrides too', + (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: HyperViewer( + html: html, + mode: HyperRenderMode.virtualized, + renderConfig: HyperRenderConfig(useMicrotaskParsing: true), + ), + ), + )); + await tester.pumpAndSettle(); + expect(paragraphColor(tester), const Color(0xFFFF0000)); + + HyperRenderDebugHooks.cssVariableOverrides.value = {'--brand': '#00ff00'}; + await tester.pumpAndSettle(); + expect(paragraphColor(tester), const Color(0xFF00FF00)); + }); + + testWidgets('compute() isolate parse receives the overrides', (tester) async { + // Default native config parses virtualized HTML in a real isolate, + // where HyperRenderDebugHooks' statics are NOT shared — the overrides + // must travel in the args record. Isolates don't run under FakeAsync. + HyperRenderDebugHooks.cssVariableOverrides.value = {'--brand': '#00ff00'}; + await tester.pumpWidget(const MaterialApp( + home: Scaffold( + body: HyperViewer( + html: html, + mode: HyperRenderMode.virtualized, + renderConfig: HyperRenderConfig(useMicrotaskParsing: false), + ), + ), + )); + for (var i = 0; + i < 50 && find.byType(HyperRenderWidget).evaluate().isEmpty; + i++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 20))); + await tester.pump(); + } + await tester.pump(const Duration(milliseconds: 400)); + expect(paragraphColor(tester), const Color(0xFF00FF00)); + }); + + testWidgets('a disposed viewer stops listening', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: HyperViewer(html: html, mode: HyperRenderMode.sync)), + )); + await tester.pumpAndSettle(); + await tester.pumpWidget(const SizedBox()); + HyperRenderDebugHooks.cssVariableOverrides.value = {'--brand': '#0000ff'}; + await tester.pump(); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/fuzz/parser_fuzz_test.dart b/test/fuzz/parser_fuzz_test.dart index 329a867..42385a2 100644 --- a/test/fuzz/parser_fuzz_test.dart +++ b/test/fuzz/parser_fuzz_test.dart @@ -161,6 +161,11 @@ void main() { '```\n```\n```\n', '[[[[[[[[[[text]]]]]]]]]]', '***___***text***___***', + // Stylesheet var()/url()/calc() shapes —

x

', + '

x', + '

x

', + '

x

', ]; for (var i = 0; i < adversarial.length; i++) { diff --git a/test/golden/goldens/blockquote.png b/test/golden/goldens/blockquote.png index ec0e782..4e9de71 100644 Binary files a/test/golden/goldens/blockquote.png and b/test/golden/goldens/blockquote.png differ diff --git a/test/golden/goldens/canvas_block_animation_boxshadow.png b/test/golden/goldens/canvas_block_animation_boxshadow.png index db7cb43..da460fa 100644 Binary files a/test/golden/goldens/canvas_block_animation_boxshadow.png and b/test/golden/goldens/canvas_block_animation_boxshadow.png differ diff --git a/test/golden/goldens/canvas_block_animation_slidein_50pct.png b/test/golden/goldens/canvas_block_animation_slidein_50pct.png index d4906fd..3eedd1f 100644 Binary files a/test/golden/goldens/canvas_block_animation_slidein_50pct.png and b/test/golden/goldens/canvas_block_animation_slidein_50pct.png differ diff --git a/test/golden/goldens/cjk_kinsoku.png b/test/golden/goldens/cjk_kinsoku.png index e8a145f..f247cdf 100644 Binary files a/test/golden/goldens/cjk_kinsoku.png and b/test/golden/goldens/cjk_kinsoku.png differ diff --git a/test/golden/goldens/cjk_ruby.png b/test/golden/goldens/cjk_ruby.png index e6cc16d..8d6261a 100644 Binary files a/test/golden/goldens/cjk_ruby.png and b/test/golden/goldens/cjk_ruby.png differ diff --git a/test/golden/goldens/code_block.png b/test/golden/goldens/code_block.png index 56ac042..1e0565a 100644 Binary files a/test/golden/goldens/code_block.png and b/test/golden/goldens/code_block.png differ diff --git a/test/golden/goldens/definition_list.png b/test/golden/goldens/definition_list.png index a512127..549ceee 100644 Binary files a/test/golden/goldens/definition_list.png and b/test/golden/goldens/definition_list.png differ diff --git a/test/golden/goldens/float_cjk.png b/test/golden/goldens/float_cjk.png index 791f9e0..484422f 100644 Binary files a/test/golden/goldens/float_cjk.png and b/test/golden/goldens/float_cjk.png differ diff --git a/test/golden/goldens/float_clear.png b/test/golden/goldens/float_clear.png index 446afc3..78600d0 100644 Binary files a/test/golden/goldens/float_clear.png and b/test/golden/goldens/float_clear.png differ diff --git a/test/golden/goldens/float_left.png b/test/golden/goldens/float_left.png index 7b5efbc..abdefaa 100644 Binary files a/test/golden/goldens/float_left.png and b/test/golden/goldens/float_left.png differ diff --git a/test/golden/goldens/float_right.png b/test/golden/goldens/float_right.png index 4fe27f0..40b825e 100644 Binary files a/test/golden/goldens/float_right.png and b/test/golden/goldens/float_right.png differ diff --git a/test/golden/goldens/full_article.png b/test/golden/goldens/full_article.png index 5d24796..9b4e2a8 100644 Binary files a/test/golden/goldens/full_article.png and b/test/golden/goldens/full_article.png differ diff --git a/test/golden/goldens/headings.png b/test/golden/goldens/headings.png index 315a14c..451a454 100644 Binary files a/test/golden/goldens/headings.png and b/test/golden/goldens/headings.png differ diff --git a/test/golden/goldens/image_placeholder.png b/test/golden/goldens/image_placeholder.png index 64c4d30..9ae7316 100644 Binary files a/test/golden/goldens/image_placeholder.png and b/test/golden/goldens/image_placeholder.png differ diff --git a/test/golden/goldens/links.png b/test/golden/goldens/links.png index f6b3c7e..761a71d 100644 Binary files a/test/golden/goldens/links.png and b/test/golden/goldens/links.png differ diff --git a/test/golden/goldens/mixed_inline.png b/test/golden/goldens/mixed_inline.png index fce73e4..f26d19c 100644 Binary files a/test/golden/goldens/mixed_inline.png and b/test/golden/goldens/mixed_inline.png differ diff --git a/test/golden/goldens/mixed_lists.png b/test/golden/goldens/mixed_lists.png index c43bc54..c86d580 100644 Binary files a/test/golden/goldens/mixed_lists.png and b/test/golden/goldens/mixed_lists.png differ diff --git a/test/golden/goldens/ordered_list.png b/test/golden/goldens/ordered_list.png index 0b8d808..f12e7c6 100644 Binary files a/test/golden/goldens/ordered_list.png and b/test/golden/goldens/ordered_list.png differ diff --git a/test/golden/goldens/rtl_arabic.png b/test/golden/goldens/rtl_arabic.png index 1ec5f64..b0c56b0 100644 Binary files a/test/golden/goldens/rtl_arabic.png and b/test/golden/goldens/rtl_arabic.png differ diff --git a/test/golden/goldens/rtl_hebrew.png b/test/golden/goldens/rtl_hebrew.png index 096633c..a2a74cb 100644 Binary files a/test/golden/goldens/rtl_hebrew.png and b/test/golden/goldens/rtl_hebrew.png differ diff --git a/test/golden/goldens/rtl_mixed.png b/test/golden/goldens/rtl_mixed.png index bce4fa6..23fb9dd 100644 Binary files a/test/golden/goldens/rtl_mixed.png and b/test/golden/goldens/rtl_mixed.png differ diff --git a/test/golden/goldens/table.png b/test/golden/goldens/table.png index a118f8f..8babef5 100644 Binary files a/test/golden/goldens/table.png and b/test/golden/goldens/table.png differ diff --git a/test/golden/goldens/table_border_spacing_collapse.png b/test/golden/goldens/table_border_spacing_collapse.png index f3e8153..a6ac565 100644 Binary files a/test/golden/goldens/table_border_spacing_collapse.png and b/test/golden/goldens/table_border_spacing_collapse.png differ diff --git a/test/golden/goldens/table_border_spacing_separate.png b/test/golden/goldens/table_border_spacing_separate.png index f5a9cbc..872d3bb 100644 Binary files a/test/golden/goldens/table_border_spacing_separate.png and b/test/golden/goldens/table_border_spacing_separate.png differ diff --git a/test/golden/goldens/table_spans.png b/test/golden/goldens/table_spans.png index dd1c7c0..7965e85 100644 Binary files a/test/golden/goldens/table_spans.png and b/test/golden/goldens/table_spans.png differ diff --git a/test/golden/goldens/text_formatting.png b/test/golden/goldens/text_formatting.png index cf953ac..245aaa3 100644 Binary files a/test/golden/goldens/text_formatting.png and b/test/golden/goldens/text_formatting.png differ diff --git a/test/golden/goldens/unordered_list.png b/test/golden/goldens/unordered_list.png index fb0e046..f4f7228 100644 Binary files a/test/golden/goldens/unordered_list.png and b/test/golden/goldens/unordered_list.png differ diff --git a/test/integration/advanced_security_test.dart b/test/integration/advanced_security_test.dart index 52cd964..5f672b6 100644 --- a/test/integration/advanced_security_test.dart +++ b/test/integration/advanced_security_test.dart @@ -73,6 +73,51 @@ void main() { expect(tester.takeException(), isNull); }); + //

Survives

'; + final sw = Stopwatch()..start(); + await tester.pumpWidget(MaterialApp( + home: + Scaffold(body: HyperViewer(html: html, mode: HyperRenderMode.sync)), + )); + await tester.pumpAndSettle(); + expect(sw.elapsed, lessThan(const Duration(seconds: 5))); + expect(tester.takeException(), isNull); + expect(find.byType(HyperRenderWidget), findsWidgets); + }); + + testWidgets('Dangerous url() schemes in

Text

'; + await tester.pumpWidget(const MaterialApp( + home: + Scaffold(body: HyperViewer(html: html, mode: HyperRenderMode.sync)), + )); + await tester.pumpAndSettle(); + final doc = tester + .widget(find.byType(HyperRenderWidget).first) + .document; + final images = []; + void walk(UDTNode n) { + images.add(n.style.backgroundImage); + n.children.forEach(walk); + } + + walk(doc); + expect(images.whereType(), isEmpty); + }); + testWidgets( 'Ensures allowedTags doesn\'t accidentally allow dangerous tags', (tester) async { diff --git a/test/paint_paths_pixel_test.dart b/test/paint_paths_pixel_test.dart new file mode 100644 index 0000000..3903059 --- /dev/null +++ b/test/paint_paths_pixel_test.dart @@ -0,0 +1,172 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hyper_render/hyper_render.dart'; + +/// Pixel-level checks for RenderHyperBox paint paths that "does not throw" +/// tests cannot see: each case renders, rasterizes, and counts pixels of +/// the colour that path is supposed to draw. +void main() { + final boundaryKey = GlobalKey(); + + Future render( + WidgetTester tester, + String html, { + bool debugBounds = false, + void Function(RenderHyperBox box)? before, + }) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold( + backgroundColor: Colors.white, + body: RepaintBoundary( + key: boundaryKey, + child: SizedBox( + width: 400, + height: 300, + child: HyperViewer( + html: html, + mode: HyperRenderMode.sync, + selectable: true, + debugShowHyperRenderBounds: debugBounds, + selectionColor: const Color(0xFF00FF00), + ), + ), + ), + ), + )); + await tester.pumpAndSettle(); + if (before != null) { + before(tester + .renderObject(find.byType(HyperRenderWidget).first)); + await tester.pumpAndSettle(); + } + final boundary = boundaryKey.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + late ui.Image image; + await tester.runAsync(() async { + image = await boundary.toImage(); + }); + return image; + } + + /// Pixels within [tolerance] per channel of [color] (alpha ignored). + /// The boundary has no background, so translucent paints keep their own + /// RGB rather than blending toward white. + Future countPixels(WidgetTester tester, ui.Image image, Color color, + {int tolerance = 40}) async { + late ByteData data; + await tester.runAsync(() async { + data = + (await image.toByteData(format: ui.ImageByteFormat.rawStraightRgba))!; + }); + final r = (color.r * 255).round(), + g = (color.g * 255).round(), + b = (color.b * 255).round(); + var n = 0; + for (var i = 0; i < data.lengthInBytes; i += 4) { + if ((data.getUint8(i) - r).abs() <= tolerance && + (data.getUint8(i + 1) - g).abs() <= tolerance && + (data.getUint8(i + 2) - b).abs() <= tolerance) { + n++; + } + } + return n; + } + + const red = Color(0xFFFF0000); + + for (final style in ['solid', 'dashed', 'dotted', 'double']) { + testWidgets('block border-style: $style paints the border colour', + (tester) async { + final image = await render( + tester, + '
' + 'Bordered
'); + final borderPixels = await countPixels(tester, image, red); + // A 6px border around a ~400px-wide box: hundreds of red pixels even + // for dotted/dashed (which leave gaps) — none would mean not painted. + expect(borderPixels, greaterThan(200), reason: style); + }); + } + + testWidgets('dashed leaves gaps a solid border does not', (tester) async { + final solid = await countPixels( + tester, + await render(tester, + '
x
'), + red); + final dashed = await countPixels( + tester, + await render(tester, + '
x
'), + red); + expect(dashed, lessThan(solid)); + }); + + testWidgets('block background and rounded border', (tester) async { + final image = await render( + tester, + '
Box
'); + expect(await countPixels(tester, image, const Color(0xFF00FF00)), + greaterThan(2000)); + expect(await countPixels(tester, image, const Color(0xFF0000FF)), + greaterThan(100)); + }); + + testWidgets('inline background and border on a span', (tester) async { + // Padding: the test font (Ahem) draws glyphs as solid em boxes, which + // would hide a background drawn at exactly the glyph box. + final image = await render( + tester, + '

Before highlighted after

'); + expect(await countPixels(tester, image, const Color(0xFFFFFF00)), + greaterThan(100)); + expect(await countPixels(tester, image, const Color(0xFFFF00FF)), + greaterThan(100)); + }); + + testWidgets('debugShowHyperRenderBounds outlines line rows', (tester) async { + const html = '

Debug bounds paint outlines

'; + final plain = await render(tester, html); + final debug = await render(tester, html, debugBounds: true); + // Line rows are outlined in 0x66007BFF. (Fragment outlines sit under + // the solid test-font glyphs, so count the line colour.) + const lineBlue = Color(0xFF007BFF); + expect(await countPixels(tester, plain, lineBlue, tolerance: 10), 0); + expect(await countPixels(tester, debug, lineBlue, tolerance: 10), + greaterThan(300)); + }); + + testWidgets('selection highlight is painted behind selected text', + (tester) async { + // Transparent text: solid test-font glyphs would cover the highlight. + const html = + '

Select all of this text please

'; + final before = await render(tester, html); + final after = await render(tester, html, before: (b) => b.selectAll()); + const green = Color(0xFF00FF00); // selectionColor passed to the viewer + final c0 = await countPixels(tester, before, green, tolerance: 10); + final c1 = await countPixels(tester, after, green, tolerance: 10); + expect(c0, 0); + expect(c1, greaterThan(1000)); + }); + + testWidgets('a float with an unloaded image paints its placeholder', + (tester) async { + final image = await render( + tester, + '

Text flows beside the float.

'); + // The placeholder is a light-grey box; plain white would mean nothing + // was drawn where the float sits. + expect( + await countPixels(tester, image, const Color(0xFFEEEEEE), + tolerance: 18), + greaterThan(500)); + }); +}