diff --git a/README.md b/README.md index 2c35d8c..e6aefe9 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Collectors are the bits of code which gather progress information. Pace includes - Ajax - Monitors ajax requests (XMLHttpRequest and WebSocket) on the page. Only `GET` requests are tracked by default, see Tracking below + Monitors ajax requests (XMLHttpRequest, fetch and WebSocket) on the page. Only `GET` requests are tracked by default, see Tracking below - Elements @@ -235,6 +235,21 @@ paceOptions = { } ``` +`fetch()` requests are tracked the same way, until their response arrives: Pace can't see a response's body load +without reading it. To stop tracking them, set `trackFetch: false` inside `ajax`. + +Pace waits for each request at most `maxWait` milliseconds after it's sent (15000 by default), so a request that never +finishes can't hold the bar forever. The request itself keeps going. Set a different number, or `false` to wait for +every request until it ends (anything other than a number from 0 to 2147483647, about 24 days, means the same): + +```javascript +paceOptions = { + ajax: { + maxWait: 30000 + } +} +``` + You can disable all ajax tracking by setting `ajax` to false: ```javascript @@ -280,9 +295,18 @@ paceOptions = { ``` A string matches any ajax request URL that contains it (case-sensitive); a regular expression is tested against -the URL. These patterns only apply to ajax requests, not to links, images or other resources on the page. +the URL. Patterns see the URL as the page wrote it, the full URL (such as `https://example.com/api/items?page=2`), and for +requests to the page's own origin (same protocol, domain and port) the part after it (`/api/items?page=2`), so a +pattern matches however the request's URL was written. They only apply to ajax requests, not to links, images or other +resources on the page. + +Requests a page keeps open on purpose, such as long polling for chat or live updates, show the bar each time one +starts, and so do slow requests a page makes in the background, such as prefetching links. Add their URLs to +`ignoreURLs`. -WebSocket connections are tracked too. To stop that, set `trackWebSockets: false` inside `ajax`. +WebSocket connections are tracked too. To stop that, set `trackWebSockets: false` inside `ajax`. When `trackFetch` or +`trackWebSockets` is false the first time Pace starts, Pace leaves `fetch` or `WebSocket` alone, so `Pace.track` can't +track them either. A few things Pace can't track: @@ -292,6 +316,7 @@ A few things Pace can't track: bundler), call `Pace.start()` early. - Requests made through a library that replaces `window.XMLHttpRequest` with its own version (such as xhook), if that library loads before Pace. Load Pace first. +- `fetch()` calls made through a copy of `fetch` that a library saved before Pace started. Avoid calling `Pace.restart()` for every request, for example from jQuery's `ajaxStart`. It restarts the bar even for ignored requests, and parallel requests restart it several times. To show the bar for `POST` requests, add `POST` to diff --git a/pace.js b/pace.js index a8d7196..c20e418 100644 --- a/pace.js +++ b/pace.js @@ -28,7 +28,9 @@ ajax: { trackMethods: ['GET'], trackWebSockets: true, + trackFetch: true, ignoreURLs: [], + maxWait: 15000, }, }; @@ -298,9 +300,10 @@ return ret; }; - // true, false, or 'force' (inside Pace.track). With `ajax: false`, nothing is + // Whether to track a request of `kind` ('xhr', 'fetch' or 'socket'): true, + // false, or 'force' (inside Pace.track). With `ajax: false`, nothing is // tracked, not even inside Pace.track. - const shouldTrack = (method) => { + const shouldTrack = (kind, method) => { if (!options.ajax) { return false; } @@ -311,28 +314,53 @@ if (ignoreStack.length) { return false; } - if (method === 'socket') { + if (kind === 'socket') { return Boolean(options.ajax.trackWebSockets); } + if (kind === 'fetch' && !options.ajax.trackFetch) { + return false; + } // Array.prototype.includes, so a string or number from a misconfiguration // matches nothing instead of matching substrings or throwing. return Array.prototype.includes.call(options.ajax.trackMethods, String(method).toUpperCase()); }; - const shouldIgnoreURL = (url) => { + // What ajax.ignoreURLs patterns are matched against: a request's URL as the page + // wrote it, its full URL, and for a request to the page's own origin the part + // after the origin (/api/items?page=2), so a pattern matches however the URL was + // written. `resolved` is the full URL when the browser already resolved it. + const urlsToMatch = (url, resolved) => { + const urls = [String(url)]; + try { + const full = new URL(resolved ?? urls[0], document.baseURI); + urls.push(full.href); + if (full.origin === location.origin) { + urls.push(full.pathname + full.search + full.hash); + } + } catch { + // A URL the browser can't parse is matched only as written. + } + return urls; + }; + + const matches = (pattern, url) => { + if (typeof pattern === 'string') { + return url.includes(pattern); + } + if (pattern instanceof RegExp) { + // A pattern with the g or y flag keeps state between test() calls. + pattern.lastIndex = 0; + return pattern.test(url); + } + return false; + }; + + // `urls` from urlsToMatch(). + const shouldIgnoreURL = (urls) => { const patterns = options.ajax?.ignoreURLs ?? []; for (let i = 0, n = patterns.length; i < n; i++) { - const pattern = patterns[i]; - if (typeof pattern === 'string') { - if (url.includes(pattern)) { - return true; - } - } else if (pattern instanceof RegExp) { - // A pattern with the g or y flag keeps state between test() calls. - pattern.lastIndex = 0; - if (pattern.test(url)) { - return true; - } + if (urls.some((candidate) => matches(patterns[i], candidate))) { + return true; } } return false; @@ -361,6 +389,8 @@ endCallbacks.get(event).push(callback); }; + // Ends the request, either because it ended or because pace stopped waiting + // for it (ajax.maxWait). const endRequest = (request) => { const event = openRequests.get(request); if (!event) { @@ -378,6 +408,15 @@ endRequest(event.request); openRequests.set(event.request, event); endCallbacks.set(event, []); + // Pace waits for a request at most ajax.maxWait ms from when it was sent, so + // one that never ends can't hold the bar forever. The request keeps going. + // false, or a value a timer can't wait for (setTimeout would treat Infinity, + // null or more than 2^31 - 1 ms as 0), means no limit. + const maxWait = options.ajax?.maxWait; + if (typeof maxWait === 'number' && maxWait >= 0 && maxWait <= 2147483647) { + const timer = setTimeout(() => endRequest(event.request), maxWait); + endCallbacks.get(event).push(() => clearTimeout(timer)); + } intercept.trigger('request', event); }; @@ -386,9 +425,10 @@ // Wraps XMLHttpRequest.prototype.open and send, and replaces WebSocket with a // subclass, so the page keeps the native classes: instanceof, constants, - // subclasses and other libraries' prototype patches keep working. Outside the - // class on purpose: class bodies are strict mode, where assigning to a - // read-only property (a page may lock these) throws. + // subclasses and other libraries' prototype patches keep working. Replaces + // fetch with a function that behaves the same. Outside the class on purpose: + // class bodies are strict mode, where assigning to a read-only property (a + // page may lock these) throws. const installIntercepts = (intercept) => { const proto = window.XMLHttpRequest.prototype; const open = proto.open; @@ -397,21 +437,22 @@ const listening = new WeakSet(); proto.open = function (...args) { const [method, url] = args; - const decision = shouldTrack(method); + const decision = shouldTrack('xhr', method); const result = open.apply(this, args); // Opening a request again cancels the one it was making (only once open() // has succeeded: when it throws, the earlier request keeps going). endRequest(this); opened.delete(this); if (decision) { - opened.set(this, { type: method, url: String(url), force: decision === 'force' }); + // The URL as open() used it, before the page can change a URL object. + opened.set(this, { kind: 'xhr', urls: urlsToMatch(url), force: decision === 'force' }); } return result; }; proto.send = function (...args) { const info = opened.get(this); opened.delete(this); - if (!info || shouldIgnoreURL(info.url)) { + if (!info || shouldIgnoreURL(info.urls)) { return send.apply(this, args); } // One listener per XMLHttpRequest, added before send() because a @@ -444,13 +485,51 @@ super(url); } const decision = shouldTrack('socket'); - if (decision && !shouldIgnoreURL(String(url))) { + if (decision && !shouldIgnoreURL(urlsToMatch(url, this.url))) { for (const name of ['open', 'error', 'close']) { this.addEventListener(name, () => endRequest(this), { once: true }); } - beginRequest(intercept, { type: 'socket', url: String(url), protocols, force: decision === 'force', request: this }); + beginRequest(intercept, { kind: 'socket', force: decision === 'force', request: this }); + } + } + }; + } + if (options.ajax.trackFetch && typeof window.fetch === 'function') { + const originalFetch = window.fetch; + // Calls the original with the same `this` and arguments, never reads or + // copies a Request's body, and returns a promise that settles like the + // original's. A request ends when that promise settles: on the response's + // headers, an error or an abort, not at the end of its body. + // Declares only `input`, so its length is 1 like the native fetch's. + window.fetch = function fetch(input) { + const init = arguments[1]; + let event = null; + // Deciding whether to track never throws into the page: fetch itself + // rejects input it can't use (a URL whose toString() throws, say). + try { + // Also a Request made in another frame, which instanceof wouldn't see. + const isRequest = Object.prototype.toString.call(input) === '[object Request]'; + const decision = shouldTrack('fetch', init?.method ?? (isRequest ? input.method : 'GET')); + if (decision && !shouldIgnoreURL(urlsToMatch(isRequest ? input.url : input))) { + event = { kind: 'fetch', force: decision === 'force' }; } + } catch {} + const promise = originalFetch.apply(this, arguments); + if (!event) { + return promise; } + event.request = promise; + beginRequest(intercept, event); + return promise.then( + (response) => { + endRequest(promise); + return response; + }, + (error) => { + endRequest(promise); + throw error; + }, + ); }; } }; @@ -488,7 +567,7 @@ let _intercept = null; - // Hooks into XMLHttpRequest and WebSocket the first time a run tracks ajax. + // Hooks into XMLHttpRequest, WebSocket and fetch the first time a run tracks ajax. const getIntercept = () => { _intercept ??= new RequestIntercept(); return _intercept; @@ -512,7 +591,7 @@ if (!isOpen(event) || this.elements.some((tracker) => tracker.event === event)) { return; } - const Tracker = event.type === 'socket' ? SocketRequestTracker : XHRRequestTracker; + const Tracker = event.kind === 'xhr' ? XHRRequestTracker : RequestTracker; this.elements.push(new Tracker(event, this.complete)); } @@ -548,7 +627,9 @@ } } - class SocketRequestTracker { + // A request that reports no progress until it ends: a fetch (whose progress + // can't be seen without reading its body) or a WebSocket connecting. + class RequestTracker { constructor(event, completeCallback) { this.event = event; this.progress = 0; diff --git a/pace.min.js b/pace.min.js index fb7f6d8..ccb43d0 100644 --- a/pace.min.js +++ b/pace.min.js @@ -2,4 +2,4 @@ * pace.js v1.3.1 * https://github.com/CodeByZach/pace/ * Licensed MIT © HubSpot, Inc. and Zachary Miller - */(function(){const O={className:"",catchupTime:100,initialRate:.03,minTime:250,ghostTime:100,maxProgressPerFrame:20,easeFactor:1.25,startOnPageLoad:!0,restartOnPushState:!0,restartOnRequestAfter:500,target:"body",elements:{checkInterval:100,selectors:["body"]},eventLag:{minSamples:10,sampleCount:3,lagThreshold:3},ajax:{trackMethods:["GET"],trackWebSockets:!0,ignoreURLs:[]}},p=()=>performance.now(),q=window.requestAnimationFrame,K=window.cancelAnimationFrame,A=(t,e,...s)=>typeof t[e]=="function"?t[e].apply(t,s):t[e],E=(t,...e)=>{for(const s of e)if(s)for(const r in s){if(!Object.prototype.hasOwnProperty.call(s,r)||r==="__proto__"||r==="constructor"||r==="prototype")continue;const n=s[r];t[r]!=null&&typeof t[r]=="object"&&n!=null&&typeof n=="object"?E(t[r],n):t[r]=n}return t},Y=t=>{let e=0,s=0;for(const r of t)e+=Math.abs(r),s++;return e/s},Q=(t="options",e=!0)=>{const s=document.querySelector(`script[data-pace-${t}]`);if(!s)return;const r=s.getAttribute(`data-pace-${t}`);if(!e)return r;try{return JSON.parse(r)}catch(n){console.error("Error parsing inline pace options",n)}},o=window.Pace||{};window.Pace=o;const j=(t,e,s)=>{const r=t.bindings?.[e];r&&(t.bindings[e]=r.filter(n=>s(n)?(n.removed=!0,!1):!0))},L=t=>{typeof reportError=="function"?reportError(t):queueMicrotask(()=>{throw t})};Object.assign(o,{on(t,e,s,r){r??=!1,this.bindings??=Object.create(null),this.bindings[t]??=[],this.bindings[t].push({handler:e,ctx:s,once:r})},once(t,e,s){this.on(t,e,s,!0)},off(t,e){j(this,t,s=>e==null||s.handler===e)},trigger(t,...e){const s=this.bindings?.[t];if(s){for(const r of s.slice())if(!r.removed){r.once&&j(this,t,n=>n===r);try{r.handler.apply(r.ctx??this,e)}catch(n){L(n)}}}}});const i=o.options=E({},O,window.paceOptions,Q()),I=()=>{for(const t of["ajax","document","eventLag","elements"])i[t]===!0&&(i[t]=O[t])};I();class W{constructor(){this.progress=0}getElement(){if(this.destroyed)return null;if(this.el==null){const e=document.querySelector(i.target);if(!e)return null;this.el=document.createElement("div"),this.el.className="pace pace-active",document.body.classList.remove("pace-done"),document.body.classList.add("pace-running");const s=document.createElement("div");s.className="pace-progress"+(i.className!==""?" "+i.className:"");const r=document.createElement("div");r.className="pace-progress-inner",s.appendChild(r);const n=document.createElement("div");n.className="pace-activity",this.el.appendChild(s),this.el.appendChild(n),e.insertBefore(this.el,e.firstChild)}return this.el}finish(){this.el?.classList.replace("pace-active","pace-inactive"),document.body.classList.remove("pace-running"),document.body.classList.add("pace-done")}update(e){this.progress=e,o.trigger("progress",e),this.render()}destroy(){this.el?.remove(),this.el=void 0,this.destroyed=!0}render(){if(document.querySelector(i.target)==null)return;const e=this.getElement();if(e){if(e.children[0].style.transform=`translate3d(${this.progress}%, 0, 0)`,!this.lastRenderedProgress||this.lastRenderedProgress|this.progress!==0|0){e.children[0].setAttribute("data-progress-text",`${this.progress|0}%`);let s;this.progress>=100?s="99":(s=this.progress<10?"0":"",s+=this.progress|0),e.children[0].setAttribute("data-progress",s)}o.trigger("change",this.progress),this.lastRenderedProgress=this.progress}}done(){return this.progress>=100}}class V{constructor(){this.bindings={}}trigger(e,s){if(this.bindings[e]!=null)for(const r of this.bindings[e].slice())r.call(this,s)}on(e,s){this.bindings[e]??=[],this.bindings[e].push(s)}off(e,s){this.bindings[e]=this.bindings[e]?.filter(r=>r!==s)}}const f=[];o.ignore=(t,...e)=>{f.unshift("ignore");const s=t.apply(null,e);return f.shift(),s},o.track=(t,...e)=>{f.unshift("track");const s=t.apply(null,e);return f.shift(),s};const F=t=>i.ajax?(t??="GET",f[0]==="track"?"force":f.length?!1:t==="socket"?!!i.ajax.trackWebSockets:Array.prototype.includes.call(i.ajax.trackMethods,String(t).toUpperCase())):!1,U=t=>{const e=i.ajax?.ignoreURLs??[];for(let s=0,r=e.length;sw.get(t.request)===t,N=(t,e)=>{if(!v(t)){e();return}b.get(t).push(e)},m=t=>{const e=w.get(t);if(!e)return;w.delete(t);const s=b.get(e);b.delete(e);for(const r of s)r()},_=(t,e)=>{m(e.request),w.set(e.request,e),b.set(e,[]),t.trigger("request",e)},k=new WeakMap,Z=t=>{const e=window.XMLHttpRequest.prototype,s=e.open,r=e.send,n=new WeakSet;if(e.open=function(...c){const[h,l]=c,u=F(h),g=s.apply(this,c);return m(this),k.delete(this),u&&k.set(this,{type:h,url:String(l),force:u==="force"}),g},e.send=function(...c){const h=k.get(this);if(k.delete(this),!h||U(h.url))return r.apply(this,c);n.has(this)||(n.add(this),this.addEventListener("readystatechange",()=>{(this.readyState===4||this.readyState===0)&&m(this)})),_(t,{...h,request:this});try{return r.apply(this,c)}catch(l){throw m(this),l}},i.ajax.trackWebSockets){const c=window.WebSocket;window.WebSocket=class extends c{constructor(l,u){u!=null?super(l,u):super(l);const g=F("socket");if(g&&!U(String(l))){for(const S of["open","error","close"])this.addEventListener(S,()=>m(this),{once:!0});_(t,{type:"socket",url:String(l),protocols:u,force:g==="force",request:this})}}}}},ee=t=>{if(T()||i.restartOnRequestAfter===!1&&!t.force)return;let e=i.restartOnRequestAfter||0;typeof e=="boolean"&&(e=0),R.add(t),setTimeout(()=>{R.delete(t),v(t)&&(T()||o.restart(),x()?.watch(t))},e)};class te extends V{constructor(){super(),Z(this),this.on("request",ee)}}let B=null;const $=()=>(B??=new te,B);class D{constructor(){this.complete=this.complete.bind(this),this.elements=[],this.onRequest=e=>this.watch(e),$().on("request",this.onRequest);for(const e of R)this.watch(e)}watch(e){if(!v(e)||this.elements.some(r=>r.event===e))return;const s=e.type==="socket"?re:se;this.elements.push(new s(e,this.complete))}complete(e){this.elements=this.elements.filter(s=>s!==e)}openEvents(){return this.elements.map(e=>e.event)}destroy(){$().off("request",this.onRequest)}}class se{constructor(e,s){this.event=e,this.progress=0,e.request.addEventListener("progress",r=>{r.lengthComputable?this.progress=100*r.loaded/r.total:this.progress=this.progress+(100-this.progress)/2},!1),N(e,()=>{s(this),this.progress=100})}}class re{constructor(e,s){this.event=e,this.progress=0,N(e,()=>{s(this),this.progress=100})}}class ne{constructor(e){e??={},this.complete=this.complete.bind(this),this.elements=[],e.selectors??=[],this.trackers=[];const s=e.selectors;for(let r=0,n=s.length;rs!==e)}destroy(){for(const e of this.trackers)e.destroy()}}class oe{constructor(e,s,r){this.selector=e,this.checkInterval=s,this.completeCallback=r,this.progress=0,this.check()}check(){document.querySelector(this.selector)?this.done():this.timer=setTimeout(()=>this.check(),this.checkInterval)}destroy(){clearTimeout(this.timer)}done(){this.completeCallback(this),this.completeCallback=null,this.progress=100}}class G{constructor(){this.progress=this.states[document.readyState]??100,this.onChange=()=>{this.states[document.readyState]!=null&&(this.progress=this.states[document.readyState])},document.addEventListener("readystatechange",this.onChange)}destroy(){document.removeEventListener("readystatechange",this.onChange)}}G.prototype.states={loading:0,interactive:50,complete:100};class ie{constructor(e){this.progress=0;let s=0;const r=[];let n=0,c=p();const h=this.interval=setInterval(()=>{const l=p()-c-50;c=p(),r.push(l),r.length>e.sampleCount&&r.shift(),s=Y(r),++n>=e.minSamples&&s=100&&(this.done=!0),s===this.last?this.sinceLastUpdate+=e:(this.sinceLastUpdate&&(this.rate=(s-this.last)/this.sinceLastUpdate),this.catchup=(s-this.progress)/i.catchupTime,this.sinceLastUpdate=0,this.last=s),s>this.progress&&(this.progress+=this.catchup*e);const r=1-Math.pow(this.progress/100,i.easeFactor);return this.progress+=r*this.rate*e,this.progress=Math.min(this.lastProgress+i.maxProgressPerFrame,this.progress),this.progress=Math.max(0,this.progress),this.progress=Math.min(100,this.progress),this.lastProgress=this.progress,this.progress}}let a=null;const x=()=>a?.collectors.find(t=>t instanceof D);let d=o.bar=new W;o.sources=[],o.running=!1;const T=()=>a!=null&&(a.state==="waiting"||a.state==="running"),X=()=>{i.restartOnPushState&&o.restart()},ce=window.history.pushState;window.history.pushState=(...t)=>{try{X()}catch(e){L(e)}return ce.apply(window.history,t)};const ae=window.history.replaceState;window.history.replaceState=(...t)=>{try{X()}catch(e){L(e)}return ae.apply(window.history,t)};const le={ajax:D,elements:ne,document:G,eventLag:ie},M=t=>{for(const e of t.collectors.splice(0))e.destroy()},z=()=>{if(a){const t=a;a=null,K(t.frame),clearTimeout(t.waitTimer),clearTimeout(t.hideTimer),M(t)}d.destroy()},he=t=>{if(d.update(100),a!==t||(t.state="finishing",M(t),o.trigger("done"),a!==t))return;const e=p()-t.startedAt;t.hideTimer=setTimeout(()=>{a=null,o.running=!1,d.finish(),o.trigger("hide")},Math.max(i.ghostTime,i.minTime-e,0))},ue=t=>{let e=p();const s=()=>{if(a!==t)return;const r=p()-e;if(r<30){t.frame=q(s);return}e=p();let n=0,c=0,h=!0;const l=t.sources;for(let u=0,g=l.length;u{const t={state:"waiting",collectors:[],sources:[],scalers:new WeakMap,uniScaler:new H};try{for(const r of["ajax","elements","document","eventLag"])if(i[r]!==!1){const n=new le[r](i[r]);t.collectors.push(n),t.sources.push(n)}const s=i.extraSources??[];for(let r=0,n=s.length;r{if(a===t){if(!d.getElement()){t.waitTimer=setTimeout(e,50);return}t.state="running",t.startedAt=p(),d.render(),a===t&&(o.trigger("start"),ue(t))}};e()};o.start=t=>{E(i,t),I(),!T()&&(z(),pe())},o.stop=()=>{z(),o.running=!1,document.body.classList.remove("pace-running"),document.body.classList.add("pace-done"),o.trigger("stop")},o.restart=()=>{o.trigger("restart");const t=x()?.openEvents()??[];o.stop(),o.start();for(const e of t)x()?.watch(e)},typeof define=="function"&&define.amd?define(()=>o):typeof exports=="object"?module.exports=o:i.startOnPageLoad&&o.start()})(); + */(function(){const I={className:"",catchupTime:100,initialRate:.03,minTime:250,ghostTime:100,maxProgressPerFrame:20,easeFactor:1.25,startOnPageLoad:!0,restartOnPushState:!0,restartOnRequestAfter:500,target:"body",elements:{checkInterval:100,selectors:["body"]},eventLag:{minSamples:10,sampleCount:3,lagThreshold:3},ajax:{trackMethods:["GET"],trackWebSockets:!0,trackFetch:!0,ignoreURLs:[],maxWait:15e3}},g=()=>performance.now(),R=window.requestAnimationFrame,Y=window.cancelAnimationFrame,U=(t,e,...s)=>typeof t[e]=="function"?t[e].apply(t,s):t[e],E=(t,...e)=>{for(const s of e)if(s)for(const r in s){if(!Object.prototype.hasOwnProperty.call(s,r)||r==="__proto__"||r==="constructor"||r==="prototype")continue;const n=s[r];t[r]!=null&&typeof t[r]=="object"&&n!=null&&typeof n=="object"?E(t[r],n):t[r]=n}return t},Q=t=>{let e=0,s=0;for(const r of t)e+=Math.abs(r),s++;return e/s},V=(t="options",e=!0)=>{const s=document.querySelector(`script[data-pace-${t}]`);if(!s)return;const r=s.getAttribute(`data-pace-${t}`);if(!e)return r;try{return JSON.parse(r)}catch(n){console.error("Error parsing inline pace options",n)}},o=window.Pace||{};window.Pace=o;const N=(t,e,s)=>{const r=t.bindings?.[e];r&&(t.bindings[e]=r.filter(n=>s(n)?(n.removed=!0,!1):!0))},L=t=>{typeof reportError=="function"?reportError(t):queueMicrotask(()=>{throw t})};Object.assign(o,{on(t,e,s,r){r??=!1,this.bindings??=Object.create(null),this.bindings[t]??=[],this.bindings[t].push({handler:e,ctx:s,once:r})},once(t,e,s){this.on(t,e,s,!0)},off(t,e){N(this,t,s=>e==null||s.handler===e)},trigger(t,...e){const s=this.bindings?.[t];if(s){for(const r of s.slice())if(!r.removed){r.once&&N(this,t,n=>n===r);try{r.handler.apply(r.ctx??this,e)}catch(n){L(n)}}}}});const i=o.options=E({},I,window.paceOptions,V()),_=()=>{for(const t of["ajax","document","eventLag","elements"])i[t]===!0&&(i[t]=I[t])};_();class B{constructor(){this.progress=0}getElement(){if(this.destroyed)return null;if(this.el==null){const e=document.querySelector(i.target);if(!e)return null;this.el=document.createElement("div"),this.el.className="pace pace-active",document.body.classList.remove("pace-done"),document.body.classList.add("pace-running");const s=document.createElement("div");s.className="pace-progress"+(i.className!==""?" "+i.className:"");const r=document.createElement("div");r.className="pace-progress-inner",s.appendChild(r);const n=document.createElement("div");n.className="pace-activity",this.el.appendChild(s),this.el.appendChild(n),e.insertBefore(this.el,e.firstChild)}return this.el}finish(){this.el?.classList.replace("pace-active","pace-inactive"),document.body.classList.remove("pace-running"),document.body.classList.add("pace-done")}update(e){this.progress=e,o.trigger("progress",e),this.render()}destroy(){this.el?.remove(),this.el=void 0,this.destroyed=!0}render(){if(document.querySelector(i.target)==null)return;const e=this.getElement();if(e){if(e.children[0].style.transform=`translate3d(${this.progress}%, 0, 0)`,!this.lastRenderedProgress||this.lastRenderedProgress|this.progress!==0|0){e.children[0].setAttribute("data-progress-text",`${this.progress|0}%`);let s;this.progress>=100?s="99":(s=this.progress<10?"0":"",s+=this.progress|0),e.children[0].setAttribute("data-progress",s)}o.trigger("change",this.progress),this.lastRenderedProgress=this.progress}}done(){return this.progress>=100}}class Z{constructor(){this.bindings={}}trigger(e,s){if(this.bindings[e]!=null)for(const r of this.bindings[e].slice())r.call(this,s)}on(e,s){this.bindings[e]??=[],this.bindings[e].push(s)}off(e,s){this.bindings[e]=this.bindings[e]?.filter(r=>r!==s)}}const w=[];o.ignore=(t,...e)=>{w.unshift("ignore");const s=t.apply(null,e);return w.shift(),s},o.track=(t,...e)=>{w.unshift("track");const s=t.apply(null,e);return w.shift(),s};const T=(t,e)=>i.ajax?(e??="GET",w[0]==="track"?"force":w.length?!1:t==="socket"?!!i.ajax.trackWebSockets:t==="fetch"&&!i.ajax.trackFetch?!1:Array.prototype.includes.call(i.ajax.trackMethods,String(e).toUpperCase())):!1,v=(t,e)=>{const s=[String(t)];try{const r=new URL(e??s[0],document.baseURI);s.push(r.href),r.origin===location.origin&&s.push(r.pathname+r.search+r.hash)}catch{}return s},ee=(t,e)=>typeof t=="string"?e.includes(t):t instanceof RegExp?(t.lastIndex=0,t.test(e)):!1,M=t=>{const e=i.ajax?.ignoreURLs??[];for(let s=0,r=e.length;see(e[s],n)))return!0;return!1},x=new Map,k=new WeakMap,j=new Set,O=t=>x.get(t.request)===t,$=(t,e)=>{if(!O(t)){e();return}k.get(t).push(e)},m=t=>{const e=x.get(t);if(!e)return;x.delete(t);const s=k.get(e);k.delete(e);for(const r of s)r()},P=(t,e)=>{m(e.request),x.set(e.request,e),k.set(e,[]);const s=i.ajax?.maxWait;if(typeof s=="number"&&s>=0&&s<=2147483647){const r=setTimeout(()=>m(e.request),s);k.get(e).push(()=>clearTimeout(r))}t.trigger("request",e)},q=new WeakMap,te=t=>{const e=window.XMLHttpRequest.prototype,s=e.open,r=e.send,n=new WeakSet;if(e.open=function(...c){const[h,a]=c,u=T("xhr",h),d=s.apply(this,c);return m(this),q.delete(this),u&&q.set(this,{kind:"xhr",urls:v(a),force:u==="force"}),d},e.send=function(...c){const h=q.get(this);if(q.delete(this),!h||M(h.urls))return r.apply(this,c);n.has(this)||(n.add(this),this.addEventListener("readystatechange",()=>{(this.readyState===4||this.readyState===0)&&m(this)})),P(t,{...h,request:this});try{return r.apply(this,c)}catch(a){throw m(this),a}},i.ajax.trackWebSockets){const c=window.WebSocket;window.WebSocket=class extends c{constructor(a,u){u!=null?super(a,u):super(a);const d=T("socket");if(d&&!M(v(a,this.url))){for(const p of["open","error","close"])this.addEventListener(p,()=>m(this),{once:!0});P(t,{kind:"socket",force:d==="force",request:this})}}}}if(i.ajax.trackFetch&&typeof window.fetch=="function"){const c=window.fetch;window.fetch=function(a){const u=arguments[1];let d=null;try{const f=Object.prototype.toString.call(a)==="[object Request]",b=T("fetch",u?.method??(f?a.method:"GET"));b&&!M(v(f?a.url:a))&&(d={kind:"fetch",force:b==="force"})}catch{}const p=c.apply(this,arguments);return d?(d.request=p,P(t,d),p.then(f=>(m(p),f),f=>{throw m(p),f})):p}}},se=t=>{if(A()||i.restartOnRequestAfter===!1&&!t.force)return;let e=i.restartOnRequestAfter||0;typeof e=="boolean"&&(e=0),j.add(t),setTimeout(()=>{j.delete(t),O(t)&&(A()||o.restart(),C()?.watch(t))},e)};class re extends Z{constructor(){super(),te(this),this.on("request",se)}}let G=null;const D=()=>(G??=new re,G);class H{constructor(){this.complete=this.complete.bind(this),this.elements=[],this.onRequest=e=>this.watch(e),D().on("request",this.onRequest);for(const e of j)this.watch(e)}watch(e){if(!O(e)||this.elements.some(r=>r.event===e))return;const s=e.kind==="xhr"?ne:oe;this.elements.push(new s(e,this.complete))}complete(e){this.elements=this.elements.filter(s=>s!==e)}openEvents(){return this.elements.map(e=>e.event)}destroy(){D().off("request",this.onRequest)}}class ne{constructor(e,s){this.event=e,this.progress=0,e.request.addEventListener("progress",r=>{r.lengthComputable?this.progress=100*r.loaded/r.total:this.progress=this.progress+(100-this.progress)/2},!1),$(e,()=>{s(this),this.progress=100})}}class oe{constructor(e,s){this.event=e,this.progress=0,$(e,()=>{s(this),this.progress=100})}}class ie{constructor(e){e??={},this.complete=this.complete.bind(this),this.elements=[],e.selectors??=[],this.trackers=[];const s=e.selectors;for(let r=0,n=s.length;rs!==e)}destroy(){for(const e of this.trackers)e.destroy()}}class ce{constructor(e,s,r){this.selector=e,this.checkInterval=s,this.completeCallback=r,this.progress=0,this.check()}check(){document.querySelector(this.selector)?this.done():this.timer=setTimeout(()=>this.check(),this.checkInterval)}destroy(){clearTimeout(this.timer)}done(){this.completeCallback(this),this.completeCallback=null,this.progress=100}}class X{constructor(){this.progress=this.states[document.readyState]??100,this.onChange=()=>{this.states[document.readyState]!=null&&(this.progress=this.states[document.readyState])},document.addEventListener("readystatechange",this.onChange)}destroy(){document.removeEventListener("readystatechange",this.onChange)}}X.prototype.states={loading:0,interactive:50,complete:100};class ae{constructor(e){this.progress=0;let s=0;const r=[];let n=0,c=g();const h=this.interval=setInterval(()=>{const a=g()-c-50;c=g(),r.push(a),r.length>e.sampleCount&&r.shift(),s=Q(r),++n>=e.minSamples&&s=100&&(this.done=!0),s===this.last?this.sinceLastUpdate+=e:(this.sinceLastUpdate&&(this.rate=(s-this.last)/this.sinceLastUpdate),this.catchup=(s-this.progress)/i.catchupTime,this.sinceLastUpdate=0,this.last=s),s>this.progress&&(this.progress+=this.catchup*e);const r=1-Math.pow(this.progress/100,i.easeFactor);return this.progress+=r*this.rate*e,this.progress=Math.min(this.lastProgress+i.maxProgressPerFrame,this.progress),this.progress=Math.max(0,this.progress),this.progress=Math.min(100,this.progress),this.lastProgress=this.progress,this.progress}}let l=null;const C=()=>l?.collectors.find(t=>t instanceof H);let y=o.bar=new B;o.sources=[],o.running=!1;const A=()=>l!=null&&(l.state==="waiting"||l.state==="running"),J=()=>{i.restartOnPushState&&o.restart()},le=window.history.pushState;window.history.pushState=(...t)=>{try{J()}catch(e){L(e)}return le.apply(window.history,t)};const he=window.history.replaceState;window.history.replaceState=(...t)=>{try{J()}catch(e){L(e)}return he.apply(window.history,t)};const ue={ajax:H,elements:ie,document:X,eventLag:ae},F=t=>{for(const e of t.collectors.splice(0))e.destroy()},K=()=>{if(l){const t=l;l=null,Y(t.frame),clearTimeout(t.waitTimer),clearTimeout(t.hideTimer),F(t)}y.destroy()},de=t=>{if(y.update(100),l!==t||(t.state="finishing",F(t),o.trigger("done"),l!==t))return;const e=g()-t.startedAt;t.hideTimer=setTimeout(()=>{l=null,o.running=!1,y.finish(),o.trigger("hide")},Math.max(i.ghostTime,i.minTime-e,0))},pe=t=>{let e=g();const s=()=>{if(l!==t)return;const r=g()-e;if(r<30){t.frame=R(s);return}e=g();let n=0,c=0,h=!0;const a=t.sources;for(let u=0,d=a.length;u{const t={state:"waiting",collectors:[],sources:[],scalers:new WeakMap,uniScaler:new z};try{for(const r of["ajax","elements","document","eventLag"])if(i[r]!==!1){const n=new ue[r](i[r]);t.collectors.push(n),t.sources.push(n)}const s=i.extraSources??[];for(let r=0,n=s.length;r{if(l===t){if(!y.getElement()){t.waitTimer=setTimeout(e,50);return}t.state="running",t.startedAt=g(),y.render(),l===t&&(o.trigger("start"),pe(t))}};e()};o.start=t=>{E(i,t),_(),!A()&&(K(),fe())},o.stop=()=>{K(),o.running=!1,document.body.classList.remove("pace-running"),document.body.classList.add("pace-done"),o.trigger("stop")},o.restart=()=>{o.trigger("restart");const t=C()?.openEvents()??[];o.stop(),o.start();for(const e of t)C()?.watch(e)},typeof define=="function"&&define.amd?define(()=>o):typeof exports=="object"?module.exports=o:i.startOnPageLoad&&o.start()})(); diff --git a/tests/ajax.spec.mjs b/tests/ajax.spec.mjs index 2bd9320..e9f4182 100644 --- a/tests/ajax.spec.mjs +++ b/tests/ajax.spec.mjs @@ -1,63 +1,115 @@ -// Tracking XMLHttpRequests made after the page has loaded. A restart happens 500 ms +// Tracking requests made after the page has loaded. A restart happens 500 ms // (restartOnRequestAfter) into a request, so for the 900 ms requests below it is // already recorded by the time the request returns. import { test, expect, idle, request, clearEvents, RESTARTED, NO_EVENT_LAG } from './fixtures.mjs'; -test.describe('with default ajax options', () => { - test.beforeEach(async ({ page, open }) => { - await open({ head: NO_EVENT_LAG }); +// The same rules apply to XMLHttpRequest and fetch(). +for (const api of ['XMLHttpRequest', 'fetch']) { + test(`${api}: ajax.ignoreURLs patterns see the full URL`, async ({ page, open }) => { + await open({ head: "" }); await idle(page); await clearEvents(page); + await request(page, { ms: 900, api }); // a relative URL + expect(await page.evaluate(() => events)).toEqual([]); }); - test('a slow GET restarts the bar, which finishes after the request', async ({ page }) => { - const loadedAt = await request(page, { ms: 900 }); + test(`${api}: ajax.ignoreURLs patterns also see the path and query of a request to the page's origin, not another's`, async ({ page, open }) => { + await page.route('http://other.test/**', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 900)); + await route.fulfill({ headers: { 'access-control-allow-origin': '*' }, body: 'ok' }); + }); + await open({ head: "" }); + await idle(page); + await clearEvents(page); + await request(page, { ms: 900, api, origin: 'http://pace.test', query: '#part' }); // an absolute URL + expect(await page.evaluate(() => events)).toEqual([]); + await request(page, { ms: 900, api, origin: 'http://other.test', query: '#part' }); await idle(page); expect(await page.evaluate(() => events)).toEqual(RESTARTED); - expect(await page.evaluate(() => eventTimes.done)).toBeGreaterThanOrEqual(loadedAt); }); - test('a GET shorter than restartOnRequestAfter (500 ms) does not restart the bar', async ({ page }) => { - await request(page, { ms: 100 }); - await page.waitForTimeout(700); // past the 500 ms mark + test(`${api}: ajax.ignoreURLs patterns also see the URL as the page wrote it`, async ({ page, open }) => { + await open({ head: "" }); + await idle(page); + await clearEvents(page); + // Neither appears in the full URL, which has %20 for the space and a lowercase domain. + await request(page, { ms: 900, api, query: '&q=a b' }); + await request(page, { ms: 900, api, origin: 'http://PACE.test' }); expect(await page.evaluate(() => events)).toEqual([]); }); - test('POST is not tracked', async ({ page }) => { - await request(page, { ms: 900, method: 'POST' }); + test(`${api}: ajax.ignoreURLs patterns see the full URL resolved against `, async ({ page, open }) => { + await open({ head: '' }); + await idle(page); + await clearEvents(page); + await page.evaluate((api) => (api === 'fetch' + ? fetch('delay?ms=900').then((response) => response.text()) + : new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'delay?ms=900'); + xhr.onload = resolve; + xhr.onerror = reject; + xhr.send(); + })), api); expect(await page.evaluate(() => events)).toEqual([]); }); - test('Pace.ignore() skips a request opened inside it', async ({ page }) => { - await request(page, { ms: 900, wrap: 'ignore' }); - expect(await page.evaluate(() => events)).toEqual([]); - }); + test.describe(`${api} with default ajax options`, () => { + test.beforeEach(async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + }); - test('Pace.track() tracks a request opened inside it, even a POST', async ({ page }) => { - await request(page, { ms: 900, method: 'POST', wrap: 'track' }); - await idle(page); - expect(await page.evaluate(() => events)).toEqual(RESTARTED); - }); -}); + test('a slow GET restarts the bar, which finishes after the request', async ({ page }) => { + const loadedAt = await request(page, { ms: 900, api }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + expect(await page.evaluate(() => eventTimes.done)).toBeGreaterThanOrEqual(loadedAt); + }); -test.describe('with ajax options', () => { - test.beforeEach(async ({ page, open }) => { - await open({ head: "" }); - await idle(page); - await clearEvents(page); - }); + test('a GET shorter than restartOnRequestAfter (500 ms) does not restart the bar', async ({ page }) => { + await request(page, { ms: 100, api }); + await page.waitForTimeout(700); // past the 500 ms mark + expect(await page.evaluate(() => events)).toEqual([]); + }); - test('ajax.trackMethods can add POST', async ({ page }) => { - await request(page, { ms: 900, method: 'POST' }); - await idle(page); - expect(await page.evaluate(() => events)).toEqual(RESTARTED); + test('POST is not tracked', async ({ page }) => { + await request(page, { ms: 900, method: 'POST', api }); + expect(await page.evaluate(() => events)).toEqual([]); + }); + + test('Pace.ignore() skips a request opened inside it', async ({ page }) => { + await request(page, { ms: 900, wrap: 'ignore', api }); + expect(await page.evaluate(() => events)).toEqual([]); + }); + + test('Pace.track() tracks a request opened inside it, even a POST', async ({ page }) => { + await request(page, { ms: 900, method: 'POST', wrap: 'track', api }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + }); }); - test('ajax.ignoreURLs skips matching URLs', async ({ page }) => { - await request(page, { ms: 900, query: '&ignored' }); - expect(await page.evaluate(() => events)).toEqual([]); + test.describe(`${api} with ajax options`, () => { + test.beforeEach(async ({ page, open }) => { + await open({ head: "" }); + await idle(page); + await clearEvents(page); + }); + + test('ajax.trackMethods can add POST', async ({ page }) => { + await request(page, { ms: 900, method: 'POST', api }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + }); + + test('ajax.ignoreURLs skips matching URLs', async ({ page }) => { + await request(page, { ms: 900, query: '&ignored', api }); + expect(await page.evaluate(() => events)).toEqual([]); + }); }); -}); +} test.describe('with ajax.trackMethods that is not an array', () => { test('a string matches nothing, not substrings', async ({ page, open }) => { diff --git a/tests/fetch.spec.mjs b/tests/fetch.spec.mjs new file mode 100644 index 0000000..399f510 --- /dev/null +++ b/tests/fetch.spec.mjs @@ -0,0 +1,246 @@ +// How pace hooks into fetch(): it replaces window.fetch with a function that calls the +// original with the same `this` and arguments and returns a promise that settles the same +// way, and tracks a request until that promise settles (the response's headers, an error or +// an abort), not until its body ends. +import { test, expect, idle, request, clearEvents, RESTARTED, NO_EVENT_LAG } from './fixtures.mjs'; + +const SAVE_FETCH = ''; + +test('pace hooks into fetch only once it starts', async ({ page, open }) => { + await open({ head: SAVE_FETCH + '' }); + expect(await page.evaluate(() => fetch === nativeFetch)).toBe(true); + await page.evaluate(() => Pace.start()); + expect(await page.evaluate(() => [fetch === nativeFetch, fetch.length === nativeFetch.length])).toEqual([false, true]); +}); + +for (const [name, options] of [ + ['trackFetch: false', '{ eventLag: false, ajax: { trackFetch: false } }'], + ['ajax: false', '{ eventLag: false, ajax: false }'], +]) { + test(`with ${name}, fetch stays native`, async ({ page, open }) => { + await open({ head: `${SAVE_FETCH}` }); + await idle(page); + expect(await page.evaluate(() => fetch === nativeFetch)).toBe(true); + }); +} + +test('a fetch during page load holds the bar until it settles', async ({ page, open }) => { + await open({ + head: NO_EVENT_LAG, + body: ``, + }); + await idle(page); + const [settledAt, doneAt] = await page.evaluate(() => [window.settledAt, eventTimes.done]); + expect(settledAt).not.toBeNull(); + expect(doneAt).toBeGreaterThanOrEqual(settledAt - 50); +}); + +test('the method comes from init, then from a Request', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + for (const [call, tracked] of [ + ["fetch(new Request('/delay?ms=900'))", true], + ["fetch(new Request('/delay?ms=900', { method: 'POST' }))", false], + ["fetch(new Request('/delay?ms=900'), { method: 'POST' })", false], + ["fetch(new Request('/delay?ms=900', { method: 'POST' }), { method: 'get' })", true], + ["fetch(new URL('/delay?ms=900', location.href))", true], + ]) { + await clearEvents(page); + await page.evaluate(`${call}.then((response) => response.text())`); + await idle(page); + expect(await page.evaluate(() => events), call).toEqual(tracked ? RESTARTED : []); + } +}); + +test('ajax.ignoreURLs patterns see the URL of a Request', async ({ page, open }) => { + await open({ head: "" }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => fetch(new Request('/delay?ms=900&ignored')).then((response) => response.text())); + expect(await page.evaluate(() => events)).toEqual([]); +}); + +test('a Request made in another frame is read like one made in this frame', async ({ page, open }) => { + await open({ head: "", body: '' }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => { + const FrameRequest = document.querySelector('iframe').contentWindow.Request; + return Promise.all([ + fetch(new FrameRequest(location.origin + '/delay?ms=900', { method: 'POST' })), // POST isn't tracked + fetch(new FrameRequest(location.origin + '/delay?ms=900&ignored')), + ]); + }); + expect(await page.evaluate(() => events)).toEqual([]); +}); + +test('turning trackFetch off in a later Pace.start() stops tracking fetch', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await page.evaluate(() => { + Pace.stop(); + Pace.start({ ajax: { trackFetch: false } }); + }); + await idle(page); + await clearEvents(page); + await request(page, { ms: 900, api: 'fetch' }); + expect(await page.evaluate(() => events)).toEqual([]); +}); + +test('fetch accepts the same `this` as the native fetch', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + const result = await page.evaluate(async () => { + const outcome = (call) => call().then(() => 'resolved', (error) => error.name); + const f = fetch; + return [ + await outcome(() => f('/delay?ms=10')), + await outcome(() => fetch.call(window, '/delay?ms=10')), + await outcome(() => fetch.call({}, '/delay?ms=10')), + await outcome(() => ({ fetch }).fetch('/delay?ms=10')), + ]; + }); + expect(result).toEqual(['resolved', 'resolved', 'TypeError', 'TypeError']); +}); + +test('fetch rejects bad input the way the native fetch does, instead of throwing', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + const result = await page.evaluate(async () => { + const outcome = (call) => { + let promise; + try { + promise = call(); + } catch (error) { + return 'threw'; + } + return promise.then(() => 'resolved', (error) => error.message ?? error.name); + }; + const bad = { toString() { throw new Error('bad input'); } }; + return [ + await outcome(() => fetch(bad)), + await outcome(() => fetch(Symbol('url'))), + await outcome(() => fetch()), + ]; + }); + expect(result[0]).toBe('bad input'); + expect(result.slice(1).every((value) => value !== 'threw' && value !== 'resolved')).toBe(true); +}); + +test('a script that wrapped fetch before pace gets the same arguments as from the page', async ({ page, open }) => { + await open({ + head: `${NO_EVENT_LAG}`, + }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => Promise.all([fetch('/delay?ms=900'), fetch('/delay?ms=10', {})])); + await idle(page); + expect(await page.evaluate(() => [calls, events])).toEqual([[1, 2], RESTARTED]); +}); + +test('a script that wraps fetch after pace still sees requests, and pace still tracks them', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => { + window.seen = []; + const inner = fetch; + window.fetch = function (input, init) { + seen.push(String(input)); + return inner.apply(this, arguments); + }; + }); + await request(page, { ms: 900, api: 'fetch' }); + await idle(page); + expect(await page.evaluate(() => [seen, events])).toEqual([['/delay?ms=900'], RESTARTED]); +}); + +test('a fetch is tracked through a navigation', async ({ page, open }) => { + await open({ head: NO_EVENT_LAG }); + await idle(page); + await clearEvents(page); + const settledAt = await page.evaluate(() => { + const settled = fetch('/delay?ms=2000').then(() => performance.now()); + setTimeout(() => history.pushState({}, '', '?next'), 700); + return settled; + }); + await idle(page); + expect(await page.evaluate(() => eventTimes.done)).toBeGreaterThanOrEqual(settledAt - 50); +}); + +test.describe('with a real server', () => { + test('a fetch ends when its response arrives, not when its body ends', async ({ page, server }) => { + await page.goto(server); + await idle(page); + await clearEvents(page); + const [respondedAt, bodyEndedAt] = await page.evaluate(async () => { + const response = await fetch('/stream?head=900&ms=3000'); + const respondedAt = performance.now(); + await response.text(); + return [respondedAt, performance.now()]; + }); + await idle(page); + const [events, doneAt] = await page.evaluate(() => [window.events, eventTimes.done]); + expect(events).toEqual(RESTARTED); + expect(doneAt).toBeGreaterThanOrEqual(respondedAt - 50); + expect(doneAt).toBeLessThan(bodyEndedAt - 1000); + }); + + test('a tracked Request keeps its body for the server', async ({ page, server }) => { + await page.goto(`${server}/?options=${encodeURIComponent("{ eventLag: false, ajax: { trackMethods: ['POST'] } }")}`); + await idle(page); + await clearEvents(page); + const result = await page.evaluate(async () => { + const request = new Request('/echo?ms=900', { method: 'POST', body: 'hello' }); + const response = await fetch(request); + return [await response.text(), request.bodyUsed]; + }); + expect(result).toEqual(['POST hello', true]); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + }); + + for (const [name, call] of [ + ['a network error', "fetch('/drop?ms=900')"], + ['an abort', "(() => { const controller = new AbortController(); setTimeout(() => controller.abort(), 900); return fetch('/hang', { signal: controller.signal }); })()"], + ]) { + test(`a fetch that fails with ${name} ends, and rejects with the same error`, async ({ page, server }) => { + await page.goto(server); + await idle(page); + await clearEvents(page); + const [reason, failedAt] = await page.evaluate(`${call}.then(() => ['resolved'], (error) => [error.name, performance.now()])`); + expect(reason).toBe(name === 'an abort' ? 'AbortError' : 'TypeError'); + await idle(page); + const [events, doneAt] = await page.evaluate(() => [window.events, eventTimes.done]); + expect(events).toEqual(RESTARTED); + expect(doneAt - failedAt).toBeLessThan(1000); // not held until maxWait + }); + } + + test('a failed fetch the page ignores is still reported as an unhandled rejection', async ({ page, server }) => { + await page.goto(server); + await idle(page); + const counts = await page.evaluate(async () => { + let unhandled = 0; + addEventListener('unhandledrejection', (event) => { + unhandled++; + event.preventDefault(); + }); + fetch('/drop'); + fetch('/drop').catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 500)); + return unhandled; + }); + expect(counts).toBe(1); + }); +}); diff --git a/tests/fixtures.mjs b/tests/fixtures.mjs index 4ff0607..c363717 100644 --- a/tests/fixtures.mjs +++ b/tests/fixtures.mjs @@ -1,4 +1,6 @@ +import crypto from 'node:crypto'; import fs from 'node:fs'; +import http from 'node:http'; import { test as base, expect } from '@playwright/test'; const root = new URL('..', import.meta.url); @@ -8,9 +10,92 @@ const root = new URL('..', import.meta.url); // + +`); + }); + server.on('upgrade', (req, socket) => { + sockets.add(socket); + socket.on('error', () => {}); + if (req.url.includes('never')) { + return; + } + const accept = crypto.createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); + setTimeout(() => { + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); + }, 1500); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + await use(`http://127.0.0.1:${server.address().port}`); + for (const socket of sockets) socket.destroy(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }, { scope: 'worker' }], open: async ({ page, paceFile }, use) => { const pace = fs.readFileSync(new URL(paceFile, root), 'utf8'); @@ -20,7 +105,7 @@ export const test = base.extend({ if (url.pathname === '/pace.js') { return route.fulfill({ contentType: 'text/javascript', body: pace }); } - if (url.pathname === '/delay') { + if (url.pathname.endsWith('/delay')) { await new Promise((resolve) => setTimeout(resolve, Number(url.searchParams.get('ms')))); return route.fulfill({ contentType: 'text/plain', body: 'ok' }); } @@ -56,23 +141,30 @@ export { expect }; // Waits until pace has finished and hidden the bar. export const idle = (page) => page.waitForFunction(() => document.querySelector('.pace.pace-inactive')); -// Sends an XHR to /delay?ms=N (plus `query`), optionally opened inside Pace.ignore or -// Pace.track (`wrap`). Resolves with the page time (performance.now()) when it loaded. -// No cache-busting needed: routing disables the HTTP cache. -export const request = (page, { ms, method = 'GET', wrap, query = '' }) => +// Sends a request to `origin`/delay?ms=N (plus `query`) with `api` (XMLHttpRequest or fetch), +// optionally inside Pace.ignore or Pace.track (`wrap`). Resolves with the page time +// (performance.now()) when it loaded. No cache-busting needed: routing disables the +// HTTP cache. +export const request = (page, { ms, method = 'GET', wrap, query = '', api = 'XMLHttpRequest', origin = '' }) => page.evaluate( - ({ ms, method, wrap, query }) => - new Promise((resolve) => { + ({ ms, method, wrap, query, api, origin }) => + new Promise((resolve, reject) => { + const url = `${origin}/delay?ms=${ms}${query}`; const send = () => { + if (api === 'fetch') { + fetch(url, { method }).then(() => resolve(performance.now()), reject); + return; + } const xhr = new XMLHttpRequest(); - xhr.open(method, `/delay?ms=${ms}${query}`); + xhr.open(method, url); xhr.onload = () => resolve(performance.now()); + xhr.onerror = reject; xhr.send(); }; if (wrap) Pace[wrap](send); else send(); }), - { ms, method, wrap, query }, + { ms, method, wrap, query, api, origin }, ); // The events of one restart, in order. diff --git a/tests/max-wait.spec.mjs b/tests/max-wait.spec.mjs new file mode 100644 index 0000000..d79f299 --- /dev/null +++ b/tests/max-wait.spec.mjs @@ -0,0 +1,135 @@ +// ajax.maxWait: pace waits for each tracked request at most this many ms from when it was sent, +// so one request that never ends can't hold the bar forever. The request itself keeps going. +import { test, expect, idle, request, clearEvents, RESTARTED } from './fixtures.mjs'; + +const options = (maxWait) => ``; + +// A value a timer can't wait for (setTimeout would treat it as 0) means no limit, like false. +for (const value of ['Infinity', 'null', 'undefined', '2 ** 31']) { + test(`with maxWait: ${value}, pace waits for a request until it ends`, async ({ page, open }) => { + await open({ head: options(value) }); + await idle(page); + await clearEvents(page); + const loadedAt = await request(page, { ms: 900 }); + await idle(page); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + expect(await page.evaluate(() => eventTimes.done)).toBeGreaterThanOrEqual(loadedAt - 50); + }); +} + +for (const [api, send] of [ + ['XMLHttpRequest', "var xhr = new XMLHttpRequest(); xhr.open('GET', '/delay?ms=4000'); xhr.onload = function () { loaded = xhr.responseText; }; xhr.send();"], + ['fetch', "fetch('/delay?ms=4000').then(function (response) { return response.text(); }).then(function (text) { loaded = text; });"], +]) { + test(`a slow ${api} during page load holds the bar only until maxWait, and still loads`, async ({ page, open }) => { + await open({ head: options(1500), body: `` }); + await idle(page); + const doneAt = await page.evaluate(() => eventTimes.done); + expect(doneAt).toBeGreaterThanOrEqual(1400); + expect(doneAt).toBeLessThan(2500); + await expect.poll(() => page.evaluate(() => window.loaded), { timeout: 5000 }).toBe('ok'); + }); +} + +test('maxWait is counted from when the request was sent, not from when the bar restarted', async ({ page, open }) => { + await open({ head: options(1500) }); + await idle(page); + await clearEvents(page); + const sentAt = await page.evaluate(() => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', '/delay?ms=4000'); + xhr.send(); + return performance.now(); + }); + // The bar is still hidden from the page load until the restart, so wait for the restart's end. + await expect.poll(() => page.evaluate(() => events.includes('hide')), { timeout: 5000 }).toBe(true); + const [events, doneAt] = await page.evaluate(() => [window.events, eventTimes.done]); + expect(events).toEqual(RESTARTED); + expect(doneAt - sentAt).toBeGreaterThanOrEqual(1400); + expect(doneAt - sentAt).toBeLessThan(1900); +}); + +test('a request past its maxWait does not hold a later navigation', async ({ page, open }) => { + await open({ head: options(1000) }); + await idle(page); + await clearEvents(page); + const loaded = request(page, { ms: 5000 }); + await expect.poll(() => page.evaluate(() => events.includes('done'))).toBe(true); // its limit passed + await clearEvents(page); + await page.evaluate(() => history.pushState({}, '', '?next')); + await idle(page); + const [events, doneAt] = await page.evaluate(() => [window.events, eventTimes.done]); + expect(events).toEqual(RESTARTED); + expect(doneAt).toBeLessThan((await loaded) - 1000); +}); + +test('maxWait also limits a request tracked with Pace.track()', async ({ page, open }) => { + await open({ head: options(1500) }); + await idle(page); + await clearEvents(page); + const loadedAt = await request(page, { ms: 3500, method: 'POST', wrap: 'track' }); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + expect(await page.evaluate(() => eventTimes.done)).toBeLessThan(loadedAt - 1000); +}); + +test('a request sent again from its own load handler gets its own maxWait', async ({ page, open }) => { + await open({ head: options(1200) }); + await idle(page); + await clearEvents(page); + const sentAt = await page.evaluate(() => { + const xhr = new XMLHttpRequest(); + xhr.onload = () => { + xhr.onload = null; + xhr.open('GET', '/delay?ms=3000'); + xhr.send(); + }; + xhr.open('GET', '/delay?ms=900'); + xhr.send(); + return performance.now(); + }); + // The bar is still hidden from the page load until the restart, so wait for the restart's end. + await expect.poll(() => page.evaluate(() => events.includes('hide')), { timeout: 5000 }).toBe(true); + // The second request is sent at about 900 ms, so its limit ends at about 2100 ms; the first + // request's limit (1200 ms) must not end it. + expect((await page.evaluate(() => eventTimes.done)) - sentAt).toBeGreaterThan(1900); +}); + +test.describe('with a real server', () => { + // A request that never ends (/hang), with the page's clock run forward: runFor() runs every + // timer and animation frame on the way, fastForward() jumps ahead and runs due timers once. + const hangAfterLoad = async (page, server, options) => { + await page.clock.install(); + await page.goto(`${server}/?options=${encodeURIComponent(options)}`); + await page.clock.runFor(3000); + expect(await page.evaluate(() => events)).toEqual(['start', 'done', 'hide']); + await page.evaluate(() => { + events = []; + fetch('/hang'); + }); + }; + + test('by default, pace stops waiting for a request 15 s after it was sent', async ({ page, server }) => { + await hangAfterLoad(page, server, '{ eventLag: false }'); + await page.clock.runFor(1000); // the bar restarts at 500 ms + await page.clock.fastForward(13500); + expect(await page.evaluate(() => events)).toEqual(['restart', 'stop', 'start']); + await page.clock.runFor(1000); + expect(await page.evaluate(() => events)).toEqual(RESTARTED); + }); + + test('with maxWait: false, pace keeps waiting for a request that never ends', async ({ page, server }) => { + await hangAfterLoad(page, server, '{ eventLag: false, ajax: { maxWait: false } }'); + await page.clock.runFor(1000); // the bar restarts at 500 ms + await page.clock.fastForward(59000); + await page.clock.runFor(1000); + expect(await page.evaluate(() => events)).toEqual(['restart', 'stop', 'start']); + }); + + test('a WebSocket whose handshake is never answered holds the bar only until maxWait', async ({ page, server }) => { + await page.goto(`${server}/?socket=never&options=${encodeURIComponent('{ eventLag: false, ajax: { maxWait: 1500 } }')}`); + await idle(page); + const doneAt = await page.evaluate(() => eventTimes.done); + expect(doneAt).toBeGreaterThanOrEqual(1400); + expect(doneAt).toBeLessThan(2500); + }); +}); diff --git a/tests/requests.spec.mjs b/tests/requests.spec.mjs index 0e92ba5..6688a15 100644 --- a/tests/requests.spec.mjs +++ b/tests/requests.spec.mjs @@ -2,9 +2,6 @@ // XMLHttpRequest.prototype.open/send and subclasses WebSocket, so pages and other // libraries keep the native objects, and it tracks a request from send() until it // ends. -import crypto from 'node:crypto'; -import fs from 'node:fs'; -import http from 'node:http'; import { test, expect, idle, request, clearEvents, RESTARTED, HOLD, NO_EVENT_LAG } from './fixtures.mjs'; // Saved before pace loads, to compare with afterwards. @@ -122,6 +119,22 @@ test('a URL object works with ajax.ignoreURLs', async ({ page, open }) => { expect(await page.evaluate(() => events)).toEqual([]); }); +test('a URL object changed after open() is matched as open() used it', async ({ page, open }) => { + await open({ head: "" }); + await idle(page); + await clearEvents(page); + await page.evaluate(() => new Promise((resolve, reject) => { + const url = new URL('/delay?ms=900&ignored', location.href); + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + url.search = '?ms=900'; + xhr.onload = resolve; + xhr.onerror = reject; + xhr.send(); + })); + expect(await page.evaluate(() => events)).toEqual([]); +}); + test('an ajax.ignoreURLs pattern with the g flag ignores every matching request', async ({ page, open }) => { await open({ head: '' }); await idle(page); @@ -305,78 +318,27 @@ test('with trackWebSockets: false, WebSocket stays native', async ({ page, open expect(await page.evaluate(() => WebSocket === NativeWebSocket)).toBe(true); }); -// A real server on 127.0.0.1, for cases Playwright's routed responses don't reproduce. (Chrome -// doesn't let the fake public origin of the other tests connect to 127.0.0.1.) It serves a page, -// pace, /delay?ms=N, and a WebSocket whose handshake is answered after 1.5 s. +// Cases that need a real server (see `server` in fixtures.mjs). test.describe('with a real server', () => { - let server; - let origin; - const sockets = new Set(); - - test.beforeAll(async ({ paceFile }) => { - const pace = fs.readFileSync(new URL(paceFile, new URL('..', import.meta.url)), 'utf8'); - server = http.createServer((req, res) => { - const url = new URL(req.url, 'http://localhost'); - if (url.pathname === '/pace.js') { - res.writeHead(200, { 'content-type': 'text/javascript' }); - return res.end(pace); - } - if (url.pathname === '/drop') { - req.socket.destroy(); // a network error - return; - } - if (url.pathname === '/delay') { - // No Content-Length (chunked), so the request's progress never reports 100% on its own - // and only its end can finish the bar. - setTimeout(() => { - res.write('ok'); - res.end(); - }, Number(url.searchParams.get('ms'))); - return; - } - res.writeHead(200, { 'content-type': 'text/html' }); - res.end(` - - -`); - }); - server.on('upgrade', (req, socket) => { - sockets.add(socket); - socket.on('error', () => {}); - const accept = crypto.createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); - setTimeout(() => { - socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); - }, 1500); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - origin = `http://127.0.0.1:${server.address().port}`; - }); - - test.afterAll(() => new Promise((resolve) => { - for (const socket of sockets) socket.destroy(); - server.closeAllConnections(); - server.close(resolve); - })); - - test('a WebSocket that is still connecting holds the bar until it opens', async ({ page }) => { - await page.goto(`${origin}/?socket`); + test('a WebSocket that is still connecting holds the bar until it opens', async ({ page, server }) => { + await page.goto(`${server}/?socket`); await idle(page); const [openedAt, doneAt] = await page.evaluate(() => [window.openedAt, eventTimes.done]); expect(openedAt).not.toBeNull(); expect(doneAt).toBeGreaterThanOrEqual(openedAt); }); - test('a request aborted in its own load handler ends', async ({ page }) => { - await page.goto(origin); + test('ajax.ignoreURLs patterns see the full URL of a WebSocket', async ({ page, server }) => { + await page.goto(`${server}/?options=${encodeURIComponent('{ eventLag: false, ajax: { ignoreURLs: [/^ws:\\/\\/127\\.0\\.0\\.1:\\d+\\/socket/] } }')}`); + await idle(page); + await clearEvents(page); + await page.evaluate(() => { window.socket = new WebSocket('/socket?never'); }); // a relative URL + await page.waitForTimeout(800); + expect(await page.evaluate(() => events)).toEqual([]); + }); + + test('a request aborted in its own load handler ends', async ({ page, server }) => { + await page.goto(server); await idle(page); await clearEvents(page); await page.evaluate(() => new Promise((resolve) => { @@ -394,8 +356,8 @@ test.describe('with a real server', () => { expect(await page.evaluate(() => events)).toEqual(RESTARTED); }); - test('a request aborted from its readystatechange handler when done ends', async ({ page }) => { - await page.goto(origin); + test('a request aborted from its readystatechange handler when done ends', async ({ page, server }) => { + await page.goto(server); await idle(page); await clearEvents(page); await page.evaluate(() => new Promise((resolve) => { @@ -417,8 +379,8 @@ test.describe('with a real server', () => { expect(await page.evaluate(() => events)).toEqual(RESTARTED); }); - test('a synchronous request that fails leaves nothing open', async ({ page }) => { - await page.goto(origin); + test('a synchronous request that fails leaves nothing open', async ({ page, server }) => { + await page.goto(server); await idle(page); await page.evaluate(() => { const xhr = new XMLHttpRequest();