diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 2c6c3d0..63f9920 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -28,6 +28,7 @@ You are preparing a new release for the **Magic Starter** Flutter plugin. Follow 3. **Tests** — All tests must pass (see context above). If failing, STOP and report. 4. **Analyzer** — Zero issues required (see context above). If issues, STOP and report. 5. **Version** — Determine the new version from $ARGUMENTS or auto-increment. +6. **Agent-facing reference** — Update `../magic/skills/magic-framework/references/plugin-starter.md` against this release and move its first-line stamp to the new version. That file, not this repo's `CLAUDE.md`, is what an agent adopting this package reads: `.pubignore` keeps `CLAUDE.md` and `.claude/` out of the published archive. It lives in another repository, so nothing here forces it; `test/skill_reference_stamp_test.dart` catches a stale stamp when a sibling checkout exists and skips when it does not, which is why this line is here too. ### Phase 2: Version Bump diff --git a/CHANGELOG.md b/CHANGELOG.md index c830e65..739504d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,12 @@ All notable changes to this project will be documented in this file. -## [Unreleased] +## [0.0.1-alpha.27] - 2026-09-09 + +### Added +- **A deep link that lands on a signed-out device now survives the login bounce.** `EnsureAuthenticated.redirectTarget` records the requested location via `MagicRouter.setIntendedUrl` before bouncing an unauthenticated visitor to login, and a new `NavigatesRoutes.navigateHome` reads it back with `pullIntendedUrl` once they authenticate, falling back to `MagicStarterConfig.homeRoute()` when no intent was stored or the stored value is not an in-app path. Nothing is recorded for the login route itself or for any other guest-only auth route (register, forgot-password, reset-password, two-factor-challenge, otp), since a bounced visitor cannot use one of those as a destination either. All five post-auth navigations (login, register auto-login, two-factor challenge, OTP verification, guest login) now call `navigateHome()` instead of navigating straight to the home route. Known limit: `redirectTarget` only ever sees `state.matchedLocation`, which carries no query string, so a recorded intent loses any `?token=...` the original link carried. + + **An intent belongs to the session that asked for it, and ending that session discards it.** Signing out flips the auth state, which re-runs go_router's redirects while the app is still on the protected route, so `EnsureAuthenticated` writes that route down as somewhere to return to. Nobody asked for it: a sign-out on `/teams/settings` would otherwise send the NEXT person who signs in on that device straight there, and the account-deletion path would send them to a deleted account's settings. `MagicStarterServiceProvider` now listens to `Auth.stateNotifier` and discards the intent whenever the state goes to signed-out, which is the one funnel all three logouts pass through: the two a user asks for, and the one the app performs on its own when magic's `AuthInterceptor` fails a token refresh, which no call-site clear can reach. In this provider rather than in `SessionScopeSync`, which listens to the same notifier: that one is opt-in and nothing in this package calls `attach()`, so an app that never adopted `SessionScopedController` would have had no clear at all. This provider boots in every starter app, so no host action is needed. The clear is deferred by a microtask because the whole record path is synchronous and clearing inline would run before the redirect that writes the value. A host route with an ASYNC `redirect` records after that microtask and is not covered. ## [0.0.1-alpha.26] - 2026-09-03 diff --git a/doc/basics/authentication.md b/doc/basics/authentication.md index 9df0c82..4aa837f 100644 --- a/doc/basics/authentication.md +++ b/doc/basics/authentication.md @@ -52,7 +52,7 @@ await MagicStarterAuthController.instance.doLogin( The login flow has three possible outcomes: -1. **Success** — the controller extracts `token` and `user` from the nested `data` key, calls `Auth.login()`, sets success state, and navigates to `MagicStarterConfig.homeRoute()`. +1. **Success** — the controller extracts `token` and `user` from the nested `data` key, calls `Auth.login()`, sets success state, and calls `navigateHome()`. That is the intended url a bounced deep link recorded, read once through `MagicRouter.pullIntendedUrl()`, and `MagicStarterConfig.homeRoute()` only as the fallback when nothing was recorded or the recorded value is not an in-app path. An intent belongs to the session that asked for it: `MagicStarterServiceProvider` listens to `Auth.stateNotifier` and discards it whenever the state goes to signed-out, so it never reaches the next person on the device. That covers the sign-out a user asks for, the account deletion, and the one the app performs on its own when a token refresh fails, since all three go through the same notifier. No host wiring is needed; the provider boots in every starter app. 2. **Two-factor required** — the controller detects the challenge flag and navigates to `MagicStarterConfig.twoFactorChallengeRoute()` with the encrypted token as a query parameter. No login occurs yet. 3. **Failure** — `handleApiError()` sets the error state with a localized fallback message. diff --git a/lib/src/cli/starter_artisan_provider.dart b/lib/src/cli/starter_artisan_provider.dart index 5a0d89e..e7b487d 100644 --- a/lib/src/cli/starter_artisan_provider.dart +++ b/lib/src/cli/starter_artisan_provider.dart @@ -13,7 +13,7 @@ import 'commands/magic_starter_uninstall_command.dart'; /// both and fails when they disagree, so a banner cannot drift behind a release /// the way the two hand-written `'0.0.1'` literals did: they were written before /// the first alpha and were still claiming 0.0.1 twenty-four releases later. -const String magicStarterVersion = '0.0.1-alpha.26'; +const String magicStarterVersion = '0.0.1-alpha.27'; /// Magic Starter's contribution to the host application's artisan registry. /// diff --git a/lib/src/http/controllers/concerns/navigates_routes.dart b/lib/src/http/controllers/concerns/navigates_routes.dart index c965eb9..5347b8f 100644 --- a/lib/src/http/controllers/concerns/navigates_routes.dart +++ b/lib/src/http/controllers/concerns/navigates_routes.dart @@ -1,5 +1,7 @@ import 'package:magic/magic.dart'; +import '../../../configuration/magic_starter_config.dart'; + /// Shared navigation helper for Magic Starter controllers. /// /// Provides a safe [navigateTo] method that checks for navigator context @@ -17,4 +19,39 @@ mixin NavigatesRoutes { MagicRoute.to(path, query: query); } + + /// Navigate to wherever a signed-in user should land after authenticating. + /// + /// This is the ONLY post-auth navigation seam: every controller action + /// that follows a successful login, registration, two-factor challenge, + /// OTP verification or guest login must call this instead of navigating + /// to [MagicStarterConfig.homeRoute] directly, or the deep-link-through- + /// login flow below silently keeps sending everyone home. + /// + /// Pulls the URL `EnsureAuthenticated` recorded before bouncing the user + /// to login (see [MagicRouter.pullIntendedUrl], a one-time read) and + /// targets it when it is a well-formed in-app path (non-null, leading + /// slash). Any other value, including no stored intent at all, falls + /// back to [MagicStarterConfig.homeRoute]. The leading-slash check is + /// defence in depth: `setIntendedUrl` is only ever called with a router + /// location internally, so this guards against a value that should not + /// be reachable rather than one that is. + void navigateHome() { + if (MagicRouter.instance.navigatorKey.currentContext == null) return; + + final String? intended = MagicRouter.instance.pullIntendedUrl(); + // `//host/path` is protocol-relative: it starts with a slash and is a + // different ORIGIN, so the leading-slash test alone lets it through. + // Nothing reachable can store one today, since only a router location is + // ever recorded, which is exactly why the check is cheap to keep. + final bool hasValidIntent = + intended != null && + intended.startsWith('/') && + !intended.startsWith('//'); + final String target = hasValidIntent + ? intended + : MagicStarterConfig.homeRoute(); + + navigateTo(target); + } } diff --git a/lib/src/http/controllers/magic_starter_auth_controller.dart b/lib/src/http/controllers/magic_starter_auth_controller.dart index 79dd290..2437df5 100644 --- a/lib/src/http/controllers/magic_starter_auth_controller.dart +++ b/lib/src/http/controllers/magic_starter_auth_controller.dart @@ -118,7 +118,7 @@ class MagicStarterAuthController extends MagicController // 3. Authenticate the user and navigate home. await Auth.login({'token': token}, MagicStarter.createUser(userData)); setSuccess(true); - navigateTo(MagicStarterConfig.homeRoute()); + navigateHome(); } on TimeoutException catch (e, stackTrace) { Log.error( '[MagicStarterAuthController.doLogin] Timeout: $e\n$stackTrace', @@ -186,7 +186,7 @@ class MagicStarterAuthController extends MagicController // 3. Auto-login when the server returns credentials immediately. await Auth.login({'token': token}, MagicStarter.createUser(userData)); setSuccess(true); - navigateTo(MagicStarterConfig.homeRoute()); + navigateHome(); return; } @@ -321,7 +321,7 @@ class MagicStarterAuthController extends MagicController // 2. Log the user in and navigate to home. await Auth.login({'token': token}, MagicStarter.createUser(userData)); setSuccess(true); - navigateTo(MagicStarterConfig.homeRoute()); + navigateHome(); } catch (e, stackTrace) { Log.error( '[MagicStarterAuthController.doTwoFactorChallenge] $e\n$stackTrace', @@ -352,6 +352,10 @@ class MagicStarterAuthController extends MagicController // 2. Clear authentication tokens and navigate to login. await Auth.logout(); + + // The intended url this sign-out just recorded is dropped by + // `SessionScopeSync`, which listens to the one notifier all three logout + // paths pass through, including the passive 401 one no call site can see. navigateTo(MagicStarterConfig.loginRoute()); } diff --git a/lib/src/http/controllers/magic_starter_guest_auth_controller.dart b/lib/src/http/controllers/magic_starter_guest_auth_controller.dart index 39163ff..aa93c54 100644 --- a/lib/src/http/controllers/magic_starter_guest_auth_controller.dart +++ b/lib/src/http/controllers/magic_starter_guest_auth_controller.dart @@ -3,7 +3,6 @@ import 'dart:math'; import 'package:magic/magic.dart'; import 'concerns/navigates_routes.dart'; -import '../../configuration/magic_starter_config.dart'; import '../../facades/magic_starter.dart'; import '../../models/magic_starter_auth_user.dart'; @@ -85,7 +84,7 @@ class MagicStarterGuestAuthController extends MagicController setSuccess(true); // 5. Navigate home. - navigateTo(MagicStarterConfig.homeRoute()); + navigateHome(); } catch (e, stackTrace) { Log.error( '[MagicStarterGuestAuthController.doGuestLogin] $e\n$stackTrace', diff --git a/lib/src/http/controllers/magic_starter_otp_controller.dart b/lib/src/http/controllers/magic_starter_otp_controller.dart index 64a7deb..edddc4a 100644 --- a/lib/src/http/controllers/magic_starter_otp_controller.dart +++ b/lib/src/http/controllers/magic_starter_otp_controller.dart @@ -135,7 +135,7 @@ class MagicStarterOtpController extends MagicController // 3. Navigate home on successful authentication. setSuccess(data); - navigateTo(MagicStarterConfig.homeRoute()); + navigateHome(); } catch (e, stackTrace) { Log.error('[MagicStarterOtpController.verifyOtp] $e\n$stackTrace'); setError(trans('errors.unexpected')); diff --git a/lib/src/middleware/ensure_authenticated.dart b/lib/src/middleware/ensure_authenticated.dart index b9412ee..57d7f72 100644 --- a/lib/src/middleware/ensure_authenticated.dart +++ b/lib/src/middleware/ensure_authenticated.dart @@ -11,6 +11,18 @@ import '../configuration/magic_starter_config.dart'; /// `redirect` callback before any page builds, so an unauthenticated boot /// lands on the login route and the gated page never mounts. /// +/// Before bouncing to login it records [location] via +/// [MagicRouter.setIntendedUrl], so `NavigatesRoutes.navigateHome` can send +/// the user back there once they authenticate. Nothing is recorded for the +/// login route itself (nothing to return to) or for any other guest-only +/// route registered in `auth_routes.dart` (register, forgot-password, +/// reset-password, two-factor-challenge, otp): a visitor bounced off one of +/// those cannot use it as a post-login destination either. +/// +/// **Known limit**: [redirectTarget] receives `state.matchedLocation` (see +/// `magic_router.dart`'s `_handleRedirect`), which carries no query string, +/// so a recorded intent loses any `?token=...` the original link carried. +/// /// ```dart /// MagicRoute.group( /// middleware: ['auth'], @@ -18,14 +30,54 @@ import '../configuration/magic_starter_config.dart'; /// ); /// ``` class EnsureAuthenticated extends MagicMiddleware { + /// Path suffixes (under the configured auth prefix) that are themselves + /// guest-only screens: see `auth_routes.dart` for the route registrations + /// this mirrors. + static const List _guestRouteSuffixes = [ + '/register', + '/forgot-password', + '/reset-password', + '/two-factor-challenge', + '/otp', + ]; + @override String? redirectTarget(String location) { + if (Auth.check()) return null; + // Guard the login route itself so the redirect can never loop: go_router // raises after more than five successive redirects. final String login = MagicStarterConfig.loginRoute(); - if (!Auth.check() && location != login) { - return login; + if (location == login) return null; + + if (!_isGuestRoute(location)) { + MagicRouter.instance.setIntendedUrl(location); } - return null; + + return login; + } + + /// Whether [location] is one of the guest-only auth routes registered in + /// `auth_routes.dart` (the login route itself is handled by the caller). + /// + /// Unreachable through THIS package's own route table, and kept anyway. + /// `auth_routes.dart:19` registers the whole auth group under + /// `middleware: ['guest']`, so this middleware never sees `/auth/register` + /// or its siblings there. What it is for is a host that applies `auth` + /// globally, over a shell route wrapping everything, which the middleware + /// being public API makes a supported configuration rather than a + /// hypothetical: without this, such an app would bounce a visitor off + /// `/auth/register`, record it, and send them back to a guest-only route + /// after they sign in. + /// + /// Named here because a reviewer asked three times whether the branch was + /// dead. It is dead for us and live for an adopter, and the two tests over + /// it call [redirectTarget] directly, so nothing shows the router reaching + /// it. + bool _isGuestRoute(String location) { + final String authPrefix = MagicStarterConfig.authPrefix(); + return _guestRouteSuffixes.any( + (suffix) => location == '$authPrefix$suffix', + ); } } diff --git a/lib/src/providers/magic_starter_service_provider.dart b/lib/src/providers/magic_starter_service_provider.dart index 2ec46dc..6722255 100644 --- a/lib/src/providers/magic_starter_service_provider.dart +++ b/lib/src/providers/magic_starter_service_provider.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:magic/magic.dart'; @@ -54,6 +56,82 @@ class MagicStarterServiceProvider extends ServiceProvider { // 2. If not, register 'indigo' as the fallback primary color. // 3. Emit info log to notify about the fallback. _bootPrimaryColorFallback(); + + _forgetIntendedUrlOnSignOut(); + } + + /// The notifier [_forgetIntendedUrlOnSignOut] is currently subscribed to. + /// + /// Held rather than resolved again at removal time for the same reason + /// `SessionScopeSync` holds its own: `Auth.stateNotifier` resolves through + /// the container, so re-binding the guard hands back a DIFFERENT notifier and + /// unsubscribing through the facade would leave this listener on the old one. + /// + /// Compared by identity rather than treated as a one-way latch, which is what + /// it was first written as and what a review caught. A latch never cleared, + /// so a second boot after a re-bind returned early and left the subscription + /// on a notifier nobody bumps any more: no clear at all, and a test suite + /// where the assertion holds only because an earlier test happened to attach + /// first. It failed under `--test-randomize-ordering-seed=1` and passed in + /// declaration order, which is the worst way for it to be wrong. + static ValueNotifier? _authState; + + /// Discards the intended url when the session that recorded it ends. + /// + /// [EnsureAuthenticated] records a protected route before bouncing to login, + /// so `navigateHome()` can send the visitor back to it afterwards. Signing + /// out flips the auth state, which re-runs go_router's redirects while the + /// app is STILL on that route, so the sign-out records it too. Nobody asked + /// for that: it would send the next person who signs in on this device to the + /// previous one's page, and on the account-deletion path to a deleted + /// account's settings. + /// + /// Hung off the auth notifier rather than off each `Auth.logout()` call site, + /// which is where this first landed and covers only the logouts a user asks + /// for. The one that matters most is the one the app performs on its own: + /// magic's `AuthInterceptor` calls `Auth.logout()` when a token refresh fails + /// (`auth_interceptor.dart:77`) and `AuthServiceProvider` installs it + /// unconditionally, so a session that simply EXPIRES on a protected route + /// took that path. The notifier is the one funnel all three pass through. + /// + /// Here rather than in `SessionScopeSync`, which listens to the same notifier + /// and was the second thing tried: that class is OPT-IN and nothing in this + /// package calls `attach()`, so an app that never adopted + /// `SessionScopedController` would have had no clear at all. This provider + /// boots in every starter app. + /// + /// Deferred by a microtask because the whole record path (`stateNotifier` -> + /// `GoRouteInformationProvider.notifyListeners` -> parse -> redirect) is + /// synchronous: clearing inline would run before the redirect that writes the + /// value. Known limit: a host route with an ASYNC `redirect` records after + /// the microtask and is not covered. Read-and-discard because + /// `pullIntendedUrl` is the one-time read and `MagicRouter` exposes no + /// separate clear. + void _forgetIntendedUrlOnSignOut() { + final ValueNotifier notifier = Auth.stateNotifier; + if (identical(_authState, notifier)) return; + + // Moves rather than adds. Booting twice against the same notifier is the + // no-op above; booting against a NEW one has to take the subscription with + // it, or the listener sits on a notifier nothing bumps. + _authState?.removeListener(_forgetIntendedUrl); + _authState = notifier..addListener(_forgetIntendedUrl); + } + + /// The listener itself, a named static so [_forgetIntendedUrlOnSignOut] can + /// remove it: `removeListener` matches by identity and a fresh closure never + /// equals the one that was added. + static void _forgetIntendedUrl() { + if (Auth.check()) return; + + scheduleMicrotask(() { + // Re-checked inside the microtask: by the time it runs the state may + // have moved again, and clearing after a login would eat the deep link + // the intent exists to serve. + if (Auth.check()) return; + + MagicRouter.instance.pullIntendedUrl(); + }); } /// Registers Gate abilities that control profile section visibility. diff --git a/pubspec.yaml b/pubspec.yaml index 803fe92..9270ffa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: magic_starter description: Starter kit for Magic Framework. Auth, Profile, Teams, Notifications — 14 opt-in features with overridable views. -version: 0.0.1-alpha.26 +version: 0.0.1-alpha.27 homepage: https://magic.fluttersdk.com/starter documentation: https://magic.fluttersdk.com/packages/starter/getting-started/installation repository: https://github.com/fluttersdk/magic_starter diff --git a/test/http/controllers/concerns/navigates_routes_test.dart b/test/http/controllers/concerns/navigates_routes_test.dart new file mode 100644 index 0000000..9466e36 --- /dev/null +++ b/test/http/controllers/concerns/navigates_routes_test.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_starter/src/configuration/magic_starter_config.dart'; +import 'package:magic_starter/src/http/controllers/concerns/navigates_routes.dart'; + +/// Bare host exercising [NavigatesRoutes] in isolation, without pulling in a +/// full controller's HTTP/auth surface. +class _TestHost with NavigatesRoutes {} + +void main() { + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + }); + + setUp(() { + MagicApp.reset(); + Magic.flush(); + TitleManager.reset(); + MagicRouter.reset(); + Auth.fake(); + }); + + tearDown(() { + Auth.unfake(); + }); + + group('NavigatesRoutes.navigateHome', () { + testWidgets('targets the stored intended URL when one is set', ( + tester, + ) async { + final host = _TestHost(); + MagicRoute.page('/', () => const Text('home')); + MagicRoute.page('/incidents/123', () => const Text('incident')); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + MagicRouter.instance.setIntendedUrl('/incidents/123'); + host.navigateHome(); + await tester.pumpAndSettle(); + + expect(MagicRouter.instance.currentPath, '/incidents/123'); + }); + + testWidgets( + 'falls back to the configured home route when no intent is stored', + (tester) async { + final host = _TestHost(); + MagicRoute.page('/', () => const Text('home')); + MagicRoute.page('/monitors', () => const Text('monitors')); + + MagicRouter.instance.setInitialLocation('/monitors'); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + host.navigateHome(); + await tester.pumpAndSettle(); + + expect( + MagicRouter.instance.currentPath, + MagicStarterConfig.homeRoute(), + ); + }, + ); + + testWidgets( + 'falls back to the configured home route when the stored intent is ' + 'poisoned', + (tester) async { + final host = _TestHost(); + MagicRoute.page('/', () => const Text('home')); + MagicRoute.page('/monitors', () => const Text('monitors')); + + MagicRouter.instance.setInitialLocation('/monitors'); + + await tester.pumpWidget( + MaterialApp.router(routerConfig: MagicRouter.instance.routerConfig), + ); + await tester.pumpAndSettle(); + + // Set directly rather than via a deep link parse, to prove + // navigateHome itself rejects a non-leading-slash value regardless + // of how it got stored. + MagicRouter.instance.setIntendedUrl('https://evil.example/x'); + host.navigateHome(); + await tester.pumpAndSettle(); + + expect( + MagicRouter.instance.currentPath, + MagicStarterConfig.homeRoute(), + ); + }, + ); + }); +} diff --git a/test/middleware/ensure_authenticated_test.dart b/test/middleware/ensure_authenticated_test.dart index b54fc7f..992385f 100644 --- a/test/middleware/ensure_authenticated_test.dart +++ b/test/middleware/ensure_authenticated_test.dart @@ -23,9 +23,14 @@ _FakeUser _fakeUser() { } void main() { + // The starter provider's boot reads `WidgetsBinding.instance` for its primary + // colour fallback, and one group below boots it for real. + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() { MagicApp.reset(); Magic.flush(); + MagicRouter.reset(); }); tearDown(() { @@ -57,5 +62,85 @@ void main() { expect(middleware.redirectTarget('/'), isNull); expect(middleware.redirectTarget('/monitors'), isNull); }); + + test('records the requested location as the intended URL before bouncing ' + 'to login', () { + Auth.fake(); + final middleware = EnsureAuthenticated(); + + middleware.redirectTarget('/incidents/123'); + + expect(MagicRouter.instance.hasIntendedUrl, isTrue); + expect(MagicRouter.instance.pullIntendedUrl(), '/incidents/123'); + }); + + test( + 'records nothing when the bounce target is itself a guest-only auth ' + 'route, so a bounced visitor is never sent back to one they cannot use', + () { + Auth.fake(); + final middleware = EnsureAuthenticated(); + + middleware.redirectTarget('/auth/register'); + expect(MagicRouter.instance.hasIntendedUrl, isFalse); + + middleware.redirectTarget('/auth/forgot-password'); + expect(MagicRouter.instance.hasIntendedUrl, isFalse); + }, + ); + + test('records nothing for an authenticated navigation', () { + Auth.fake(user: _fakeUser()); + final middleware = EnsureAuthenticated(); + + middleware.redirectTarget('/monitors'); + + expect(MagicRouter.instance.hasIntendedUrl, isFalse); + }); + }); + + group('the intended url a sign-out recorded', () { + /// Boots the starter provider, which is what registers the clear. + /// + /// Through the provider rather than a helper, because the finding this + /// covers was that the clear used to hang off `SessionScopeSync.attach()`, + /// which is OPT-IN and which nothing in this package calls: an app that + /// never adopted session scoping had no clear at all. Booting the provider + /// is what every starter app does, so that is what the test does. + Future bootProvider() async { + final provider = MagicStarterServiceProvider(MagicApp.instance); + provider.register(); + await provider.boot(); + } + + test('is discarded when the session ends', () async { + Auth.fake(user: _fakeUser()); + await bootProvider(); + + // What EnsureAuthenticated writes down when the auth flip re-runs + // go_router's redirects while the app is still on a protected route. + MagicRouter.instance.setIntendedUrl('/teams/settings'); + + await Auth.logout(); + await pumpEventQueue(); + + expect(MagicRouter.instance.hasIntendedUrl, isFalse); + }); + + test('survives the login it was recorded for', () async { + Auth.fake(); + await bootProvider(); + + // The whole point of the intent: a deep link lands on a signed-out + // device, the middleware records it, and the login that follows consumes + // it. Signing in bumps the same notifier a sign-out does, so a clear hung + // off the bump itself would eat the feature it protects. + MagicRouter.instance.setIntendedUrl('/incidents/1'); + + await Auth.login({'token': 't'}, _fakeUser()); + await pumpEventQueue(); + + expect(MagicRouter.instance.pullIntendedUrl(), '/incidents/1'); + }); }); } diff --git a/test/skill_reference_stamp_test.dart b/test/skill_reference_stamp_test.dart new file mode 100644 index 0000000..73e6bca --- /dev/null +++ b/test/skill_reference_stamp_test.dart @@ -0,0 +1,65 @@ +// The agent-facing reference for this package lives in ANOTHER repository. +// +// `.pubignore` excludes `CLAUDE.md` and `.claude/`, so an agent adopting this +// package from pub.dev never sees them. What it lands on is +// `magic/skills/magic-framework/references/plugin-starter.md`, which is +// versioned by `magic`'s releases rather than by this package's, and therefore +// drifts silently. Measured once: the reference was stamped alpha.23 against a +// shipped alpha.27 and still documented the notification UI that had moved out +// of this package entirely. +// +// This test is the cheapest gate that exists for a cross-repository document. +// It cannot run in CI, which clones no siblings, so it SKIPS there rather than +// failing. Releases are cut locally, which is where it fires, and +// `.claude/commands/release.md` carries the same requirement in prose for the +// case where somebody runs the suite somewhere else entirely. +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// The reference this package owns, in the sibling `magic` checkout. +const String _referencePath = + '../magic/skills/magic-framework/references/plugin-starter.md'; + +/// Reads `version:` out of this package's own manifest. +String _shippedVersion() { + final RegExp pattern = RegExp(r'^version:\s*(\S+)', multiLine: true); + final RegExpMatch? match = pattern.firstMatch( + File('pubspec.yaml').readAsStringSync(), + ); + + expect(match, isNotNull, reason: 'pubspec.yaml declares no version'); + + return match!.group(1)!; +} + +void main() { + test('the magic skill reference is stamped with the shipped version', () { + final File reference = File(_referencePath); + + if (!reference.existsSync()) { + // No sibling checkout. CI is the normal case here, and a hard failure + // would make every merge depend on a repository this one does not + // declare. + markTestSkipped( + 'no sibling magic checkout at $_referencePath, so the reference ' + 'stamp cannot be compared here', + ); + + return; + } + + final String version = _shippedVersion(); + final String firstLine = reference.readAsLinesSync().first; + + expect( + firstLine, + contains('magic_starter v$version'), + reason: + 'the reference an agent reads is stamped for a different version ' + 'than this package ships, which is how it came to document a ' + 'contract that no longer compiles. Update $_referencePath against ' + 'the current source, then move its stamp to v$version.', + ); + }); +}