From 1d3053ffc2ce1752faf3c07dec14ce0020b6e11f Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Sat, 28 Feb 2026 12:45:36 +0200 Subject: [PATCH 1/2] fix: make inject() resilient to page navigation during initialization Replace manual evaluate-based polling loops with waitForFunction, which natively survives execution context destruction caused by page navigation (e.g. Chrome's internal IndexedDB recovery after system sleep/resume). Also move the framenavigated listener registration before the initial inject() call, so navigation events during inject are handled by the existing listener. Co-Authored-By: Claude Opus 4.6 --- src/Client.js | 60 ++++----- tests/ab-comparison.js | 203 +++++++++++++++++++++++++++++++ tests/inject-navigation.js | 243 +++++++++++++++++++++++++++++++++++++ 3 files changed, 468 insertions(+), 38 deletions(-) create mode 100644 tests/ab-comparison.js create mode 100644 tests/inject-navigation.js diff --git a/src/Client.js b/src/Client.js index c4df1bfaaa..3562b99179 100644 --- a/src/Client.js +++ b/src/Client.js @@ -112,27 +112,14 @@ class Client extends EventEmitter { * Private function */ async inject() { - if ( - this.options.authTimeoutMs === undefined || - this.options.authTimeoutMs == 0 - ) { - this.options.authTimeoutMs = 30000; - } - let start = Date.now(); - let timeout = this.options.authTimeoutMs; - let res = false; - while (start > Date.now() - timeout) { - res = await this.pupPage.evaluate( - 'window.Debug?.VERSION != undefined', - ); - if (res) { - break; - } - await new Promise((r) => setTimeout(r, 200)); - } - if (!res) { - throw 'auth timeout'; - } + const authTimeout = this.options.authTimeoutMs || 30000; + await this.pupPage + .waitForFunction('window.Debug?.VERSION != undefined', { + timeout: authTimeout, + }) + .catch(() => { + throw 'auth timeout'; + }); await this.setDeviceName( this.options.deviceName, this.options.browserName, @@ -320,27 +307,24 @@ class Client extends EventEmitter { webCacheOptions, ); - await webCache.persist(this.currentIndexHtml, version); + await webCache.persist( + this.currentIndexHtml, + version, + ); } //Load util functions (serializers, helper functions) await this.pupPage.evaluate(LoadUtils); - let start = Date.now(); - let res = false; - while (start > Date.now() - 30000) { - // Check window.WWebJS Injection - res = await this.pupPage.evaluate( + // Check window.WWebJS Injection + await this.pupPage + .waitForFunction( 'window.WWebJS != undefined', - ); - if (res) { - break; - } - await new Promise((r) => setTimeout(r, 200)); - } - if (!res) { - throw 'ready timeout'; - } + { timeout: 30000 }, + ) + .catch(() => { + throw 'ready timeout'; + }); /** * Current connection information @@ -490,8 +474,6 @@ class Client extends EventEmitter { referer: 'https://whatsapp.com/', }); - await this.inject(); - this.pupPage.on('framenavigated', async (frame) => { if (frame.url().includes('post_logout=1') || this.lastLoggedOut) { this.emit(Events.DISCONNECTED, 'LOGOUT'); @@ -502,6 +484,8 @@ class Client extends EventEmitter { } await this.inject(); }); + + await this.inject(); } /** diff --git a/tests/ab-comparison.js b/tests/ab-comparison.js new file mode 100644 index 0000000000..e682a79c1f --- /dev/null +++ b/tests/ab-comparison.js @@ -0,0 +1,203 @@ +/** + * A/B Comparison: Old inject vs New inject during navigation + * + * Reproduces the exact error: "Execution context was destroyed" + * + * Uses a local HTTP server to serve real pages with working scripts. + * Navigation is triggered from Node.js (same effect as Chrome's internal navigation). + */ + +const http = require('http'); +const puppeteer = require('puppeteer'); +const chai = require('chai'); +const expect = chai.expect; + +// Page that sets Debug.VERSION after a delay +function makePage(delayMs) { + return `
WhatsApp Web
+ +`; +} + +// Old inject: manual polling with page.evaluate (commit 6f909bc, lines 105-112) +async function oldInjectPolling(page, timeout = 10000) { + const start = Date.now(); + let res = false; + while (start > (Date.now() - timeout)) { + res = await page.evaluate('window.Debug?.VERSION != undefined'); + if (res) break; + await new Promise(r => setTimeout(r, 200)); + } + if (!res) throw new Error('auth timeout'); + return true; +} + +// New inject: waitForFunction (current fork main, line 98) +async function newInjectPolling(page, timeout = 10000) { + await page.waitForFunction('window.Debug?.VERSION != undefined', { timeout }); + return true; +} + +describe('A/B: Old vs New inject during navigation', function () { + this.timeout(30000); + let browser; + let server; + let serverUrl; + + before(async function () { + // Start local HTTP server + server = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + // Page sets Debug.VERSION after 800ms + res.end(makePage(800)); + }); + await new Promise(resolve => { + server.listen(0, '127.0.0.1', () => { + serverUrl = `http://127.0.0.1:${server.address().port}`; + resolve(); + }); + }); + + browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] }); + }); + + after(async function () { + if (browser) await browser.close(); + if (server) await new Promise(resolve => server.close(resolve)); + }); + + // ────────────────────────────────────────────────────────── + // A: page.evaluate FAILS when context is destroyed (deterministic proof) + // ────────────────────────────────────────────────────────── + it('A (PROOF): page.evaluate throws "context destroyed" during navigation', async function () { + const page = await browser.newPage(); + try { + await page.goto(serverUrl, { waitUntil: 'load' }); + + // Start a long-running evaluate (simulates an evaluate in-flight during navigation) + const evalPromise = page.evaluate(async () => { + await new Promise(r => setTimeout(r, 5000)); + return window.Debug?.VERSION; + }); + + // Navigate while evaluate is running (like IndexedDB recovery) + await new Promise(r => setTimeout(r, 300)); + await page.goto(serverUrl, { waitUntil: 'load' }); + + let error = null; + try { + await evalPromise; + } catch (err) { + error = err; + } + + // evaluate should have thrown with context-destroyed + expect(error).to.not.be.null; + expect(error.message.toLowerCase()).to.satisfy(msg => + msg.includes('context') || + msg.includes('navigat') || + msg.includes('detach') || + msg.includes('target') + ); + console.log(' [A] page.evaluate threw:', error.message); + } finally { + await page.close(); + } + }); + + // ────────────────────────────────────────────────────────── + // B: NEW CODE - waitForFunction SURVIVES navigation + // ────────────────────────────────────────────────────────── + it('B (NEW CODE): waitForFunction SURVIVES navigation', async function () { + const page = await browser.newPage(); + try { + await page.goto(serverUrl, { waitUntil: 'load' }); + + // Start new-style polling + const pollPromise = newInjectPolling(page, 15000); + + // Same navigation trigger + await new Promise(r => setTimeout(r, 300)); + page.evaluate(() => { + window.location.reload(); + }).catch(() => {}); + + // Should survive and resolve + const result = await pollPromise; + expect(result).to.equal(true); + + const version = await page.evaluate('window.Debug.VERSION'); + expect(version).to.equal('2.3000.0'); + console.log(' [B] Survived navigation! Got version:', version); + } finally { + await page.close(); + } + }); + + // ────────────────────────────────────────────────────────── + // C: FULL FIX - both mechanisms together + // ────────────────────────────────────────────────────────── + it('C (FULL FIX): framenavigated + waitForFunction', async function () { + const page = await browser.newPage(); + try { + let framenavigatedCount = 0; + let injectViaListenerOk = false; + + // Register BEFORE inject (our fix) + page.on('framenavigated', async () => { + framenavigatedCount++; + try { + await page.waitForFunction('window.Debug?.VERSION != undefined', { timeout: 10000 }); + await page.evaluate('window.Debug.VERSION'); + injectViaListenerOk = true; + } catch (e) { /* ignore */ } + }); + + await page.goto(serverUrl, { waitUntil: 'load' }); + + const pollPromise = newInjectPolling(page, 15000); + + // Navigation mid-inject + await new Promise(r => setTimeout(r, 300)); + page.evaluate(() => { + window.location.reload(); + }).catch(() => {}); + + await pollPromise; + await new Promise(r => setTimeout(r, 2000)); + + expect(framenavigatedCount).to.be.greaterThan(0); + expect(injectViaListenerOk).to.equal(true); + console.log(' [C] framenavigated:', framenavigatedCount, '| inject via listener:', injectViaListenerOk); + } finally { + await page.close(); + } + }); + + // ────────────────────────────────────────────────────────── + // D: SANITY - both work WITHOUT navigation + // ────────────────────────────────────────────────────────── + it('D (SANITY): both work when there is no navigation', async function () { + const page1 = await browser.newPage(); + try { + await page1.goto(serverUrl, { waitUntil: 'load' }); + await oldInjectPolling(page1, 10000); + console.log(' [D] Old code works without navigation'); + } finally { + await page1.close(); + } + + const page2 = await browser.newPage(); + try { + await page2.goto(serverUrl, { waitUntil: 'load' }); + await newInjectPolling(page2, 10000); + console.log(' [D] New code works without navigation'); + } finally { + await page2.close(); + } + }); +}); diff --git a/tests/inject-navigation.js b/tests/inject-navigation.js new file mode 100644 index 0000000000..fc91ed60f0 --- /dev/null +++ b/tests/inject-navigation.js @@ -0,0 +1,243 @@ +/** + * E2E tests: waitForFunction survives page navigation (context destruction) + * + * Simulates the real-world scenario: + * After sleep/resume, Chrome's IndexedDB recovery causes an internal + * page navigation that destroys the execution context. + * waitForFunction should continue polling in the new context. + */ + +const puppeteer = require('puppeteer'); +const chai = require('chai'); +const expect = chai.expect; + +describe('inject() navigation resilience', function () { + this.timeout(30000); + + let browser; + let page; + + beforeEach(async function () { + browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] }); + page = await browser.newPage(); + }); + + afterEach(async function () { + if (browser) await browser.close(); + }); + + // ────────────────────────────────────────────────────────── + // Test 1: waitForFunction survives navigation + // ────────────────────────────────────────────────────────── + describe('waitForFunction survives navigation', function () { + it('should resolve after navigation destroys and recreates the context', async function () { + // Page 1: variable is NOT set + await page.setContent('
Page 1
'); + + // Start waiting for a variable that doesn't exist yet + const waitPromise = page.waitForFunction( + 'window.testReady === true', + { timeout: 15000 } + ); + + // Simulate navigation mid-wait (like IndexedDB recovery) + await new Promise(r => setTimeout(r, 500)); + await page.goto('data:text/html,
Page 2
'); + + // waitForFunction should resolve in the new context + await waitPromise; + + const result = await page.evaluate('window.testReady'); + expect(result).to.equal(true); + }); + + it('should resolve even with multiple navigations', async function () { + await page.setContent('Page 1'); + + const waitPromise = page.waitForFunction( + 'window.finalReady === true', + { timeout: 15000 } + ); + + // Navigation 1 + await new Promise(r => setTimeout(r, 300)); + await page.goto('data:text/html,Page 2'); + + // Navigation 2 + await new Promise(r => setTimeout(r, 300)); + await page.goto('data:text/html,Page 3'); + + // Navigation 3 - finally sets the variable + await new Promise(r => setTimeout(r, 300)); + await page.goto('data:text/html,Page 4'); + + await waitPromise; + + const result = await page.evaluate('window.finalReady'); + expect(result).to.equal(true); + }); + }); + + // ────────────────────────────────────────────────────────── + // Test 2: Simulates the exact WhatsApp inject scenario + // ────────────────────────────────────────────────────────── + describe('WhatsApp inject scenario simulation', function () { + it('should survive IndexedDB-recovery-like navigation during Debug.VERSION wait', async function () { + // Initial page load - WhatsApp Web loading + await page.setContent(`
Loading WhatsApp...
+ + `); + + // Start the same waitForFunction that inject() uses + const waitPromise = page.waitForFunction( + 'window.Debug?.VERSION != undefined', + { timeout: 15000 } + ); + + // Simulate IndexedDB recovery navigation after 500ms + await new Promise(r => setTimeout(r, 500)); + await page.goto(`data:text/html,
WhatsApp Recovered
+ + `); + + // waitForFunction should survive the navigation and resolve in new context + await waitPromise; + + const version = await page.evaluate('window.Debug.VERSION'); + expect(version).to.equal('2.3000.0'); + }); + }); + + // ────────────────────────────────────────────────────────── + // Test 3: framenavigated listener fires after navigation + // ────────────────────────────────────────────────────────── + describe('framenavigated listener ordering', function () { + it('should fire framenavigated when registered before navigation-triggering code', async function () { + await page.setContent('
Initial
'); + + let framenavigatedFired = false; + let navigatedUrl = ''; + + // Register listener BEFORE any inject-like code (our fix) + page.on('framenavigated', (frame) => { + framenavigatedFired = true; + navigatedUrl = frame.url(); + }); + + // Simulate navigation (like IndexedDB recovery) + await page.goto('data:text/html,
After Navigation
'); + + expect(framenavigatedFired).to.equal(true); + expect(navigatedUrl).to.include('data:text/html'); + }); + + it('should call inject-like function via framenavigated after context destruction', async function () { + await page.setContent('
Initial
'); + + let injectCallCount = 0; + const injectFn = async () => { + await page.evaluate('1 + 1'); // simple evaluate to verify context works + injectCallCount++; + }; + + // Register framenavigated BEFORE (our fix) + page.on('framenavigated', async () => { + await injectFn(); + }); + + // Navigate - this destroys old context, creates new one + await page.goto('data:text/html,
Recovered
'); + + // Give the async listener time to run + await new Promise(r => setTimeout(r, 500)); + + expect(injectCallCount).to.be.greaterThan(0); + }); + }); + + // ────────────────────────────────────────────────────────── + // Test 4: page.evaluate FAILS during navigation (contrast) + // ────────────────────────────────────────────────────────── + describe('page.evaluate does NOT survive navigation (contrast test)', function () { + it('should throw when evaluate runs during navigation', async function () { + await page.setContent('Initial'); + + let evaluateError = null; + + // Start a long-running evaluate, then navigate mid-way + const evalPromise = page.evaluate(async () => { + // Wait inside the browser context + await new Promise(r => setTimeout(r, 5000)); + return 'done'; + }).catch(err => { + evaluateError = err; + }); + + // Navigate while evaluate is running + await new Promise(r => setTimeout(r, 200)); + await page.goto('data:text/html,New Page'); + + await evalPromise; + + // evaluate should have thrown (context destroyed) + expect(evaluateError).to.not.be.null; + expect(evaluateError.message.toLowerCase()).to.satisfy( + msg => msg.includes('context') || msg.includes('navigat') || msg.includes('detach') + ); + }); + }); + + // ────────────────────────────────────────────────────────── + // Test 5: Full flow - framenavigated + waitForFunction together + // ────────────────────────────────────────────────────────── + describe('Full flow: framenavigated + waitForFunction', function () { + it('should recover fully when combining both mechanisms', async function () { + await page.setContent(`
Loading
+ + `); + + let framenavigatedInjectCalled = false; + + // Step 1: Register framenavigated BEFORE inject (our fix) + page.on('framenavigated', async () => { + try { + // Simulate inject: wait for Debug.VERSION then do work + await page.waitForFunction('window.Debug?.VERSION != undefined', { timeout: 10000 }); + await page.evaluate('window.Debug.VERSION'); // simulate store injection + framenavigatedInjectCalled = true; + } catch (e) { + // Ignore - test will fail on assertion if this doesn't work + } + }); + + // Step 2: Start inject (waitForFunction) + const injectPromise = page.waitForFunction( + 'window.Debug?.VERSION != undefined', + { timeout: 15000 } + ); + + // Step 3: Navigation destroys context mid-wait + await new Promise(r => setTimeout(r, 500)); + await page.goto(`data:text/html,
Recovered
+ + `); + + // Step 4: Both should succeed + await injectPromise; // waitForFunction survives navigation + + // Give framenavigated handler time to complete + await new Promise(r => setTimeout(r, 2000)); + expect(framenavigatedInjectCalled).to.equal(true); + }); + }); +}); From b199c22dada7b68c131d072369873590adf21115 Mon Sep 17 00:00:00 2001 From: Adi Aharoni Date: Sat, 28 Feb 2026 23:12:03 +0200 Subject: [PATCH 2/2] chore: remove test files from upstream PR --- src/Client.js | 12 +- tests/ab-comparison.js | 203 ------------------------------- tests/inject-navigation.js | 243 ------------------------------------- 3 files changed, 4 insertions(+), 454 deletions(-) delete mode 100644 tests/ab-comparison.js delete mode 100644 tests/inject-navigation.js diff --git a/src/Client.js b/src/Client.js index 3562b99179..78d6276bad 100644 --- a/src/Client.js +++ b/src/Client.js @@ -307,10 +307,7 @@ class Client extends EventEmitter { webCacheOptions, ); - await webCache.persist( - this.currentIndexHtml, - version, - ); + await webCache.persist(this.currentIndexHtml, version); } //Load util functions (serializers, helper functions) @@ -318,10 +315,9 @@ class Client extends EventEmitter { // Check window.WWebJS Injection await this.pupPage - .waitForFunction( - 'window.WWebJS != undefined', - { timeout: 30000 }, - ) + .waitForFunction('window.WWebJS != undefined', { + timeout: 30000, + }) .catch(() => { throw 'ready timeout'; }); diff --git a/tests/ab-comparison.js b/tests/ab-comparison.js deleted file mode 100644 index e682a79c1f..0000000000 --- a/tests/ab-comparison.js +++ /dev/null @@ -1,203 +0,0 @@ -/** - * A/B Comparison: Old inject vs New inject during navigation - * - * Reproduces the exact error: "Execution context was destroyed" - * - * Uses a local HTTP server to serve real pages with working scripts. - * Navigation is triggered from Node.js (same effect as Chrome's internal navigation). - */ - -const http = require('http'); -const puppeteer = require('puppeteer'); -const chai = require('chai'); -const expect = chai.expect; - -// Page that sets Debug.VERSION after a delay -function makePage(delayMs) { - return `
WhatsApp Web
- -`; -} - -// Old inject: manual polling with page.evaluate (commit 6f909bc, lines 105-112) -async function oldInjectPolling(page, timeout = 10000) { - const start = Date.now(); - let res = false; - while (start > (Date.now() - timeout)) { - res = await page.evaluate('window.Debug?.VERSION != undefined'); - if (res) break; - await new Promise(r => setTimeout(r, 200)); - } - if (!res) throw new Error('auth timeout'); - return true; -} - -// New inject: waitForFunction (current fork main, line 98) -async function newInjectPolling(page, timeout = 10000) { - await page.waitForFunction('window.Debug?.VERSION != undefined', { timeout }); - return true; -} - -describe('A/B: Old vs New inject during navigation', function () { - this.timeout(30000); - let browser; - let server; - let serverUrl; - - before(async function () { - // Start local HTTP server - server = http.createServer((req, res) => { - res.writeHead(200, { 'Content-Type': 'text/html' }); - // Page sets Debug.VERSION after 800ms - res.end(makePage(800)); - }); - await new Promise(resolve => { - server.listen(0, '127.0.0.1', () => { - serverUrl = `http://127.0.0.1:${server.address().port}`; - resolve(); - }); - }); - - browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] }); - }); - - after(async function () { - if (browser) await browser.close(); - if (server) await new Promise(resolve => server.close(resolve)); - }); - - // ────────────────────────────────────────────────────────── - // A: page.evaluate FAILS when context is destroyed (deterministic proof) - // ────────────────────────────────────────────────────────── - it('A (PROOF): page.evaluate throws "context destroyed" during navigation', async function () { - const page = await browser.newPage(); - try { - await page.goto(serverUrl, { waitUntil: 'load' }); - - // Start a long-running evaluate (simulates an evaluate in-flight during navigation) - const evalPromise = page.evaluate(async () => { - await new Promise(r => setTimeout(r, 5000)); - return window.Debug?.VERSION; - }); - - // Navigate while evaluate is running (like IndexedDB recovery) - await new Promise(r => setTimeout(r, 300)); - await page.goto(serverUrl, { waitUntil: 'load' }); - - let error = null; - try { - await evalPromise; - } catch (err) { - error = err; - } - - // evaluate should have thrown with context-destroyed - expect(error).to.not.be.null; - expect(error.message.toLowerCase()).to.satisfy(msg => - msg.includes('context') || - msg.includes('navigat') || - msg.includes('detach') || - msg.includes('target') - ); - console.log(' [A] page.evaluate threw:', error.message); - } finally { - await page.close(); - } - }); - - // ────────────────────────────────────────────────────────── - // B: NEW CODE - waitForFunction SURVIVES navigation - // ────────────────────────────────────────────────────────── - it('B (NEW CODE): waitForFunction SURVIVES navigation', async function () { - const page = await browser.newPage(); - try { - await page.goto(serverUrl, { waitUntil: 'load' }); - - // Start new-style polling - const pollPromise = newInjectPolling(page, 15000); - - // Same navigation trigger - await new Promise(r => setTimeout(r, 300)); - page.evaluate(() => { - window.location.reload(); - }).catch(() => {}); - - // Should survive and resolve - const result = await pollPromise; - expect(result).to.equal(true); - - const version = await page.evaluate('window.Debug.VERSION'); - expect(version).to.equal('2.3000.0'); - console.log(' [B] Survived navigation! Got version:', version); - } finally { - await page.close(); - } - }); - - // ────────────────────────────────────────────────────────── - // C: FULL FIX - both mechanisms together - // ────────────────────────────────────────────────────────── - it('C (FULL FIX): framenavigated + waitForFunction', async function () { - const page = await browser.newPage(); - try { - let framenavigatedCount = 0; - let injectViaListenerOk = false; - - // Register BEFORE inject (our fix) - page.on('framenavigated', async () => { - framenavigatedCount++; - try { - await page.waitForFunction('window.Debug?.VERSION != undefined', { timeout: 10000 }); - await page.evaluate('window.Debug.VERSION'); - injectViaListenerOk = true; - } catch (e) { /* ignore */ } - }); - - await page.goto(serverUrl, { waitUntil: 'load' }); - - const pollPromise = newInjectPolling(page, 15000); - - // Navigation mid-inject - await new Promise(r => setTimeout(r, 300)); - page.evaluate(() => { - window.location.reload(); - }).catch(() => {}); - - await pollPromise; - await new Promise(r => setTimeout(r, 2000)); - - expect(framenavigatedCount).to.be.greaterThan(0); - expect(injectViaListenerOk).to.equal(true); - console.log(' [C] framenavigated:', framenavigatedCount, '| inject via listener:', injectViaListenerOk); - } finally { - await page.close(); - } - }); - - // ────────────────────────────────────────────────────────── - // D: SANITY - both work WITHOUT navigation - // ────────────────────────────────────────────────────────── - it('D (SANITY): both work when there is no navigation', async function () { - const page1 = await browser.newPage(); - try { - await page1.goto(serverUrl, { waitUntil: 'load' }); - await oldInjectPolling(page1, 10000); - console.log(' [D] Old code works without navigation'); - } finally { - await page1.close(); - } - - const page2 = await browser.newPage(); - try { - await page2.goto(serverUrl, { waitUntil: 'load' }); - await newInjectPolling(page2, 10000); - console.log(' [D] New code works without navigation'); - } finally { - await page2.close(); - } - }); -}); diff --git a/tests/inject-navigation.js b/tests/inject-navigation.js deleted file mode 100644 index fc91ed60f0..0000000000 --- a/tests/inject-navigation.js +++ /dev/null @@ -1,243 +0,0 @@ -/** - * E2E tests: waitForFunction survives page navigation (context destruction) - * - * Simulates the real-world scenario: - * After sleep/resume, Chrome's IndexedDB recovery causes an internal - * page navigation that destroys the execution context. - * waitForFunction should continue polling in the new context. - */ - -const puppeteer = require('puppeteer'); -const chai = require('chai'); -const expect = chai.expect; - -describe('inject() navigation resilience', function () { - this.timeout(30000); - - let browser; - let page; - - beforeEach(async function () { - browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox'] }); - page = await browser.newPage(); - }); - - afterEach(async function () { - if (browser) await browser.close(); - }); - - // ────────────────────────────────────────────────────────── - // Test 1: waitForFunction survives navigation - // ────────────────────────────────────────────────────────── - describe('waitForFunction survives navigation', function () { - it('should resolve after navigation destroys and recreates the context', async function () { - // Page 1: variable is NOT set - await page.setContent('
Page 1
'); - - // Start waiting for a variable that doesn't exist yet - const waitPromise = page.waitForFunction( - 'window.testReady === true', - { timeout: 15000 } - ); - - // Simulate navigation mid-wait (like IndexedDB recovery) - await new Promise(r => setTimeout(r, 500)); - await page.goto('data:text/html,
Page 2
'); - - // waitForFunction should resolve in the new context - await waitPromise; - - const result = await page.evaluate('window.testReady'); - expect(result).to.equal(true); - }); - - it('should resolve even with multiple navigations', async function () { - await page.setContent('Page 1'); - - const waitPromise = page.waitForFunction( - 'window.finalReady === true', - { timeout: 15000 } - ); - - // Navigation 1 - await new Promise(r => setTimeout(r, 300)); - await page.goto('data:text/html,Page 2'); - - // Navigation 2 - await new Promise(r => setTimeout(r, 300)); - await page.goto('data:text/html,Page 3'); - - // Navigation 3 - finally sets the variable - await new Promise(r => setTimeout(r, 300)); - await page.goto('data:text/html,Page 4'); - - await waitPromise; - - const result = await page.evaluate('window.finalReady'); - expect(result).to.equal(true); - }); - }); - - // ────────────────────────────────────────────────────────── - // Test 2: Simulates the exact WhatsApp inject scenario - // ────────────────────────────────────────────────────────── - describe('WhatsApp inject scenario simulation', function () { - it('should survive IndexedDB-recovery-like navigation during Debug.VERSION wait', async function () { - // Initial page load - WhatsApp Web loading - await page.setContent(`
Loading WhatsApp...
- - `); - - // Start the same waitForFunction that inject() uses - const waitPromise = page.waitForFunction( - 'window.Debug?.VERSION != undefined', - { timeout: 15000 } - ); - - // Simulate IndexedDB recovery navigation after 500ms - await new Promise(r => setTimeout(r, 500)); - await page.goto(`data:text/html,
WhatsApp Recovered
- - `); - - // waitForFunction should survive the navigation and resolve in new context - await waitPromise; - - const version = await page.evaluate('window.Debug.VERSION'); - expect(version).to.equal('2.3000.0'); - }); - }); - - // ────────────────────────────────────────────────────────── - // Test 3: framenavigated listener fires after navigation - // ────────────────────────────────────────────────────────── - describe('framenavigated listener ordering', function () { - it('should fire framenavigated when registered before navigation-triggering code', async function () { - await page.setContent('
Initial
'); - - let framenavigatedFired = false; - let navigatedUrl = ''; - - // Register listener BEFORE any inject-like code (our fix) - page.on('framenavigated', (frame) => { - framenavigatedFired = true; - navigatedUrl = frame.url(); - }); - - // Simulate navigation (like IndexedDB recovery) - await page.goto('data:text/html,
After Navigation
'); - - expect(framenavigatedFired).to.equal(true); - expect(navigatedUrl).to.include('data:text/html'); - }); - - it('should call inject-like function via framenavigated after context destruction', async function () { - await page.setContent('
Initial
'); - - let injectCallCount = 0; - const injectFn = async () => { - await page.evaluate('1 + 1'); // simple evaluate to verify context works - injectCallCount++; - }; - - // Register framenavigated BEFORE (our fix) - page.on('framenavigated', async () => { - await injectFn(); - }); - - // Navigate - this destroys old context, creates new one - await page.goto('data:text/html,
Recovered
'); - - // Give the async listener time to run - await new Promise(r => setTimeout(r, 500)); - - expect(injectCallCount).to.be.greaterThan(0); - }); - }); - - // ────────────────────────────────────────────────────────── - // Test 4: page.evaluate FAILS during navigation (contrast) - // ────────────────────────────────────────────────────────── - describe('page.evaluate does NOT survive navigation (contrast test)', function () { - it('should throw when evaluate runs during navigation', async function () { - await page.setContent('Initial'); - - let evaluateError = null; - - // Start a long-running evaluate, then navigate mid-way - const evalPromise = page.evaluate(async () => { - // Wait inside the browser context - await new Promise(r => setTimeout(r, 5000)); - return 'done'; - }).catch(err => { - evaluateError = err; - }); - - // Navigate while evaluate is running - await new Promise(r => setTimeout(r, 200)); - await page.goto('data:text/html,New Page'); - - await evalPromise; - - // evaluate should have thrown (context destroyed) - expect(evaluateError).to.not.be.null; - expect(evaluateError.message.toLowerCase()).to.satisfy( - msg => msg.includes('context') || msg.includes('navigat') || msg.includes('detach') - ); - }); - }); - - // ────────────────────────────────────────────────────────── - // Test 5: Full flow - framenavigated + waitForFunction together - // ────────────────────────────────────────────────────────── - describe('Full flow: framenavigated + waitForFunction', function () { - it('should recover fully when combining both mechanisms', async function () { - await page.setContent(`
Loading
- - `); - - let framenavigatedInjectCalled = false; - - // Step 1: Register framenavigated BEFORE inject (our fix) - page.on('framenavigated', async () => { - try { - // Simulate inject: wait for Debug.VERSION then do work - await page.waitForFunction('window.Debug?.VERSION != undefined', { timeout: 10000 }); - await page.evaluate('window.Debug.VERSION'); // simulate store injection - framenavigatedInjectCalled = true; - } catch (e) { - // Ignore - test will fail on assertion if this doesn't work - } - }); - - // Step 2: Start inject (waitForFunction) - const injectPromise = page.waitForFunction( - 'window.Debug?.VERSION != undefined', - { timeout: 15000 } - ); - - // Step 3: Navigation destroys context mid-wait - await new Promise(r => setTimeout(r, 500)); - await page.goto(`data:text/html,
Recovered
- - `); - - // Step 4: Both should succeed - await injectPromise; // waitForFunction survives navigation - - // Give framenavigated handler time to complete - await new Promise(r => setTimeout(r, 2000)); - expect(framenavigatedInjectCalled).to.equal(true); - }); - }); -});