docusaurus-plugin-generate-schema-docs 1.7.1 → 1.8.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.
- package/README.md +22 -0
- package/__tests__/ExampleDataLayer.test.js +149 -2
- package/__tests__/__fixtures__/schema-processing/components/dataLayer.json +9 -0
- package/__tests__/__fixtures__/schema-processing/event-reference.json +14 -0
- package/__tests__/__fixtures__/schema-processing/purchase-event.json +14 -0
- package/__tests__/components/PropertyRow.test.js +30 -0
- package/__tests__/components/__snapshots__/ConnectorLines.visualRegression.test.js.snap +3 -3
- package/__tests__/helpers/exampleModel.test.js +135 -0
- package/__tests__/helpers/schema-processing.test.js +56 -0
- package/__tests__/helpers/schemaToTableData.test.js +41 -0
- package/__tests__/helpers/snippetTargets.test.js +744 -0
- package/__tests__/helpers/trackingTargets.test.js +42 -0
- package/__tests__/runtimePayload.android.test.js +292 -0
- package/__tests__/runtimePayload.ios.test.js +282 -0
- package/__tests__/runtimePayload.web.test.js +32 -0
- package/__tests__/validateSchemas.test.js +53 -1
- package/components/ExampleDataLayer.js +191 -56
- package/components/PropertyRow.js +3 -2
- package/components/SchemaRows.css +10 -1
- package/helpers/exampleModel.js +70 -0
- package/helpers/schema-processing.js +41 -5
- package/helpers/schemaToExamples.js +52 -2
- package/helpers/schemaToTableData.js +40 -2
- package/helpers/snippetTargets.js +853 -0
- package/helpers/trackingTargets.js +73 -0
- package/helpers/validator.js +1 -0
- package/package.json +1 -1
- package/test-data/payloadContracts.js +155 -0
- package/validateSchemas.js +15 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_TRACKING_TARGET,
|
|
3
|
+
resolveTrackingTargets,
|
|
4
|
+
} from '../../helpers/trackingTargets';
|
|
5
|
+
|
|
6
|
+
describe('trackingTargets', () => {
|
|
7
|
+
it('returns the configured targets when valid', () => {
|
|
8
|
+
const schema = {
|
|
9
|
+
'x-tracking-targets': ['web-datalayer-js', 'android-firebase-java-sdk'],
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const result = resolveTrackingTargets(schema, { schemaFile: 'event.json' });
|
|
13
|
+
expect(result).toEqual({
|
|
14
|
+
targets: ['web-datalayer-js', 'android-firebase-java-sdk'],
|
|
15
|
+
warning: null,
|
|
16
|
+
errors: [],
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('falls back to default target when key is missing', () => {
|
|
21
|
+
const result = resolveTrackingTargets(
|
|
22
|
+
{},
|
|
23
|
+
{ schemaFile: 'event.json', isQuiet: false },
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(result.targets).toEqual([DEFAULT_TRACKING_TARGET]);
|
|
27
|
+
expect(result.errors).toEqual([]);
|
|
28
|
+
expect(result.warning).toContain(DEFAULT_TRACKING_TARGET);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns an error for unknown targets', () => {
|
|
32
|
+
const schema = {
|
|
33
|
+
'x-tracking-targets': ['web-not-supported-js'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const result = resolveTrackingTargets(schema, { schemaFile: 'event.json' });
|
|
37
|
+
|
|
38
|
+
expect(result.targets).toEqual([]);
|
|
39
|
+
expect(result.errors).toHaveLength(1);
|
|
40
|
+
expect(result.errors[0]).toContain('web-not-supported-js');
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect", "expectPayloadContract"] }] */
|
|
2
|
+
const { generateSnippetForTarget } = require('../helpers/snippetTargets');
|
|
3
|
+
const { PAYLOAD_CONTRACTS } = require('../test-data/payloadContracts');
|
|
4
|
+
|
|
5
|
+
const ANDROID_CONTRACTS = PAYLOAD_CONTRACTS.filter(
|
|
6
|
+
(contract) => contract.expected?.firebase,
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
function upperSnakeToSnake(value) {
|
|
10
|
+
return value.toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function unquote(value) {
|
|
14
|
+
if (value.startsWith('"') && value.endsWith('"')) return JSON.parse(value);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveAndroidEventToken(token) {
|
|
19
|
+
if (token.startsWith('"') && token.endsWith('"')) return JSON.parse(token);
|
|
20
|
+
const match = token.match(/^FirebaseAnalytics\.Event\.([A-Z0-9_]+)$/);
|
|
21
|
+
if (match) return upperSnakeToSnake(match[1]);
|
|
22
|
+
throw new Error(`Unsupported Android event token: ${token}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveAndroidParamToken(token) {
|
|
26
|
+
if (token.startsWith('"') && token.endsWith('"')) return JSON.parse(token);
|
|
27
|
+
const match = token.match(/^FirebaseAnalytics\.Param\.([A-Z0-9_]+)$/);
|
|
28
|
+
if (match) return upperSnakeToSnake(match[1]);
|
|
29
|
+
throw new Error(`Unsupported Android parameter token: ${token}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveAndroidUserPropertyToken(token) {
|
|
33
|
+
if (token.startsWith('"') && token.endsWith('"')) return JSON.parse(token);
|
|
34
|
+
const match = token.match(/^FirebaseAnalytics\.UserProperty\.([A-Z0-9_]+)$/);
|
|
35
|
+
if (!match)
|
|
36
|
+
throw new Error(`Unsupported Android user property token: ${token}`);
|
|
37
|
+
if (match[1] === 'ALLOW_AD_PERSONALIZATION_SIGNALS') {
|
|
38
|
+
return 'allow_personalized_ads';
|
|
39
|
+
}
|
|
40
|
+
return upperSnakeToSnake(match[1]);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveAndroidValue(token) {
|
|
44
|
+
const value = token.trim();
|
|
45
|
+
if (value === 'null') return null;
|
|
46
|
+
if (value.startsWith('"') && value.endsWith('"')) return JSON.parse(value);
|
|
47
|
+
const longMatch = value.match(/^(-?\d+)L$/);
|
|
48
|
+
if (longMatch) return Number(longMatch[1]);
|
|
49
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
50
|
+
throw new Error(`Unsupported Android value token: ${value}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function splitArgs(content) {
|
|
54
|
+
let quote = null;
|
|
55
|
+
let escaped = false;
|
|
56
|
+
let roundDepth = 0;
|
|
57
|
+
let squareDepth = 0;
|
|
58
|
+
let curlyDepth = 0;
|
|
59
|
+
|
|
60
|
+
for (let idx = 0; idx < content.length; idx += 1) {
|
|
61
|
+
const char = content[idx];
|
|
62
|
+
|
|
63
|
+
if (quote) {
|
|
64
|
+
if (escaped) {
|
|
65
|
+
escaped = false;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (char === '\\') {
|
|
69
|
+
escaped = true;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (char === quote) {
|
|
73
|
+
quote = null;
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (char === '"' || char === "'") {
|
|
79
|
+
quote = char;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (char === '(') {
|
|
83
|
+
roundDepth += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (char === ')') {
|
|
87
|
+
roundDepth -= 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (char === '[') {
|
|
91
|
+
squareDepth += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (char === ']') {
|
|
95
|
+
squareDepth -= 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (char === '{') {
|
|
99
|
+
curlyDepth += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (char === '}') {
|
|
103
|
+
curlyDepth -= 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
char === ',' &&
|
|
108
|
+
roundDepth === 0 &&
|
|
109
|
+
squareDepth === 0 &&
|
|
110
|
+
curlyDepth === 0
|
|
111
|
+
) {
|
|
112
|
+
return [content.slice(0, idx).trim(), content.slice(idx + 1).trim()];
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
throw new Error(`Unable to split function arguments: ${content}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function parseKotlinRuntimePayload(snippet) {
|
|
120
|
+
const lines = snippet.split('\n').map((line) => line.trim());
|
|
121
|
+
const itemBundles = {};
|
|
122
|
+
const userProperties = {};
|
|
123
|
+
const eventParams = {};
|
|
124
|
+
let eventName = null;
|
|
125
|
+
let inLogEvent = false;
|
|
126
|
+
|
|
127
|
+
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
128
|
+
const line = lines[idx];
|
|
129
|
+
if (!line) continue;
|
|
130
|
+
|
|
131
|
+
const itemStart = line.match(/^val (item\d+) = Bundle\(\)\.apply \{$/);
|
|
132
|
+
if (itemStart) {
|
|
133
|
+
const itemName = itemStart[1];
|
|
134
|
+
itemBundles[itemName] = {};
|
|
135
|
+
idx += 1;
|
|
136
|
+
while (idx < lines.length && lines[idx] !== '}') {
|
|
137
|
+
const putLine = lines[idx];
|
|
138
|
+
const putMatch = putLine.match(/^put(?:String|Long|Double)\((.+)\)$/);
|
|
139
|
+
if (!putMatch)
|
|
140
|
+
throw new Error(`Unsupported Kotlin item line: ${putLine}`);
|
|
141
|
+
const [keyToken, valueToken] = splitArgs(putMatch[1]);
|
|
142
|
+
itemBundles[itemName][resolveAndroidParamToken(keyToken)] =
|
|
143
|
+
resolveAndroidValue(valueToken);
|
|
144
|
+
idx += 1;
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const userMatch = line.match(
|
|
150
|
+
/^firebaseAnalytics\.setUserProperty\((.+)\)$/,
|
|
151
|
+
);
|
|
152
|
+
if (userMatch) {
|
|
153
|
+
const [keyToken, valueToken] = splitArgs(userMatch[1]);
|
|
154
|
+
const key = resolveAndroidUserPropertyToken(keyToken);
|
|
155
|
+
const value = resolveAndroidValue(valueToken);
|
|
156
|
+
userProperties[key] = value === null ? null : String(value);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const logStartMatch = line.match(
|
|
161
|
+
/^firebaseAnalytics\.logEvent\((.+)\) \{$/,
|
|
162
|
+
);
|
|
163
|
+
if (logStartMatch) {
|
|
164
|
+
eventName = resolveAndroidEventToken(logStartMatch[1].trim());
|
|
165
|
+
inLogEvent = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (inLogEvent && line === '}') {
|
|
170
|
+
inLogEvent = false;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (inLogEvent) {
|
|
175
|
+
const paramMatch = line.match(/^param\((.+)\)$/);
|
|
176
|
+
if (!paramMatch)
|
|
177
|
+
throw new Error(`Unsupported Kotlin param line: ${line}`);
|
|
178
|
+
const [keyToken, valueToken] = splitArgs(paramMatch[1]);
|
|
179
|
+
const key = resolveAndroidParamToken(keyToken);
|
|
180
|
+
const itemsMatch = valueToken.match(/^arrayOf\((.+)\)$/);
|
|
181
|
+
if (itemsMatch) {
|
|
182
|
+
const names = itemsMatch[1]
|
|
183
|
+
.split(',')
|
|
184
|
+
.map((name) => name.trim())
|
|
185
|
+
.filter(Boolean);
|
|
186
|
+
eventParams[key] = names.map((name) => itemBundles[name]);
|
|
187
|
+
} else {
|
|
188
|
+
eventParams[key] = resolveAndroidValue(valueToken);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { eventName, parameters: eventParams, userProperties };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseJavaRuntimePayload(snippet) {
|
|
197
|
+
const lines = snippet.split('\n').map((line) => line.trim());
|
|
198
|
+
const bundles = {};
|
|
199
|
+
const userProperties = {};
|
|
200
|
+
let eventName = null;
|
|
201
|
+
|
|
202
|
+
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
203
|
+
const line = lines[idx];
|
|
204
|
+
if (!line) continue;
|
|
205
|
+
|
|
206
|
+
const bundleStart = line.match(/^Bundle (\w+) = new Bundle\(\);$/);
|
|
207
|
+
if (bundleStart) {
|
|
208
|
+
bundles[bundleStart[1]] = {};
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const putMatch = line.match(/^(\w+)\.put(?:String|Long|Double)\((.+)\);$/);
|
|
213
|
+
if (putMatch) {
|
|
214
|
+
const bundleName = putMatch[1];
|
|
215
|
+
const [keyToken, valueToken] = splitArgs(putMatch[2]);
|
|
216
|
+
bundles[bundleName][resolveAndroidParamToken(keyToken)] =
|
|
217
|
+
resolveAndroidValue(valueToken);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const parcelableMatch = line.match(
|
|
222
|
+
/^eventParams\.putParcelableArray\((.+), new Parcelable\[\]\{(.+)\}\);$/,
|
|
223
|
+
);
|
|
224
|
+
if (parcelableMatch) {
|
|
225
|
+
const key = resolveAndroidParamToken(parcelableMatch[1].trim());
|
|
226
|
+
const names = parcelableMatch[2]
|
|
227
|
+
.split(',')
|
|
228
|
+
.map((name) => name.trim())
|
|
229
|
+
.filter(Boolean);
|
|
230
|
+
bundles.eventParams[key] = names.map((name) => bundles[name]);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const userMatch = line.match(
|
|
235
|
+
/^mFirebaseAnalytics\.setUserProperty\((.+)\);$/,
|
|
236
|
+
);
|
|
237
|
+
if (userMatch) {
|
|
238
|
+
const [keyToken, valueToken] = splitArgs(userMatch[1]);
|
|
239
|
+
const key = resolveAndroidUserPropertyToken(keyToken);
|
|
240
|
+
const value = resolveAndroidValue(valueToken);
|
|
241
|
+
userProperties[key] = value === null ? null : String(value);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const logMatch = line.match(
|
|
246
|
+
/^mFirebaseAnalytics\.logEvent\((.+), eventParams\);$/,
|
|
247
|
+
);
|
|
248
|
+
if (logMatch) {
|
|
249
|
+
eventName = resolveAndroidEventToken(logMatch[1].trim());
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { eventName, parameters: bundles.eventParams || {}, userProperties };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function expectPayloadContract(parsed, expectedContract) {
|
|
257
|
+
expect(parsed.eventName).toBe(expectedContract.eventName);
|
|
258
|
+
expect(parsed.parameters || {}).toEqual(expectedContract.parameters || {});
|
|
259
|
+
expect(parsed.userProperties || {}).toEqual(
|
|
260
|
+
expectedContract.userProperties || {},
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
describe('runtime payload contracts (android)', () => {
|
|
265
|
+
it.each(ANDROID_CONTRACTS)(
|
|
266
|
+
'validates Kotlin runtime payload for $id ($class)',
|
|
267
|
+
(contract) => {
|
|
268
|
+
const snippet = generateSnippetForTarget({
|
|
269
|
+
targetId: 'android-firebase-kotlin-sdk',
|
|
270
|
+
example: contract.example,
|
|
271
|
+
schema: { properties: {} },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const parsed = parseKotlinRuntimePayload(snippet);
|
|
275
|
+
expectPayloadContract(parsed, contract.expected.firebase);
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
it.each(ANDROID_CONTRACTS)(
|
|
280
|
+
'validates Java runtime payload for $id ($class)',
|
|
281
|
+
(contract) => {
|
|
282
|
+
const snippet = generateSnippetForTarget({
|
|
283
|
+
targetId: 'android-firebase-java-sdk',
|
|
284
|
+
example: contract.example,
|
|
285
|
+
schema: { properties: {} },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
const parsed = parseJavaRuntimePayload(snippet);
|
|
289
|
+
expectPayloadContract(parsed, contract.expected.firebase);
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
});
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/* eslint jest/expect-expect: ["warn", { "assertFunctionNames": ["expect", "expectPayloadContract"] }] */
|
|
2
|
+
const { generateSnippetForTarget } = require('../helpers/snippetTargets');
|
|
3
|
+
const { PAYLOAD_CONTRACTS } = require('../test-data/payloadContracts');
|
|
4
|
+
const IOS_CONTRACTS = PAYLOAD_CONTRACTS.filter(
|
|
5
|
+
(contract) => contract.expected?.firebase,
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
function stripTrailingComma(value) {
|
|
9
|
+
return value.replace(/,+$/, '').trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function splitPair(line) {
|
|
13
|
+
const match = line.match(/^(.+?):\s*(.+)$/);
|
|
14
|
+
if (!match) {
|
|
15
|
+
throw new Error(`Cannot parse key/value line: ${line}`);
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
keyToken: stripTrailingComma(match[1].trim()),
|
|
19
|
+
valueToken: stripTrailingComma(match[2].trim()),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toSnakeCaseFromPascal(value) {
|
|
24
|
+
return value.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resolveEventToken(token) {
|
|
28
|
+
if (token.startsWith('"') && token.endsWith('"')) return JSON.parse(token);
|
|
29
|
+
if (token.startsWith('@"') && token.endsWith('"')) {
|
|
30
|
+
return JSON.parse(token.slice(1));
|
|
31
|
+
}
|
|
32
|
+
if (token.startsWith('AnalyticsEvent')) {
|
|
33
|
+
return toSnakeCaseFromPascal(token.slice('AnalyticsEvent'.length));
|
|
34
|
+
}
|
|
35
|
+
if (token.startsWith('kFIREvent')) {
|
|
36
|
+
return toSnakeCaseFromPascal(token.slice('kFIREvent'.length));
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Unsupported event token: ${token}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolveParamToken(token) {
|
|
42
|
+
if (token.startsWith('"') && token.endsWith('"')) return JSON.parse(token);
|
|
43
|
+
if (token.startsWith('@"') && token.endsWith('"')) {
|
|
44
|
+
return JSON.parse(token.slice(1));
|
|
45
|
+
}
|
|
46
|
+
if (token === 'AnalyticsParameterAdNetworkClickID') return 'aclid';
|
|
47
|
+
if (token === 'kFIRParameterAdNetworkClickID') return 'aclid';
|
|
48
|
+
if (token.startsWith('AnalyticsParameter')) {
|
|
49
|
+
return toSnakeCaseFromPascal(token.slice('AnalyticsParameter'.length));
|
|
50
|
+
}
|
|
51
|
+
if (token.startsWith('kFIRParameter')) {
|
|
52
|
+
return toSnakeCaseFromPascal(token.slice('kFIRParameter'.length));
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`Unsupported parameter token: ${token}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveUserPropertyToken(token) {
|
|
58
|
+
if (token.startsWith('"') && token.endsWith('"')) return JSON.parse(token);
|
|
59
|
+
if (token.startsWith('@"') && token.endsWith('"')) {
|
|
60
|
+
return JSON.parse(token.slice(1));
|
|
61
|
+
}
|
|
62
|
+
if (token === 'AnalyticsUserPropertySignUpMethod') return 'sign_up_method';
|
|
63
|
+
if (token === 'kFIRUserPropertySignUpMethod') return 'sign_up_method';
|
|
64
|
+
if (token === 'AnalyticsUserPropertyAllowAdPersonalizationSignals') {
|
|
65
|
+
return 'allow_personalized_ads';
|
|
66
|
+
}
|
|
67
|
+
if (token === 'kFIRUserPropertyAllowAdPersonalizationSignals') {
|
|
68
|
+
return 'allow_personalized_ads';
|
|
69
|
+
}
|
|
70
|
+
throw new Error(`Unsupported user property token: ${token}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveSwiftValue(token) {
|
|
74
|
+
const value = token.trim();
|
|
75
|
+
if (value === 'nil') return null;
|
|
76
|
+
if (value.startsWith('"') && value.endsWith('"')) return JSON.parse(value);
|
|
77
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
78
|
+
throw new Error(`Unsupported Swift value token: ${value}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveObjcValue(token) {
|
|
82
|
+
const value = token.trim();
|
|
83
|
+
if (value === 'nil') return null;
|
|
84
|
+
if (value.startsWith('@"') && value.endsWith('"')) {
|
|
85
|
+
return JSON.parse(value.slice(1));
|
|
86
|
+
}
|
|
87
|
+
const numberMatch = value.match(/^@\((-?\d+(?:\.\d+)?)\)$/);
|
|
88
|
+
if (numberMatch) return Number(numberMatch[1]);
|
|
89
|
+
throw new Error(`Unsupported Obj-C value token: ${value}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseSwiftDictionary(lines, startIndex) {
|
|
93
|
+
const payload = {};
|
|
94
|
+
let idx = startIndex + 1;
|
|
95
|
+
while (idx < lines.length && lines[idx].trim() !== ']') {
|
|
96
|
+
const line = lines[idx].trim();
|
|
97
|
+
if (line.length > 0) {
|
|
98
|
+
const { keyToken, valueToken } = splitPair(line);
|
|
99
|
+
payload[resolveParamToken(keyToken)] = resolveSwiftValue(valueToken);
|
|
100
|
+
}
|
|
101
|
+
idx += 1;
|
|
102
|
+
}
|
|
103
|
+
return { payload, endIndex: idx };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseObjcDictionary(lines, startIndex) {
|
|
107
|
+
const payload = {};
|
|
108
|
+
let idx = startIndex + 1;
|
|
109
|
+
while (idx < lines.length && lines[idx].trim() !== '} mutableCopy];') {
|
|
110
|
+
const line = lines[idx].trim();
|
|
111
|
+
if (line.length > 0) {
|
|
112
|
+
const { keyToken, valueToken } = splitPair(line);
|
|
113
|
+
payload[resolveParamToken(keyToken)] = resolveObjcValue(valueToken);
|
|
114
|
+
}
|
|
115
|
+
idx += 1;
|
|
116
|
+
}
|
|
117
|
+
return { payload, endIndex: idx };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function parseSwiftRuntimePayload(snippet) {
|
|
121
|
+
const lines = snippet.split('\n');
|
|
122
|
+
const itemsByName = {};
|
|
123
|
+
const userProperties = {};
|
|
124
|
+
let eventName = null;
|
|
125
|
+
let parameters = null;
|
|
126
|
+
|
|
127
|
+
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
128
|
+
const line = lines[idx].trim();
|
|
129
|
+
if (!line) continue;
|
|
130
|
+
|
|
131
|
+
const itemStart = line.match(/^var (item\d+): \[String: Any\] = \[$/);
|
|
132
|
+
if (itemStart) {
|
|
133
|
+
const parsed = parseSwiftDictionary(lines, idx);
|
|
134
|
+
itemsByName[itemStart[1]] = parsed.payload;
|
|
135
|
+
idx = parsed.endIndex;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const userPropertyMatch = line.match(
|
|
140
|
+
/^Analytics\.setUserProperty\((.+), forName: (.+)\)$/,
|
|
141
|
+
);
|
|
142
|
+
if (userPropertyMatch) {
|
|
143
|
+
const value = resolveSwiftValue(userPropertyMatch[1].trim());
|
|
144
|
+
const key = resolveUserPropertyToken(userPropertyMatch[2].trim());
|
|
145
|
+
userProperties[key] = value === null ? null : String(value);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (line === 'var eventParams: [String: Any] = [') {
|
|
150
|
+
const parsed = parseSwiftDictionary(lines, idx);
|
|
151
|
+
parameters = parsed.payload;
|
|
152
|
+
idx = parsed.endIndex;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const itemsAssignmentMatch = line.match(/^eventParams\[(.+)\] = \[(.+)\]$/);
|
|
157
|
+
if (itemsAssignmentMatch) {
|
|
158
|
+
const key = resolveParamToken(itemsAssignmentMatch[1].trim());
|
|
159
|
+
const names = itemsAssignmentMatch[2]
|
|
160
|
+
.split(',')
|
|
161
|
+
.map((name) => name.trim())
|
|
162
|
+
.filter(Boolean);
|
|
163
|
+
parameters[key] = names.map((name) => itemsByName[name]);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const logEventMatch = line.match(
|
|
168
|
+
/^Analytics\.logEvent\((.+), parameters: (.+)\)$/,
|
|
169
|
+
);
|
|
170
|
+
if (logEventMatch) {
|
|
171
|
+
eventName = resolveEventToken(logEventMatch[1].trim());
|
|
172
|
+
if (logEventMatch[2].trim() === 'nil') {
|
|
173
|
+
parameters = null;
|
|
174
|
+
}
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { eventName, parameters, userProperties };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function parseObjcRuntimePayload(snippet) {
|
|
183
|
+
const lines = snippet.split('\n');
|
|
184
|
+
const itemsByName = {};
|
|
185
|
+
const userProperties = {};
|
|
186
|
+
let eventName = null;
|
|
187
|
+
let parameters = null;
|
|
188
|
+
|
|
189
|
+
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
190
|
+
const line = lines[idx].trim();
|
|
191
|
+
if (!line) continue;
|
|
192
|
+
|
|
193
|
+
const itemStart = line.match(/^NSMutableDictionary \*(item\d+) = \[@\{$/);
|
|
194
|
+
if (itemStart) {
|
|
195
|
+
const parsed = parseObjcDictionary(lines, idx);
|
|
196
|
+
itemsByName[itemStart[1]] = parsed.payload;
|
|
197
|
+
idx = parsed.endIndex;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const userPropertyMatch = line.match(
|
|
202
|
+
/^\[FIRAnalytics setUserPropertyString:(.+) forName:(.+)\];$/,
|
|
203
|
+
);
|
|
204
|
+
if (userPropertyMatch) {
|
|
205
|
+
const value = resolveObjcValue(userPropertyMatch[1].trim());
|
|
206
|
+
const key = resolveUserPropertyToken(userPropertyMatch[2].trim());
|
|
207
|
+
userProperties[key] = value === null ? null : String(value);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (line === 'NSMutableDictionary *eventParams = [@{') {
|
|
212
|
+
const parsed = parseObjcDictionary(lines, idx);
|
|
213
|
+
parameters = parsed.payload;
|
|
214
|
+
idx = parsed.endIndex;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const itemsAssignmentMatch = line.match(
|
|
219
|
+
/^eventParams\[(.+)\] = @\[(.+)\];$/,
|
|
220
|
+
);
|
|
221
|
+
if (itemsAssignmentMatch) {
|
|
222
|
+
const key = resolveParamToken(itemsAssignmentMatch[1].trim());
|
|
223
|
+
const names = itemsAssignmentMatch[2]
|
|
224
|
+
.split(',')
|
|
225
|
+
.map((name) => name.trim())
|
|
226
|
+
.filter(Boolean);
|
|
227
|
+
parameters[key] = names.map((name) => itemsByName[name]);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const logEventMatch = line.match(
|
|
232
|
+
/^\[FIRAnalytics logEventWithName:(.+) parameters:(.+)\];$/,
|
|
233
|
+
);
|
|
234
|
+
if (logEventMatch) {
|
|
235
|
+
eventName = resolveEventToken(logEventMatch[1].trim());
|
|
236
|
+
if (logEventMatch[2].trim() === 'nil') {
|
|
237
|
+
parameters = null;
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { eventName, parameters, userProperties };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function expectPayloadContract(parsed, expectedContract) {
|
|
247
|
+
expect(parsed.eventName).toBe(expectedContract.eventName);
|
|
248
|
+
expect(parsed.parameters || {}).toEqual(expectedContract.parameters || {});
|
|
249
|
+
expect(parsed.userProperties || {}).toEqual(
|
|
250
|
+
expectedContract.userProperties || {},
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
describe('runtime payload contracts (ios)', () => {
|
|
255
|
+
it.each(IOS_CONTRACTS)(
|
|
256
|
+
'validates Swift runtime payload for $id ($class)',
|
|
257
|
+
(contract) => {
|
|
258
|
+
const snippet = generateSnippetForTarget({
|
|
259
|
+
targetId: 'ios-firebase-swift-sdk',
|
|
260
|
+
example: contract.example,
|
|
261
|
+
schema: { properties: {} },
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const parsed = parseSwiftRuntimePayload(snippet);
|
|
265
|
+
expectPayloadContract(parsed, contract.expected.firebase);
|
|
266
|
+
},
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
it.each(IOS_CONTRACTS)(
|
|
270
|
+
'validates Obj-C runtime payload for $id ($class)',
|
|
271
|
+
(contract) => {
|
|
272
|
+
const snippet = generateSnippetForTarget({
|
|
273
|
+
targetId: 'ios-firebase-objc-sdk',
|
|
274
|
+
example: contract.example,
|
|
275
|
+
schema: { properties: {} },
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const parsed = parseObjcRuntimePayload(snippet);
|
|
279
|
+
expectPayloadContract(parsed, contract.expected.firebase);
|
|
280
|
+
},
|
|
281
|
+
);
|
|
282
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const { generateSnippetForTarget } = require('../helpers/snippetTargets');
|
|
2
|
+
const { PAYLOAD_CONTRACTS } = require('../test-data/payloadContracts');
|
|
3
|
+
|
|
4
|
+
const WEB_CONTRACTS = PAYLOAD_CONTRACTS.filter(
|
|
5
|
+
(contract) => contract.expected?.web,
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
function executeWebSnippet(snippet, dataLayerName = 'dataLayer') {
|
|
9
|
+
const push = jest.fn();
|
|
10
|
+
const runtimeWindow = {
|
|
11
|
+
[dataLayerName]: { push },
|
|
12
|
+
};
|
|
13
|
+
const runner = new Function('window', snippet);
|
|
14
|
+
runner(runtimeWindow);
|
|
15
|
+
return push.mock.calls.map(([payload]) => payload);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('runtime payload contracts (web)', () => {
|
|
19
|
+
it.each(WEB_CONTRACTS)(
|
|
20
|
+
'validates web payload for $id ($class)',
|
|
21
|
+
(contract) => {
|
|
22
|
+
const snippet = generateSnippetForTarget({
|
|
23
|
+
targetId: 'web-datalayer-js',
|
|
24
|
+
example: contract.example,
|
|
25
|
+
schema: { properties: {} },
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const pushedPayloads = executeWebSnippet(snippet);
|
|
29
|
+
expect(pushedPayloads).toEqual([contract.expected.web.payload]);
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
});
|