(
find
.descendant(
of: find.byWidgetPredicate((widget) =>
widget is Text &&
(widget.textSpan?.toPlainText() ?? widget.data ?? '')
.contains(label)),
matching: find.byType(RichText),
)
.first,
);
final plain = paragraph.text.toPlainText();
final start = plain.indexOf(label);
final box = paragraph
.getBoxesForSelection(TextSelection(
baseOffset: start, extentOffset: start + label.length))
.first;
await tester.tapAt(paragraph.localToGlobal(
Offset(box.left + 2, (box.top + box.bottom) / 2)));
await tester.pumpAndSettle();
}
testWidgets('renders a page through the full app', (tester) async {
await pumpApp(tester);
final page = parseSource(pageSource, 'https://example.com/index.html');
deps.browser.currentPage.value = page;
await tester.pumpAndSettle();
expect(find.text('Signal Bars'), findsWidgets);
expect(find.textContaining('Collecting retro phones'), findsOneWidget);
expect(find.textContaining('Sign guestbook'), findsOneWidget);
});
testWidgets('shows the broken-page error and its text view', (tester) async {
await pumpApp(tester);
deps.browser.currentPage.value = null;
deps.browser.error.value = const BrokenPage('Half a page & more');
await tester.pumpAndSettle();
expect(find.textContaining('markup error'), findsOneWidget);
await tester.tap(find.textContaining('Show page text'));
await tester.pumpAndSettle();
expect(find.textContaining('Half a page & more'), findsOneWidget);
});
testWidgets('shows the load-failed error page', (tester) async {
await pumpApp(tester);
deps.browser.error.value = const LoadFailed();
await tester.pumpAndSettle();
expect(find.textContaining('could not be loaded'), findsOneWidget);
expect(find.textContaining('Try again'), findsOneWidget);
});
testWidgets('a first start gives the directory bookmark once', (tester) async {
final db = MewsDatabase.test(NativeDatabase.memory());
addTearDown(db.close);
// The first start of the app wires in the directory of sites, named in
// the reader's language.
var deps = AppDependencies(
database: db, fetch: FakeFetch(const {}), strings: strings);
await deps.start();
expect(await db.allBookmarks(), hasLength(1));
final bookmark = (await db.allBookmarks()).single;
expect(bookmark.url, 'https://mews.page/directory');
expect(bookmark.title, 'Mews directory');
expect(deps.bookmarks.isBookmarked(bookmark.url), isTrue);
// The reader removes it, as any bookmark can be removed.
await deps.bookmarks.toggle(bookmark.url, bookmark.title);
expect(deps.bookmarks.isBookmarked(bookmark.url), isFalse);
// A later start does not give it again: what the reader removed stays
// removed.
deps = AppDependencies(
database: db, fetch: FakeFetch(const {}), strings: strings);
await deps.start();
expect(await db.allBookmarks(), isEmpty);
});
testWidgets('a post a refresh finds arrives unread on the posts page',
(tester) async {
// The site's index, as gemtext with dated links (spec 6.1).
var index = '''
# A capsule
=> a.gmi 2026-10-08 Post a
=> b.gmi 2026-09-21 Post b
''';
final sources = {'gemini://capsule.example': index};
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: FakeFetch(sources),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
// Subscribing takes on what the site already published as read.
await stubDeps.browser.open('gemini://capsule.example');
await tester.pumpAndSettle();
await stubDeps.subscriptions.toggle(stubDeps.browser.currentPage.value!);
// The site publishes a new post, and a refresh finds it.
sources['gemini://capsule.example'] = '''
# A capsule
=> a.gmi 2026-10-08 Post a
=> b.gmi 2026-09-21 Post b
=> c.gmi 2026-10-11 New post
''';
await stubDeps.subscriptions.refreshAll();
// On the posts page the new post carries weight and the back catalogue
// does not, which is how the page says unread.
await stubDeps.browser.open('mews:posts');
await tester.pumpAndSettle();
// Whether a row's glyphs carry bold weight somewhere in their spans.
// TextSpan.visitChildren walks whole subtrees, so this walks the children
// itself, one level per step.
bool bold(String label) {
final text = tester.widget(find.textContaining(label).first);
InlineSpan? span = text.textSpan;
bool weight(InlineSpan span) {
if (span is TextSpan && span.style?.fontWeight == FontWeight.bold) {
return true;
}
final children = span is TextSpan ? span.children : null;
if (children == null) return false;
for (final child in children) {
if (weight(child)) return true;
}
return false;
}
return span != null && weight(span);
}
expect(bold('New post'), isTrue, reason: 'a found post is unread');
expect(bold('Post a'), isFalse, reason: 'the back catalogue was read');
expect(find.textContaining('1 unread post'), findsOneWidget);
});
testWidgets('a web page that will not load can open in a web browser',
(tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: FakeFetch(const {}),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
// A web address that answers nothing: the error offers the way out,
// since the web can be shown outside this reader.
await stubDeps.browser.open('https://missing.example.org/');
await tester.pumpAndSettle();
expect(stubDeps.browser.error.value, isA());
expect(find.text('Open in a web browser'), findsOneWidget);
// A protocol page has nowhere else to go, so only trying again is
// offered there.
await stubDeps.browser.open('gemini://missing.example.org/');
await tester.pumpAndSettle();
expect(stubDeps.browser.error.value, isA());
expect(find.text('Open in a web browser'), findsNothing);
});
testWidgets('the hub opens the settings page, which applies a choice in place',
(tester) async {
await pumpApp(tester);
// The hub opens from the left address bar icon.
await tester.tap(find.byIcon(Icons.menu));
await tester.pumpAndSettle();
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
expect(find.text('Mews'), findsOneWidget);
expect(find.textContaining('No bookmarks yet'), findsOneWidget);
// Its menu reaches settings, which carries the strip of our own pages.
await tapRun(tester, 'Settings');
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
expect(find.text('Settings'), findsOneWidget);
expect(find.text('Typeface'), findsOneWidget);
expect(find.text('Theme'), findsOneWidget);
expect(find.text('Colors'), findsOneWidget);
// The buried Library section is gone; the strip replaced it.
expect(find.text('Library'), findsNothing);
Future scrollTo(Finder target) async {
// Search down the page, then back up for targets above.
for (var i = 0; i < 20 && target.evaluate().isEmpty; i++) {
await tester.drag(find.byType(ListView), const Offset(0, -200));
await tester.pumpAndSettle();
}
for (var i = 0; i < 40 && target.evaluate().isEmpty; i++) {
await tester.drag(find.byType(ListView), const Offset(0, 200));
await tester.pumpAndSettle();
}
await tester.ensureVisible(target);
await tester.pumpAndSettle();
}
// Choosing a value applies it and stays put: no new walk history entry.
await scrollTo(find.text('Dark'));
await tapRun(tester, 'Dark');
expect(deps.settings.themeMode.value, 'dark');
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
expect(deps.browser.canGoBack, isTrue);
final beforeSize = deps.browser.currentPage.value;
await scrollTo(find.text('Warm'));
await tapRun(tester, 'Warm');
expect(deps.settings.variant.value, 'warm');
await scrollTo(find.text('Larger text'));
await tapRun(tester, 'Larger text');
expect(deps.settings.fontScale.value, 1.125);
expect(deps.browser.currentPage.value, isNot(same(beforeSize)));
// The strip navigates and joins the walk history.
await scrollTo(find.textContaining('Bookmarks'));
await tapRun(tester, 'History');
expect(deps.browser.currentPage.value?.url, MewsUrls.history);
await deps.browser.back();
await tester.pumpAndSettle();
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
});
testWidgets('a fetched page cannot run an action link', (tester) async {
final stubDeps = AppDependencies(
database: MewsDatabase.test(NativeDatabase.memory()),
fetch: FakeFetch({
'https://example.com/evil.html': _page('Evil',
'Evil
Go
'),
}),
strings: strings,
);
await tester.pumpWidget(MewsApp(deps: stubDeps, strings: strings));
await tester.pumpAndSettle();
await stubDeps.browser.open('https://example.com/evil.html');
await tester.pumpAndSettle();
expect(stubDeps.settings.themeMode.value, 'system');
// Following the link from a fetched page changes nothing and goes nowhere
// (spec 8.1: page content must not act for the reader).
await tapRun(tester, 'Go');
expect(stubDeps.settings.themeMode.value, 'system');
expect(stubDeps.browser.currentPage.value?.url,
'https://example.com/evil.html');
// The same link works from one of our own pages.
await stubDeps.browser.open(MewsUrls.settings);
await stubDeps.browser.open('mews:do/set-theme?value=dark');
expect(stubDeps.settings.themeMode.value, 'dark');
expect(stubDeps.browser.currentPage.value?.url, MewsUrls.settings);
});
testWidgets('a form on one of our pages navigates instead of fetching',
(tester) async {
await pumpApp(tester);
// The fetch client cannot speak the mews scheme, so a form on an internal
// page must be routed before it ever reaches the actor.
await deps.browser.open(MewsUrls.bookmarks);
await deps.browser.submitForm(MewsUrls.bookmarks, 'get', {'q': 'retro'});
await tester.pumpAndSettle();
expect(deps.browser.currentPage.value?.url, 'mews:bookmarks?q=retro');
expect(deps.browser.error.value, isNull);
// A form whose action is an action link applies it in place.
await deps.browser.open(MewsUrls.settings);
await deps.browser.submitForm('mews:do/set-theme', 'get', {'value': 'dark'});
await tester.pumpAndSettle();
expect(deps.settings.themeMode.value, 'dark');
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
expect(deps.browser.error.value, isNull);
});
testWidgets('choosing a language changes the interface in place',
(tester) async {
await pumpApp(tester);
await deps.browser.open(MewsUrls.settings);
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
// The language group sits below the fold.
Future scrollTo(String label) async {
final target = find.textContaining(label);
for (var i = 0; i < 30 && target.evaluate().isEmpty; i++) {
await tester.drag(find.byType(ListView), const Offset(0, -200));
await tester.pumpAndSettle();
}
await tester.ensureVisible(target.first);
await tester.pumpAndSettle();
}
// Each language is named in its own words, so it can be found from an
// interface the reader cannot read.
await scrollTo('Deutsch');
final stepsBack = deps.browser.canGoBack;
await tapRun(tester, 'Deutsch');
expect(deps.settings.language.value, 'de');
// Applied in place, like the other reader choices.
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
expect(deps.browser.canGoBack, stepsBack);
expect(find.text('Einstellungen'), findsOneWidget);
expect(find.text('Settings'), findsNothing);
// Hebrew turns the whole page around.
await scrollTo('עברית');
await tapRun(tester, 'עברית');
expect(deps.settings.language.value, 'he');
expect(deps.browser.currentPage.value?.direction, TextDirection.rtl);
// Back to English.
await scrollTo('English');
await tapRun(tester, 'English');
expect(deps.settings.language.value, 'en-US');
expect(deps.browser.currentPage.value?.direction, TextDirection.ltr);
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('bare mews: and an unknown name resolve predictably',
(tester) async {
await pumpApp(tester);
await deps.browser.open('mews:');
await tester.pumpAndSettle();
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
await deps.browser.open('mews:nonsense');
await tester.pumpAndSettle();
expect(deps.browser.currentPage.value, isNull);
expect(deps.browser.error.value, isA());
});
testWidgets('a session with no history starts at the hub', (tester) async {
await pumpApp(tester);
await deps.browser.restoreLastSession();
await tester.pumpAndSettle();
expect(deps.browser.currentPage.value?.url, MewsUrls.hub);
});
testWidgets('bookmark and subscribe live on the address bar', (tester) async {
await pumpApp(tester);
// A page with dated entries can be subscribed to.
final page = parseSource(pageSource, 'https://example.com/index.html');
deps.browser.currentPage.value = page;
await tester.pumpAndSettle();
expect(find.byIcon(Icons.notifications_outlined), findsOneWidget);
await tester.tap(find.byIcon(Icons.notifications_outlined));
await tester.pumpAndSettle();
expect(deps.subscriptions.isSubscribed('https://example.com/index.html'), isTrue);
expect(find.byIcon(Icons.notifications_active), findsOneWidget);
expect(find.byIcon(Icons.star_border), findsOneWidget);
await tester.tap(find.byIcon(Icons.star_border));
await tester.pumpAndSettle();
expect(deps.bookmarks.isBookmarked('https://example.com/index.html'), isTrue);
expect(find.byIcon(Icons.star), findsOneWidget);
// Pages with neither Atom feed nor dated links offer no subscription.
final plain = parseSource(
_page('Plain', 'Plain
No entries here.
'),
'https://example.com/plain.html');
deps.browser.currentPage.value = plain;
await tester.pumpAndSettle();
expect(find.byIcon(Icons.notifications_outlined), findsNothing);
});
testWidgets('the address bar hides on scroll down and returns on scroll up',
(tester) async {
await pumpApp(tester);
// A page long enough to scroll.
final tall = pageSource.replaceFirst(
'