-
Notifications
You must be signed in to change notification settings - Fork 0
feat: cross flow wallet auth #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
cyaiox
wants to merge
6
commits into
main
Choose a base branch
from
feat/at-cross-flow-1-wallet-handoff
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
58303b7
feat(@cross): Flow 1 client-to-web wallet handoff with device-pairing…
cyaiox aa3a6f0
refactor(@cross): rename to CrossWalletLogin* + align persistentDataPath
cyaiox 7b2f570
merge: pull main into feat/at-cross-flow-1-wallet-handoff
cyaiox 49f0434
feat(@cross): Flow 2 web-first signup → signed download → in-world id…
cyaiox c859c28
refactor(@cross): address review feedback — shared helpers, scoped co…
cyaiox 9e0557f
refactor(@cross): address re-review — fix stale CLAUDE.md spec name, …
cyaiox File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file was deleted.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| namespace ExplorerAutomation.Tests.Tests; | ||
|
|
||
| /// <summary> | ||
| /// Shared file-paths the cross-stack `@cross` flows use to communicate between | ||
| /// the Unity Explorer (writer) and the Playwright orchestrator (reader). Both | ||
| /// files live in Unity's `Application.persistentDataPath`, which resolves to | ||
| /// `~/Library/Application Support/Decentraland/Explorer/` on macOS — the same | ||
| /// directory the Unity client uses for its other runtime state (analytics | ||
| /// queue, sentry, profile cache, etc.). | ||
| /// | ||
| /// VERIFIED AT RUNTIME — the folder is "Explorer", NOT "Decentraland Unity | ||
| /// Explorer". The companion unity-explorer PR (#8862, branch | ||
| /// `chore/expose-requestid-for-cross-tests`, which adds the OpenUrl hook these | ||
| /// flows depend on) documents the macOS path as | ||
| /// `.../Decentraland/Decentraland Unity Explorer/auth-url.txt` in its | ||
| /// "Coordination required" prose, but that is a documentation slip: the PR | ||
| /// touches only `UnityAppWebBrowser.cs` (it does not rename `productName` in | ||
| /// `ProjectSettings.asset`), and the hook it adds writes the real | ||
| /// `auth-url.txt` to `.../Decentraland/Explorer/`. The on-disk file, plus the | ||
| /// sibling `com.Decentraland.Explorer` PlayerPrefs plist dir, confirm | ||
| /// `productName == "Explorer"` for this build. Do not "align" these constants | ||
| /// to the PR prose — that would point the pollers at a directory Unity never | ||
| /// writes to and every `@cross` flow would time out. | ||
| /// | ||
| /// TS-side mirror: `getAuthUrlPath()` + `getAuthVerificationCodePath()` in | ||
| /// `web/tests/auth/helpers/auth-request-bridge.ts` (which delegate to | ||
| /// `getCrossStackPath('explorer', …)` in `persistent-data-path.ts`). | ||
| /// </summary> | ||
| internal static class CrossPlatformPaths | ||
| { | ||
| /// <summary> | ||
| /// URL the Unity client opened for the wallet auth flow, written by the | ||
| /// `#if ALTTESTER` hook in `UnityAppWebBrowser.OpenUrl`. The Playwright | ||
| /// spec polls for this file's appearance and uses its contents to drive | ||
| /// its own browser to the same target. | ||
| /// </summary> | ||
| public static string AuthUrlPath => | ||
| Path.Combine( | ||
| Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), | ||
| "Library", "Application Support", "Decentraland", "Explorer", | ||
| "auth-url.txt"); | ||
|
|
||
| /// <summary> | ||
| /// Validation code surfaced on `Verification.Dapp.Screen`, written by | ||
| /// the C# <see cref="WalletLoginCodeRead"/> stage after the browser side | ||
| /// has reached `/auth/requests/<id>` (which triggers the auth-api | ||
| /// `request-validation-status` event that activates the Unity-side | ||
| /// screen). Playwright reads this and asserts it equals the code | ||
| /// rendered on the web RequestPage — the actual device-pairing safety | ||
| /// check end-users perform visually. | ||
| /// </summary> | ||
| public static string AuthVerificationCodePath => | ||
| Path.Combine( | ||
| Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), | ||
| "Library", "Application Support", "Decentraland", "Explorer", | ||
| "auth-verification-code.txt"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Stage 1 of the cross-stack wallet-login flow — fire the wallet auth flow | ||
| /// and confirm the client opened the system-browser URL (intercepted under | ||
| /// `#if ALTTESTER` to a file). Runs against a freshly-launched (logged-out) | ||
| /// Explorer. Extends <see cref="LoggedOutAuthBaseTest"/> so its | ||
| /// `EnsureInWorld` override lands the client at the LoginSelection screen | ||
| /// regardless of starting state. | ||
| /// | ||
| /// Clicks the Metamask button. The client opens a SocketIO websocket to | ||
| /// auth-api, posts a `dcl_personal_sign` request, receives back | ||
| /// `{ requestId, code }`, and calls `webBrowser.OpenUrl(...)`. In ALTTESTER | ||
| /// builds the URL is written to <see cref="CrossPlatformPaths.AuthUrlPath"/> | ||
| /// instead of opening the system browser. This fixture asserts the file | ||
| /// appears within a generous timeout — the Playwright spec then reads the | ||
| /// file, derives the requestId, and drives its own browser to the same | ||
| /// `/auth/requests/<id>` URL. | ||
| /// | ||
| /// We intentionally do NOT wait for `Verification.Dapp.Screen` here: that | ||
| /// screen only appears AFTER the server emits a `request-validation-status` | ||
| /// SocketIO event, which only happens once the browser side reaches the | ||
| /// requests page. Waiting on it would deadlock with the Playwright | ||
| /// orchestrator (which is itself waiting for this stage to complete). | ||
| /// Reading the code is the job of <see cref="WalletLoginCodeRead"/>. | ||
| /// </summary> | ||
| [AllureSuite("Wallet Login")] | ||
| [Category("CrossVerify")] | ||
| [Order(16)] | ||
| public class WalletLoginCapture : LoggedOutAuthBaseTest | ||
| { | ||
| [Test] | ||
| public void TestCaptureWalletAuthHandoff() | ||
| { | ||
| // Clean any stale URL file from a prior run. | ||
| if (File.Exists(CrossPlatformPaths.AuthUrlPath)) | ||
| File.Delete(CrossPlatformPaths.AuthUrlPath); | ||
|
|
||
| Assert.That(Views.AuthenticationMainScreen.LoginSelectionScreen.IsPresent(), Is.True, | ||
| "Expected the LoginSelection sub-screen after LoggedOutAuthBaseTest.EnsureInWorld."); | ||
|
|
||
| Views.AuthenticationMainScreen.MetamaskButton.Click(); | ||
| Reporter.Log("Metamask button clicked — waiting for auth-url.txt to appear"); | ||
|
|
||
| // Poll for the URL file. The UnityAppWebBrowser.OpenUrl hook writes | ||
| // it the moment Application.OpenURL would be called — typically 1-3 | ||
| // seconds after the click (auth-api websocket connect + dcl_personal_sign | ||
| // round trip). Generous 30s timeout for slow CI / cold caches. | ||
| var deadline = DateTime.UtcNow.AddSeconds(30); | ||
| while (DateTime.UtcNow < deadline && !File.Exists(CrossPlatformPaths.AuthUrlPath)) | ||
| { | ||
| Thread.Sleep(250); | ||
| } | ||
|
|
||
| Assert.That(File.Exists(CrossPlatformPaths.AuthUrlPath), Is.True, | ||
| $"Expected the OpenURL hook to write {CrossPlatformPaths.AuthUrlPath} within 30s. " + | ||
| "Either the click didn't trigger the auth flow, the auth-api request failed, " + | ||
| "or the running Explorer build doesn't have the ALTTESTER-gated OpenURL hook " + | ||
| "(needs unity-explorer branch `chore/expose-requestid-for-cross-tests` or later)."); | ||
|
|
||
| var url = File.ReadAllText(CrossPlatformPaths.AuthUrlPath).Trim(); | ||
| Assert.That(url, Does.StartWith("http"), | ||
| $"auth-url.txt should hold an http(s) URL, got: {url}"); | ||
| Reporter.Log($"Auth URL captured (Playwright will read this from disk): {url}"); | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Stage 2 of the cross-stack wallet-login flow — read the validation code | ||
| /// from Unity's `Verification.Dapp.Screen` once the auth-api has emitted the | ||
| /// `request-validation-status` event (i.e. once the browser side has reached | ||
| /// `/auth/requests/<id>`). Writes the code to | ||
| /// <see cref="CrossPlatformPaths.AuthVerificationCodePath"/> so Playwright can | ||
| /// read and compare it against the code rendered on the web RequestPage. | ||
| /// | ||
| /// This stage exists *because* the device-pairing safety check is the actual | ||
| /// product feature being tested: client and web display the same code, the | ||
| /// user verifies they match before approving. Skipping it (clicking Approve | ||
| /// immediately) makes the test green but doesn't validate the security | ||
| /// property real users rely on. | ||
| /// </summary> | ||
| [AllureSuite("Wallet Login")] | ||
| [Category("CrossVerify")] | ||
| [Order(17)] | ||
| public class WalletLoginCodeRead : LoggedOutAuthBaseTest | ||
| { | ||
| /// <summary> | ||
| /// EnsureInWorld override: when the dapp verification screen is active | ||
| /// (post-Metamask, post-browser-reach), neither the LoginSelection nor | ||
| /// the OtpVerification sub-screens are visible, and the cached-account | ||
| /// "Use a Different Account" button is missing — so the parent class's | ||
| /// fallback would throw. We just return without navigation: the test | ||
| /// expects the verification screen to be already present, and any | ||
| /// state-recovery would defeat the purpose of reading the code on it. | ||
| /// </summary> | ||
| protected override void EnsureInWorld() | ||
| { | ||
| // No-op: the orchestrator (Playwright spec) ensures the verification | ||
| // screen is up before invoking this stage. | ||
| } | ||
|
|
||
| [Test] | ||
| public void TestReadVerificationCode() | ||
| { | ||
| // Clean any stale file before writing. | ||
| if (File.Exists(CrossPlatformPaths.AuthVerificationCodePath)) | ||
| File.Delete(CrossPlatformPaths.AuthVerificationCodePath); | ||
|
|
||
| // Wait for the verification screen to surface its code. The screen | ||
| // appears once auth-api sends "request-validation-status" — which | ||
| // requires the browser side to be on /auth/requests/<id>. 60s timeout | ||
| // covers the browser navigation + auth-api round-trip. | ||
| var code = Views.VerificationDappAuthScreen.CodeText.GetText(60); | ||
| Assert.That(code, Is.Not.Empty.And.Matches("^[0-9]+$"), | ||
| "Expected Verification.Dapp.Screen Code element to render a numeric code"); | ||
| Reporter.Log($"Unity-side verification code: {code}"); | ||
|
|
||
| Directory.CreateDirectory(Path.GetDirectoryName(CrossPlatformPaths.AuthVerificationCodePath)!); | ||
| File.WriteAllText(CrossPlatformPaths.AuthVerificationCodePath, code); | ||
| Reporter.Log($"Wrote code to {CrossPlatformPaths.AuthVerificationCodePath}"); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| namespace ExplorerAutomation.Tests.Tests; | ||
|
|
||
| /// <summary> | ||
| /// Convergence stage for the wallet-login-with-emote scenario. Shelled out | ||
| /// by the Playwright orchestrator (`explorer-wallet-login.spec.ts`) via | ||
| /// `runExplorerTest('TestInWorldAndRunEmote')` once the client has accepted | ||
| /// the device-pairing approval and transitioned in-world. Asserts the | ||
| /// player-facing HUD is up and exercises a Fist Pump emote — catches | ||
| /// regressions in both the auth handshake and the post-login in-world | ||
| /// experience. | ||
| /// | ||
| /// Inherits from <see cref="BaseTest"/> so its `OneTimeSetUp` waits through | ||
| /// splash → loading → main menu (handles the post-sign-in transition). | ||
| /// </summary> | ||
| [AllureSuite("Wallet Login")] | ||
| [Category("CrossVerify")] | ||
| [Order(18)] | ||
| public class WalletLoginInWorldEmote : BaseTest | ||
| { | ||
| [Test] | ||
| public void TestInWorldAndRunEmote() | ||
| { | ||
| Assert.That(Views.MainMenu.IsPresent(), Is.True, | ||
| "Main menu (sidebar) should be visible after the wallet-login handoff completes."); | ||
|
|
||
| // Equip Fist Pump to slot 0 — mirrors BackpackEmotesTests.TestSearchAndEquipFistPump | ||
| // verbatim, then triggers the equipped emote with the slot-0 hotkey (`1`). | ||
| PressKey(AltKeyCode.I); | ||
| Views.ExplorePanel.WaitFor(); | ||
| Views.ExplorePanel.Backpack.EmotesTabButton.Click(); | ||
|
|
||
| Views.ExplorePanel.Backpack.SearchBar.SetText("Fist Pump"); | ||
| Wait(2); | ||
|
|
||
| Views.ExplorePanel.Backpack.Emotes.UnequipEmoteIfPresent(0); | ||
| Views.ExplorePanel.Backpack.Emotes.SetEmote(0, 0); | ||
| Reporter.Log("Fist Pump equipped to slot 0"); | ||
|
|
||
| Views.ExplorePanel.Close(); | ||
| Views.ExplorePanel.WaitForGone(); | ||
|
|
||
| // Trigger slot 0. The avatar animation is not observable via AltTester reliably, | ||
| // so the assertion is implicit: the hotkey doesn't throw, the backpack stays | ||
| // closed, the client stays connected. | ||
| PressKey(AltKeyCode.Alpha1); | ||
| Wait(3); | ||
| Reporter.Log("Slot 0 emote triggered"); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| namespace ExplorerAutomation.Tests.Tests; | ||
|
|
||
| /// <summary> | ||
| /// Shared file path used by the cross-stack web-first signup flow (Flow 2) | ||
| /// to communicate the expected in-world username from the Playwright | ||
| /// orchestrator (writer) to this C# fixture (reader). | ||
| /// | ||
| /// Lives in `~/Library/Application Support/DecentralandLauncherLight/` — | ||
| /// the same directory the desktop launcher uses for its own state and | ||
| /// where it consumes `auth-token-bridge.txt`. Keeping all cross-stack | ||
| /// communication files in one well-known dir mirrors what Flow 1 does | ||
| /// with the Explorer's `Application.persistentDataPath` for its own | ||
| /// `auth-url.txt` / `auth-verification-code.txt` pair. | ||
| /// | ||
| /// TS-side mirror: `getExpectedUsernamePath()` / | ||
| /// `writeExpectedUsername()` in | ||
| /// `web/tests/auth/helpers/token-bridge.ts`. | ||
| /// </summary> | ||
| internal static class CrossWebFirstLoginPaths | ||
| { | ||
| public static string ExpectedUsernamePath => | ||
| Path.Combine( | ||
| Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), | ||
| "Library", "Application Support", "DecentralandLauncherLight", | ||
| "expected-username.txt"); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Flow 2 — Stage 5a, identity guard. The launcher consumes | ||
| /// <c>auth-token-bridge.txt</c> on startup and the Explorer authenticates | ||
| /// against catalysts. This fixture reads the expected username the | ||
| /// Playwright spec wrote to <see cref="CrossWebFirstLoginPaths.ExpectedUsernamePath"/> | ||
| /// before launching the client, polls until the chat-panel titlebar's | ||
| /// <c>[TXT] UserName</c> label matches it (the catalyst profile fetch is | ||
| /// asynchronous; the label updates only after auth + profile resolve), and | ||
| /// fails fast with diagnostic output on timeout. | ||
| /// | ||
| /// The assertion is load-bearing: a raw "Explorer reached in-world" alone | ||
| /// can pass on a launcher that silently consumed a stale bridge file or | ||
| /// booted a profile cache from a prior run with a different identity. Both | ||
| /// manifest as "main menu visible + emote plays" with the wrong account, | ||
| /// masking real auth-handoff regressions. Comparing the displayed username | ||
| /// to the QuickSetup username is the cheap, deterministic way to catch | ||
| /// that class of cache-pollution bugs. | ||
| /// | ||
| /// Inherits from <see cref="BaseTest"/> but overrides | ||
| /// <see cref="BaseTest.EnsureInWorld"/> to avoid the default implementation's | ||
| /// immediate-click on <c>JumpIntoWorldButton</c> when it sees the auth | ||
| /// screen — that would ride past whatever state we need to read. Our | ||
| /// override only waits for splash to clear; the test body owns the polling. | ||
| /// | ||
| /// The subsequent <c>WalletLoginInWorldEmote</c> fixture (Order 18, separate | ||
| /// <c>dotnet test</c> invocation) inherits the standard <c>EnsureInWorld</c> | ||
| /// which clicks Jump and rides to in-world if needed. | ||
| /// </summary> | ||
| [AllureSuite("Web-First Login")] | ||
| [Category("CrossVerify")] | ||
| [Order(17)] | ||
| public class WebFirstLoginUsernameAssert : BaseTest | ||
| { | ||
| /// <summary> | ||
| /// Wait for splash only; defer screen-state assertions to the [Test] | ||
| /// body. We can't pin to a single screen here because Flow 2's | ||
| /// token-bridge boot can legitimately land on either the cached-account | ||
| /// screen ("Welcome <name>" + JumpIntoWorld button) OR auto-ride | ||
| /// past it depending on launcher behavior and account state. | ||
| /// </summary> | ||
| protected override void EnsureInWorld() | ||
| { | ||
| if (Views.SplashScreen.IsPresent()) | ||
| { | ||
| Reporter.Log("Splash screen detected — waiting for it to clear"); | ||
| Views.SplashScreen.WaitForGone(60); | ||
| } | ||
| } | ||
|
|
||
| [Test] | ||
| public void TestInWorldUsernameMatches() | ||
| { | ||
| Assert.That(File.Exists(CrossWebFirstLoginPaths.ExpectedUsernamePath), Is.True, | ||
| $"Expected the Playwright orchestrator to write the expected username to " + | ||
| $"{CrossWebFirstLoginPaths.ExpectedUsernamePath} before launching the client. " + | ||
| "Without it, this fixture has nothing to compare against."); | ||
|
|
||
| var expected = File.ReadAllText(CrossWebFirstLoginPaths.ExpectedUsernamePath).Trim(); | ||
| Assert.That(expected, Is.Not.Empty, | ||
| "expected-username.txt was written but is empty."); | ||
|
|
||
| // The Lobby.ExistingAccount.Screen `Title` element renders either | ||
| // "Welcome <username>" (first-launch / new account just minted via | ||
| // the auth-token-bridge) or "Welcome back <username>" (returning | ||
| // account whose profile already exists on catalysts). Both forms | ||
| // are valid evidence the bridge token landed on the right identity. | ||
| // Catalyst profile fetch is asynchronous, so the label only | ||
| // resolves once the profile data is back — 180s covers cold-cache | ||
| // lookups for brand-new accounts on CI. | ||
| var newUserGreeting = $"Welcome {expected}"; | ||
| var returningGreeting = $"Welcome back {expected}"; | ||
| var deadline = DateTime.UtcNow.AddSeconds(180); | ||
| string lastSeen = null; | ||
| while (DateTime.UtcNow < deadline) | ||
| { | ||
| try { lastSeen = Views.AuthenticationMainScreen.UsernameLabel.GetText(2); } | ||
| catch { /* element not yet in scene — keep polling */ } | ||
|
|
||
| var trimmed = lastSeen?.Trim(); | ||
| if (string.Equals(trimmed, newUserGreeting, StringComparison.Ordinal) || | ||
| string.Equals(trimmed, returningGreeting, StringComparison.Ordinal)) | ||
| { | ||
| Reporter.Log($"Welcome-screen greeting matched '{trimmed}'"); | ||
| return; | ||
| } | ||
| Thread.Sleep(1000); | ||
| } | ||
|
|
||
| throw new AssertionException( | ||
| $"Welcome-screen greeting was '{lastSeen}', expected '{newUserGreeting}' or " + | ||
| $"'{returningGreeting}' within 180s. " + | ||
| "Likely causes: (a) the launcher consumed a stale auth-token-bridge.txt from a " + | ||
| "prior run, (b) the Explorer's Thirdweb identity cache shadowed the bridge token " + | ||
| "(spec should clearExplorerIdentityCache + launch Explorer freshly), " + | ||
| "(c) the dapp minted a download URL that didn't carry the just-signed-up identity, " + | ||
| "or (d) catalyst profile resolution is slow / failing. " + | ||
| "Run DiagnoseAuthScreenUsernameLabel to inspect what's actually rendered."); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P1] The path components
"Decentraland", "Explorer"may not match Unity's actualApplication.persistentDataPath, which usescompanyName/productNamefromProjectSettings.asset. PR #8862's description states the macOS path is~/Library/Application Support/Decentraland/Decentraland Unity Explorer/. If the product name is indeed"Decentraland Unity Explorer"(not just"Explorer"), this path will never find the file Unity writes.Please verify by logging
Application.persistentDataPathat runtime and aligning this constant.