@@ -170,4 +170,135 @@ describe('Timesync', () => {
170170 }
171171 } ) ;
172172 } ) ;
173+
174+ // Regression tests for the Cordova/Capacitor fix: on mobile webviews the HTTP
175+ // request to /_timesync can fail (CORS, URL resolution). updateOffset()
176+ // should force DDP transport when Meteor.isCordova is true.
177+ describe ( 'transport selection' , ( ) => {
178+ // Spy on the DDP side via Meteor.callAsync monkey-patch, and on the HTTP
179+ // side via PerformanceObserver: `fetch` is imported as a module binding in
180+ // timesync-client.js and cannot be swapped from the test, but every fetch
181+ // still surfaces as a `resource` entry in the Performance API.
182+ function installTransportSpies ( syncUrl , counters ) {
183+ const originalCallAsync = Meteor . callAsync ;
184+ Meteor . callAsync = function ( methodName ) {
185+ if ( methodName === '_timeSync' ) counters . ddp ++ ;
186+ return originalCallAsync . apply ( this , arguments ) ;
187+ } ;
188+
189+ const po = new PerformanceObserver ( ( list ) => {
190+ for ( const entry of list . getEntries ( ) ) {
191+ if ( entry . name && entry . name . indexOf ( syncUrl ) !== - 1 ) counters . http ++ ;
192+ }
193+ } ) ;
194+ po . observe ( { type : 'resource' , buffered : false } ) ;
195+
196+ return function restore ( ) {
197+ Meteor . callAsync = originalCallAsync ;
198+ po . disconnect ( ) ;
199+ } ;
200+ }
201+
202+ it ( 'forces DDP transport when Meteor.isCordova is true' , function ( done ) {
203+ this . timeout ( 10000 ) ;
204+
205+ const originalIsCordova = Meteor . isCordova ;
206+ const originalForceDDP = TimeSync . forceDDP ;
207+ const originalUseDDP = SyncInternals . useDDP ;
208+ const syncUrl = TimeSync . getSyncUrl ( ) ;
209+
210+ const counters = { ddp : 0 , http : 0 } ;
211+ const restoreSpies = installTransportSpies ( syncUrl , counters ) ;
212+
213+ // Simulate a Cordova client whose DDP connection flag has not yet flipped.
214+ // Without the fix, this combination would route through HTTP fetch.
215+ Meteor . isCordova = true ;
216+ TimeSync . forceDDP = false ;
217+ SyncInternals . useDDP = false ;
218+
219+ function cleanup ( ) {
220+ restoreSpies ( ) ;
221+ Meteor . isCordova = originalIsCordova ;
222+ TimeSync . forceDDP = originalForceDDP ;
223+ SyncInternals . useDDP = originalUseDDP ;
224+ // A second resync rearms the internal setInterval with the restored
225+ // state, avoiding a transport-mismatched timer leaking into later tests.
226+ TimeSync . resync ( ) ;
227+ }
228+
229+ TimeSync . resync ( ) ;
230+
231+ simplePoll (
232+ ( ) => counters . ddp >= 1 ,
233+ ( ) => {
234+ // Give PerformanceObserver a tick to flush any pending resource
235+ // entries before asserting that HTTP was not used.
236+ Meteor . setTimeout ( ( ) => {
237+ cleanup ( ) ;
238+ try {
239+ assert . isAtLeast ( counters . ddp , 1 ,
240+ "Meteor.callAsync('_timeSync') must be used on Cordova" ) ;
241+ assert . equal ( counters . http , 0 ,
242+ "HTTP fetch to the sync URL must not be used on Cordova" ) ;
243+ done ( ) ;
244+ } catch ( err ) {
245+ done ( err ) ;
246+ }
247+ } , 100 ) ;
248+ } ,
249+ ( ) => {
250+ cleanup ( ) ;
251+ done ( new Error ( 'Cordova client did not route through DDP within 5s' ) ) ;
252+ } ,
253+ 5000 , 50
254+ ) ;
255+ } ) ;
256+
257+ it ( 'uses HTTP transport on a plain browser by default' , function ( done ) {
258+ this . timeout ( 10000 ) ;
259+
260+ const originalIsCordova = Meteor . isCordova ;
261+ const originalForceDDP = TimeSync . forceDDP ;
262+ const originalUseDDP = SyncInternals . useDDP ;
263+ const syncUrl = TimeSync . getSyncUrl ( ) ;
264+
265+ const counters = { ddp : 0 , http : 0 } ;
266+ const restoreSpies = installTransportSpies ( syncUrl , counters ) ;
267+
268+ Meteor . isCordova = false ;
269+ TimeSync . forceDDP = false ;
270+ SyncInternals . useDDP = false ;
271+
272+ function cleanup ( ) {
273+ restoreSpies ( ) ;
274+ Meteor . isCordova = originalIsCordova ;
275+ TimeSync . forceDDP = originalForceDDP ;
276+ SyncInternals . useDDP = originalUseDDP ;
277+ TimeSync . resync ( ) ;
278+ }
279+
280+ TimeSync . resync ( ) ;
281+
282+ simplePoll (
283+ ( ) => counters . http >= 1 ,
284+ ( ) => {
285+ cleanup ( ) ;
286+ try {
287+ assert . isAtLeast ( counters . http , 1 ,
288+ 'HTTP fetch to the sync URL must be used on a plain browser' ) ;
289+ assert . equal ( counters . ddp , 0 ,
290+ "Meteor.callAsync('_timeSync') must not be used on a plain browser" ) ;
291+ done ( ) ;
292+ } catch ( err ) {
293+ done ( err ) ;
294+ }
295+ } ,
296+ ( ) => {
297+ cleanup ( ) ;
298+ done ( new Error ( 'Browser client did not route through HTTP within 5s' ) ) ;
299+ } ,
300+ 5000 , 50
301+ ) ;
302+ } ) ;
303+ } ) ;
173304} ) ;
0 commit comments