Skip to content

Commit b3ae7a7

Browse files
Enhance pageLifecycle tests to support multiple navigation libraries and improve test isolation
- Mocked turbolinksUtils module to simulate different navigation library scenarios. - Added tests for Turbo, Turbolinks 5, and Turbolinks 2 event listeners. - Improved test structure for better isolation and dynamic module loading. - Included tests for handling multiple callbacks and server-side rendering scenarios. - Ensured that event listeners are not initialized multiple times.
1 parent 37bbb0c commit b3ae7a7

File tree

1 file changed

+221
-58
lines changed

1 file changed

+221
-58
lines changed

node_package/tests/pageLifecycle.test.js

Lines changed: 221 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -2,92 +2,255 @@
22
* @jest-environment jsdom
33
*/
44

5-
import { onPageLoaded } from '../src/pageLifecycle.ts';
5+
// Mock the turbolinksUtils module before importing pageLifecycle
6+
jest.mock('../src/turbolinksUtils.ts', () => ({
7+
debugTurbolinks: jest.fn(),
8+
turbolinksInstalled: jest.fn(() => false),
9+
turbolinksSupported: jest.fn(() => false),
10+
turboInstalled: jest.fn(() => false),
11+
turbolinksVersion5: jest.fn(() => false),
12+
}));
13+
14+
// Import will be done dynamically in tests to allow module reset
615

716
describe('pageLifecycle', () => {
817
let originalReadyState;
9-
let setupPageNavigationListenersSpy;
18+
let addEventListenerSpy;
19+
let removeEventListenerSpy;
20+
21+
// Helper function to set document.readyState
22+
const setReadyState = (state) => {
23+
Object.defineProperty(document, 'readyState', {
24+
value: state,
25+
writable: true,
26+
});
27+
};
28+
29+
// We use require here instead of a global import at the top because we need to dynamically reload the module in each test.
30+
// This allows us to reset the module state between tests using jest.resetModules(), ensuring test isolation and preventing state leakage.
31+
// eslint-disable-next-line global-require
32+
const importPageLifecycle = () => require('../src/pageLifecycle.ts');
33+
34+
// Helper function to create navigation library mock
35+
const createNavigationMock = (overrides = {}) => ({
36+
debugTurbolinks: jest.fn(),
37+
turbolinksInstalled: jest.fn(() => false),
38+
turbolinksSupported: jest.fn(() => false),
39+
turboInstalled: jest.fn(() => false),
40+
turbolinksVersion5: jest.fn(() => false),
41+
...overrides,
42+
});
1043

1144
beforeEach(() => {
1245
// Store the original readyState
1346
originalReadyState = document.readyState;
14-
15-
// Reset the isPageLifecycleInitialized state by reloading the module
47+
48+
// Mock document.addEventListener and removeEventListener
49+
addEventListenerSpy = jest.spyOn(document, 'addEventListener').mockImplementation(() => {});
50+
removeEventListenerSpy = jest.spyOn(document, 'removeEventListener').mockImplementation(() => {});
51+
52+
// Reset DOM state - use Object.defineProperty to set readyState
53+
setReadyState('loading');
54+
55+
// Reset all global state by reloading the module AFTER setting up mocks
1656
jest.resetModules();
17-
18-
// Mock setupPageNavigationListeners to track when it's called
19-
setupPageNavigationListenersSpy = jest.fn();
20-
21-
// We need to mock the internal function - this is a bit tricky since it's not exported
22-
// For this test, we'll verify the behavior indirectly by checking when callbacks are executed
2357
});
2458

2559
afterEach(() => {
2660
// Restore original readyState
27-
Object.defineProperty(document, 'readyState', {
28-
value: originalReadyState,
29-
writable: true
61+
Object.defineProperty(document, 'readyState', {
62+
value: originalReadyState,
63+
writable: true,
3064
});
65+
66+
// Restore spies
67+
addEventListenerSpy.mockRestore();
68+
removeEventListenerSpy.mockRestore();
3169
});
3270

3371
it('should initialize page event listeners immediately when document.readyState is "complete"', () => {
34-
// Set readyState to 'complete'
35-
Object.defineProperty(document, 'readyState', {
36-
value: 'complete',
37-
writable: true
38-
});
39-
72+
setReadyState('complete');
4073
const callback = jest.fn();
41-
42-
// Import fresh module with the mocked readyState
43-
const { onPageLoaded } = require('../src/pageLifecycle.ts');
44-
45-
// This should trigger immediate execution when readyState is 'complete'
74+
const { onPageLoaded } = importPageLifecycle();
75+
4676
onPageLoaded(callback);
47-
48-
// The callback should be called immediately since we're treating 'complete' as already loaded
49-
// Note: The actual implementation may vary, this test verifies the behavior exists
77+
78+
// Since no navigation library is mocked, callbacks should run immediately
79+
expect(callback).toHaveBeenCalledTimes(1);
80+
// Should not add DOMContentLoaded listener since readyState is not 'loading'
81+
expect(addEventListenerSpy).not.toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
5082
});
5183

5284
it('should initialize page event listeners immediately when document.readyState is "interactive"', () => {
53-
// Set readyState to 'interactive' (not 'loading')
54-
Object.defineProperty(document, 'readyState', {
55-
value: 'interactive',
56-
writable: true
57-
});
58-
85+
setReadyState('interactive');
5986
const callback = jest.fn();
60-
61-
// Import fresh module with the mocked readyState
62-
const { onPageLoaded } = require('../src/pageLifecycle.ts');
63-
64-
// This should trigger immediate setup since readyState is not 'loading'
87+
const { onPageLoaded } = importPageLifecycle();
88+
6589
onPageLoaded(callback);
66-
67-
// Verify that we don't wait for DOMContentLoaded when readyState is already 'interactive'
68-
// The specific implementation details may vary, but the key is that it doesn't wait
90+
91+
// Since no navigation library is mocked, callbacks should run immediately
92+
expect(callback).toHaveBeenCalledTimes(1);
93+
// Should not add DOMContentLoaded listener since readyState is not 'loading'
94+
expect(addEventListenerSpy).not.toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
6995
});
7096

7197
it('should wait for DOMContentLoaded when document.readyState is "loading"', () => {
72-
// Set readyState to 'loading'
73-
Object.defineProperty(document, 'readyState', {
74-
value: 'loading',
75-
writable: true
76-
});
77-
98+
setReadyState('loading');
7899
const callback = jest.fn();
79-
80-
// Import fresh module with the mocked readyState
81-
const { onPageLoaded } = require('../src/pageLifecycle.ts');
82-
83-
// Add event listener to capture DOMContentLoaded listeners
84-
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
85-
100+
const { onPageLoaded } = importPageLifecycle();
101+
86102
onPageLoaded(callback);
87-
103+
104+
// Should not call callback immediately since readyState is 'loading'
105+
expect(callback).not.toHaveBeenCalled();
88106
// Verify that a DOMContentLoaded listener was added when readyState is 'loading'
89107
expect(addEventListenerSpy).toHaveBeenCalledWith('DOMContentLoaded', expect.any(Function));
90-
91-
addEventListenerSpy.mockRestore();
92108
});
93-
});
109+
110+
describe('with Turbo navigation library', () => {
111+
beforeEach(() => {
112+
jest.doMock('../src/turbolinksUtils.ts', () =>
113+
createNavigationMock({
114+
turboInstalled: jest.fn(() => true),
115+
}),
116+
);
117+
});
118+
119+
afterEach(() => {
120+
jest.dontMock('../src/turbolinksUtils.ts');
121+
});
122+
123+
it('should set up Turbo event listeners when Turbo is installed', () => {
124+
setReadyState('complete');
125+
const { onPageLoaded } = importPageLifecycle();
126+
const callback = jest.fn();
127+
128+
onPageLoaded(callback);
129+
130+
// Should add Turbo event listeners
131+
expect(addEventListenerSpy).toHaveBeenCalledWith('turbo:before-render', expect.any(Function));
132+
expect(addEventListenerSpy).toHaveBeenCalledWith('turbo:render', expect.any(Function));
133+
// Callback should be called immediately
134+
expect(callback).toHaveBeenCalledTimes(1);
135+
});
136+
});
137+
138+
describe('with Turbolinks 5 navigation library', () => {
139+
beforeEach(() => {
140+
jest.doMock('../src/turbolinksUtils.ts', () =>
141+
createNavigationMock({
142+
turbolinksInstalled: jest.fn(() => true),
143+
turbolinksSupported: jest.fn(() => true),
144+
turbolinksVersion5: jest.fn(() => true),
145+
}),
146+
);
147+
});
148+
149+
afterEach(() => {
150+
jest.dontMock('../src/turbolinksUtils.ts');
151+
});
152+
153+
it('should set up Turbolinks 5 event listeners when Turbolinks 5 is installed', () => {
154+
setReadyState('complete');
155+
const { onPageLoaded } = importPageLifecycle();
156+
const callback = jest.fn();
157+
158+
onPageLoaded(callback);
159+
160+
// Should add Turbolinks 5 event listeners
161+
expect(addEventListenerSpy).toHaveBeenCalledWith('turbolinks:before-render', expect.any(Function));
162+
expect(addEventListenerSpy).toHaveBeenCalledWith('turbolinks:render', expect.any(Function));
163+
// Callback should be called immediately
164+
expect(callback).toHaveBeenCalledTimes(1);
165+
});
166+
});
167+
168+
describe('with Turbolinks 2 navigation library', () => {
169+
beforeEach(() => {
170+
jest.doMock('../src/turbolinksUtils.ts', () =>
171+
createNavigationMock({
172+
turbolinksInstalled: jest.fn(() => true),
173+
turbolinksSupported: jest.fn(() => true),
174+
}),
175+
);
176+
});
177+
178+
afterEach(() => {
179+
jest.dontMock('../src/turbolinksUtils.ts');
180+
});
181+
182+
it('should set up Turbolinks 2 event listeners when Turbolinks 2 is installed', () => {
183+
setReadyState('complete');
184+
const { onPageLoaded } = importPageLifecycle();
185+
const callback = jest.fn();
186+
187+
onPageLoaded(callback);
188+
189+
// Should add Turbolinks 2 event listeners
190+
expect(addEventListenerSpy).toHaveBeenCalledWith('page:before-unload', expect.any(Function));
191+
expect(addEventListenerSpy).toHaveBeenCalledWith('page:change', expect.any(Function));
192+
// Turbolinks 2 does NOT call callbacks immediately - only sets up listeners
193+
expect(callback).not.toHaveBeenCalled();
194+
});
195+
});
196+
197+
describe('multiple callbacks', () => {
198+
it('should handle multiple page loaded callbacks', () => {
199+
setReadyState('complete');
200+
const { onPageLoaded } = importPageLifecycle();
201+
const callback1 = jest.fn();
202+
const callback2 = jest.fn();
203+
const callback3 = jest.fn();
204+
205+
onPageLoaded(callback1);
206+
onPageLoaded(callback2);
207+
onPageLoaded(callback3);
208+
209+
// Since no navigation library is mocked (all return false), callbacks should be called immediately
210+
expect(callback1).toHaveBeenCalledTimes(1);
211+
expect(callback2).toHaveBeenCalledTimes(1);
212+
expect(callback3).toHaveBeenCalledTimes(1);
213+
});
214+
});
215+
216+
describe('server-side rendering', () => {
217+
it('should not initialize when window is undefined', () => {
218+
// Mock window as undefined
219+
const originalWindow = global.window;
220+
delete global.window;
221+
222+
const { onPageLoaded } = importPageLifecycle();
223+
const callback = jest.fn();
224+
225+
onPageLoaded(callback);
226+
227+
// Should not call callback or add event listeners
228+
expect(callback).not.toHaveBeenCalled();
229+
expect(addEventListenerSpy).not.toHaveBeenCalled();
230+
231+
// Restore window
232+
global.window = originalWindow;
233+
});
234+
});
235+
236+
describe('preventing duplicate initialization', () => {
237+
it('should not initialize listeners multiple times', () => {
238+
setReadyState('loading');
239+
const { onPageLoaded } = importPageLifecycle();
240+
const callback1 = jest.fn();
241+
const callback2 = jest.fn();
242+
243+
// First call should initialize and call addEventListener
244+
onPageLoaded(callback1);
245+
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
246+
247+
// Second call should not add more listeners (isPageLifecycleInitialized is true)
248+
onPageLoaded(callback2);
249+
expect(addEventListenerSpy).toHaveBeenCalledTimes(1);
250+
251+
// Both callbacks should be called
252+
expect(callback1).not.toHaveBeenCalled();
253+
expect(callback2).not.toHaveBeenCalled();
254+
});
255+
});
256+
});

0 commit comments

Comments
 (0)