@dhis2/app-service-offline 2.10.0 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/build/cjs/__tests__/integration.test.js +337 -0
  2. package/build/cjs/index.js +39 -1
  3. package/build/cjs/lib/__tests__/clear-sensitive-caches.test.js +131 -0
  4. package/build/cjs/lib/__tests__/offline-provider.test.js +127 -0
  5. package/build/cjs/lib/__tests__/use-cacheable-section.test.js +227 -0
  6. package/build/cjs/lib/cacheable-section-state.js +218 -0
  7. package/build/cjs/lib/cacheable-section.js +156 -0
  8. package/build/cjs/lib/clear-sensitive-caches.js +87 -0
  9. package/build/cjs/lib/global-state-service.js +95 -0
  10. package/build/cjs/lib/offline-interface.js +86 -0
  11. package/build/cjs/lib/offline-provider.js +53 -0
  12. package/build/cjs/types.js +0 -1
  13. package/build/cjs/utils/__tests__/render-counter.test.js +55 -0
  14. package/build/cjs/utils/render-counter.js +26 -0
  15. package/build/cjs/utils/test-mocks.js +40 -0
  16. package/build/es/__tests__/integration.test.js +327 -0
  17. package/build/es/index.js +5 -1
  18. package/build/es/lib/__tests__/clear-sensitive-caches.test.js +123 -0
  19. package/build/es/lib/__tests__/offline-provider.test.js +117 -0
  20. package/build/es/lib/__tests__/use-cacheable-section.test.js +218 -0
  21. package/build/es/lib/cacheable-section-state.js +199 -0
  22. package/build/es/lib/cacheable-section.js +137 -0
  23. package/build/es/lib/clear-sensitive-caches.js +78 -0
  24. package/build/es/lib/global-state-service.js +70 -0
  25. package/build/es/lib/offline-interface.js +65 -0
  26. package/build/es/lib/offline-provider.js +40 -0
  27. package/build/es/types.js +0 -1
  28. package/build/es/utils/__tests__/render-counter.test.js +40 -0
  29. package/build/es/utils/render-counter.js +11 -0
  30. package/build/es/utils/test-mocks.js +30 -0
  31. package/build/types/index.d.ts +4 -0
  32. package/build/types/lib/cacheable-section-state.d.ts +66 -0
  33. package/build/types/lib/cacheable-section.d.ts +52 -0
  34. package/build/types/lib/clear-sensitive-caches.d.ts +16 -0
  35. package/build/types/lib/global-state-service.d.ts +16 -0
  36. package/build/types/lib/offline-interface.d.ts +26 -0
  37. package/build/types/lib/offline-provider.d.ts +19 -0
  38. package/build/types/types.d.ts +50 -0
  39. package/build/types/utils/render-counter.d.ts +10 -0
  40. package/build/types/utils/test-mocks.d.ts +11 -0
  41. package/package.json +2 -2
package/build/es/index.js CHANGED
@@ -1 +1,5 @@
1
- export { useOnlineStatus } from './lib/online-status';
1
+ export { OfflineProvider } from './lib/offline-provider';
2
+ export { CacheableSection, useCacheableSection } from './lib/cacheable-section';
3
+ export { useCachedSections } from './lib/cacheable-section-state';
4
+ export { useOnlineStatus } from './lib/online-status';
5
+ export { clearSensitiveCaches } from './lib/clear-sensitive-caches';
@@ -0,0 +1,123 @@
1
+ import FDBFactory from 'fake-indexeddb/lib/FDBFactory';
2
+ import { openDB } from 'idb';
3
+ import 'fake-indexeddb/auto';
4
+ import { clearSensitiveCaches, SECTIONS_DB, SECTIONS_STORE } from '../clear-sensitive-caches'; // Mocks for CacheStorage API
5
+
6
+ const keysMockDefault = jest.fn().mockImplementation(async () => []);
7
+ const deleteMockDefault = jest.fn().mockImplementation(async () => null);
8
+ const cachesDefault = {
9
+ keys: keysMockDefault,
10
+ delete: deleteMockDefault
11
+ };
12
+ window.caches = cachesDefault;
13
+ afterEach(() => {
14
+ window.caches = cachesDefault;
15
+ jest.clearAllMocks();
16
+ }); // silence debug logs for these tests
17
+
18
+ const originalDebug = console.debug;
19
+ beforeAll(() => {
20
+ jest.spyOn(console, 'debug').mockImplementation((...args) => {
21
+ const pattern = /Clearing sensitive caches/;
22
+
23
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
24
+ return;
25
+ }
26
+
27
+ return originalDebug.call(console, ...args);
28
+ });
29
+ });
30
+ afterAll(() => {
31
+ ;
32
+ console.debug.mockRestore();
33
+ });
34
+ it('does not fail if there are no caches or no sections-db', () => {
35
+ return expect(clearSensitiveCaches()).resolves.toBeDefined();
36
+ });
37
+ it('clears potentially sensitive caches', async () => {
38
+ const keysMock = jest.fn().mockImplementation(async () => ['cache1', 'cache2', 'app-shell']);
39
+ window.caches = { ...cachesDefault,
40
+ keys: keysMock
41
+ };
42
+ await clearSensitiveCaches();
43
+ expect(deleteMockDefault).toHaveBeenCalledTimes(3);
44
+ expect(deleteMockDefault.mock.calls[0][0]).toBe('cache1');
45
+ expect(deleteMockDefault.mock.calls[1][0]).toBe('cache2');
46
+ expect(deleteMockDefault.mock.calls[2][0]).toBe('app-shell');
47
+ });
48
+ it('preserves keepable caches', async () => {
49
+ const keysMock = jest.fn().mockImplementation(async () => ['cache1', 'cache2', 'app-shell', 'other-assets', 'workbox-precache-v2-https://hey.howareya.now/']);
50
+ window.caches = { ...cachesDefault,
51
+ keys: keysMock
52
+ };
53
+ await clearSensitiveCaches();
54
+ expect(deleteMockDefault).toHaveBeenCalledTimes(3);
55
+ expect(deleteMockDefault.mock.calls[0][0]).toBe('cache1');
56
+ expect(deleteMockDefault.mock.calls[1][0]).toBe('cache2');
57
+ expect(deleteMockDefault.mock.calls[2][0]).toBe('app-shell');
58
+ expect(deleteMockDefault).not.toHaveBeenCalledWith('other-assets');
59
+ expect(deleteMockDefault).not.toHaveBeenCalledWith('workbox-precache-v2-https://hey.howareya.now/');
60
+ });
61
+ describe('clears sections-db', () => {
62
+ // Test DB
63
+ function openTestDB(dbName) {
64
+ // simplified version of app platform openDB logic
65
+ return openDB(dbName, 1, {
66
+ upgrade(db) {
67
+ db.createObjectStore(SECTIONS_STORE, {
68
+ keyPath: 'sectionId'
69
+ });
70
+ }
71
+
72
+ });
73
+ }
74
+
75
+ afterEach(() => {
76
+ // reset indexedDB state
77
+ window.indexedDB = new FDBFactory();
78
+ });
79
+ it('clears sections-db if it exists', async () => {
80
+ // Open and populate test DB
81
+ const db = await openTestDB(SECTIONS_DB);
82
+ await db.put(SECTIONS_STORE, {
83
+ sectionId: 'id-1',
84
+ lastUpdated: new Date(),
85
+ requests: 3
86
+ });
87
+ await db.put(SECTIONS_STORE, {
88
+ sectionId: 'id-2',
89
+ lastUpdated: new Date(),
90
+ requests: 3
91
+ });
92
+ await clearSensitiveCaches(); // Sections-db should be cleared
93
+
94
+ const allSections = await db.getAll(SECTIONS_STORE);
95
+ expect(allSections).toHaveLength(0);
96
+ });
97
+ it("doesn't clear sections-db if it doesn't exist and doesn't open a new one", async () => {
98
+ const openMock = jest.fn();
99
+ window.indexedDB.open = openMock;
100
+ expect(await indexedDB.databases()).not.toContain(SECTIONS_DB);
101
+ await clearSensitiveCaches();
102
+ expect(openMock).not.toHaveBeenCalled();
103
+ return expect(await indexedDB.databases()).not.toContain(SECTIONS_DB);
104
+ });
105
+ it("doesn't handle IDB if 'databases' property is not on window.indexedDB", async () => {
106
+ // Open DB -- 'indexedDB.open' _would_ get called in this test
107
+ // if 'databases' property exists
108
+ await openTestDB(SECTIONS_DB);
109
+ const openMock = jest.fn();
110
+ window.indexedDB.open = openMock; // Remove 'databases' from indexedDB prototype for this test
111
+ // (simulates Firefox environment)
112
+
113
+ const idbProto = Object.getPrototypeOf(window.indexedDB);
114
+ const databases = idbProto.databases;
115
+ delete idbProto.databases;
116
+ expect('databases' in window.indexedDB).toBe(false);
117
+ await expect(clearSensitiveCaches()).resolves.toBeDefined();
118
+ expect(openMock).not.toHaveBeenCalled(); // Restore indexedDB prototype for later tests
119
+
120
+ idbProto.databases = databases;
121
+ expect('databases' in window.indexedDB).toBe(true);
122
+ });
123
+ });
@@ -0,0 +1,117 @@
1
+ import { render, screen, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+ import { mockOfflineInterface } from '../../utils/test-mocks';
4
+ import { useCacheableSection, CacheableSection } from '../cacheable-section';
5
+ import { useCachedSections } from '../cacheable-section-state';
6
+ import { OfflineProvider } from '../offline-provider'; // Suppress 'act' warning for these tests
7
+
8
+ const originalError = console.error;
9
+ beforeEach(() => {
10
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
11
+ const pattern = /Warning: An update to .* inside a test was not wrapped in act/;
12
+
13
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
14
+ return;
15
+ }
16
+
17
+ return originalError.call(console, ...args);
18
+ });
19
+ });
20
+ afterEach(() => {
21
+ jest.clearAllMocks() // syntax needed to appease typescript
22
+ ;
23
+ console.error.mockRestore();
24
+ });
25
+ describe('Testing offline provider', () => {
26
+ it('Should render without failing', () => {
27
+ render( /*#__PURE__*/React.createElement(OfflineProvider, {
28
+ offlineInterface: mockOfflineInterface
29
+ }, /*#__PURE__*/React.createElement("div", {
30
+ "data-testid": "test-div"
31
+ })));
32
+ expect(screen.getByTestId('test-div')).toBeInTheDocument();
33
+ });
34
+ it('Should initialize the offline interface with an update prompt', () => {
35
+ render( /*#__PURE__*/React.createElement(OfflineProvider, {
36
+ offlineInterface: mockOfflineInterface
37
+ }));
38
+ expect(mockOfflineInterface.init).toHaveBeenCalledTimes(1); // Expect to have been called with a 'promptUpdate' function
39
+
40
+ const arg = mockOfflineInterface.init.mock.calls[0][0];
41
+ expect(arg).toHaveProperty('promptUpdate');
42
+ expect(typeof arg['promptUpdate']).toBe('function');
43
+ });
44
+ it('Should sync cached sections with indexedDB', async () => {
45
+ const testOfflineInterface = { ...mockOfflineInterface,
46
+ getCachedSections: jest.fn().mockResolvedValue([{
47
+ sectionId: '1',
48
+ lastUpdated: 'date1'
49
+ }, {
50
+ sectionId: '2',
51
+ lastUpdated: 'date2'
52
+ }])
53
+ };
54
+
55
+ const CachedSections = () => {
56
+ const {
57
+ cachedSections
58
+ } = useCachedSections();
59
+ return /*#__PURE__*/React.createElement("div", {
60
+ "data-testid": "sections"
61
+ }, JSON.stringify(cachedSections));
62
+ };
63
+
64
+ render( /*#__PURE__*/React.createElement(OfflineProvider, {
65
+ offlineInterface: testOfflineInterface
66
+ }, /*#__PURE__*/React.createElement(CachedSections, null)));
67
+ const {
68
+ getByTestId
69
+ } = screen;
70
+ expect(testOfflineInterface.getCachedSections).toHaveBeenCalled();
71
+ await waitFor(() => getByTestId('sections').textContent !== '{}');
72
+ const textContent = JSON.parse(getByTestId('sections').textContent || '');
73
+ expect(textContent).toEqual({
74
+ 1: {
75
+ lastUpdated: 'date1'
76
+ },
77
+ 2: {
78
+ lastUpdated: 'date2'
79
+ }
80
+ });
81
+ });
82
+ it('Should provide the relevant contexts to consumers', () => {
83
+ const TestConsumer = () => {
84
+ useCacheableSection('id');
85
+ return /*#__PURE__*/React.createElement(CacheableSection, {
86
+ loadingMask: /*#__PURE__*/React.createElement("div", null),
87
+ id: 'id'
88
+ }, /*#__PURE__*/React.createElement("div", {
89
+ "data-testid": "test-div"
90
+ }));
91
+ };
92
+
93
+ render( /*#__PURE__*/React.createElement(OfflineProvider, {
94
+ offlineInterface: mockOfflineInterface
95
+ }, /*#__PURE__*/React.createElement(TestConsumer, null)));
96
+ expect(screen.getByTestId('test-div')).toBeInTheDocument();
97
+ });
98
+ it('Should render without failing when no offlineInterface is provided', () => {
99
+ render( /*#__PURE__*/React.createElement(OfflineProvider, null, /*#__PURE__*/React.createElement("div", {
100
+ "data-testid": "test-div"
101
+ })));
102
+ expect(screen.getByTestId('test-div')).toBeInTheDocument();
103
+ });
104
+ it('Should render without failing if PWA is not enabled', () => {
105
+ const testOfflineInterface = { ...mockOfflineInterface,
106
+ pwaEnabled: false
107
+ };
108
+ render( /*#__PURE__*/React.createElement(OfflineProvider, {
109
+ offlineInterface: testOfflineInterface
110
+ }, /*#__PURE__*/React.createElement("div", {
111
+ "data-testid": "test-div"
112
+ }))); // Init should still be called - see comments in offline-provider.js
113
+
114
+ expect(testOfflineInterface.init).toHaveBeenCalled();
115
+ expect(screen.getByTestId('test-div')).toBeInTheDocument();
116
+ });
117
+ });
@@ -0,0 +1,218 @@
1
+ /* eslint-disable react/display-name, react/prop-types */
2
+ import { renderHook, act } from '@testing-library/react-hooks';
3
+ import React from 'react';
4
+ import { errorRecordingMock, failedMessageRecordingMock, mockOfflineInterface } from '../../utils/test-mocks';
5
+ import { useCacheableSection } from '../cacheable-section';
6
+ import { OfflineProvider } from '../offline-provider'; // Suppress 'act' warning for these tests
7
+
8
+ const originalError = console.error;
9
+ beforeEach(() => {
10
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
11
+ const pattern = /Warning: An update to .* inside a test was not wrapped in act/;
12
+
13
+ if (typeof args[0] === 'string' && pattern.test(args[0])) {
14
+ return;
15
+ }
16
+
17
+ return originalError.call(console, ...args);
18
+ });
19
+ });
20
+ afterEach(() => {
21
+ jest.clearAllMocks() // This syntax appeases typescript:
22
+ ;
23
+ console.error.mockRestore();
24
+ });
25
+ it('renders in the default state initially', () => {
26
+ const {
27
+ result
28
+ } = renderHook(() => useCacheableSection('one'), {
29
+ wrapper: ({
30
+ children
31
+ }) => /*#__PURE__*/React.createElement(OfflineProvider, {
32
+ offlineInterface: mockOfflineInterface
33
+ }, children)
34
+ });
35
+ expect(result.current.recordingState).toBe('default');
36
+ expect(result.current.isCached).toBe(false);
37
+ expect(result.current.lastUpdated).toBeUndefined();
38
+ });
39
+ it('handles a successful recording', async done => {
40
+ const [sectionId, timeoutDelay] = ['one', 1234];
41
+ const testOfflineInterface = { ...mockOfflineInterface,
42
+ getCachedSections: jest.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([{
43
+ sectionId: sectionId,
44
+ lastUpdated: new Date()
45
+ }])
46
+ };
47
+ const {
48
+ result,
49
+ waitFor
50
+ } = renderHook(() => useCacheableSection(sectionId), {
51
+ wrapper: ({
52
+ children
53
+ }) => /*#__PURE__*/React.createElement(OfflineProvider, {
54
+ offlineInterface: testOfflineInterface
55
+ }, children)
56
+ });
57
+
58
+ const assertRecordingStarted = () => {
59
+ expect(result.current.recordingState).toBe('recording');
60
+ };
61
+
62
+ const assertRecordingCompleted = async () => {
63
+ expect(result.current.recordingState).toBe('default'); // Test that 'isCached' gets updated
64
+
65
+ expect(testOfflineInterface.getCachedSections).toBeCalledTimes(2);
66
+ await waitFor(() => result.current.isCached === true);
67
+ expect(result.current.isCached).toBe(true);
68
+ expect(result.current.lastUpdated).toBeInstanceOf(Date); // If this cb is not called, test should time out and fail
69
+
70
+ done();
71
+ };
72
+
73
+ await act(async () => {
74
+ await result.current.startRecording({
75
+ onStarted: assertRecordingStarted,
76
+ onCompleted: assertRecordingCompleted,
77
+ recordingTimeoutDelay: timeoutDelay
78
+ });
79
+ }); // At this stage, recording should be 'pending'
80
+
81
+ expect(result.current.recordingState).toBe('pending'); // Check correct options sent to offline interface
82
+
83
+ const options = mockOfflineInterface.startRecording.mock.calls[0][0];
84
+ expect(options.sectionId).toBe(sectionId);
85
+ expect(options.recordingTimeoutDelay).toBe(timeoutDelay);
86
+ expect(typeof options.onStarted).toBe('function');
87
+ expect(typeof options.onCompleted).toBe('function');
88
+ expect(typeof options.onError).toBe('function'); // Make sure all async assertions are called
89
+
90
+ expect.assertions(11);
91
+ });
92
+ it('handles a recording that encounters an error', async done => {
93
+ // Suppress the expected error from console (in addition to 'act' warning)
94
+ jest.spyOn(console, 'error').mockImplementation((...args) => {
95
+ const actPattern = /Warning: An update to .* inside a test was not wrapped in act/;
96
+ const errPattern = /Error during recording/;
97
+ const matchesPattern = actPattern.test(args[0]) || errPattern.test(args[0]);
98
+
99
+ if (typeof args[0] === 'string' && matchesPattern) {
100
+ return;
101
+ }
102
+
103
+ return originalError.call(console, ...args);
104
+ });
105
+ const testOfflineInterface = { ...mockOfflineInterface,
106
+ startRecording: errorRecordingMock
107
+ };
108
+ const {
109
+ result
110
+ } = renderHook(() => useCacheableSection('one'), {
111
+ wrapper: ({
112
+ children
113
+ }) => /*#__PURE__*/React.createElement(OfflineProvider, {
114
+ offlineInterface: testOfflineInterface
115
+ }, children)
116
+ });
117
+
118
+ const assertRecordingStarted = () => {
119
+ expect(result.current.recordingState).toBe('recording');
120
+ };
121
+
122
+ const assertRecordingError = error => {
123
+ expect(result.current.recordingState).toBe('error');
124
+ expect(error.message).toMatch(/test err/); // see errorRecordingMock
125
+
126
+ expect(console.error).toHaveBeenCalledWith('Error during recording:', error); // Expect only one call, from initialization:
127
+
128
+ expect(mockOfflineInterface.getCachedSections).toBeCalledTimes(1); // If this cb is not called, test should time out and fail
129
+
130
+ done();
131
+ };
132
+
133
+ await act(async () => {
134
+ await result.current.startRecording({
135
+ onStarted: assertRecordingStarted,
136
+ onError: assertRecordingError
137
+ });
138
+ }); // At this stage, recording should be 'pending'
139
+
140
+ expect(result.current.recordingState).toBe('pending'); // Make sure all async assertions are called
141
+
142
+ expect.assertions(6);
143
+ });
144
+ it('handles an error starting the recording', async () => {
145
+ const testOfflineInterface = { ...mockOfflineInterface,
146
+ startRecording: failedMessageRecordingMock
147
+ };
148
+ const {
149
+ result
150
+ } = renderHook(() => useCacheableSection('err'), {
151
+ wrapper: ({
152
+ children
153
+ }) => /*#__PURE__*/React.createElement(OfflineProvider, {
154
+ offlineInterface: testOfflineInterface
155
+ }, children)
156
+ });
157
+ await expect(result.current.startRecording()).rejects.toThrow('Failed message' // from failedMessageRecordingMock
158
+ );
159
+ });
160
+ it('handles remove and updates sections', async () => {
161
+ const sectionId = 'one';
162
+ const testOfflineInterface = { ...mockOfflineInterface,
163
+ getCachedSections: jest.fn().mockResolvedValueOnce([{
164
+ sectionId: sectionId,
165
+ lastUpdated: new Date()
166
+ }]).mockResolvedValueOnce([])
167
+ };
168
+ const {
169
+ result,
170
+ waitFor
171
+ } = renderHook(() => useCacheableSection(sectionId), {
172
+ wrapper: ({
173
+ children
174
+ }) => /*#__PURE__*/React.createElement(OfflineProvider, {
175
+ offlineInterface: testOfflineInterface
176
+ }, children)
177
+ }); // Wait for state to sync with indexedDB
178
+
179
+ await waitFor(() => result.current.isCached === true);
180
+ let success;
181
+ await act(async () => {
182
+ success = await result.current.remove();
183
+ });
184
+ expect(success).toBe(true); // Test that 'isCached' gets updated
185
+
186
+ expect(testOfflineInterface.getCachedSections).toBeCalledTimes(2);
187
+ await waitFor(() => result.current.isCached === false);
188
+ expect(result.current.isCached).toBe(false);
189
+ expect(result.current.lastUpdated).toBeUndefined();
190
+ });
191
+ it('handles a change in ID', async () => {
192
+ const testOfflineInterface = { ...mockOfflineInterface,
193
+ getCachedSections: jest.fn().mockResolvedValue([{
194
+ sectionId: 'id-one',
195
+ lastUpdated: new Date()
196
+ }])
197
+ };
198
+ const {
199
+ result,
200
+ waitFor,
201
+ rerender
202
+ } = renderHook((...args) => useCacheableSection(...args), {
203
+ wrapper: ({
204
+ children
205
+ }) => /*#__PURE__*/React.createElement(OfflineProvider, {
206
+ offlineInterface: testOfflineInterface
207
+ }, children),
208
+ initialProps: 'id-one'
209
+ }); // Wait for state to sync with indexedDB
210
+
211
+ await waitFor(() => result.current.isCached === true);
212
+ rerender('id-two'); // Test that 'isCached' gets updated
213
+ // expect(testOfflineInterface.getCachedSections).toBeCalledTimes(2)
214
+
215
+ await waitFor(() => result.current.isCached === false);
216
+ expect(result.current.isCached).toBe(false);
217
+ expect(result.current.lastUpdated).toBeUndefined();
218
+ });