@chipmobilesdk/rn-notification 0.4.0 → 0.4.1

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 (75) hide show
  1. package/CHANGELOG.md +174 -166
  2. package/README.md +414 -410
  3. package/package.json +58 -75
  4. package/skills/sdk-rn-notification/SKILL.md +172 -173
  5. package/src/channel/migrate.ts +285 -289
  6. package/src/channel/tone-map.ts +130 -130
  7. package/src/channel/validate-tone.ts +61 -61
  8. package/src/contract/channel.ts +192 -188
  9. package/src/contract/diagnostics.ts +102 -102
  10. package/src/contract/errors.ts +134 -134
  11. package/src/contract/index.ts +10 -10
  12. package/src/contract/interaction.ts +62 -62
  13. package/src/contract/notification.ts +118 -118
  14. package/src/contract/permission.ts +55 -60
  15. package/src/contract/reconcile.ts +88 -88
  16. package/src/contract/recurrence.ts +129 -129
  17. package/src/diagnostics/burst.ts +101 -101
  18. package/src/diagnostics/events.ts +72 -72
  19. package/src/diagnostics/redact.ts +72 -72
  20. package/src/diagnostics/snapshot.ts +67 -64
  21. package/src/engine/adapter.ts +172 -172
  22. package/src/engine/codec.ts +89 -89
  23. package/src/engine/notify-kit-engine.ts +706 -707
  24. package/src/index.ts +208 -208
  25. package/src/initialize.ts +240 -236
  26. package/src/interaction/index.ts +146 -146
  27. package/src/maintenance/cycle.ts +150 -154
  28. package/src/maintenance/permission-refresh.ts +39 -41
  29. package/src/maintenance/reanchor.ts +101 -101
  30. package/src/maintenance/toggle.ts +66 -64
  31. package/src/maintenance/verify.ts +83 -83
  32. package/src/maintenance/window-refresh.ts +64 -64
  33. package/src/permission/index.ts +183 -183
  34. package/src/permission/state.ts +77 -77
  35. package/src/push/envelope.ts +165 -165
  36. package/src/push/fcm-provider.ts +143 -146
  37. package/src/push/gate.ts +190 -190
  38. package/src/push/index.ts +33 -29
  39. package/src/push/instructions.ts +114 -109
  40. package/src/push/provider-adapter.ts +54 -54
  41. package/src/push/signout.ts +65 -65
  42. package/src/reconcile/chunk.ts +53 -53
  43. package/src/reconcile/diff.ts +142 -145
  44. package/src/reconcile/duplicates.ts +73 -73
  45. package/src/reconcile/index.ts +180 -180
  46. package/src/reconcile/truncate.ts +56 -56
  47. package/src/recurrence/clock-guard.ts +73 -71
  48. package/src/recurrence/expand.ts +326 -319
  49. package/src/recurrence/index.ts +270 -259
  50. package/src/repeat/expiry.ts +53 -55
  51. package/src/repeat/index.ts +54 -54
  52. package/src/repeat/marker.ts +72 -72
  53. package/src/repeat/place.ts +73 -73
  54. package/src/schedule/anchor.ts +278 -267
  55. package/src/schedule/index.ts +174 -181
  56. package/src/schedule/partition.ts +88 -85
  57. package/src/schedule/resolve-moment.ts +80 -80
  58. package/src/schedule/validate-content.ts +111 -111
  59. package/src/schedule/validate-identifier.ts +126 -126
  60. package/src/schedule/writer.ts +55 -55
  61. package/src/state/caps.ts +71 -71
  62. package/src/state/dedup-ledger.ts +35 -35
  63. package/src/state/interaction-buffer.ts +73 -73
  64. package/src/state/mmkv-store.ts +75 -75
  65. package/src/state/permission-history.ts +41 -41
  66. package/src/state/record.ts +266 -263
  67. package/src/state/reset.ts +41 -41
  68. package/src/state/series-ledger.ts +91 -91
  69. package/src/state/store.ts +40 -40
  70. package/src/testing/conformance/index.ts +258 -248
  71. package/src/testing/fake-engine.ts +462 -468
  72. package/src/testing/fake-provider.ts +126 -129
  73. package/src/testing/faults.ts +72 -72
  74. package/src/testing/index.ts +23 -28
  75. package/src/testing/measure.ts +337 -337
@@ -1,248 +1,258 @@
1
- /**
2
- * The shared conformance suite.
3
- *
4
- * The executable definition of what an engine adapter must do. Run against the
5
- * real engine and against the fake, it is what backs the claim that testing
6
- * with fakes proves something about production — if the two ever diverge, this
7
- * is where it shows up rather than on a user's device.
8
- *
9
- * Written as a plain async function rather than a Jest suite so it can be
10
- * driven from the demo app on a real device as well as from continuous
11
- * integration.
12
- */
13
-
14
- import type { NotificationEngineAdapter } from '../../engine/adapter';
15
- import type { ResolvedTone } from '../../contract/channel';
16
-
17
- const TONE: ResolvedTone = {
18
- toneId: 'conformance',
19
- version: 1,
20
- android: {
21
- channelId: 'conformance.v1',
22
- importance: 'default',
23
- vibration: false,
24
- badge: false,
25
- bypassDnd: false,
26
- name: 'Fixture tone',
27
- loopSound: false,
28
- alarmCategory: false,
29
- },
30
- ios: { categoryId: 'conformance', interruptionLevel: 'active', badge: false, repeatRefused: false },
31
- };
32
-
33
- export interface ConformanceCheck {
34
- readonly name: string;
35
- readonly passed: boolean;
36
- readonly detail?: string;
37
- }
38
-
39
- export interface ConformanceReport {
40
- readonly adapter: string;
41
- readonly checks: readonly ConformanceCheck[];
42
- readonly passed: number;
43
- readonly failed: number;
44
- }
45
-
46
- export interface ConformanceOptions {
47
- /** Injected so the suite never depends on wall-clock time. */
48
- readonly now?: () => number;
49
- /** Identifier prefix, so a device run cannot collide with real notifications. */
50
- readonly prefix?: string;
51
- }
52
-
53
- export async function runConformanceSuite(
54
- adapter: NotificationEngineAdapter,
55
- options: ConformanceOptions = {},
56
- ): Promise<ConformanceReport> {
57
- const now = options.now ?? (() => Date.now());
58
- const prefix = options.prefix ?? 'conformance';
59
- const checks: ConformanceCheck[] = [];
60
-
61
- const check = async (name: string, run: () => Promise<boolean | string>): Promise<void> => {
62
- try {
63
- const outcome = await run();
64
- checks.push(
65
- outcome === true
66
- ? { name, passed: true }
67
- : { name, passed: false, detail: typeof outcome === 'string' ? outcome : 'returned false' },
68
- );
69
- } catch (error) {
70
- checks.push({
71
- name,
72
- passed: false,
73
- detail: error instanceof Error ? error.message : String(error),
74
- });
75
- }
76
- };
77
-
78
- const id = (suffix: string) => `${prefix}:${suffix}`;
79
- const at = now() + 3_600_000;
80
-
81
- const schedule = (suffix: string, overrides: Record<string, unknown> = {}) =>
82
- adapter.schedule({
83
- request: {
84
- id: id(suffix),
85
- content: { title: `Conformance ${suffix}`, body: 'Suite entry' },
86
- tone: 'conformance',
87
- moment: { at },
88
- ...overrides,
89
- },
90
- resolvedAt: at,
91
- tone: TONE,
92
- approximate: false,
93
- });
94
-
95
- // Start clean so a previous run cannot mask a failure.
96
- await adapter.cancelAll();
97
-
98
- await check('reports its capabilities', async () => {
99
- const capabilities = await adapter.getCapabilities();
100
- return (
101
- capabilities.pendingCeiling > 0 ||
102
- `pendingCeiling must be positive, got ${capabilities.pendingCeiling}`
103
- );
104
- });
105
-
106
- await check('schedules and reads back an entry', async () => {
107
- await schedule('a');
108
- const pending = await adapter.getPending();
109
- return pending.some(entry => entry.id === id('a')) || 'scheduled entry was not returned';
110
- });
111
-
112
- await check('returns enough detail to diff, not merely to count', async () => {
113
- const [entry] = (await adapter.getPending()).filter(e => e.id === id('a'));
114
- if (entry === undefined) return 'entry missing';
115
-
116
- return (
117
- (entry.content.title.length > 0 && entry.resolvedAt > 0 && entry.anchoring !== undefined) ||
118
- 'entry lacked content, instant, or anchoring mode'
119
- );
120
- });
121
-
122
- await check('replaces rather than duplicates on the same identifier', async () => {
123
- await schedule('a', { content: { title: 'Replaced', body: 'Newer' } });
124
- const matching = (await adapter.getPending()).filter(entry => entry.id === id('a'));
125
- return matching.length === 1 || `expected exactly 1 entry, found ${matching.length}`;
126
- });
127
-
128
- await check('round-trips a routing payload unchanged', async () => {
129
- await schedule('routing', { routing: { screen: 'task', nested: { deep: true } } });
130
- const entry = (await adapter.getPending()).find(e => e.id === id('routing'));
131
- return (
132
- JSON.stringify(entry?.routing) === JSON.stringify({ screen: 'task', nested: { deep: true } }) ||
133
- `routing payload changed: ${JSON.stringify(entry?.routing)}`
134
- );
135
- });
136
-
137
- await check('preserves the anchoring mode it was given', async () => {
138
- await schedule('absolute', { moment: { at, anchoring: 'absolute' } });
139
- const entry = (await adapter.getPending()).find(e => e.id === id('absolute'));
140
- return entry?.anchoring === 'absolute' || `expected absolute, got ${entry?.anchoring}`;
141
- });
142
-
143
- await check('cancels one entry without touching the others', async () => {
144
- const before = (await adapter.getPending()).length;
145
- await adapter.cancel(id('a'));
146
- const after = await adapter.getPending();
147
-
148
- return (
149
- (after.length === before - 1 && !after.some(entry => entry.id === id('a'))) ||
150
- 'cancel removed the wrong number of entries'
151
- );
152
- });
153
-
154
- await check('cancelling something absent is harmless', async () => {
155
- await adapter.cancel(id('never-existed'));
156
- return true;
157
- });
158
-
159
- await check('reports permission state without throwing', async () => {
160
- const state = await adapter.getPermission('display');
161
- return (
162
- ['notAsked', 'granted', 'denied', 'permanentlyDenied', 'provisional', 'notRequired'].includes(
163
- state,
164
- ) || `unexpected permission state: ${state}`
165
- );
166
- });
167
-
168
- await check('reports background restrictions, admitting unknown', async () => {
169
- const report = await adapter.getRestrictions();
170
- return (
171
- ['unrestricted', 'restricted', 'unknown'].includes(report.batteryOptimization) ||
172
- `unexpected restriction status: ${report.batteryOptimization}`
173
- );
174
- });
175
-
176
- await check('returns pending entries in ascending delivery order', async () => {
177
- const pending = await adapter.getPending();
178
- const instants = pending.map(entry => entry.resolvedAt);
179
- const sorted = [...instants].sort((x, y) => x - y);
180
- return JSON.stringify(instants) === JSON.stringify(sorted) || 'entries were not time-ordered';
181
- });
182
-
183
- await check('cancelAll leaves nothing behind', async () => {
184
- await adapter.cancelAll();
185
- const pending = await adapter.getPending();
186
- return pending.length === 0 || `${pending.length} entries survived cancelAll`;
187
- });
188
-
189
- // --- Repeat expiry -------------------------------------------------------
190
- // A substituted engine must be checked on these, not trusted: every one of
191
- // them is a way to end up with a device that will not stop ringing, or with
192
- // a consumer cancelling an artifact it was never meant to see.
193
-
194
- await check('answers whether it can repeat an alert, rather than guessing', async () => {
195
- const capabilities = await adapter.getCapabilities();
196
- return (
197
- typeof capabilities.supportsRepeatingAlert === 'boolean' ||
198
- 'supportsRepeatingAlert was not a boolean'
199
- );
200
- });
201
-
202
- await check('reports a repeating alert it cannot honour', async () => {
203
- const capabilities = await adapter.getCapabilities();
204
- if (capabilities.supportsRepeatingAlert) return true;
205
-
206
- // A partially emulated repeat is worse than an honest refusal: the
207
- // consumer believes the alarm is bounded when nothing bounds it.
208
- const repeating: ResolvedTone = {
209
- ...TONE,
210
- android: { ...TONE.android, loopSound: true, repeatForMs: 900_000 },
211
- ios: { ...TONE.ios, repeatRefused: true },
212
- };
213
- return repeating.ios.repeatRefused || 'refusal was not recorded on the resolved tone';
214
- });
215
-
216
- await check('keeps stop markers out of the consumer-visible pending set', async () => {
217
- const stops = await adapter.listRepeatStops();
218
- const pending = await adapter.getPending();
219
- const leaked = pending.filter(entry => stops.some(stop => stop.markerId === entry.id));
220
- return (
221
- leaked.length === 0 ||
222
- `${leaked.length} package-owned marker(s) were reported as consumer notifications`
223
- );
224
- });
225
-
226
- await check('silencing a notification that is gone is an answer, not a failure', async () => {
227
- // The ordinary case after the user has handled it. An engine that throws
228
- // here turns the commonest path into an error.
229
- const silenced = await adapter.silenceDelivered(id('never-displayed'));
230
- return silenced === false || 'reported silencing a notification it was not displaying';
231
- });
232
-
233
- await check('says whether a background delivery handler is registered', async () => {
234
- return (
235
- typeof adapter.hasBackgroundDeliveryHandler() === 'boolean' ||
236
- 'hasBackgroundDeliveryHandler was not a boolean'
237
- );
238
- });
239
-
240
- const passed = checks.filter(entry => entry.passed).length;
241
-
242
- return {
243
- adapter: adapter.name,
244
- checks,
245
- passed,
246
- failed: checks.length - passed,
247
- };
248
- }
1
+ /**
2
+ * The shared conformance suite.
3
+ *
4
+ * The executable definition of what an engine adapter must do. Run against the
5
+ * real engine and against the fake, it is what backs the claim that testing
6
+ * with fakes proves something about production — if the two ever diverge, this
7
+ * is where it shows up rather than on a user's device.
8
+ *
9
+ * Written as a plain async function rather than a Jest suite so it can be
10
+ * driven from the demo app on a real device as well as from continuous
11
+ * integration.
12
+ */
13
+
14
+ import type { NotificationEngineAdapter } from '../../engine/adapter';
15
+ import type { ResolvedTone } from '../../contract/channel';
16
+
17
+ const TONE: ResolvedTone = {
18
+ toneId: 'conformance',
19
+ version: 1,
20
+ android: {
21
+ channelId: 'conformance.v1',
22
+ importance: 'default',
23
+ vibration: false,
24
+ badge: false,
25
+ bypassDnd: false,
26
+ name: 'Fixture tone',
27
+ loopSound: false,
28
+ alarmCategory: false,
29
+ },
30
+ ios: {
31
+ categoryId: 'conformance',
32
+ interruptionLevel: 'active',
33
+ badge: false,
34
+ repeatRefused: false,
35
+ },
36
+ };
37
+
38
+ export interface ConformanceCheck {
39
+ readonly name: string;
40
+ readonly passed: boolean;
41
+ readonly detail?: string;
42
+ }
43
+
44
+ export interface ConformanceReport {
45
+ readonly adapter: string;
46
+ readonly checks: readonly ConformanceCheck[];
47
+ readonly passed: number;
48
+ readonly failed: number;
49
+ }
50
+
51
+ export interface ConformanceOptions {
52
+ /** Injected so the suite never depends on wall-clock time. */
53
+ readonly now?: () => number;
54
+ /** Identifier prefix, so a device run cannot collide with real notifications. */
55
+ readonly prefix?: string;
56
+ }
57
+
58
+ export async function runConformanceSuite(
59
+ adapter: NotificationEngineAdapter,
60
+ options: ConformanceOptions = {},
61
+ ): Promise<ConformanceReport> {
62
+ const now = options.now ?? (() => Date.now());
63
+ const prefix = options.prefix ?? 'conformance';
64
+ const checks: ConformanceCheck[] = [];
65
+
66
+ const check = async (name: string, run: () => Promise<boolean | string>): Promise<void> => {
67
+ try {
68
+ const outcome = await run();
69
+ checks.push(
70
+ outcome === true
71
+ ? { name, passed: true }
72
+ : {
73
+ name,
74
+ passed: false,
75
+ detail: typeof outcome === 'string' ? outcome : 'returned false',
76
+ },
77
+ );
78
+ } catch (error) {
79
+ checks.push({
80
+ name,
81
+ passed: false,
82
+ detail: error instanceof Error ? error.message : String(error),
83
+ });
84
+ }
85
+ };
86
+
87
+ const id = (suffix: string) => `${prefix}:${suffix}`;
88
+ const at = now() + 3_600_000;
89
+
90
+ const schedule = (suffix: string, overrides: Record<string, unknown> = {}) =>
91
+ adapter.schedule({
92
+ request: {
93
+ id: id(suffix),
94
+ content: { title: `Conformance ${suffix}`, body: 'Suite entry' },
95
+ tone: 'conformance',
96
+ moment: { at },
97
+ ...overrides,
98
+ },
99
+ resolvedAt: at,
100
+ tone: TONE,
101
+ approximate: false,
102
+ });
103
+
104
+ // Start clean so a previous run cannot mask a failure.
105
+ await adapter.cancelAll();
106
+
107
+ await check('reports its capabilities', async () => {
108
+ const capabilities = await adapter.getCapabilities();
109
+ return (
110
+ capabilities.pendingCeiling > 0 ||
111
+ `pendingCeiling must be positive, got ${capabilities.pendingCeiling}`
112
+ );
113
+ });
114
+
115
+ await check('schedules and reads back an entry', async () => {
116
+ await schedule('a');
117
+ const pending = await adapter.getPending();
118
+ return pending.some(entry => entry.id === id('a')) || 'scheduled entry was not returned';
119
+ });
120
+
121
+ await check('returns enough detail to diff, not merely to count', async () => {
122
+ const [entry] = (await adapter.getPending()).filter(e => e.id === id('a'));
123
+ if (entry === undefined) return 'entry missing';
124
+
125
+ return (
126
+ (entry.content.title.length > 0 && entry.resolvedAt > 0 && entry.anchoring !== undefined) ||
127
+ 'entry lacked content, instant, or anchoring mode'
128
+ );
129
+ });
130
+
131
+ await check('replaces rather than duplicates on the same identifier', async () => {
132
+ await schedule('a', { content: { title: 'Replaced', body: 'Newer' } });
133
+ const matching = (await adapter.getPending()).filter(entry => entry.id === id('a'));
134
+ return matching.length === 1 || `expected exactly 1 entry, found ${matching.length}`;
135
+ });
136
+
137
+ await check('round-trips a routing payload unchanged', async () => {
138
+ await schedule('routing', { routing: { screen: 'task', nested: { deep: true } } });
139
+ const entry = (await adapter.getPending()).find(e => e.id === id('routing'));
140
+ return (
141
+ JSON.stringify(entry?.routing) ===
142
+ JSON.stringify({ screen: 'task', nested: { deep: true } }) ||
143
+ `routing payload changed: ${JSON.stringify(entry?.routing)}`
144
+ );
145
+ });
146
+
147
+ await check('preserves the anchoring mode it was given', async () => {
148
+ await schedule('absolute', { moment: { at, anchoring: 'absolute' } });
149
+ const entry = (await adapter.getPending()).find(e => e.id === id('absolute'));
150
+ return entry?.anchoring === 'absolute' || `expected absolute, got ${entry?.anchoring}`;
151
+ });
152
+
153
+ await check('cancels one entry without touching the others', async () => {
154
+ const before = (await adapter.getPending()).length;
155
+ await adapter.cancel(id('a'));
156
+ const after = await adapter.getPending();
157
+
158
+ return (
159
+ (after.length === before - 1 && !after.some(entry => entry.id === id('a'))) ||
160
+ 'cancel removed the wrong number of entries'
161
+ );
162
+ });
163
+
164
+ await check('cancelling something absent is harmless', async () => {
165
+ await adapter.cancel(id('never-existed'));
166
+ return true;
167
+ });
168
+
169
+ await check('reports permission state without throwing', async () => {
170
+ const state = await adapter.getPermission('display');
171
+ return (
172
+ ['notAsked', 'granted', 'denied', 'permanentlyDenied', 'provisional', 'notRequired'].includes(
173
+ state,
174
+ ) || `unexpected permission state: ${state}`
175
+ );
176
+ });
177
+
178
+ await check('reports background restrictions, admitting unknown', async () => {
179
+ const report = await adapter.getRestrictions();
180
+ return (
181
+ ['unrestricted', 'restricted', 'unknown'].includes(report.batteryOptimization) ||
182
+ `unexpected restriction status: ${report.batteryOptimization}`
183
+ );
184
+ });
185
+
186
+ await check('returns pending entries in ascending delivery order', async () => {
187
+ const pending = await adapter.getPending();
188
+ const instants = pending.map(entry => entry.resolvedAt);
189
+ const sorted = [...instants].sort((x, y) => x - y);
190
+ return JSON.stringify(instants) === JSON.stringify(sorted) || 'entries were not time-ordered';
191
+ });
192
+
193
+ await check('cancelAll leaves nothing behind', async () => {
194
+ await adapter.cancelAll();
195
+ const pending = await adapter.getPending();
196
+ return pending.length === 0 || `${pending.length} entries survived cancelAll`;
197
+ });
198
+
199
+ // --- Repeat expiry -------------------------------------------------------
200
+ // A substituted engine must be checked on these, not trusted: every one of
201
+ // them is a way to end up with a device that will not stop ringing, or with
202
+ // a consumer cancelling an artifact it was never meant to see.
203
+
204
+ await check('answers whether it can repeat an alert, rather than guessing', async () => {
205
+ const capabilities = await adapter.getCapabilities();
206
+ return (
207
+ typeof capabilities.supportsRepeatingAlert === 'boolean' ||
208
+ 'supportsRepeatingAlert was not a boolean'
209
+ );
210
+ });
211
+
212
+ await check('reports a repeating alert it cannot honour', async () => {
213
+ const capabilities = await adapter.getCapabilities();
214
+ if (capabilities.supportsRepeatingAlert) return true;
215
+
216
+ // A partially emulated repeat is worse than an honest refusal: the
217
+ // consumer believes the alarm is bounded when nothing bounds it.
218
+ const repeating: ResolvedTone = {
219
+ ...TONE,
220
+ android: { ...TONE.android, loopSound: true, repeatForMs: 900_000 },
221
+ ios: { ...TONE.ios, repeatRefused: true },
222
+ };
223
+ return repeating.ios.repeatRefused || 'refusal was not recorded on the resolved tone';
224
+ });
225
+
226
+ await check('keeps stop markers out of the consumer-visible pending set', async () => {
227
+ const stops = await adapter.listRepeatStops();
228
+ const pending = await adapter.getPending();
229
+ const leaked = pending.filter(entry => stops.some(stop => stop.markerId === entry.id));
230
+ return (
231
+ leaked.length === 0 ||
232
+ `${leaked.length} package-owned marker(s) were reported as consumer notifications`
233
+ );
234
+ });
235
+
236
+ await check('silencing a notification that is gone is an answer, not a failure', async () => {
237
+ // The ordinary case after the user has handled it. An engine that throws
238
+ // here turns the commonest path into an error.
239
+ const silenced = await adapter.silenceDelivered(id('never-displayed'));
240
+ return silenced === false || 'reported silencing a notification it was not displaying';
241
+ });
242
+
243
+ await check('says whether a background delivery handler is registered', async () => {
244
+ return (
245
+ typeof adapter.hasBackgroundDeliveryHandler() === 'boolean' ||
246
+ 'hasBackgroundDeliveryHandler was not a boolean'
247
+ );
248
+ });
249
+
250
+ const passed = checks.filter(entry => entry.passed).length;
251
+
252
+ return {
253
+ adapter: adapter.name,
254
+ checks,
255
+ passed,
256
+ failed: checks.length - passed,
257
+ };
258
+ }