test(mobile): journey-first testing guidance and suite cleanup (#8397)

## Summary

This PR does two things. It updates the mobile testing conventions to
favor a few journey tests over many small logic tests. It also applies
those conventions to the existing mobile suite, one commit per step.

### Testing conventions (`AGENTS.md`, `mobile/README.md`)

- Start with one widget journey per user-visible flow, driven through
the real widgets, notifiers and coordinators. Assert what the user sees
and what is durably saved.
- Cover retry, cancel and resume as steps inside the journey, not as
separate tests.
- Fake only external boundaries (network, relay, OS, browser, storage).
- Use focused, table-driven tests only for security and data-loss
invariants, protocol or crypto vectors, and pure functions with many
input combinations.
- When review finds a defect, extend an existing journey or table
instead of adding a test per finding.
- Delete a lower-level test only once a journey demonstrably catches the
same failures.
- Every test must fail when the production code it guards is broken.

These conventions keep the Review-Proven Rules' safety bar and change
only how coverage is organized.

### Suite inventory and plan

The suite has 234 Dart test files, about 110k lines and about 2,600
declared tests. Native tests are separate: BuzzPushKit, RunnerTests,
NativeControlTests and Android. Line-saving estimates are hypotheses,
not a deletion budget. Each deletion needs a replacement that rejects a
representative production mutation.

1. **CI and reliability.** Run the native tests CI skipped, make
time-dependent tests deterministic, and make tests that could not fail
falsifiable. *(done, see below)*
2. **Shared harness.** Add one fake relay (socket plus HTTP boundary,
with the real `RelaySessionNotifier`) in `test/helpers/`. Today 38 files
each write their own `RelaySessionNotifier` fake. *(done, see below)*
3. **Per-area journeys.** Convert page tests that fake the flow's own
notifiers (for example, `channel_detail_page_test` overrides about 60
providers). Fold near-duplicates into tables. Remove constant-pinning,
pixel-geometry and call-count tests once a journey covers the outcome.
Split `channel_detail_page_test.dart` (17.7k lines). Each area commit
reports before/after counts and the mutation it rejects.
4. **CI speed.** Shard by file instead of by test, since every shard
currently compiles the whole suite. *(done, see below)*

Keep as-is: relay protocol and NIP handling, crypto vectors, auth, push
security, persistence and journal invariants, recipient routing, and
real-socket transport and cancellation tests.

### Step 1 changes

- **Android CI** runs the 23 JVM unit tests, which were never run
before. It retries the APK build once, only on SDK or plugin download
failures (the cause of 4 recent `main` failures).
- **iOS CI** runs all RunnerTests (previously 2 of 99) and the
previously uncalled Me-agent accessibility script. The Swift Native lane
builds the simulator app once, in the background while the simulator
boots and runs the native-control scripts, instead of twice. The lane
stays non-required.
- **Stale RunnerTests.** Two reaction-details tests still expected the
filter list that #8280 replaced; they now cover the toggle list. #8393
moved navigation bar controls onto a shared glass surface and made
unknown Tabler symbols assert. The avatar and members-activity tests now
find controls on that surface, three tests use real symbol names, and a
240 pt title width pinned to the old control sizes is removed (its gap
assertions remain).
- **Known bug, not fixed here.** Since #8393 the DM title avatar image
is nil, because `makeItem` returns avatar data as a custom-view item. No
screen passes a title avatar yet. The test marks it `XCTExpectFailure`.
- **Typing retry** and **sections backoff** tests use fake time instead
of about 37 s of real sleeps and a `Stopwatch < 100ms` threshold.
- **Manage-channel sheet tests** previously asserted inside callbacks
that the sheet's `catch (_)` swallows. A wrong saved description made
the test loop until killed. The tests now record arguments, assert
outside the caught code, and cancel instead of re-presenting endlessly.
- **`video_viewer_transport_test` removed.** It exercised only
`package:http`. Removing the production `request.sink.close()` already
fails `video_viewer_test`.

### Step 2 changes

- **`test/helpers/fake_relay.dart`** fakes only the websocket (through
the session's socket factory) and the HTTP bridge (through its client).
The real `RelaySessionNotifier` still connects from `build()`, handles
OK and CLOSED, batches events, backs off and replays subscriptions.
- The fake serves the NIP-CW channel window. It can hold OK and live
echo independently, reject a chosen publish, and drop the connection.
Any request it cannot answer faithfully fails the test at teardown,
because the app often catches relay errors and falls back.
- **`channel_messages_journey_test`**: history through the channel
window, a live message, a send that is pending until OK and stays one
row after the echo, a rejected send rolled back, then drop and reconnect
with missed and later messages.
- **`channel_send_journey_test`**: type and send on `ChannelDetailPage`
with no feature-notifier overrides. The row and sending spinner show at
once, OK settles them, and a rejected send puts the text back in the
composer and shows a snackbar.
- Existing per-file relay fakes and `SidebarRelay` are unchanged. Step 3
migrates them area by area.

### Step 4 changes

- `scripts/mobile-test-shard.sh` splits the 235 test files across the 3
shards, balanced by line count. `flutter test --total-shards` split
tests, so each shard compiled and loaded every file. Locally at 4
workers, one shard took 63 s instead of 73 s and used 40% less CPU.
`channel_detail_page_test.dart` (17.7k lines) is now the long pole;
splitting it in step 3 shortens the slowest shard.

Out of scope here: requiring branches to be up to date before merge (a
repository setting), and the onboarding tests in #8375.

## Validation

- Mutation checks:
  - Typing retry bound: test fails.
  - Sections backoff reset: test fails.
- Wrong saved description: fails in about 11 s. The old tests hung
instead.
  - Removed sink close: `video_viewer_test` fails.
- Changed Dart files pass locally, and `flutter analyze` is clean.
- Android `testDebugUnitTest` passes locally (23 tests). The Me-agent
accessibility script passes on a local simulator.
- Step 2 mutation checks, each made a journey fail: accepted OK ignored,
rejected row not rolled back, optimistic row not added, draft not
restored, local message left pending after OK, channel window replaced
by the fallback, live subscriptions not replayed after reconnect.
- Both journeys run in about 1 s on fake time.
- All 99 RunnerTests pass on an iPhone 16 iOS 26.5 simulator, the CI
destination, after rebasing on #8398.
- The shard script partitions all 235 test files exactly once.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-10-19 16:19:07 -07:00
committed by GitHub
parent 3ec6327618
commit edbfbf08db
14 changed files with 904 additions and 344 deletions

View File

@@ -58,8 +58,7 @@ jobs:
key: pnpm-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
# Mobile lanes run in parallel; the required check reads the combined
# matrix result. `flutter test` shards split tests, not files, so every test
# shard still compiles the whole suite.
# matrix result. Test shards split files, so each compiles only its own.
mobile:
name: Mobile (${{ matrix.name }})
runs-on: ubuntu-latest
@@ -134,7 +133,7 @@ jobs:
run: cd mobile && flutter analyze
- name: Test
if: matrix.lane == 'test'
run: cd mobile && flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example --total-shards=3 --shard-index=${{ matrix.shard }}
run: /bin/bash scripts/mobile-test-shard.sh ${{ matrix.shard }} 3 --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
- name: Test configured authentication domains
if: matrix.lane == 'checks'
run: >-
@@ -165,9 +164,20 @@ jobs:
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('mobile/android/**/*.gradle*', 'mobile/android/gradle/wrapper/gradle-wrapper.properties', 'mobile/pubspec.lock') }}
restore-keys: gradle-${{ runner.os }}-
# SDK component and plugin downloads intermittently fail on hosted
# runners; retry once only for those, so real build failures still fail.
- name: Build Android debug APK
if: matrix.lane == 'android'
run: just mobile-build-android
run: |
set -o pipefail
log="$RUNNER_TEMP/android-build.log"
if ! just mobile-build-android 2>&1 | tee "$log"; then
grep -qE 'InstallFailedException|Could not (resolve|GET|download)' "$log"
just mobile-build-android
fi
- name: Test Android JVM units
if: matrix.lane == 'android'
run: cd mobile/android && ./gradlew :app:testDebugUnitTest --console=plain
- name: Save Gradle cache
if: matrix.lane == 'android' && github.event_name == 'push'
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5
@@ -198,37 +208,43 @@ jobs:
- uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1
- name: Install Flutter dependencies
run: cd mobile && flutter pub get
- name: Build
if: matrix.lane == 'native'
run: swift build --package-path mobile/ios/BuzzPushKit
- name: Build release
if: matrix.lane == 'native'
run: swift build -c release --package-path mobile/ios/BuzzPushKit
# `swift test` builds BuzzPushKit; the Release lane covers its release build.
- name: Test
if: matrix.lane == 'native'
run: swift test --package-path mobile/ios/BuzzPushKit
- name: Test native pagination geometry
if: matrix.lane == 'native'
run: /bin/bash scripts/test-mobile-pagination.sh
- name: Build complete iOS simulator app
- name: Generate iOS build configuration
if: matrix.lane == 'native'
run: cd mobile && flutter build ios --simulator --debug --no-pub
- name: Test native controls and push navigation bridge
run: cd mobile && flutter build ios --simulator --debug --no-pub --config-only
# Build the complete simulator app and RunnerTests once, in the
# background, while the simulator boots and runs the native-control
# scripts. The build also guards Apple Silicon simulator linking (#7646).
- name: Test native controls and Runner unit tests
if: matrix.lane == 'native'
run: |
device=$(xcrun simctl create "Pagination accessibility" "iPhone 16")
device=$(xcrun simctl create "Native tests" "iPhone 16")
trap 'xcrun simctl shutdown "$device"; xcrun simctl delete "$device"' EXIT
xcode=(xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner
-configuration Debug -destination "platform=iOS Simulator,id=$device"
CODE_SIGNING_ALLOWED=NO)
build_log="$RUNNER_TEMP/runner-build.log"
"${xcode[@]}" build-for-testing > "$build_log" 2>&1 &
build=$!
xcrun simctl boot "$device"
xcrun simctl bootstatus "$device" -b
/bin/bash scripts/test-mobile-pagination-accessibility.sh "$device"
/bin/bash scripts/test-mobile-carousel.sh "$device"
/bin/bash scripts/test-mobile-member-picker.sh "$device"
xcodebuild -workspace mobile/ios/Runner.xcworkspace -scheme Runner \
-configuration Debug -destination "platform=iOS Simulator,id=$device" \
-parallel-testing-enabled NO \
-only-testing:RunnerTests/BuzzPushNavigationBridgeTests \
-only-testing:RunnerTests/IdentityFilePluginTests \
CODE_SIGNING_ALLOWED=NO test
/bin/bash scripts/test-mobile-me-agent-accessibility.sh "$device"
if ! wait "$build"; then
cat "$build_log"
exit 1
fi
tail -n 5 "$build_log"
"${xcode[@]}" -parallel-testing-enabled NO -only-testing:RunnerTests \
test-without-building
- name: Build complete unsigned iOS release
if: matrix.lane == 'release'
run: |

View File

@@ -765,12 +765,37 @@ usage.
### Testing Conventions
- Prefer **widget tests** over unit tests for UI components — test the
whole widget tree, not individual methods.
- Use `ProviderScope(overrides: [...])` to inject fake notifiers.
- Fake notifiers should extend the real notifier class and override `build()`.
- Use the `WidgetHelpers.testable()` wrapper for simple widget tests or
build a custom `ProviderScope` + `MaterialApp` when you need specific overrides.
Favor a few functional tests over many small ones. Every test is code we
maintain, so each one must protect a distinct user outcome or invariant.
- **Start with journeys.** For each user-visible flow, write one widget test
that drives it through real widgets, notifiers and coordinators. Assert what
the user sees and what is durably saved, not internal stages or call counts.
Where Flutter can observe it, include semantics and non-primary input in the
journey: screen-reader labels and actions, keyboard, alternate gestures, text
scale and reduced motion.
- **Put failure paths inside the journey.** Retry, cancel, resume and a second
request arriving midway are steps of a journey, not separate tests.
- **Fake only external boundaries:** network, relay, OS, browser and storage.
Keep the flow's own notifiers and coordinators real. Override boundary
providers with `ProviderScope(overrides: [...])`, using
`WidgetHelpers.testable()` or a custom `ProviderScope` + `MaterialApp`.
- **Use focused tests only for what a journey can't enumerate or observe.**
That means security and data-loss invariants, protocol or crypto vectors,
pure functions with many input combinations, and accessibility or platform
behavior owned by native code that a widget journey can't observe, such as
VoiceOver stops and traits, Dynamic Type layout and reduced motion in native
views. Make them table-driven where cases repeat.
- **Extend before adding.** When a review finds a defect, add a step to an
existing journey or a row to an existing table. Don't add a new test per
finding. When a Review-Proven Rule says to test each path, cover the paths
as journey steps or table rows.
- **Delete what a journey covers.** Remove a lower-level test once a journey
demonstrably catches the same failures; passing through the same path is not
enough. Keep distinct safety, interop, accessibility and native-platform
invariants.
- **Prove it can fail.** Break the production code a test guards once and
confirm the test fails. A test that only exercises a fake protects nothing.
---

View File

@@ -240,6 +240,10 @@ flutter test --dart-define=BUZZ_PUSH_GATEWAY_URL=https://push.example
Or from the repo root: `just mobile-check` and `just mobile-test`.
When writing tests, favor a few journey tests that drive real user flows over
many small logic tests. See
[Testing Conventions](../AGENTS.md#testing-conventions).
## Android release signing
Android release builds fail unless all upload-key inputs are supplied through the

View File

@@ -128,15 +128,16 @@ final class NativeReactionDetailsTests: XCTestCase {
XCTAssertEqual(updatedRow.arrangedSubviews[0].subviews.compactMap { $0 as? UILabel }.first?.text, "🎉")
}
@MainActor func testFiltersPreserveRowsAndUpdatedAccessibilityNames() {
@MainActor func testTogglesListEveryReactorAndRelabelUpdatedProfiles() {
let controller = NativeReactionDetailsViewController(data: [
"initialEmoji": "❤️",
"reactions": [
["emoji": "❤️", "label": "Heart", "count": 2, "users": ["human", "agent"]],
["emoji": "🔥", "label": "Fire", "count": 1, "users": ["agent"]],
],
"profiles": ["human": ["name": "Honey"], "agent": ["name": "Honey (agent)"]],
])
var toggled: [String] = []
controller.onToggle = { toggled.append($0) }
controller.loadViewIfNeeded()
let table = UITableView()
func labels() -> [String] {
@@ -147,21 +148,18 @@ final class NativeReactionDetailsTests: XCTestCase {
func descendants(_ view: UIView) -> [UIView] {
view.subviews.flatMap { [$0] + descendants($0) }
}
func select(_ label: String) {
func tap(_ label: String) {
let button = descendants(controller.view).compactMap { $0 as? UIButton }
.first { $0.accessibilityLabel == label }
XCTAssertNotNil(button)
XCTAssertNotNil(button, label)
button?.sendActions(for: .primaryActionTriggered)
}
XCTAssertEqual(labels(), ["Honey, Heart", "Honey (agent), Heart"])
select("All 3")
XCTAssertEqual(labels(), ["Honey, Heart", "Honey (agent), Heart", "Honey (agent), Fire"])
select("Fire 1")
XCTAssertEqual(labels(), ["Honey (agent), Fire"])
tap("Fire 1")
tap("Heart 2")
XCTAssertEqual(toggled, ["🔥", "❤️"])
controller.updateProfiles(["agent": ["name": "Helper (agent)"]])
XCTAssertEqual(labels(), ["Helper (agent), Fire"])
select("Heart 2")
XCTAssertEqual(labels(), ["Honey, Heart", "Helper (agent), Heart"])
XCTAssertEqual(labels(), ["Honey, Heart", "Helper (agent), Heart", "Helper (agent), Fire"])
}
}
@@ -183,7 +181,7 @@ final class NativeMessageLayoutTests: XCTestCase {
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
let filter = descendants(controller.view).compactMap { $0 as? UIButton }
.first { $0.accessibilityLabel == "All 12345" }
.first { $0.accessibilityLabel == "Heart 12345" }
XCTAssertNotNil(filter)
let viewport = descendants(controller.view).compactMap { $0 as? UIScrollView }
.first { !($0 is UITableView) }
@@ -196,7 +194,7 @@ final class NativeMessageLayoutTests: XCTestCase {
@MainActor func testOversizedMenuStaysInsideShortViewportAndScrollsToLastAction() {
let controller = NativeMessageMenuViewController(data: [
"actions": (0..<12).map { ["id": "action\($0)", "title": "Action \($0)", "symbol": "star"] },
"actions": (0..<12).map { ["id": "action\($0)", "title": "Action \($0)", "symbol": "heart"] },
"reactions": [["emoji": "❤️", "label": "Heart"]],
], sourceRect: CGRect(x: 16, y: 180, width: 288, height: 80), preview: UIView())
controller.onPreviewReady = { _ in }

View File

@@ -83,8 +83,12 @@ class RunnerTests: XCTestCase {
parent.view.layoutIfNeeded()
try await Task.sleep(nanoseconds: 200_000_000)
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
// Edge controls sit on a shared glass surface (#8393).
func avatar() throws -> UIButton {
try XCTUnwrap(navigation.topViewController?.navigationItem.leftBarButtonItems?.first?.customView as? UIButton)
let surface = try XCTUnwrap(navigation.topViewController?.navigationItem.leftBarButtonItems?.first?.customView)
return try XCTUnwrap(descendants(of: surface).first {
$0.accessibilityIdentifier == "community-navigation-avatar"
} as? UIButton)
}
// A rectangular custom view makes UIKit's surrounding glass a pill.
let buttonBounds = try avatar().bounds
@@ -187,8 +191,9 @@ class RunnerTests: XCTestCase {
(view is UIControl ? [view] : []) + view.subviews.flatMap(controls)
}
let buttons = controls(navigation.navigationBar)
let back = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Back" })
let huddle = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Huddle" })
let found = "width \(width), controls: \(buttons.map { $0.accessibilityLabel ?? String(describing: type(of: $0)) })"
let back = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Back" }, found)
let huddle = try XCTUnwrap(buttons.first { $0.accessibilityLabel == "Huddle" }, found)
let backFrame = back.convert(back.bounds, to: navigation.navigationBar)
let huddleFrame = huddle.convert(huddle.bounds, to: navigation.navigationBar)
XCTAssertGreaterThan(titleFrame.minX, backFrame.maxX)
@@ -196,7 +201,6 @@ class RunnerTests: XCTestCase {
// A long name uses the available slot, leaving only UIKit's button gaps.
XCTAssertLessThan(titleFrame.minX - backFrame.maxX, 24)
XCTAssertLessThan(huddleFrame.minX - titleFrame.maxX, 24)
if width >= 393 { XCTAssertGreaterThan(titleFrame.width, 240) }
}
}
@@ -313,17 +317,21 @@ class RunnerTests: XCTestCase {
viewIdentifier: 999994, arguments: ["title": "Group", "actions": [idle]])
parent.view.addSubview(bar.view())
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
func item() throws -> UIBarButtonItem { try XCTUnwrap(navigation.topViewController?.navigationItem.rightBarButtonItems?.first) }
let idleImage = try XCTUnwrap(item().image?.pngData())
// Edge controls sit on a shared glass surface (#8393).
func item() throws -> UIButton {
let surface = try XCTUnwrap(navigation.topViewController?.navigationItem.rightBarButtonItems?.first?.customView)
return try XCTUnwrap(descendants(of: surface).first { $0.accessibilityLabel == "View members" } as? UIButton)
}
let idleImage = try XCTUnwrap(item().image(for: .normal)?.pngData())
var working = idle
working["activityColor"] = 0xFF00FF00
working["activityLabel"] = "Agent working"
messenger.configure(["title": "Group", "actions": [working]])
XCTAssertEqual(try item().accessibilityValue, "Agent working")
XCTAssertNotEqual(try item().image?.pngData(), idleImage)
XCTAssertNotEqual(try item().image(for: .normal)?.pngData(), idleImage)
messenger.configure(["title": "Group", "actions": [idle]])
XCTAssertNil(try item().accessibilityValue)
XCTAssertEqual(try item().image?.pngData(), idleImage)
XCTAssertEqual(try item().image(for: .normal)?.pngData(), idleImage)
}
@MainActor
@@ -395,7 +403,11 @@ class RunnerTests: XCTestCase {
XCTAssertEqual(title.accessibilityTraits, .header)
XCTAssertFalse(title.accessibilityActivate())
let avatar = try XCTUnwrap(descendants(of: title.contentView).first { $0.accessibilityIdentifier == "dm-navigation-avatar" } as? UIImageView)
XCTAssertNotNil(avatar.image)
// Since #8393 makeItem returns avatar data as a custom-view item, so the
// title receives a nil image. No screen passes a title avatar yet.
XCTExpectFailure("DM title avatar image is nil since #8393") {
XCTAssertNotNil(avatar.image)
}
XCTAssertEqual(avatar.frame.size, CGSize(width: 32, height: 32))
let presence = try XCTUnwrap(descendants(of: title.contentView).first { $0.accessibilityIdentifier == "dm-navigation-presence" })
XCTAssertEqual(presence.backgroundColor, UIColor.green)
@@ -1175,7 +1187,7 @@ class RunnerTests: XCTestCase {
var didSelect = false
let button = makeNativeAttachmentMenuButton(
title: "Photos",
symbol: "photo",
symbol: "photo.on.rectangle.angled",
action: { didSelect = true }
)
let titleLabel = button.subviews.compactMap { $0 as? UILabel }.first
@@ -1707,7 +1719,7 @@ class RunnerTests: XCTestCase {
NativeMessageActionDefinition(
arguments: [
"id": "followThread", "title": "Follow thread",
"symbol": "bell", "group": "utility",
"symbol": "bell.slash", "group": "utility",
]
)
)

View File

@@ -433,7 +433,10 @@ void main() {
presentations.add(
Map<Object?, Object?>.from(call.arguments as Map),
);
expect(call.method, 'presentChannel');
// Cancel instead of looping if a failed save keeps re-presenting.
if (call.method != 'presentChannel' || presentations.length > 2) {
return {'action': 'cancel'};
}
return {
'action': 'save',
'name': 'renamed',
@@ -444,7 +447,7 @@ void main() {
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(bridge, null),
);
var attempts = 0;
final updates = <(String?, String?)>[];
await tester.pumpWidget(
_modalApp(
channel: _channel(),
@@ -455,13 +458,12 @@ void main() {
joinedAt: DateTime(2025),
),
],
// Record rather than assert here: the sheet catches update errors.
createChannelActions: (ref) => _FakeChannelActions(
ref,
onUpdateChannel: (_, name, description) async {
attempts++;
if (attempts == 1) throw Exception('offline');
expect(name, 'renamed');
expect(description, 'Updated description');
updates.add((name, description));
if (updates.length == 1) throw Exception('offline');
},
),
),
@@ -471,6 +473,10 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
expect(updates, [
('renamed', 'Updated description'),
('renamed', 'Updated description'),
]);
expect(presentations, hasLength(2));
expect(presentations.first['title'], 'Edit channel');
expect(presentations.first['multiline'], false);
@@ -489,13 +495,15 @@ void main() {
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final canvas = Completer<ChannelCanvas>();
const bridge = MethodChannel('buzz/profile_text_editor');
var opened = false;
final opened = <(bool, Object?)>[];
var saved = false;
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(bridge, (call) async {
expect(canvas.isCompleted, isFalse);
expect((call.arguments as Map)['canvasLoaded'], isFalse);
opened = true;
opened.add((
canvas.isCompleted,
(call.arguments as Map)['canvasLoaded'],
));
if (opened.length > 1) return null;
return {
'action': 'save',
'name': 'renamed',
@@ -530,7 +538,8 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pump();
expect(opened, isTrue);
// Presented once, before the canvas resolved, and marked not loaded.
expect(opened, [(false, false)]);
expect(saved, isTrue);
canvas.complete(
const ChannelCanvas(content: null, updatedAt: null, authorPubkey: null),
@@ -545,34 +554,22 @@ void main() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const bridge = MethodChannel('buzz/profile_text_editor');
var channelPresentations = 0;
var savedDetails = false;
var savedCanvas = false;
final canvasPresentations = <Map<Object?, Object?>>[];
final channelPresentations = <Map<Object?, Object?>>[];
final updates = <(String?, String?)>[];
final canvases = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(bridge, (call) async {
final args = Map<Object?, Object?>.from(call.arguments as Map);
if (call.method == 'present') {
expect(args['title'], 'Canvas');
expect(args['multiline'], true);
canvasPresentations.add(args);
return 'New canvas';
}
expect(call.method, 'presentChannel');
channelPresentations++;
if (channelPresentations == 1) {
return {
'action': 'canvas',
'name': 'Draft name',
'description': 'Draft description',
};
}
expect(args['canvasContent'], 'New canvas');
expect(args['canvasLoaded'], true);
expect(args['initialValue'], 'Draft name');
expect(args['description'], 'Draft description');
expect(args['originalName'], 'general');
expect(args['originalDescription'], '');
channelPresentations.add(args);
// Cancel instead of looping if a failed save keeps re-presenting.
if (channelPresentations.length > 2) return {'action': 'cancel'};
return {
'action': 'save',
'action': channelPresentations.length == 1 ? 'canvas' : 'save',
'name': 'Draft name',
'description': 'Draft description',
};
@@ -593,15 +590,10 @@ void main() {
],
createChannelActions: (ref) => _FakeChannelActions(
ref,
onUpdateChannel: (_, name, description) async {
expect(name, 'Draft name');
expect(description, 'Draft description');
savedDetails = true;
},
onSetCanvas: (content) async {
expect(content, 'New canvas');
savedCanvas = true;
},
// Record rather than assert here: the sheet catches save errors.
onUpdateChannel: (_, name, description) async =>
updates.add((name, description)),
onSetCanvas: (content) async => canvases.add(content),
),
),
);
@@ -610,9 +602,20 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
expect(channelPresentations, 2);
expect(savedDetails, isTrue);
expect(savedCanvas, isTrue);
expect(canvasPresentations, [containsPair('title', 'Canvas')]);
expect(canvasPresentations.single['multiline'], true);
expect(channelPresentations, hasLength(2));
expect(channelPresentations.last, {
...channelPresentations.last,
'canvasContent': 'New canvas',
'canvasLoaded': true,
'initialValue': 'Draft name',
'description': 'Draft description',
'originalName': 'general',
'originalDescription': '',
});
expect(updates, [('Draft name', 'Draft description')]);
expect(canvases, ['New canvas']);
expect(find.byType(ManageChannelSheet), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
@@ -620,7 +623,7 @@ void main() {
testWidgets(
'canvas preview opens its content and removal retries without losing drafts',
(tester) async {
var removals = 0;
final removals = <String>[];
await tester.pumpWidget(
_modalApp(
channel: _channel(),
@@ -635,8 +638,8 @@ void main() {
createChannelActions: (ref) => _FakeChannelActions(
ref,
onSetCanvas: (content) async {
expect(content, '');
if (++removals == 1) throw Exception('offline');
removals.add(content);
if (removals.length == 1) throw Exception('offline');
},
),
),
@@ -670,7 +673,7 @@ void main() {
await tester.ensureVisible(find.text('Remove Canvas'));
await tester.tap(find.text('Remove Canvas'));
await tester.pumpAndSettle();
expect(removals, attempt);
expect(removals, List.filled(attempt, ''));
await tester.tap(find.text('Remove'));
await tester.pumpAndSettle();
if (attempt == 0) {
@@ -701,22 +704,15 @@ void main() {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
const bridge = MethodChannel('buzz/profile_text_editor');
var presentations = 0;
var attempts = 0;
final presentations = <(String, Map<Object?, Object?>)>[];
final attempts = <String>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(bridge, (call) async {
final args = Map<Object?, Object?>.from(call.arguments as Map);
expect(call.method, 'presentChannel');
expect(args['canvasLoaded'], true);
expect(
args['canvasContent'],
presentations < 2 ? 'Existing canvas' : '',
);
if (presentations > 0) {
expect(args['initialValue'], 'Draft name');
expect(args['description'], 'Draft description');
}
if (++presentations == 3) return null;
presentations.add((
call.method,
Map<Object?, Object?>.from(call.arguments as Map),
));
if (presentations.length >= 3) return null;
return {
'action': 'removeCanvas',
'name': 'Draft name',
@@ -735,8 +731,8 @@ void main() {
createChannelActions: (ref) => _FakeChannelActions(
ref,
onSetCanvas: (content) async {
expect(content, '');
if (++attempts == 1) throw Exception('offline');
attempts.add(content);
if (attempts.length == 1) throw Exception('offline');
},
),
),
@@ -746,8 +742,22 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.text('Manage channel'));
await tester.pumpAndSettle();
expect(presentations, 3);
expect(attempts, 2);
expect(presentations.map((p) => p.$1), List.filled(3, 'presentChannel'));
expect(presentations.map((p) => p.$2['canvasLoaded']), [
true,
true,
true,
]);
expect(presentations.map((p) => p.$2['canvasContent']), [
'Existing canvas',
'Existing canvas',
'',
]);
for (final (_, args) in presentations.skip(1)) {
expect(args['initialValue'], 'Draft name');
expect(args['description'], 'Draft description');
}
expect(attempts, ['', '']);
debugDefaultTargetPlatformOverride = null;
},
);

View File

@@ -0,0 +1,129 @@
import 'package:buzz/features/channels/channel_messages_provider.dart';
import 'package:buzz/features/channels/pending_local_messages_provider.dart';
import 'package:buzz/features/channels/send_message_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../helpers/fake_relay.dart';
const _channel = '11111111-1111-4111-8111-111111111111';
void main() {
test(
'a channel loads, follows live messages, settles sends and recovers',
() {
fakeAsync((async) {
final relay = FakeRelay();
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
NostrEvent message(String text, int createdAt) => relay.sign(
kind: EventKind.streamMessage,
content: text,
createdAt: createdAt,
tags: [
['h', _channel],
],
);
relay.seed([message('first', now - 20), message('second', now - 10)]);
final container = ProviderContainer(overrides: relay.overrides);
addTearDown(container.dispose);
final messages = container.listen(
channelMessagesProvider(_channel),
(_, _) {},
);
List<String> timeline() => [
for (final event in messages.read().requireValue) event.content,
];
Set<String> pending() =>
container.read(pendingLocalMessagesProvider(_channel)).keys.toSet();
void settle() => async.elapse(const Duration(milliseconds: 50));
// The live subscription starts at the second it opens, so live
// messages are dated from the filter it actually installed.
int liveSince() =>
relay.reqs
.expand((req) => req.skip(2).cast<Map<String, dynamic>>())
.lastWhere(
(filter) =>
(filter['kinds'] as List).contains(
EventKind.streamMessage,
) &&
((filter['#h'] as List?)?.contains(_channel) ??
false) &&
filter['since'] != null,
)['since']
as int;
settle();
expect(
container.read(relaySessionProvider).status,
SessionStatus.connected,
);
expect(timeline(), ['first', 'second']);
expect(relay.queries, hasLength(1), reason: 'read the channel window');
expect(
relay.reqs.where((req) => (req[1] as String).startsWith('h-')),
isEmpty,
reason: 'no fallback to websocket history',
);
final since = liveSince();
relay.deliver(message('from a teammate', since));
relay.deliver(message('from before the subscription', since - 1));
settle();
expect(timeline(), contains('from a teammate'));
expect(timeline(), isNot(contains('from before the subscription')));
// Pending until OK, settled by OK alone, one row after the echo.
final ok = relay.holdOk();
final echo = relay.holdEcho();
var sent = false;
container
.read(sendMessageProvider)(channelId: _channel, content: 'hello')
.then((_) => sent = true);
settle();
final sentEvent = relay.published.single;
expect(sentEvent.kind, EventKind.streamMessage);
expect(sentEvent.pubkey, relay.pubkey);
expect(sentEvent.getTagValue('h'), _channel);
expect(pending(), {sentEvent.id});
expect(timeline(), contains('hello'));
ok.complete();
settle();
expect(sent, isTrue);
expect(pending(), isEmpty);
expect(timeline().where((text) => text == 'hello'), hasLength(1));
echo.complete();
settle();
expect(timeline().where((text) => text == 'hello'), hasLength(1));
// A rejected send leaves no row and is not stored by the relay.
relay.rejectNext('blocked: not allowed');
Object? failure;
container
.read(sendMessageProvider)(channelId: _channel, content: 'nope')
.catchError((Object error) => failure = error);
settle();
expect(failure, isNotNull);
expect(pending(), isEmpty);
expect(timeline(), isNot(contains('nope')));
expect(relay.stored.map((e) => e.content), isNot(contains('nope')));
// After a network drop the session reconnects, recovers what it missed
// and follows the channel again, without duplicating known rows.
relay.drop();
relay.seed([message('while offline', since)]);
async.elapse(const Duration(seconds: 2));
expect(relay.sockets, hasLength(2));
expect(timeline(), contains('while offline'));
relay.deliver(message('after reconnect', liveSince()));
settle();
expect(timeline(), contains('after reconnect'));
expect(timeline().where((text) => text == 'hello'), hasLength(1));
});
},
);
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:shared_preferences/shared_preferences.dart';
@@ -292,32 +293,33 @@ void main() {
});
test('backoff resets after full recovery so later failures start from the '
'base delay', () async {
await setUpEnv();
// Climb the failure counter: 5 rate-limited attempts before success.
// With a 5ms base, attempt 5 would wait 5<<5 = 160ms if the counter
// were never reset.
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 5);
final manager = buildManager(relaySession: relay);
await manager.initialize();
await _waitUntil(() => relay.subscribeCalls > 5);
'base delay', () {
fakeAsync((async) {
setUpEnv();
async.flushMicrotasks();
// Climb the failure counter: 5 rate-limited attempts before success.
// With a 5ms base, attempt 5 would wait 5<<5 = 160ms if the counter
// were never reset.
final relay = _RateLimitedRelaySession(failuresBeforeSuccess: 5);
final manager = buildManager(relaySession: relay);
manager.initialize();
async.elapse(const Duration(seconds: 1));
expect(relay.subscribeCalls, greaterThan(5));
// Fully recovered. A later transient failure (late CLOSED) must retry
// from the base delay, not the climbed ceiling.
final subscribeCallsAfterRecovery = relay.subscribeCalls;
relay.closeLiveSubscription('rate-limited: quota exceeded');
final stopwatch = Stopwatch()..start();
await _waitUntil(() => relay.subscribeCalls > subscribeCallsAfterRecovery);
stopwatch.stop();
expect(
stopwatch.elapsedMilliseconds,
lessThan(100),
reason:
'retry after recovery must wait ~base delay (5ms), '
'not the pre-recovery backoff (160ms)',
);
manager.dispose(flushPending: false);
// Fully recovered. A later transient failure (late CLOSED) must retry
// from the base delay, not the climbed ceiling.
final subscribeCallsAfterRecovery = relay.subscribeCalls;
relay.closeLiveSubscription('rate-limited: quota exceeded');
async.elapse(const Duration(milliseconds: 6));
expect(
relay.subscribeCalls,
greaterThan(subscribeCallsAfterRecovery),
reason:
'retry after recovery must wait ~base delay (5ms), '
'not the pre-recovery backoff (160ms)',
);
manager.dispose(flushPending: false);
});
});
}

View File

@@ -0,0 +1,106 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_detail_page.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../helpers/fake_relay.dart';
const _channelId = '11111111-2222-4333-8444-555555555555';
final _channel = Channel(
id: _channelId,
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'abc123',
createdAt: DateTime(2025),
memberCount: 5,
isMember: true,
);
void main() {
testWidgets('a sent message shows at once, settles, and a rejected one '
'comes back to the composer', (tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final relay = FakeRelay();
relay.seed([
relay.sign(
kind: EventKind.streamMessage,
content: 'earlier message',
createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60,
tags: [
['h', _channelId],
],
),
]);
await tester.pumpWidget(
ProviderScope(
overrides: [
...relay.overrides,
savedPrefsProvider.overrideWithValue(prefs),
appLifecycleProvider.overrideWith(_Resumed.new),
],
child: MaterialApp(
theme: AppTheme.light(),
home: ChannelDetailPage(channel: _channel),
),
),
);
await tester.pumpAndSettle();
expect(find.text('earlier message'), findsOneWidget);
Future<void> send(String text) async {
if (find.byType(TextField).evaluate().isEmpty) {
await tester.tap(find.text('Message #general'));
await tester.pumpAndSettle();
}
await tester.enterText(find.byType(TextField), text);
await tester.pump();
await tester.tap(find.byIcon(BuzzIcons.arrowUp));
await tester.pump();
}
String draft() =>
tester.widget<TextField>(find.byType(TextField)).controller!.text;
final ok = relay.holdOk();
await send('hello team');
expect(find.text('hello team'), findsOneWidget);
expect(draft(), isEmpty);
expect(find.bySemanticsLabel('Sending message'), findsOneWidget);
final sent = relay.published.where(
(e) => e.kind == EventKind.streamMessage,
);
expect(sent.single.content, 'hello team');
expect(sent.single.getTagValue('h'), _channelId);
ok.complete();
await tester.pumpAndSettle();
expect(find.text('hello team'), findsOneWidget);
expect(find.bySemanticsLabel('Sending message'), findsNothing);
expect(find.byType(SnackBar), findsNothing);
relay.rejectNext('blocked: not allowed here');
await send('nope');
await tester.pumpAndSettle();
expect(find.text('nope'), findsOneWidget, reason: 'only the draft');
expect(draft(), 'nope');
expect(find.byType(SnackBar), findsOneWidget);
expect(find.text('hello team'), findsOneWidget);
});
}
class _Resumed extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}

View File

@@ -1,5 +1,6 @@
import 'package:buzz/features/channels/channel_typing_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../helpers/thread_ids.dart';
@@ -33,34 +34,41 @@ class _Relay extends RelaySessionNotifier {
}
}
/// Lets the container rebuild and the notifier's microtasks run under fake
/// time, so retry backoff is driven by [FakeAsync.elapse] instead of sleeps.
void _settle(ProviderContainer container, FakeAsync async) {
container.pump();
async.flushMicrotasks();
}
void main() {
test(
'initial rejection retries with a bound and no uncaught error',
() async {
() => fakeAsync((async) {
final relay = _Relay()..reject = true;
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relay)],
);
container.listen(channelTypingProvider('chat'), (_, _) {});
await container.pump();
_settle(container, async);
expect(relay.calls, 1);
for (final seconds in [1, 2, 4, 8]) {
await Future<void>.delayed(Duration(milliseconds: seconds * 1000 + 20));
async.elapse(Duration(milliseconds: seconds * 1000 + 20));
}
expect(relay.calls, 4);
container.dispose();
},
}),
);
test(
'terminal close clears typing and retries; disposal cancels recovery',
() async {
() => fakeAsync((async) {
final relay = _Relay();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relay)],
);
final provider = channelTypingProvider('chat');
container.listen(provider, (_, _) {});
await container.pump();
_settle(container, async);
relay.receive(
NostrEvent(
id: 'typing',
@@ -76,62 +84,62 @@ void main() {
);
expect(container.read(provider), hasLength(1));
relay.close!('terminal');
await container.pump();
_settle(container, async);
expect(container.read(provider), isEmpty);
expect(relay.active, 0);
await Future<void>.delayed(const Duration(milliseconds: 1100));
async.elapse(const Duration(milliseconds: 1100));
expect(relay.active, 1);
expect(relay.calls, 2);
relay.close!('terminal again');
await container.pump();
_settle(container, async);
container.dispose();
await Future<void>.delayed(const Duration(milliseconds: 2100));
async.elapse(const Duration(milliseconds: 2100));
expect(relay.calls, 2);
expect(relay.active, 0);
},
}),
);
test(
'terminal closure loops stay bounded until sustained recovery',
() async {
() => fakeAsync((async) {
final relay = _Relay();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => relay)],
);
addTearDown(container.dispose);
container.listen(channelTypingProvider('chat'), (_, _) {});
await container.pump();
_settle(container, async);
for (final delay in [1, 2, 4]) {
final calls = relay.calls;
relay.close!('rejected after admission');
await container.pump();
_settle(container, async);
if (delay > 1) {
await Future<void>.delayed(const Duration(milliseconds: 1100));
async.elapse(const Duration(milliseconds: 1100));
expect(relay.calls, calls);
}
await Future<void>.delayed(
async.elapse(
Duration(milliseconds: delay * 1000 + 50 - (delay > 1 ? 1100 : 0)),
);
expect(relay.calls, calls + 1);
}
relay.close!('fourth rejection');
await container.pump();
await Future<void>.delayed(const Duration(milliseconds: 1100));
_settle(container, async);
async.elapse(const Duration(milliseconds: 1100));
expect(relay.calls, 4);
relay.setStatus(SessionStatus.disconnected);
await container.pump();
_settle(container, async);
relay.setStatus(SessionStatus.connected);
await container.pump();
_settle(container, async);
relay.close!('first rejection after reconnect');
await container.pump();
await Future<void>.delayed(const Duration(milliseconds: 1100));
_settle(container, async);
async.elapse(const Duration(milliseconds: 1100));
final recoveredCalls = relay.calls;
await Future<void>.delayed(const Duration(milliseconds: 8100));
async.elapse(const Duration(milliseconds: 8100));
relay.close!('closure after sustained health');
await container.pump();
await Future<void>.delayed(const Duration(milliseconds: 1100));
_settle(container, async);
async.elapse(const Duration(milliseconds: 1100));
expect(relay.calls, recoveredCalls + 1);
expect(relay.active, 1);
},
}),
);
test('transport rebuilds release each typing subscription', () async {
final relay = _Relay();

View File

@@ -416,14 +416,6 @@ void main() {
},
);
// Transport: request sink must be closed before send().
//
// Real-IO loopback probes (TestWidgetsFlutterBinding intercepts HttpClient
// within this suite) live in video_viewer_transport_test.dart, which uses
// plain test() without TestWidgetsFlutterBinding. The fake drain probe
// below covers the same contract without the binding conflict.
//
//
// Red-with-old-code: before the fix the `localController` was created but
// only published to `controller.value` after a successful initialize()+play().
// On error the outer `catch (loadError)` ran without ever calling

View File

@@ -1,169 +0,0 @@
// Real-IO transport probes for MediaVideoViewerPage.
//
// These tests exercise the actual IOClient (dart:io) transport path for
// AbortableStreamedRequest. They are intentionally in a SEPARATE file
// from video_viewer_test.dart because TestWidgetsFlutterBinding.ensureInitialized()
// in the main test file installs a suite-wide HttpClient override that returns
// status 400 for ALL requests — including plain test() calls in the same suite.
// By isolating these here, the real loopback HttpServer can be reached.
//
// Transport probe #1: request sink must be closed before send().
// A local loopback server reads the full request body before replying.
// Without the `unawaited(request.sink.close())` fix, IOClient.send() blocks
// at `stream.pipe(ioRequest)` forever — test times out.
//
// Transport probe #2: abort trigger cancels an in-flight download.
// The server holds the response open via a teardown-controlled release gate
// so the abort fires against a genuinely in-flight transfer rather than
// racing a completed response. After server confirms request arrival,
// the abort is triggered; send() must throw the typed
// RequestAbortedException within a bounded deadline.
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:http/io_client.dart' show IOClient;
import 'package:flutter_test/flutter_test.dart';
void main() {
test(
'Transport: request sink is closed — real IO loopback probe (success)',
() async {
final videoBytes = <int>[0, 1, 2, 3];
// Start a local HTTP server that reads the full request body BEFORE
// sending the response. If the sink is not closed, the drain hangs.
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
server.listen((req) async {
await req.drain<void>(); // hangs if sink was not closed
req.response
..statusCode = 200
..headers.contentType = ContentType('video', 'mp4')
..contentLength = videoBytes.length
..add(videoBytes);
await req.response.close();
});
final serverUrl =
'http://${server.address.host}:${server.port}/video.mp4';
final client = IOClient(
HttpClient()..idleTimeout = const Duration(milliseconds: 1),
);
addTearDown(client.close);
final requestAbort = Completer<void>();
final request = http.AbortableStreamedRequest(
'GET',
Uri.parse(serverUrl),
abortTrigger: requestAbort.future,
);
// THE FIX: close the sink before send so the pipe completes.
unawaited(request.sink.close());
final response = await client
.send(request)
.timeout(
const Duration(seconds: 5),
onTimeout: () =>
throw TimeoutException('send() did not complete within 5 s'),
);
expect(
response.statusCode,
200,
reason: 'sink closed → server receives full request → replies 200',
);
await response.stream.drain<void>().timeout(
const Duration(seconds: 5),
onTimeout: () => throw TimeoutException(
'response drain did not complete within 5 s',
),
);
},
);
test(
'Transport: abort trigger cancels an in-flight download — real IO loopback',
() async {
// The server signals that the request has arrived (so the abort fires
// AFTER the connection is established, not before).
final requestArrivedCompleter = Completer<void>();
// Teardown-controlled gate: the server handler awaits this before
// calling response.close(), so the response body stays open until the
// test explicitly releases it (on success) or teardown releases it (on
// failure). Without this gate, response.close() would send an empty 200
// immediately and the abort would race against a completed response —
// making the probe scheduling-sensitive rather than a controlled
// in-flight cancellation.
final releaseResponse = Completer<void>();
final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
addTearDown(() => server.close(force: true));
addTearDown(() {
if (!releaseResponse.isCompleted) releaseResponse.complete();
});
server.listen((req) async {
await req.drain<void>();
// Signal that the server has received the request, then hold the
// response open until the test releases it or teardown fires.
if (!requestArrivedCompleter.isCompleted) {
requestArrivedCompleter.complete();
}
// Await the teardown-controlled gate before attempting to close.
// After an abort the client may have already torn down the connection,
// so close() may throw — caught and discarded here.
await releaseResponse.future;
try {
await req.response.close();
} catch (_) {
// Connection may already be torn down by the client abort — expected.
}
});
final serverUrl =
'http://${server.address.host}:${server.port}/video.mp4';
final client = IOClient(
HttpClient()..idleTimeout = const Duration(milliseconds: 1),
);
addTearDown(client.close);
final requestAbort = Completer<void>();
final request = http.AbortableStreamedRequest(
'GET',
Uri.parse(serverUrl),
abortTrigger: requestAbort.future,
);
unawaited(request.sink.close());
// Start the request, wait for server-arrival confirmation, then abort.
final sendFuture = client.send(request);
// Bounded wait: if the server doesn't see the request within 5s, fail.
await requestArrivedCompleter.future.timeout(
const Duration(seconds: 5),
onTimeout: () => throw TimeoutException(
'Loopback server did not receive request within 5 s',
),
);
requestAbort.complete();
// send() must throw RequestAbortedException within a bounded deadline.
// The typed assertion distinguishes an abort from any other exception.
await expectLater(
sendFuture.timeout(const Duration(seconds: 5)),
throwsA(isA<http.RequestAbortedException>()),
reason: 'abort must cause send() to throw RequestAbortedException',
);
// Release the server handler so it can exit cleanly. The abort may
// have already torn down the connection, so close() in the handler may
// throw and be discarded. Teardown also releases this gate; this is
// belt-and-suspenders cleanup for the success path.
if (!releaseResponse.isCompleted) releaseResponse.complete();
},
);
}

View File

@@ -0,0 +1,390 @@
import 'dart:async';
import 'dart:convert';
import 'package:buzz/shared/auth/auth_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:nostr/nostr.dart' as nostr;
/// An in-memory relay behind the real [RelaySessionNotifier].
///
/// Only the process boundary is fake: the websocket (through the session's
/// socket factory) and the HTTP bridge (through its HTTP client). The session
/// still connects from `build()`, matches REQ/EOSE/OK/CLOSED, batches events,
/// backs off and replays live subscriptions. Replies arrive on microtasks, so
/// tests drive timers with `fakeAsync` or `tester.pump`, never real sleeps.
///
/// It is not a relay emulator: authorization, NIP-42 and JSON framing stay
/// with the relay and socket tests. A request it cannot answer faithfully is
/// recorded and fails the test at teardown, because app code often catches
/// relay errors and falls back.
class FakeRelay {
FakeRelay() {
addTearDown(() {
expect(unanswerable, isEmpty, reason: 'requests FakeRelay cannot answer');
});
}
final keys = nostr.Keys.generate();
final stored = <NostrEvent>[];
final published = <NostrEvent>[];
final reqs = <List<dynamic>>[];
final queries = <List<Map<String, dynamic>>>[];
final sockets = <FakeRelaySocket>[];
final unanswerable = <String>[];
final _rejections = <({bool Function(NostrEvent) match, String message})>[];
Completer<void>? _okHold;
Completer<void>? _echoHold;
String get pubkey => keys.public;
late final session = RelaySessionNotifier(
socketFactory: _openSocket,
httpClient: MockClient(_handleHttp),
);
List<Override> get overrides => [
relaySessionProvider.overrideWith(() => session),
relayConfigProvider.overrideWith(() => _FixedRelayConfig(keys.nsec)),
authProvider.overrideWith(_SignedInAuth.new),
];
/// Stores history without notifying live subscriptions.
void seed(Iterable<NostrEvent> events) => stored.addAll(events);
/// Another client published [event]: store it and fan it out.
void deliver(NostrEvent event) {
stored.add(event);
_fanOut(event);
}
/// Signs an event as [author] (default: a fresh member, not the app user).
NostrEvent sign({
required int kind,
required List<List<String>> tags,
String content = '',
int? createdAt,
nostr.Keys? author,
}) {
final event = nostr.Event.from(
kind: kind,
tags: tags,
content: content,
createdAt: createdAt,
secretKey: (author ?? nostr.Keys.generate()).secret,
verify: false,
);
return NostrEvent.fromJson(event.toMap());
}
/// The next published event that [match]es is answered `OK false` and is
/// neither stored nor fanned out.
void rejectNext(String message, {bool Function(NostrEvent)? match}) =>
_rejections.add((match: match ?? (_) => true, message: message));
/// Withholds OK for accepted events until the completer completes.
Completer<void> holdOk() => _okHold = Completer<void>();
/// Withholds live fan-out of accepted events until the completer completes.
/// The relay dispatches after commit, so echo and OK have no fixed order.
Completer<void> holdEcho() => _echoHold = Completer<void>();
/// Drops the current connection, as a network loss would. Its live
/// subscriptions and queued replies die with it; stored history survives.
void drop([Object? error]) => sockets.last.drop(error);
RelaySocket _openSocket({
required String wsUrl,
required String? nsec,
required void Function(List<dynamic> message) onMessage,
required void Function() onConnected,
required void Function(Object? error) onDisconnected,
}) {
final socket = FakeRelaySocket._(
this,
onMessage: onMessage,
onConnected: onConnected,
onDisconnected: onDisconnected,
);
sockets.add(socket);
return socket;
}
void _receive(FakeRelaySocket socket, List<dynamic> message) {
switch (message.first) {
case 'REQ':
reqs.add(message);
final subId = message[1] as String;
final filters = message.skip(2).cast<Map<String, dynamic>>().toList();
final history = _query(filters);
if (history == null) return;
socket._live[subId] = filters;
scheduleMicrotask(() {
for (final event in history) {
socket._reply(['EVENT', subId, event.toJson()]);
}
socket._reply(['EOSE', subId]);
});
case 'CLOSE':
socket._live.remove(message[1]);
case 'EVENT':
_publish(
socket,
NostrEvent.fromJson(message[1] as Map<String, dynamic>),
);
default:
unanswerable.add('${message.first} frame');
}
}
void _publish(FakeRelaySocket socket, NostrEvent event) {
published.add(event);
final index = _rejections.indexWhere((r) => r.match(event));
if (index >= 0) {
final message = _rejections.removeAt(index).message;
scheduleMicrotask(() => socket._reply(['OK', event.id, false, message]));
return;
}
stored.add(event);
final okHold = _okHold?.future;
final echoHold = _echoHold?.future;
Future<void> ok() async {
await okHold;
socket._reply(['OK', event.id, true, '']);
}
Future<void> echo() async {
await echoHold;
_fanOut(event);
}
scheduleMicrotask(ok);
scheduleMicrotask(echo);
}
void _fanOut(NostrEvent event) {
for (final socket in sockets) {
for (final MapEntry(key: subId, value: filters) in socket._live.entries) {
if (filters.any((f) => _matches(f, event, live: true))) {
socket._reply(['EVENT', subId, event.toJson()]);
}
}
}
}
/// Stored events for [filters], or null after recording why it cannot
/// answer. Each filter applies its own limit before the union, as the
/// relay does.
List<NostrEvent>? _query(List<Map<String, dynamic>> filters) {
if (!_answerable(filters)) return null;
final result = <String, NostrEvent>{};
for (final filter in filters) {
final limit = filter['limit'] as int?;
final matches = stored.where((e) => _matches(filter, e)).toList()
..sort(_newestFirst);
for (final event in limit == null ? matches : matches.take(limit)) {
result[event.id] = event;
}
}
return result.values.toList()..sort(_newestFirst);
}
/// Whether [_matches] implements every key in [filters]; records why not.
/// Checked before matching, so an empty store cannot hide it.
bool _answerable(List<Map<String, dynamic>> filters) {
final unknown = {
for (final filter in filters)
...filter.keys.where(
(key) => !_filterKeys.contains(key) && !key.startsWith('#'),
),
};
if (unknown.isEmpty) return true;
unanswerable.add('filter keys ${unknown.toList()}');
return false;
}
/// NIP-01 matching for a filter [_answerable] accepted.
bool _matches(
Map<String, dynamic> filter,
NostrEvent event, {
bool live = false,
}) {
if (!live && filter['limit'] == 0) return false;
bool allows(String key, Object value) =>
(filter[key] as List?)?.contains(value) ?? true;
if (!allows('ids', event.id) ||
!allows('authors', event.pubkey) ||
!allows('kinds', event.kind)) {
return false;
}
final since = filter['since'] as int?;
final until = filter['until'] as int?;
if (since != null && event.createdAt < since) return false;
if (until != null && event.createdAt > until) return false;
for (final MapEntry(:key, :value) in filter.entries) {
if (!key.startsWith('#')) continue;
final name = key.substring(1);
final wanted = (value as List).cast<String>();
if (!event.tags.any(
(tag) => tag.length > 1 && tag[0] == name && wanted.contains(tag[1]),
)) {
return false;
}
}
return true;
}
Future<http.Response> _handleHttp(http.Request request) async {
if (request.method != 'POST' ||
request.url.path != '/query' ||
request.headers['Authorization'] == null) {
unanswerable.add('${request.method} ${request.url.path}');
return http.Response('unanswerable', 501);
}
final filters = (jsonDecode(request.body) as List)
.cast<Map<String, dynamic>>();
queries.add(filters);
final events = filters.length == 1 && filters.single['top_level'] == true
? _channelWindow(filters.single)
: _query(filters);
if (events == null) return http.Response('unanswerable', 501);
return http.Response(jsonEncode([for (final e in events) e.toJson()]), 200);
}
/// The NIP-CW top-level channel window: one page of top-level rows plus its
/// kind:39006 bounds. Thread summaries and aux events are not synthesized
/// yet; a journey that needs them adds them here.
List<NostrEvent>? _channelWindow(Map<String, dynamic> filter) {
final channel = ((filter['#h'] as List).single as String).toLowerCase();
final beforeId = filter['before_id'] as String?;
final until = filter['until'] as int?;
final limit = filter['limit'] as int;
final base = {
for (final MapEntry(:key, :value) in filter.entries)
if (!_windowKeys.contains(key)) key: value,
};
if (!_answerable([base])) return null;
final rows = <NostrEvent>[];
for (final event in stored) {
if (!_matches(base, event) || event.threadReference.parentId != null) {
continue;
}
// Rows strictly after the cursor in `created_at DESC, id ASC` order.
if (until != null &&
beforeId != null &&
!(event.createdAt < until ||
(event.createdAt == until && event.id.compareTo(beforeId) > 0))) {
continue;
}
rows.add(event);
}
rows.sort(_newestFirst);
final page = rows.take(limit).toList();
final hasMore = rows.length > limit;
final cursor = until == null || beforeId == null
? 'head'
: '$until:${beforeId.toLowerCase()}';
return [
...page,
NostrEvent(
id: 'bounds-$channel-$cursor',
pubkey: 'relay',
createdAt: 0,
kind: EventKind.channelWindowBounds,
tags: [
['d', '$channel:$cursor'],
],
content: jsonEncode({
'has_more': hasMore,
'next_cursor': hasMore
? {'created_at': page.last.createdAt, 'id': page.last.id}
: null,
}),
sig: 'sig',
),
];
}
}
/// The socket the session talks to; [FakeRelay] owns both ends.
class FakeRelaySocket extends RelaySocket {
FakeRelaySocket._(
this._relay, {
required super.onMessage,
required super.onConnected,
required super.onDisconnected,
}) : _onMessage = onMessage,
_onConnected = onConnected,
_onDisconnected = onDisconnected,
super(wsUrl: 'wss://relay.test', nsec: null);
final FakeRelay _relay;
final void Function(List<dynamic> message) _onMessage;
final void Function() _onConnected;
final void Function(Object? error) _onDisconnected;
final _live = <String, List<Map<String, dynamic>>>{};
bool _open = false;
@override
Future<void> connect() async {
_open = true;
_onConnected();
}
@override
void send(List<dynamic> payload) {
if (_open) _relay._receive(this, payload);
}
@override
Future<void> disconnect() async => dispose();
@override
void dispose() {
_open = false;
_live.clear();
}
void drop(Object? error) {
if (!_open) return;
dispose();
_onDisconnected(error);
}
void _reply(List<dynamic> message) {
if (_open) _onMessage(message);
}
}
const _filterKeys = {'ids', 'authors', 'kinds', 'since', 'until', 'limit'};
const _windowKeys = {
'top_level',
'include_summaries',
'include_aux',
'before_id',
'until',
'limit',
};
int _newestFirst(NostrEvent a, NostrEvent b) => a.createdAt != b.createdAt
? b.createdAt.compareTo(a.createdAt)
: a.id.compareTo(b.id);
class _FixedRelayConfig extends RelayConfigNotifier {
_FixedRelayConfig(this._nsec);
final String _nsec;
@override
RelayConfig build() =>
RelayConfig(baseUrl: 'https://relay.test', nsec: _nsec);
}
class _SignedInAuth extends AuthNotifier {
@override
Future<AuthState> build() async =>
const AuthState(status: AuthStatus.authenticated);
}

37
scripts/mobile-test-shard.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Run one shard of the mobile Dart tests, split by file.
#
# `flutter test --total-shards` splits tests, so every shard still compiles
# and loads every test file. Splitting files means each shard compiles only
# its own. Files are balanced by line count, largest first, so the split is
# deterministic and needs no timing history.
#
# Usage: mobile-test-shard.sh INDEX TOTAL [flutter test args...]
set -euo pipefail
index=$1
total=$2
shift 2
cd "$(dirname "$0")/../mobile"
files=()
while IFS= read -r file; do
files+=("$file")
done < <(
find test -name '*_test.dart' -exec wc -l {} + |
awk '$2 != "total" { print $1, $2 }' |
sort -k1,1nr -k2,2 |
awk -v index_="$index" -v total="$total" '
{
best = 0
for (i = 1; i < total; i++) if (load[i] < load[best]) best = i
load[best] += $1
if (best == index_) print $2
}'
)
if [ ${#files[@]} -eq 0 ]; then
echo "Shard $index of $total has no test files" >&2
exit 1
fi
echo "Shard $index of $total: ${#files[@]} test files"
flutter test "$@" "${files[@]}"