|
| 1 | +// SPEC-48 T6.2 — the per-repo Settings path, mounted for real. |
| 2 | +// |
| 3 | +// What this proves that the unit and widget tests cannot: the WHOLE path from the |
| 4 | +// live repo snapshot through the dynamic registry to a rendered row, inside the |
| 5 | +// real `SettingsWindow`, on a real macOS build. |
| 6 | +// |
| 7 | +// reposProvider → sectionsFor() → the nav pane → tap → RepositorySettingsPage |
| 8 | +// → repoSettingsViewFor() → RepositorySettingsSection → the rows |
| 9 | +// |
| 10 | +// Every hop above is covered by a unit or widget test in isolation. None of them |
| 11 | +// covers the composition, and this repo has already been bitten there once: the |
| 12 | +// desktop shell mounts Settings *outside* a GoRouter, so a section that navigated |
| 13 | +// with `context.go` threw at runtime while every test stayed green. A test that |
| 14 | +// mounts the section directly cannot catch that class of fault; this one can. |
| 15 | +// |
| 16 | +// Deliberately NOT extended onto the daemon control socket (see the plan's T6.2): |
| 17 | +// the daemon-side behaviour is proven by the server tests, and routing this through |
| 18 | +// the socket would add infrastructure for no extra coverage. The repo snapshot is |
| 19 | +// stubbed at `reposProvider`, which is exactly the seam `SettingsWindow` reads. |
| 20 | +// |
| 21 | +// Run: app/tool/e2e-desktop-settings.sh |
| 22 | +// |
| 23 | +// ignore_for_file: depend_on_referenced_packages |
| 24 | +import 'dart:io' show Platform; |
| 25 | + |
| 26 | +import 'package:flutter/material.dart'; |
| 27 | +import 'package:flutter_riverpod/flutter_riverpod.dart'; |
| 28 | +import 'package:flutter_test/flutter_test.dart'; |
| 29 | +import 'package:integration_test/integration_test.dart'; |
| 30 | +import 'package:makit/desktop/daemon/daemon_lifecycle.dart'; |
| 31 | +import 'package:makit/desktop/desktop_app.dart' show desktopControllerProvider; |
| 32 | +import 'package:makit/desktop/desktop_controller.dart'; |
| 33 | +import 'package:makit/desktop/screens/fake_control_client.dart'; |
| 34 | +import 'package:makit/desktop/settings/sections/repository_section.dart'; |
| 35 | +import 'package:makit/desktop/settings/server_config.dart'; |
| 36 | +import 'package:makit/desktop/settings/settings_nav_pane.dart'; |
| 37 | +import 'package:makit/desktop/settings/settings_window.dart'; |
| 38 | +import 'package:makit/store/connection.dart'; |
| 39 | +import 'package:makit/store/models.dart'; |
| 40 | +import 'package:makit/store/store.dart'; |
| 41 | +import 'package:makit/ui/home/repo_monogram.dart'; |
| 42 | +import 'package:shared_preferences/shared_preferences.dart'; |
| 43 | + |
| 44 | +/// Two pinned repos and one unpinned, so the pinned filter is exercised by the |
| 45 | +/// same fixture that exercises the rows. |
| 46 | +/// |
| 47 | +/// `Diana` carries a worktree-root override and a chosen hue; `makit` inherits |
| 48 | +/// everything. The difference is the point: the section has to report each repo's |
| 49 | +/// own state, and a bug that reads the wrong repo's settings passes any fixture |
| 50 | +/// where both repos look the same. |
| 51 | +/// The real home, because the section abbreviates paths against `HOME` at runtime. |
| 52 | +/// Hardcoding `/Users/le` made the `~/trees/diana` and `~/.worktrees` assertions hold |
| 53 | +/// on exactly one machine. |
| 54 | +final String _home = Platform.environment['HOME'] ?? '/root'; |
| 55 | + |
| 56 | +final _repos = <RepoInfo>[ |
| 57 | + RepoInfo.fromJson({ |
| 58 | + 'id': 'p-diana', |
| 59 | + 'name': 'Diana', |
| 60 | + 'path': '$_home/Work/XDent/Diana', |
| 61 | + 'pinned': true, |
| 62 | + 'isGitRepo': true, |
| 63 | + 'defaultBranch': 'main', |
| 64 | + 'currentBranch': 'main', |
| 65 | + 'worktrees': const <Map<String, dynamic>>[], |
| 66 | + 'settings': { |
| 67 | + 'worktreeRoot': {'value': '$_home/trees/diana', 'source': 'override'}, |
| 68 | + 'provider': {'value': 'forgejo', 'source': 'override'}, |
| 69 | + 'defaultBranch': {'value': 'trunk', 'source': 'override'}, |
| 70 | + 'logoHue': 2, |
| 71 | + 'hasRemote': true, |
| 72 | + 'forge': { |
| 73 | + 'software': 'forgejo', |
| 74 | + 'host': 'forgejo.internal.test', |
| 75 | + 'authed': true, |
| 76 | + }, |
| 77 | + }, |
| 78 | + })!, |
| 79 | + RepoInfo.fromJson({ |
| 80 | + 'id': 'p-makit', |
| 81 | + 'name': 'makit', |
| 82 | + 'path': '$_home/Work/makit', |
| 83 | + 'pinned': true, |
| 84 | + 'isGitRepo': true, |
| 85 | + 'defaultBranch': 'main', |
| 86 | + 'currentBranch': 'main', |
| 87 | + 'worktrees': const <Map<String, dynamic>>[], |
| 88 | + 'settings': { |
| 89 | + 'worktreeRoot': {'value': '$_home/.worktrees', 'source': 'default'}, |
| 90 | + 'provider': {'value': 'auto', 'source': 'default'}, |
| 91 | + 'hasRemote': true, |
| 92 | + }, |
| 93 | + })!, |
| 94 | + RepoInfo.fromJson({ |
| 95 | + 'id': 'p-noticed', |
| 96 | + 'name': 'noticed', |
| 97 | + 'path': '/tmp/noticed', |
| 98 | + 'pinned': false, |
| 99 | + 'isGitRepo': true, |
| 100 | + 'worktrees': const <Map<String, dynamic>>[], |
| 101 | + })!, |
| 102 | +]; |
| 103 | + |
| 104 | +late SharedPreferences _prefs; |
| 105 | + |
| 106 | +Widget _app() => ProviderScope( |
| 107 | + overrides: [ |
| 108 | + reposProvider.overrideWithValue(ReposState(_repos)), |
| 109 | + serverConfigProvider.overrideWith( |
| 110 | + (ref) => ServerConfigController(_prefs, const ServerConfig()), |
| 111 | + ), |
| 112 | + desktopControllerProvider.overrideWithValue( |
| 113 | + DesktopController( |
| 114 | + client: FakeControlClient(), |
| 115 | + lifecycle: DaemonLifecycle( |
| 116 | + resolver: MakitCliResolver(shellLookup: () async => null), |
| 117 | + ), |
| 118 | + ), |
| 119 | + ), |
| 120 | + connectionProvider.overrideWithValue(MakitConnState()), |
| 121 | + ], |
| 122 | + child: MaterialApp(home: SettingsWindow(onClose: () {})), |
| 123 | +); |
| 124 | + |
| 125 | +Future<void> _openRepo(WidgetTester tester, String name) async { |
| 126 | + final row = find.descendant( |
| 127 | + of: find.byType(SettingsNavPane), |
| 128 | + matching: find.text(name), |
| 129 | + ); |
| 130 | + await tester.ensureVisible(row); |
| 131 | + await tester.pumpAndSettle(); |
| 132 | + await tester.tap(row); |
| 133 | + await tester.pumpAndSettle(); |
| 134 | +} |
| 135 | + |
| 136 | +void main() { |
| 137 | + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); |
| 138 | + |
| 139 | + setUp(() async { |
| 140 | + SharedPreferences.setMockInitialValues({}); |
| 141 | + _prefs = await SharedPreferences.getInstance(); |
| 142 | + }); |
| 143 | + |
| 144 | + testWidgets('a pinned repo gets a reachable section in the real window', ( |
| 145 | + tester, |
| 146 | + ) async { |
| 147 | + await tester.pumpWidget(_app()); |
| 148 | + await tester.pumpAndSettle(); |
| 149 | + |
| 150 | + // The sidebar lists the pinned repos and not the merely-noticed one. |
| 151 | + expect(find.text('Diana'), findsWidgets); |
| 152 | + expect(find.text('makit'), findsWidgets); |
| 153 | + expect(find.text('noticed'), findsNothing); |
| 154 | + |
| 155 | + await _openRepo(tester, 'Diana'); |
| 156 | + |
| 157 | + // The section rendered — not an empty pane, and not the fallback section. |
| 158 | + // Group headers are upper-cased by `SettingsSectionHeader`, so these assert the |
| 159 | + // rendered string rather than the source one. |
| 160 | + expect(find.byType(RepositorySettingsSection), findsOneWidget); |
| 161 | + expect(find.text('IDENTITY'), findsOneWidget); |
| 162 | + expect(find.text('WORKTREES'), findsOneWidget); |
| 163 | + // And the rows themselves, so "the section mounted" is not mistaken for "the |
| 164 | + // section rendered its contents". |
| 165 | + expect(find.text('Logo'), findsOneWidget); |
| 166 | + expect(find.text('Git provider'), findsOneWidget); |
| 167 | + expect(find.text('Worktree root'), findsOneWidget); |
| 168 | + }); |
| 169 | + |
| 170 | + testWidgets('the rows carry THIS repo\'s values, resolved from the snapshot', ( |
| 171 | + tester, |
| 172 | + ) async { |
| 173 | + await tester.pumpWidget(_app()); |
| 174 | + await tester.pumpAndSettle(); |
| 175 | + await _openRepo(tester, 'Diana'); |
| 176 | + |
| 177 | + // The override, home-abbreviated, and the badge that distinguishes it from an |
| 178 | + // inherited root — the one row where that distinction is the whole feature. |
| 179 | + expect(find.text('~/trees/diana'), findsOneWidget); |
| 180 | + expect(find.text('overridden'), findsOneWidget); |
| 181 | + // The provider override relabels the row rather than reporting detection. |
| 182 | + expect(find.textContaining('Set to Forgejo'), findsOneWidget); |
| 183 | + // The default-branch override wins over the DTO's git-derived `main`. |
| 184 | + expect(find.text('trunk'), findsOneWidget); |
| 185 | + }); |
| 186 | + |
| 187 | + testWidgets('switching repos re-renders from the newly selected repo', ( |
| 188 | + tester, |
| 189 | + ) async { |
| 190 | + // The bug this guards: a section built once and cached would keep showing the |
| 191 | + // first repo's values under the second repo's title, which reads as correct. |
| 192 | + await tester.pumpWidget(_app()); |
| 193 | + await tester.pumpAndSettle(); |
| 194 | + |
| 195 | + await _openRepo(tester, 'Diana'); |
| 196 | + expect(find.text('overridden'), findsOneWidget); |
| 197 | + |
| 198 | + await _openRepo(tester, 'makit'); |
| 199 | + expect( |
| 200 | + find.text('~/trees/diana'), |
| 201 | + findsNothing, |
| 202 | + reason: "Diana's root leaked into makit", |
| 203 | + ); |
| 204 | + expect( |
| 205 | + find.text('overridden'), |
| 206 | + findsNothing, |
| 207 | + reason: 'makit inherits, so nothing is overridden', |
| 208 | + ); |
| 209 | + expect(find.text('~/.worktrees'), findsOneWidget); |
| 210 | + }); |
| 211 | + |
| 212 | + testWidgets('the sidebar draws each repo its own mark, with the chosen hue', ( |
| 213 | + tester, |
| 214 | + ) async { |
| 215 | + await tester.pumpWidget(_app()); |
| 216 | + await tester.pumpAndSettle(); |
| 217 | + |
| 218 | + final marks = find.descendant( |
| 219 | + of: find.byType(SettingsNavPane), |
| 220 | + matching: find.byType(RepoMonogram), |
| 221 | + ); |
| 222 | + expect(marks, findsNWidgets(2), reason: 'one mark per pinned repo'); |
| 223 | + final diana = tester |
| 224 | + .widgetList<RepoMonogram>(marks) |
| 225 | + .firstWhere((m) => m.name == 'Diana'); |
| 226 | + expect(diana.hue, 2, reason: 'the stored hue must reach the sidebar'); |
| 227 | + }); |
| 228 | + |
| 229 | + testWidgets('search reaches a repo row and lands on THAT repo', ( |
| 230 | + tester, |
| 231 | + ) async { |
| 232 | + // The nav pane searches the DYNAMIC sections; on the static list "worktree root" |
| 233 | + // found nothing and the result would have been titled `repo:<id>`. |
| 234 | + // |
| 235 | + // Landing on the RIGHT repo is asserted, not just landing on a repo section: with |
| 236 | + // two pinned repos the query matches both, so tapping `.first` and checking only |
| 237 | + // that some repo section rendered would pass even if the search sent the user to |
| 238 | + // the other repo. The result's subtitle is the repo name, so the tap targets |
| 239 | + // Diana specifically and the assertion is on a value only Diana has. |
| 240 | + await tester.pumpWidget(_app()); |
| 241 | + await tester.pumpAndSettle(); |
| 242 | + |
| 243 | + await tester.enterText(find.byType(TextField).first, 'worktree root'); |
| 244 | + await tester.pumpAndSettle(); |
| 245 | + |
| 246 | + final results = find.widgetWithText(ListTile, 'Diana'); |
| 247 | + expect(results, findsOneWidget, reason: 'one matching row for Diana'); |
| 248 | + await tester.tap(results); |
| 249 | + await tester.pumpAndSettle(); |
| 250 | + |
| 251 | + expect(find.byType(RepositorySettingsSection), findsOneWidget); |
| 252 | + expect( |
| 253 | + find.text('~/trees/diana'), |
| 254 | + findsOneWidget, |
| 255 | + reason: 'landed on Diana, not makit', |
| 256 | + ); |
| 257 | + }); |
| 258 | +} |
0 commit comments