From b17e3c096a6afb5e7d478b04f03b9ec88bf772f2 Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sun, 8 Nov 2026 22:06:00 -0500 Subject: [PATCH 1/2] Track fetch() and stop waiting for requests after 15 s - fetch() is tracked like XMLHttpRequest: the same trackMethods (GET by default), restartOnRequestAfter, ignoreURLs, Pace.ignore and Pace.track. Pace replaces window.fetch with a function that 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, so unhandled rejections are still reported. A fetch ends when that promise settles (on the response's headers, an error or an abort), since its body's progress can't be seen without reading it. ajax.trackFetch: false turns it off. - ajax.maxWait (15000 ms by default): pace waits for each tracked request, WebSocket and request forced with Pace.track at most that long from when it was sent, then stops waiting for it for good. The request keeps going. Before, one request that never ended held the bar near 100% forever. false, or anything other than a number from 0 to 2^31 - 1 ms (the most a timer can wait; setTimeout treats Infinity, null and larger numbers as 0), means no limit. - ajax.ignoreURLs patterns see the URL as the page wrote it, the full URL, and for requests to the page's own origin the part after the origin (/api/items?page=2), so a pattern matches however the URL was written: relative or absolute, a string, a URL or a Request. An XMLHttpRequest's URL is read when open() uses it. The real server the request tests use moves into the test fixtures, with endpoints for responses that arrive in parts, requests that never end and WebSocket handshakes that are never answered. The ajax rule tests now run for both XMLHttpRequest and fetch. --- README.md | 31 ++++- pace.js | 133 +++++++++++++++++----- pace.min.js | 2 +- tests/ajax.spec.mjs | 124 ++++++++++++++------ tests/fetch.spec.mjs | 246 ++++++++++++++++++++++++++++++++++++++++ tests/fixtures.mjs | 114 +++++++++++++++++-- tests/max-wait.spec.mjs | 131 +++++++++++++++++++++ tests/requests.spec.mjs | 106 ++++++----------- 8 files changed, 738 insertions(+), 149 deletions(-) create mode 100644 tests/fetch.spec.mjs create mode 100644 tests/max-wait.spec.mjs 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..b50a6bb --- /dev/null +++ b/tests/max-wait.spec.mjs @@ -0,0 +1,131 @@ +// 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. + 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(14500); + 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(60000); + 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(); From 2e8638d09389c9e6bb9c170ad85e9131f43790ff Mon Sep 17 00:00:00 2001 From: Zachary Miller Date: Sun, 8 Nov 2026 22:25:53 -0500 Subject: [PATCH 2/2] Jump the page clock ahead in the long maxWait tests The two tests that run the page's fake clock for 15 and 60 seconds ran every timer and animation frame on the way, which took about 4 and 17 seconds of real time. They now run the clock only around the moments that matter and jump over the rest with fastForward(), so each takes about half a second and still fails if maxWait isn't applied or false doesn't turn it off. --- tests/max-wait.spec.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/max-wait.spec.mjs b/tests/max-wait.spec.mjs index b50a6bb..d79f299 100644 --- a/tests/max-wait.spec.mjs +++ b/tests/max-wait.spec.mjs @@ -95,7 +95,8 @@ test('a request sent again from its own load handler gets its own maxWait', asyn }); test.describe('with a real server', () => { - // A request that never ends (/hang), with the page's clock run forward. + // 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)}`); @@ -109,7 +110,8 @@ test.describe('with a real server', () => { 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(14500); + 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); @@ -117,7 +119,9 @@ test.describe('with a real server', () => { 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(60000); + 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']); });