diff --git a/packages/core/src/page.js b/packages/core/src/page.js index 8920b3a4a..1a5db7aac 100644 --- a/packages/core/src/page.js +++ b/packages/core/src/page.js @@ -25,22 +25,40 @@ export const DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT = 500; // IMPORTANT: this body is intentionally ES5 — it is evaluated in the // page's realm and must work in any browser the page targets. Don't // "modernize" with arrow functions, let/const, or optional chaining. +// +// serializeFunction() injects the Percy helpers object as `arguments[0]`, so +// the caller's first argument is `arguments[1]`. export const WAIT_FOR_CUSTOM_ELEMENTS_BODY = ` - var deadline = Date.now() + (arguments[0] || 500); + var timeoutMs = typeof arguments[1] === 'number' && isFinite(arguments[1]) && arguments[1] > 0 + ? arguments[1] + : 500; + var deadline = Date.now() + timeoutMs; return new Promise(function(resolve) { + var settled = false; + function finish() { + if (settled) return; + settled = true; + resolve(); + } + setTimeout(finish, timeoutMs); function tick() { + if (settled) return; var undef = document.querySelectorAll(":not(:defined)"); - if (!undef.length) return resolve(); - if (Date.now() >= deadline) return resolve(); + if (!undef.length) return finish(); + if (Date.now() >= deadline) return finish(); var names = {}; for (var i = 0; i < undef.length; i++) names[undef[i].localName] = true; var promises = Object.keys(names).map(function(n) { - return window.customElements.whenDefined(n).catch(function(){}); + try { + return window.customElements.whenDefined(n).catch(function(){}); + } catch (e) { + return Promise.resolve(); + } }); Promise.race([ Promise.all(promises), new Promise(function(r) { setTimeout(r, 100); }) - ]).then(tick); + ]).then(tick, finish); } tick(); }); diff --git a/packages/core/test/percy.test.js b/packages/core/test/percy.test.js index 0b93c1be3..992d1b0ab 100644 --- a/packages/core/test/percy.test.js +++ b/packages/core/test/percy.test.js @@ -4,7 +4,11 @@ import Percy from '@percy/core'; import Pako from 'pako'; import DetectProxy from '@percy/client/detect-proxy'; import { validateSnapshotOptions } from '../src/snapshot.js'; -import { Page, WAIT_FOR_CUSTOM_ELEMENTS_BODY } from '../src/page.js'; +import { + Page, + WAIT_FOR_CUSTOM_ELEMENTS_BODY, + DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT +} from '../src/page.js'; describe('Percy', () => { let percy, server; @@ -216,6 +220,31 @@ describe('Percy', () => { ])); }); + it('does not hang on a page holding a never-defined custom element', async () => { + // PER-10405: an unregistered tag matches `:not(:defined)` forever and its + // `customElements.whenDefined()` never resolves, so the wait must fall back + // on its own ceiling. + server.reply('/', () => [200, 'text/html', ( + '

hi

' + )]); + await percy.browser.launch(); + let page = await percy.browser.page(); + await page.goto('http://localhost:8000'); + + let undefinedCount = await page.eval( + () => document.querySelectorAll(':not(:defined)').length); + expect(undefinedCount).toBe(1); + + let start = Date.now(); + await expectAsync(page.eval( + WAIT_FOR_CUSTOM_ELEMENTS_BODY, + DEFAULT_WAIT_FOR_CUSTOM_ELEMENTS_TIMEOUT + )).toBeResolved(); + let elapsed = Date.now() - start; + + expect(elapsed).toBeLessThan(5000); + }); + it('runs readiness check before serializing when readiness option is set', async () => { server.reply('/', () => [200, 'text/html', '

Hello Percy!

']);