w3wallets 1.0.0-beta.8 → 1.0.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -2,10 +2,16 @@ import * as playwright_test from 'playwright/test';
2
2
  import { Page, test, BrowserContext } from '@playwright/test';
3
3
 
4
4
  interface IWallet {
5
- readonly page: Page;
5
+ page: Page;
6
6
  gotoOnboardPage(): Promise<void>;
7
7
  approve(): Promise<void>;
8
8
  deny(): Promise<void>;
9
+ /**
10
+ * Called by the cache builder after setupFn completes, before the browser
11
+ * context is closed. Wallet implementations can override this to force
12
+ * persistence of in-memory state (e.g., locking the vault).
13
+ */
14
+ beforeCacheClose?(): Promise<void>;
9
15
  }
10
16
  /**
11
17
  * Configuration object for a wallet.
@@ -78,7 +84,7 @@ declare function withWallets<const T extends readonly WalletConfig[]>(test: type
78
84
  }, playwright_test.PlaywrightWorkerArgs & playwright_test.PlaywrightWorkerOptions>;
79
85
 
80
86
  declare abstract class Wallet implements IWallet {
81
- readonly page: Page;
87
+ page: Page;
82
88
  readonly extensionId: string;
83
89
  constructor(page: Page, extensionId: string);
84
90
  abstract gotoOnboardPage(): Promise<void>;
@@ -121,10 +127,22 @@ declare class Metamask extends Wallet {
121
127
  * confirmation-cancel-button is available.
122
128
  */
123
129
  private dismissQueuedNotifications;
130
+ /**
131
+ * Close extra MetaMask extension pages that the service worker opened
132
+ * during onboarding (e.g. home.html#/onboarding/completion).
133
+ * Keeps only this.page alive.
134
+ */
135
+ private closeExtraExtensionPages;
136
+ /**
137
+ * Poll the URL until it leaves a confirmation route. Uses polling instead
138
+ * of waitForURL because MetaMask uses HashRouter and hash-only changes
139
+ * are not reliably caught by Playwright's navigation events.
140
+ */
141
+ private waitForConfirmRouteExit;
124
142
  /**
125
143
  * Wait for a target button while handling the Transaction Shield popup.
126
- * Always navigates to sidepanel.html fresh so MetaMask's
127
- * ConfirmationHandler can route to the pending approval.
144
+ * Navigates to sidepanel.html so MetaMask's ConfirmationHandler routes
145
+ * to the pending approval, then waits for the button to become visible.
128
146
  */
129
147
  private waitAndClickButton;
130
148
  approve(): Promise<void>;
@@ -133,6 +151,12 @@ declare class Metamask extends Wallet {
133
151
  * Lock the MetaMask wallet
134
152
  */
135
153
  lock(): Promise<void>;
154
+ /**
155
+ * Force-persist the vault by locking the wallet. Called by the cache builder
156
+ * before closing the browser context. MetaMask MV3 stores the vault lazily —
157
+ * locking forces the encrypted vault to be written to chrome.storage.local.
158
+ */
159
+ beforeCacheClose(): Promise<void>;
136
160
  /**
137
161
  * Unlock MetaMask with password.
138
162
  * After unlocking, stabilizes the wallet UI by handling post-unlock
package/dist/index.d.ts CHANGED
@@ -2,10 +2,16 @@ import * as playwright_test from 'playwright/test';
2
2
  import { Page, test, BrowserContext } from '@playwright/test';
3
3
 
4
4
  interface IWallet {
5
- readonly page: Page;
5
+ page: Page;
6
6
  gotoOnboardPage(): Promise<void>;
7
7
  approve(): Promise<void>;
8
8
  deny(): Promise<void>;
9
+ /**
10
+ * Called by the cache builder after setupFn completes, before the browser
11
+ * context is closed. Wallet implementations can override this to force
12
+ * persistence of in-memory state (e.g., locking the vault).
13
+ */
14
+ beforeCacheClose?(): Promise<void>;
9
15
  }
10
16
  /**
11
17
  * Configuration object for a wallet.
@@ -78,7 +84,7 @@ declare function withWallets<const T extends readonly WalletConfig[]>(test: type
78
84
  }, playwright_test.PlaywrightWorkerArgs & playwright_test.PlaywrightWorkerOptions>;
79
85
 
80
86
  declare abstract class Wallet implements IWallet {
81
- readonly page: Page;
87
+ page: Page;
82
88
  readonly extensionId: string;
83
89
  constructor(page: Page, extensionId: string);
84
90
  abstract gotoOnboardPage(): Promise<void>;
@@ -121,10 +127,22 @@ declare class Metamask extends Wallet {
121
127
  * confirmation-cancel-button is available.
122
128
  */
123
129
  private dismissQueuedNotifications;
130
+ /**
131
+ * Close extra MetaMask extension pages that the service worker opened
132
+ * during onboarding (e.g. home.html#/onboarding/completion).
133
+ * Keeps only this.page alive.
134
+ */
135
+ private closeExtraExtensionPages;
136
+ /**
137
+ * Poll the URL until it leaves a confirmation route. Uses polling instead
138
+ * of waitForURL because MetaMask uses HashRouter and hash-only changes
139
+ * are not reliably caught by Playwright's navigation events.
140
+ */
141
+ private waitForConfirmRouteExit;
124
142
  /**
125
143
  * Wait for a target button while handling the Transaction Shield popup.
126
- * Always navigates to sidepanel.html fresh so MetaMask's
127
- * ConfirmationHandler can route to the pending approval.
144
+ * Navigates to sidepanel.html so MetaMask's ConfirmationHandler routes
145
+ * to the pending approval, then waits for the button to become visible.
128
146
  */
129
147
  private waitAndClickButton;
130
148
  approve(): Promise<void>;
@@ -133,6 +151,12 @@ declare class Metamask extends Wallet {
133
151
  * Lock the MetaMask wallet
134
152
  */
135
153
  lock(): Promise<void>;
154
+ /**
155
+ * Force-persist the vault by locking the wallet. Called by the cache builder
156
+ * before closing the browser context. MetaMask MV3 stores the vault lazily —
157
+ * locking forces the encrypted vault to be written to chrome.storage.local.
158
+ */
159
+ beforeCacheClose(): Promise<void>;
136
160
  /**
137
161
  * Unlock MetaMask with password.
138
162
  * After unlocking, stabilizes the wallet UI by handling post-unlock
package/dist/index.js CHANGED
@@ -104,8 +104,6 @@ var LAST_RESORT_CLICK_TIMEOUT = 1e4;
104
104
  var LOCK_SCREEN_TIMEOUT = 3e4;
105
105
  var MENU_BUTTON_TIMEOUT = 3e4;
106
106
  var ONBOARD_VISIBLE_TIMEOUT = 3e4;
107
- var ROUTE_RETRY_TIMEOUT = 5e3;
108
- var MAX_ROUTE_ATTEMPTS = 5;
109
107
  var MNEMONIC_KEY_DELAY = 5;
110
108
  var MNEMONIC_WORD_DELAY = 100;
111
109
 
@@ -399,9 +397,28 @@ var Metamask = class extends Wallet {
399
397
  });
400
398
  await openWalletBtn.click();
401
399
  await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
400
+ const readyIndicator = this.page.getByTestId("account-options-menu-button");
401
+ const lockInput = this.page.getByTestId("unlock-password");
402
+ const state = await Promise.race([
403
+ readyIndicator.waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }).then(() => "ready"),
404
+ lockInput.waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }).then(() => "locked")
405
+ ]).catch(() => "timeout");
406
+ debug(`metamask.onboard: post-navigate state=${state}`);
407
+ if (state === "locked") {
408
+ debug("metamask.onboard: vault locked after onboarding, auto-unlocking");
409
+ await lockInput.fill(password ?? this.defaultPassword);
410
+ await this.page.getByTestId("unlock-submit").click();
411
+ await this.page.waitForSelector('[data-testid="unlock-password"]', {
412
+ state: "hidden",
413
+ timeout: LOCK_SCREEN_TIMEOUT
414
+ });
415
+ await this.stabilizePostUnlock();
416
+ }
417
+ await this.closeExtraExtensionPages();
402
418
  await this.page.goto(
403
419
  `chrome-extension://${this.extensionId}/sidepanel.html`
404
420
  );
421
+ await this.page.waitForLoadState("networkidle");
405
422
  debug("metamask.onboard: complete");
406
423
  }
407
424
  /**
@@ -517,10 +534,37 @@ var Metamask = class extends Wallet {
517
534
  }
518
535
  await (0, import_test3.expect)(readyIndicator).toBeVisible({ timeout: POST_UNLOCK_TIMEOUT });
519
536
  }
537
+ /**
538
+ * Close extra MetaMask extension pages that the service worker opened
539
+ * during onboarding (e.g. home.html#/onboarding/completion).
540
+ * Keeps only this.page alive.
541
+ */
542
+ async closeExtraExtensionPages() {
543
+ const extOrigin = `chrome-extension://${this.extensionId}`;
544
+ const context = this.page.context();
545
+ const extras = context.pages().filter((p) => p !== this.page && p.url().startsWith(extOrigin));
546
+ if (extras.length > 0) {
547
+ debug(
548
+ `metamask.closeExtraExtensionPages: closing ${extras.length} extra page(s)`
549
+ );
550
+ await Promise.all(extras.map((p) => p.close()));
551
+ }
552
+ }
553
+ /**
554
+ * Poll the URL until it leaves a confirmation route. Uses polling instead
555
+ * of waitForURL because MetaMask uses HashRouter and hash-only changes
556
+ * are not reliably caught by Playwright's navigation events.
557
+ */
558
+ async waitForConfirmRouteExit(routePattern) {
559
+ const deadline = Date.now() + POST_CLICK_TIMEOUT;
560
+ while (routePattern.test(this.page.url()) && Date.now() < deadline) {
561
+ await this.page.waitForTimeout(500);
562
+ }
563
+ }
520
564
  /**
521
565
  * Wait for a target button while handling the Transaction Shield popup.
522
- * Always navigates to sidepanel.html fresh so MetaMask's
523
- * ConfirmationHandler can route to the pending approval.
566
+ * Navigates to sidepanel.html so MetaMask's ConfirmationHandler routes
567
+ * to the pending approval, then waits for the button to become visible.
524
568
  */
525
569
  async waitAndClickButton(btnLocator) {
526
570
  debug(`metamask.waitAndClickButton: navigating to sidepanel`);
@@ -531,63 +575,36 @@ var Metamask = class extends Wallet {
531
575
  btnLocator.first().waitFor({ state: "visible", timeout }).then(() => "button"),
532
576
  popup.first().waitFor({ state: "visible", timeout }).then(() => "popup")
533
577
  ]).catch(() => "timeout");
534
- const handlePopupAndClick = async () => {
535
- await this.dismissPopups();
536
- await btnLocator.first().waitFor({ state: "visible", timeout: BUTTON_OR_POPUP_TIMEOUT });
537
- await btnLocator.first().click();
538
- };
539
- let routeFound = false;
540
- for (let attempt = 0; attempt < MAX_ROUTE_ATTEMPTS; attempt++) {
541
- await this.page.goto(sidepanelUrl);
542
- try {
543
- await this.page.waitForURL(confirmRoutePattern, {
544
- timeout: ROUTE_RETRY_TIMEOUT
545
- });
546
- routeFound = true;
547
- break;
548
- } catch {
549
- debug(
550
- `metamask.waitAndClickButton: route attempt ${attempt + 1}/${MAX_ROUTE_ATTEMPTS} failed. URL: ${this.page.url()}`
551
- );
552
- }
553
- }
554
- if (!routeFound) {
555
- console.warn(
556
- `[w3wallets] confirmation route not found after ${MAX_ROUTE_ATTEMPTS} attempts. URL: ${this.page.url()}`
578
+ await this.page.goto(sidepanelUrl);
579
+ let result = await waitForButtonOrPopup(BUTTON_OR_POPUP_TIMEOUT);
580
+ if (result === "timeout") {
581
+ debug(
582
+ `metamask.waitAndClickButton: first wait timed out, reloading. URL: ${this.page.url()}`
557
583
  );
584
+ await this.page.goto(sidepanelUrl);
585
+ result = await waitForButtonOrPopup(BUTTON_OR_POPUP_TIMEOUT);
558
586
  }
559
- const result = await waitForButtonOrPopup(BUTTON_OR_POPUP_TIMEOUT);
560
587
  debug(
561
588
  `metamask.waitAndClickButton: result=${result}, URL=${this.page.url()}`
562
589
  );
563
590
  if (result === "button") {
564
591
  await btnLocator.first().click();
565
- await this.page.waitForURL((url) => !confirmRoutePattern.test(url.toString()), {
566
- timeout: POST_CLICK_TIMEOUT
567
- }).catch(() => {
568
- console.warn(
569
- `[w3wallets] still on confirmation route after click. URL: ${this.page.url()}`
570
- );
571
- });
592
+ await this.waitForConfirmRouteExit(confirmRoutePattern);
572
593
  return;
573
594
  }
574
595
  if (result === "popup") {
575
- await handlePopupAndClick();
576
- await this.page.waitForURL((url) => !confirmRoutePattern.test(url.toString()), {
577
- timeout: POST_CLICK_TIMEOUT
578
- }).catch(() => {
579
- console.warn(
580
- `[w3wallets] still on confirmation route after popup dismiss. URL: ${this.page.url()}`
581
- );
582
- });
596
+ await this.dismissPopups();
597
+ await btnLocator.first().waitFor({ state: "visible", timeout: BUTTON_OR_POPUP_TIMEOUT });
598
+ await btnLocator.first().click();
599
+ await this.waitForConfirmRouteExit(confirmRoutePattern);
583
600
  return;
584
601
  }
585
602
  const isOnConfirmRoute = confirmRoutePattern.test(this.page.url());
586
603
  debug(
587
- `metamask.waitAndClickButton: timeout after ${BUTTON_OR_POPUP_TIMEOUT}ms. URL: ${this.page.url()}, onConfirmRoute: ${isOnConfirmRoute}`
604
+ `metamask.waitAndClickButton: timeout. URL: ${this.page.url()}, onConfirmRoute: ${isOnConfirmRoute}`
588
605
  );
589
606
  console.warn(
590
- `[w3wallets] no button or popup found after ${BUTTON_OR_POPUP_TIMEOUT / 1e3}s. URL: ${this.page.url()}`
607
+ `[w3wallets] no button or popup found after waiting. URL: ${this.page.url()}`
591
608
  );
592
609
  await btnLocator.first().click({ timeout: LAST_RESORT_CLICK_TIMEOUT });
593
610
  }
@@ -612,6 +629,46 @@ var Metamask = class extends Wallet {
612
629
  await menuBtn.click({ force: true });
613
630
  await this.page.locator("text=Log out").click();
614
631
  }
632
+ /**
633
+ * Force-persist the vault by locking the wallet. Called by the cache builder
634
+ * before closing the browser context. MetaMask MV3 stores the vault lazily —
635
+ * locking forces the encrypted vault to be written to chrome.storage.local.
636
+ */
637
+ async beforeCacheClose() {
638
+ debug("metamask.beforeCacheClose: locking wallet to force vault persistence");
639
+ await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
640
+ const menuBtn = this.page.getByTestId("account-options-menu-button");
641
+ const lockInput = this.page.getByTestId("unlock-password");
642
+ const state = await Promise.race([
643
+ menuBtn.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT }).then(() => "ready"),
644
+ lockInput.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT }).then(() => "locked")
645
+ ]).catch(() => "timeout");
646
+ debug(`metamask.beforeCacheClose: state=${state}`);
647
+ if (state === "ready") {
648
+ await this.lock();
649
+ await lockInput.waitFor({ state: "visible", timeout: LOCK_SCREEN_TIMEOUT }).catch(() => {
650
+ });
651
+ } else if (state === "timeout") {
652
+ debug("metamask.beforeCacheClose: forcing state sync via new UI connection");
653
+ const syncPage = await this.page.context().newPage();
654
+ await syncPage.goto(`chrome-extension://${this.extensionId}/home.html`);
655
+ await syncPage.waitForLoadState("networkidle");
656
+ const syncMenuBtn = syncPage.getByTestId("account-options-menu-button");
657
+ const syncLockInput = syncPage.getByTestId("unlock-password");
658
+ const syncState = await Promise.race([
659
+ syncMenuBtn.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT }).then(() => "ready"),
660
+ syncLockInput.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT }).then(() => "locked")
661
+ ]).catch(() => "timeout");
662
+ debug(`metamask.beforeCacheClose: syncPage state=${syncState}`);
663
+ if (syncState === "ready") {
664
+ await syncMenuBtn.click({ force: true });
665
+ await syncPage.locator("text=Log out").click();
666
+ await syncLockInput.waitFor({ state: "visible", timeout: LOCK_SCREEN_TIMEOUT }).catch(() => {
667
+ });
668
+ }
669
+ await syncPage.close();
670
+ }
671
+ }
615
672
  /**
616
673
  * Unlock MetaMask with password.
617
674
  * After unlocking, stabilizes the wallet UI by handling post-unlock
@@ -622,13 +679,21 @@ var Metamask = class extends Wallet {
622
679
  debug("metamask.unlock: starting");
623
680
  const pwd = password ?? this.defaultPassword;
624
681
  await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
625
- const passwordInput = this.page.getByTestId("unlock-password");
626
- await passwordInput.fill(pwd);
627
- await this.page.getByTestId("unlock-submit").click();
628
- await this.page.waitForSelector('[data-testid="unlock-password"]', {
629
- state: "hidden",
630
- timeout: LOCK_SCREEN_TIMEOUT
631
- });
682
+ const lockInput = this.page.getByTestId("unlock-password");
683
+ const readyIndicator = this.page.getByTestId("account-options-menu-button");
684
+ const state = await Promise.race([
685
+ lockInput.waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }).then(() => "locked"),
686
+ readyIndicator.waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }).then(() => "ready")
687
+ ]).catch(() => "timeout");
688
+ debug(`metamask.unlock: state=${state}`);
689
+ if (state === "locked") {
690
+ await lockInput.fill(pwd);
691
+ await this.page.getByTestId("unlock-submit").click();
692
+ await this.page.waitForSelector('[data-testid="unlock-password"]', {
693
+ state: "hidden",
694
+ timeout: LOCK_SCREEN_TIMEOUT
695
+ });
696
+ }
632
697
  await this.stabilizePostUnlock();
633
698
  debug("metamask.unlock: complete");
634
699
  }
package/dist/index.mjs CHANGED
@@ -68,8 +68,6 @@ var LAST_RESORT_CLICK_TIMEOUT = 1e4;
68
68
  var LOCK_SCREEN_TIMEOUT = 3e4;
69
69
  var MENU_BUTTON_TIMEOUT = 3e4;
70
70
  var ONBOARD_VISIBLE_TIMEOUT = 3e4;
71
- var ROUTE_RETRY_TIMEOUT = 5e3;
72
- var MAX_ROUTE_ATTEMPTS = 5;
73
71
  var MNEMONIC_KEY_DELAY = 5;
74
72
  var MNEMONIC_WORD_DELAY = 100;
75
73
 
@@ -363,9 +361,28 @@ var Metamask = class extends Wallet {
363
361
  });
364
362
  await openWalletBtn.click();
365
363
  await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
364
+ const readyIndicator = this.page.getByTestId("account-options-menu-button");
365
+ const lockInput = this.page.getByTestId("unlock-password");
366
+ const state = await Promise.race([
367
+ readyIndicator.waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }).then(() => "ready"),
368
+ lockInput.waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }).then(() => "locked")
369
+ ]).catch(() => "timeout");
370
+ debug(`metamask.onboard: post-navigate state=${state}`);
371
+ if (state === "locked") {
372
+ debug("metamask.onboard: vault locked after onboarding, auto-unlocking");
373
+ await lockInput.fill(password ?? this.defaultPassword);
374
+ await this.page.getByTestId("unlock-submit").click();
375
+ await this.page.waitForSelector('[data-testid="unlock-password"]', {
376
+ state: "hidden",
377
+ timeout: LOCK_SCREEN_TIMEOUT
378
+ });
379
+ await this.stabilizePostUnlock();
380
+ }
381
+ await this.closeExtraExtensionPages();
366
382
  await this.page.goto(
367
383
  `chrome-extension://${this.extensionId}/sidepanel.html`
368
384
  );
385
+ await this.page.waitForLoadState("networkidle");
369
386
  debug("metamask.onboard: complete");
370
387
  }
371
388
  /**
@@ -481,10 +498,37 @@ var Metamask = class extends Wallet {
481
498
  }
482
499
  await expect(readyIndicator).toBeVisible({ timeout: POST_UNLOCK_TIMEOUT });
483
500
  }
501
+ /**
502
+ * Close extra MetaMask extension pages that the service worker opened
503
+ * during onboarding (e.g. home.html#/onboarding/completion).
504
+ * Keeps only this.page alive.
505
+ */
506
+ async closeExtraExtensionPages() {
507
+ const extOrigin = `chrome-extension://${this.extensionId}`;
508
+ const context = this.page.context();
509
+ const extras = context.pages().filter((p) => p !== this.page && p.url().startsWith(extOrigin));
510
+ if (extras.length > 0) {
511
+ debug(
512
+ `metamask.closeExtraExtensionPages: closing ${extras.length} extra page(s)`
513
+ );
514
+ await Promise.all(extras.map((p) => p.close()));
515
+ }
516
+ }
517
+ /**
518
+ * Poll the URL until it leaves a confirmation route. Uses polling instead
519
+ * of waitForURL because MetaMask uses HashRouter and hash-only changes
520
+ * are not reliably caught by Playwright's navigation events.
521
+ */
522
+ async waitForConfirmRouteExit(routePattern) {
523
+ const deadline = Date.now() + POST_CLICK_TIMEOUT;
524
+ while (routePattern.test(this.page.url()) && Date.now() < deadline) {
525
+ await this.page.waitForTimeout(500);
526
+ }
527
+ }
484
528
  /**
485
529
  * Wait for a target button while handling the Transaction Shield popup.
486
- * Always navigates to sidepanel.html fresh so MetaMask's
487
- * ConfirmationHandler can route to the pending approval.
530
+ * Navigates to sidepanel.html so MetaMask's ConfirmationHandler routes
531
+ * to the pending approval, then waits for the button to become visible.
488
532
  */
489
533
  async waitAndClickButton(btnLocator) {
490
534
  debug(`metamask.waitAndClickButton: navigating to sidepanel`);
@@ -495,63 +539,36 @@ var Metamask = class extends Wallet {
495
539
  btnLocator.first().waitFor({ state: "visible", timeout }).then(() => "button"),
496
540
  popup.first().waitFor({ state: "visible", timeout }).then(() => "popup")
497
541
  ]).catch(() => "timeout");
498
- const handlePopupAndClick = async () => {
499
- await this.dismissPopups();
500
- await btnLocator.first().waitFor({ state: "visible", timeout: BUTTON_OR_POPUP_TIMEOUT });
501
- await btnLocator.first().click();
502
- };
503
- let routeFound = false;
504
- for (let attempt = 0; attempt < MAX_ROUTE_ATTEMPTS; attempt++) {
505
- await this.page.goto(sidepanelUrl);
506
- try {
507
- await this.page.waitForURL(confirmRoutePattern, {
508
- timeout: ROUTE_RETRY_TIMEOUT
509
- });
510
- routeFound = true;
511
- break;
512
- } catch {
513
- debug(
514
- `metamask.waitAndClickButton: route attempt ${attempt + 1}/${MAX_ROUTE_ATTEMPTS} failed. URL: ${this.page.url()}`
515
- );
516
- }
517
- }
518
- if (!routeFound) {
519
- console.warn(
520
- `[w3wallets] confirmation route not found after ${MAX_ROUTE_ATTEMPTS} attempts. URL: ${this.page.url()}`
542
+ await this.page.goto(sidepanelUrl);
543
+ let result = await waitForButtonOrPopup(BUTTON_OR_POPUP_TIMEOUT);
544
+ if (result === "timeout") {
545
+ debug(
546
+ `metamask.waitAndClickButton: first wait timed out, reloading. URL: ${this.page.url()}`
521
547
  );
548
+ await this.page.goto(sidepanelUrl);
549
+ result = await waitForButtonOrPopup(BUTTON_OR_POPUP_TIMEOUT);
522
550
  }
523
- const result = await waitForButtonOrPopup(BUTTON_OR_POPUP_TIMEOUT);
524
551
  debug(
525
552
  `metamask.waitAndClickButton: result=${result}, URL=${this.page.url()}`
526
553
  );
527
554
  if (result === "button") {
528
555
  await btnLocator.first().click();
529
- await this.page.waitForURL((url) => !confirmRoutePattern.test(url.toString()), {
530
- timeout: POST_CLICK_TIMEOUT
531
- }).catch(() => {
532
- console.warn(
533
- `[w3wallets] still on confirmation route after click. URL: ${this.page.url()}`
534
- );
535
- });
556
+ await this.waitForConfirmRouteExit(confirmRoutePattern);
536
557
  return;
537
558
  }
538
559
  if (result === "popup") {
539
- await handlePopupAndClick();
540
- await this.page.waitForURL((url) => !confirmRoutePattern.test(url.toString()), {
541
- timeout: POST_CLICK_TIMEOUT
542
- }).catch(() => {
543
- console.warn(
544
- `[w3wallets] still on confirmation route after popup dismiss. URL: ${this.page.url()}`
545
- );
546
- });
560
+ await this.dismissPopups();
561
+ await btnLocator.first().waitFor({ state: "visible", timeout: BUTTON_OR_POPUP_TIMEOUT });
562
+ await btnLocator.first().click();
563
+ await this.waitForConfirmRouteExit(confirmRoutePattern);
547
564
  return;
548
565
  }
549
566
  const isOnConfirmRoute = confirmRoutePattern.test(this.page.url());
550
567
  debug(
551
- `metamask.waitAndClickButton: timeout after ${BUTTON_OR_POPUP_TIMEOUT}ms. URL: ${this.page.url()}, onConfirmRoute: ${isOnConfirmRoute}`
568
+ `metamask.waitAndClickButton: timeout. URL: ${this.page.url()}, onConfirmRoute: ${isOnConfirmRoute}`
552
569
  );
553
570
  console.warn(
554
- `[w3wallets] no button or popup found after ${BUTTON_OR_POPUP_TIMEOUT / 1e3}s. URL: ${this.page.url()}`
571
+ `[w3wallets] no button or popup found after waiting. URL: ${this.page.url()}`
555
572
  );
556
573
  await btnLocator.first().click({ timeout: LAST_RESORT_CLICK_TIMEOUT });
557
574
  }
@@ -576,6 +593,46 @@ var Metamask = class extends Wallet {
576
593
  await menuBtn.click({ force: true });
577
594
  await this.page.locator("text=Log out").click();
578
595
  }
596
+ /**
597
+ * Force-persist the vault by locking the wallet. Called by the cache builder
598
+ * before closing the browser context. MetaMask MV3 stores the vault lazily —
599
+ * locking forces the encrypted vault to be written to chrome.storage.local.
600
+ */
601
+ async beforeCacheClose() {
602
+ debug("metamask.beforeCacheClose: locking wallet to force vault persistence");
603
+ await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
604
+ const menuBtn = this.page.getByTestId("account-options-menu-button");
605
+ const lockInput = this.page.getByTestId("unlock-password");
606
+ const state = await Promise.race([
607
+ menuBtn.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT }).then(() => "ready"),
608
+ lockInput.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT }).then(() => "locked")
609
+ ]).catch(() => "timeout");
610
+ debug(`metamask.beforeCacheClose: state=${state}`);
611
+ if (state === "ready") {
612
+ await this.lock();
613
+ await lockInput.waitFor({ state: "visible", timeout: LOCK_SCREEN_TIMEOUT }).catch(() => {
614
+ });
615
+ } else if (state === "timeout") {
616
+ debug("metamask.beforeCacheClose: forcing state sync via new UI connection");
617
+ const syncPage = await this.page.context().newPage();
618
+ await syncPage.goto(`chrome-extension://${this.extensionId}/home.html`);
619
+ await syncPage.waitForLoadState("networkidle");
620
+ const syncMenuBtn = syncPage.getByTestId("account-options-menu-button");
621
+ const syncLockInput = syncPage.getByTestId("unlock-password");
622
+ const syncState = await Promise.race([
623
+ syncMenuBtn.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT }).then(() => "ready"),
624
+ syncLockInput.waitFor({ state: "visible", timeout: MENU_BUTTON_TIMEOUT }).then(() => "locked")
625
+ ]).catch(() => "timeout");
626
+ debug(`metamask.beforeCacheClose: syncPage state=${syncState}`);
627
+ if (syncState === "ready") {
628
+ await syncMenuBtn.click({ force: true });
629
+ await syncPage.locator("text=Log out").click();
630
+ await syncLockInput.waitFor({ state: "visible", timeout: LOCK_SCREEN_TIMEOUT }).catch(() => {
631
+ });
632
+ }
633
+ await syncPage.close();
634
+ }
635
+ }
579
636
  /**
580
637
  * Unlock MetaMask with password.
581
638
  * After unlocking, stabilizes the wallet UI by handling post-unlock
@@ -586,13 +643,21 @@ var Metamask = class extends Wallet {
586
643
  debug("metamask.unlock: starting");
587
644
  const pwd = password ?? this.defaultPassword;
588
645
  await this.page.goto(`chrome-extension://${this.extensionId}/home.html`);
589
- const passwordInput = this.page.getByTestId("unlock-password");
590
- await passwordInput.fill(pwd);
591
- await this.page.getByTestId("unlock-submit").click();
592
- await this.page.waitForSelector('[data-testid="unlock-password"]', {
593
- state: "hidden",
594
- timeout: LOCK_SCREEN_TIMEOUT
595
- });
646
+ const lockInput = this.page.getByTestId("unlock-password");
647
+ const readyIndicator = this.page.getByTestId("account-options-menu-button");
648
+ const state = await Promise.race([
649
+ lockInput.waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }).then(() => "locked"),
650
+ readyIndicator.waitFor({ state: "visible", timeout: POST_UNLOCK_TIMEOUT }).then(() => "ready")
651
+ ]).catch(() => "timeout");
652
+ debug(`metamask.unlock: state=${state}`);
653
+ if (state === "locked") {
654
+ await lockInput.fill(pwd);
655
+ await this.page.getByTestId("unlock-submit").click();
656
+ await this.page.waitForSelector('[data-testid="unlock-password"]', {
657
+ state: "hidden",
658
+ timeout: LOCK_SCREEN_TIMEOUT
659
+ });
660
+ }
596
661
  await this.stabilizePostUnlock();
597
662
  debug("metamask.unlock: complete");
598
663
  }
@@ -89,7 +89,8 @@ async function waitForStorageStable(helperPage, helperUrl) {
89
89
  const POLL_INTERVAL = STORAGE_POLL_INTERVAL;
90
90
  const STABLE_CHECKS_REQUIRED = STORAGE_STABLE_CHECKS;
91
91
  const start = Date.now();
92
- let lastKeyCount = -1;
92
+ let lastHash = "";
93
+ let lastKeyCount = 0;
93
94
  let stableCount = 0;
94
95
  while (Date.now() - start < TIMEOUT) {
95
96
  await sleep(POLL_INTERVAL);
@@ -100,16 +101,19 @@ async function waitForStorageStable(helperPage, helperUrl) {
100
101
  { timeout: HELPER_PAGE_TIMEOUT }
101
102
  );
102
103
  const title = await helperPage.title();
103
- const keyCount = parseInt(title.split(":")[1], 10);
104
+ const parts = title.split(":");
105
+ const keyCount = parseInt(parts[1], 10);
106
+ const hash = parts[2] ?? "";
104
107
  if (keyCount === 0) continue;
105
- if (keyCount === lastKeyCount) {
108
+ if (hash === lastHash) {
106
109
  stableCount++;
107
110
  } else {
108
111
  stableCount = 1;
112
+ lastHash = hash;
109
113
  lastKeyCount = keyCount;
110
114
  }
111
115
  debug(
112
- `Storage poll: ${keyCount} keys (stable ${stableCount}/${STABLE_CHECKS_REQUIRED})`
116
+ `Storage poll: ${keyCount} keys, hash=${hash.slice(0, 8)} (stable ${stableCount}/${STABLE_CHECKS_REQUIRED})`
113
117
  );
114
118
  if (stableCount >= STABLE_CHECKS_REQUIRED) {
115
119
  console.log(` Storage stabilized at ${keyCount} keys`);
@@ -176,16 +180,26 @@ async function buildCacheForSetup(compiledFilePath, originalFilePath, options2 =
176
180
  await config.setupFn(wallet, page);
177
181
  await page.goto(`chrome-extension://${extensionId}/home.html`);
178
182
  await page.waitForLoadState("networkidle");
179
- await page.goto(`chrome-extension://${extensionId}/home.html`);
180
- await page.waitForLoadState("networkidle");
183
+ if (wallet.beforeCacheClose) {
184
+ debug("buildCache: calling wallet.beforeCacheClose()");
185
+ await wallet.beforeCacheClose();
186
+ await sleep(2e3);
187
+ }
181
188
  try {
182
189
  const extDir = import_path2.default.join(process.cwd(), W3WALLETS_DIR, config.extensionDir);
183
190
  const helperJs = import_path2.default.join(extDir, "_w3wallets_helper.js");
184
191
  const helperHtml = import_path2.default.join(extDir, "_w3wallets_helper.html");
185
192
  import_fs2.default.writeFileSync(
186
193
  helperJs,
187
- `chrome.storage.local.get(null, (data) => {
188
- document.title = "done:" + Object.keys(data).length;
194
+ `chrome.storage.local.get(null, async (data) => {
195
+ const keys = Object.keys(data);
196
+ const json = JSON.stringify(data);
197
+ // Compute a simple hash of all values via SubtleCrypto
198
+ const buf = new TextEncoder().encode(json);
199
+ const hashBuf = await crypto.subtle.digest("SHA-256", buf);
200
+ const hashArr = Array.from(new Uint8Array(hashBuf));
201
+ const hash = hashArr.map(b => b.toString(16).padStart(2, "0")).join("");
202
+ document.title = "done:" + keys.length + ":" + hash;
189
203
  });`
190
204
  );
191
205
  import_fs2.default.writeFileSync(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "w3wallets",
3
3
  "description": "browser wallets for playwright",
4
- "version": "1.0.0-beta.8",
4
+ "version": "1.0.0-beta.9",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "homepage": "https://github.com/Maksandre/w3wallets",