@vitronai/themis 0.1.4 → 0.1.6
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/CHANGELOG.md +7 -0
- package/README.md +57 -29
- package/docs/agents-adoption.md +5 -2
- package/docs/api.md +45 -26
- package/docs/migration.md +2 -2
- package/docs/showcases.md +41 -6
- package/docs/vscode-extension.md +12 -12
- package/docs/why-themis.md +5 -3
- package/package.json +17 -4
- package/src/artifact-paths.js +111 -0
- package/src/artifacts.js +107 -39
- package/src/cli.js +156 -18
- package/src/contract-runtime.js +1166 -0
- package/src/environment.js +1 -1
- package/src/expect.js +1 -1
- package/src/generate.js +37 -1209
- package/src/gitignore.js +53 -0
- package/src/init.js +2 -13
- package/src/migrate.js +6 -1
- package/src/module-loader.js +13 -3
- package/src/reporter.js +14 -12
- package/src/runtime.js +2 -2
- package/src/test-utils.js +1 -1
- package/templates/AGENTS.themis.md +3 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const Module = require('module');
|
|
5
|
+
|
|
6
|
+
function listExportNames(moduleExports) {
|
|
7
|
+
const keys = getEnumerableKeys(moduleExports);
|
|
8
|
+
const namedKeys = keys.filter((key) => key !== '__esModule' && key !== 'default').sort();
|
|
9
|
+
const hasExplicitDefault = keys.includes('default');
|
|
10
|
+
const needsImplicitDefault = hasImplicitDefaultExport(moduleExports, namedKeys);
|
|
11
|
+
const names = [];
|
|
12
|
+
|
|
13
|
+
if (hasExplicitDefault || needsImplicitDefault) {
|
|
14
|
+
names.push('default');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
names.push(...namedKeys);
|
|
18
|
+
return names;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildModuleContract(moduleExports) {
|
|
22
|
+
const names = listExportNames(moduleExports);
|
|
23
|
+
const contract = {};
|
|
24
|
+
|
|
25
|
+
for (const name of names) {
|
|
26
|
+
contract[name] = normalizeModuleValue(readExportValue(moduleExports, name));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return contract;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readExportValue(moduleExports, name) {
|
|
33
|
+
if (name === 'default') {
|
|
34
|
+
if (
|
|
35
|
+
moduleExports &&
|
|
36
|
+
(typeof moduleExports === 'object' || typeof moduleExports === 'function') &&
|
|
37
|
+
Object.prototype.hasOwnProperty.call(moduleExports, 'default')
|
|
38
|
+
) {
|
|
39
|
+
return moduleExports.default;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return moduleExports;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return moduleExports[name];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getEnumerableKeys(value) {
|
|
49
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return Object.keys(value).sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function hasImplicitDefaultExport(moduleExports, namedKeys) {
|
|
57
|
+
if (moduleExports === null || moduleExports === undefined) {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof moduleExports === 'function') {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (typeof moduleExports !== 'object') {
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (Array.isArray(moduleExports)) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return namedKeys.length === 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeModuleValue(value) {
|
|
77
|
+
if (value === null) {
|
|
78
|
+
return { kind: 'null' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const primitiveType = typeof value;
|
|
82
|
+
if (primitiveType === 'undefined') {
|
|
83
|
+
return { kind: 'undefined' };
|
|
84
|
+
}
|
|
85
|
+
if (primitiveType === 'string' || primitiveType === 'number' || primitiveType === 'boolean') {
|
|
86
|
+
return { kind: primitiveType, value };
|
|
87
|
+
}
|
|
88
|
+
if (primitiveType === 'bigint') {
|
|
89
|
+
return { kind: 'bigint', value: String(value) };
|
|
90
|
+
}
|
|
91
|
+
if (primitiveType === 'symbol') {
|
|
92
|
+
return { kind: 'symbol', value: String(value) };
|
|
93
|
+
}
|
|
94
|
+
if (primitiveType === 'function') {
|
|
95
|
+
return normalizeFunction(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
return {
|
|
100
|
+
kind: 'array',
|
|
101
|
+
length: value.length,
|
|
102
|
+
itemTypes: [...new Set(value.map(classifyValue))].sort()
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (value instanceof Date) {
|
|
107
|
+
return { kind: 'date' };
|
|
108
|
+
}
|
|
109
|
+
if (value instanceof RegExp) {
|
|
110
|
+
return { kind: 'regexp', source: value.source, flags: value.flags };
|
|
111
|
+
}
|
|
112
|
+
if (value instanceof Map) {
|
|
113
|
+
return { kind: 'map', size: value.size };
|
|
114
|
+
}
|
|
115
|
+
if (value instanceof Set) {
|
|
116
|
+
return { kind: 'set', size: value.size };
|
|
117
|
+
}
|
|
118
|
+
if (isPlainObject(value)) {
|
|
119
|
+
return { kind: 'object', keys: Object.keys(value).sort() };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
kind: 'instance',
|
|
124
|
+
constructor: getConstructorName(value),
|
|
125
|
+
keys: Object.keys(value).sort()
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeBehaviorValue(value, seen = new Set()) {
|
|
130
|
+
if (value === null || value === undefined) {
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const primitiveType = typeof value;
|
|
135
|
+
if (primitiveType === 'string' || primitiveType === 'number' || primitiveType === 'boolean') {
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
if (primitiveType === 'bigint') {
|
|
139
|
+
return { kind: 'bigint', value: String(value) };
|
|
140
|
+
}
|
|
141
|
+
if (primitiveType === 'symbol') {
|
|
142
|
+
return { kind: 'symbol', value: String(value) };
|
|
143
|
+
}
|
|
144
|
+
if (primitiveType === 'function') {
|
|
145
|
+
return normalizeFunction(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (seen.has(value)) {
|
|
149
|
+
return { kind: 'circular' };
|
|
150
|
+
}
|
|
151
|
+
seen.add(value);
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
if (isReactLikeElement(value)) {
|
|
155
|
+
return {
|
|
156
|
+
kind: 'element',
|
|
157
|
+
type: normalizeElementType(value.type),
|
|
158
|
+
key: value.key === undefined ? null : value.key,
|
|
159
|
+
props: normalizeBehaviorValue(value.props || {}, seen)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (Array.isArray(value)) {
|
|
164
|
+
return value.map((item) => normalizeBehaviorValue(item, seen));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (value instanceof Date) {
|
|
168
|
+
return { kind: 'date', value: value.toISOString() };
|
|
169
|
+
}
|
|
170
|
+
if (value instanceof RegExp) {
|
|
171
|
+
return { kind: 'regexp', source: value.source, flags: value.flags };
|
|
172
|
+
}
|
|
173
|
+
if (value instanceof Map) {
|
|
174
|
+
return {
|
|
175
|
+
kind: 'map',
|
|
176
|
+
entries: [...value.entries()].map(([key, entryValue]) => ([
|
|
177
|
+
normalizeBehaviorValue(key, seen),
|
|
178
|
+
normalizeBehaviorValue(entryValue, seen)
|
|
179
|
+
]))
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (value instanceof Set) {
|
|
183
|
+
return {
|
|
184
|
+
kind: 'set',
|
|
185
|
+
values: [...value.values()].map((entryValue) => normalizeBehaviorValue(entryValue, seen))
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (isPlainObject(value)) {
|
|
189
|
+
const normalized = {};
|
|
190
|
+
for (const key of Object.keys(value).sort()) {
|
|
191
|
+
normalized[key] = normalizeBehaviorValue(value[key], seen);
|
|
192
|
+
}
|
|
193
|
+
return normalized;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
kind: 'instance',
|
|
198
|
+
constructor: getConstructorName(value),
|
|
199
|
+
keys: normalizeBehaviorValue(Object.keys(value).sort(), seen)
|
|
200
|
+
};
|
|
201
|
+
} finally {
|
|
202
|
+
seen.delete(value);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function clonePlainData(value) {
|
|
207
|
+
if (value === undefined) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
return JSON.parse(JSON.stringify(value));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function normalizeRouteResult(value) {
|
|
214
|
+
if (typeof Response !== 'undefined' && value instanceof Response) {
|
|
215
|
+
const headers = {};
|
|
216
|
+
for (const [key, headerValue] of value.headers.entries()) {
|
|
217
|
+
headers[key] = headerValue;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const text = await value.clone().text();
|
|
221
|
+
const body = tryParseJson(text);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
kind: 'response',
|
|
225
|
+
status: value.status,
|
|
226
|
+
statusText: value.statusText,
|
|
227
|
+
redirected: value.redirected,
|
|
228
|
+
headers,
|
|
229
|
+
body: body === null ? text : body
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return normalizeBehaviorValue(value);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function createRequestFromSpec(spec) {
|
|
237
|
+
const requestSpec = spec || {};
|
|
238
|
+
const headers = { ...(requestSpec.headers || {}) };
|
|
239
|
+
let body = undefined;
|
|
240
|
+
|
|
241
|
+
if (requestSpec.json !== undefined) {
|
|
242
|
+
if (!Object.prototype.hasOwnProperty.call(headers, 'content-type')) {
|
|
243
|
+
headers['content-type'] = 'application/json';
|
|
244
|
+
}
|
|
245
|
+
body = JSON.stringify(requestSpec.json);
|
|
246
|
+
} else if (requestSpec.body !== undefined) {
|
|
247
|
+
body = requestSpec.body;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return new Request(requestSpec.url, {
|
|
251
|
+
method: requestSpec.method || 'GET',
|
|
252
|
+
headers,
|
|
253
|
+
body
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function assertSourceFreshness(sourceFile, expectedHash, sourceLabel, regenerateCommand) {
|
|
258
|
+
const currentHash = hashSourceFile(sourceFile);
|
|
259
|
+
if (currentHash === expectedHash) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
throw new Error(
|
|
264
|
+
'Themis generated test is stale for ' + sourceLabel +
|
|
265
|
+
'. Run: ' + regenerateCommand +
|
|
266
|
+
'. Expected source hash ' + expectedHash +
|
|
267
|
+
' but found ' + currentHash + '.'
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function hashSourceFile(sourceFile) {
|
|
272
|
+
return crypto.createHash('sha1').update(fs.readFileSync(sourceFile, 'utf8')).digest('hex');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function tryParseJson(value) {
|
|
276
|
+
if (!value) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
return JSON.parse(value);
|
|
282
|
+
} catch (_error) {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function normalizeElementType(type) {
|
|
288
|
+
if (typeof type === 'string') {
|
|
289
|
+
return type;
|
|
290
|
+
}
|
|
291
|
+
if (typeof type === 'symbol') {
|
|
292
|
+
return String(type);
|
|
293
|
+
}
|
|
294
|
+
if (typeof type === 'function') {
|
|
295
|
+
return type.name || '(anonymous)';
|
|
296
|
+
}
|
|
297
|
+
return normalizeBehaviorValue(type);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function loadModuleWithReactHarness(sourceFile, run) {
|
|
301
|
+
const harness = { stateRecords: [], stateSlots: [], cursor: 0 };
|
|
302
|
+
|
|
303
|
+
return withPatchedModule(sourceFile, 'react', (actualReact) => {
|
|
304
|
+
const base = actualReact && (typeof actualReact === 'object' || typeof actualReact === 'function')
|
|
305
|
+
? actualReact
|
|
306
|
+
: {};
|
|
307
|
+
|
|
308
|
+
function beginRender() {
|
|
309
|
+
harness.cursor = 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function useState(initialValue) {
|
|
313
|
+
const slotIndex = harness.cursor;
|
|
314
|
+
harness.cursor += 1;
|
|
315
|
+
let slot = harness.stateSlots[slotIndex];
|
|
316
|
+
|
|
317
|
+
if (!slot) {
|
|
318
|
+
const startingValue = typeof initialValue === 'function' ? initialValue() : initialValue;
|
|
319
|
+
slot = {
|
|
320
|
+
currentValue: startingValue,
|
|
321
|
+
record: {
|
|
322
|
+
initial: normalizeBehaviorValue(startingValue),
|
|
323
|
+
updates: []
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
harness.stateSlots[slotIndex] = slot;
|
|
327
|
+
harness.stateRecords.push(slot.record);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function setValue(nextValue) {
|
|
331
|
+
slot.currentValue = typeof nextValue === 'function' ? nextValue(slot.currentValue) : nextValue;
|
|
332
|
+
slot.record.updates.push(normalizeBehaviorValue(slot.currentValue));
|
|
333
|
+
return slot.currentValue;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return [slot.currentValue, setValue];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
...base,
|
|
341
|
+
useState,
|
|
342
|
+
__THEMIS_BEGIN_RENDER__: beginRender
|
|
343
|
+
};
|
|
344
|
+
}, () => {
|
|
345
|
+
const resolvedSource = require.resolve(sourceFile);
|
|
346
|
+
delete require.cache[resolvedSource];
|
|
347
|
+
const moduleExports = require(sourceFile);
|
|
348
|
+
return run({
|
|
349
|
+
moduleExports,
|
|
350
|
+
stateRecords: harness.stateRecords,
|
|
351
|
+
beginRender() {
|
|
352
|
+
harness.cursor = 0;
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function withPatchedModule(sourceFile, request, buildExports, run) {
|
|
359
|
+
let resolvedRequest;
|
|
360
|
+
try {
|
|
361
|
+
resolvedRequest = require.resolve(request, { paths: [path.dirname(sourceFile)] });
|
|
362
|
+
} catch (_error) {
|
|
363
|
+
return run();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const previousLoad = Module._load;
|
|
367
|
+
const hadExisting = Object.prototype.hasOwnProperty.call(require.cache, resolvedRequest);
|
|
368
|
+
const previousCacheEntry = require.cache[resolvedRequest];
|
|
369
|
+
|
|
370
|
+
delete require.cache[resolvedRequest];
|
|
371
|
+
const actualExports = previousLoad.call(Module, resolvedRequest, null, false);
|
|
372
|
+
const patchedExports = buildExports(actualExports);
|
|
373
|
+
delete require.cache[resolvedRequest];
|
|
374
|
+
|
|
375
|
+
Module._load = function themisPatchedModuleLoad(targetRequest, parent, isMain) {
|
|
376
|
+
const resolvedTarget = Module._resolveFilename(targetRequest, parent, isMain);
|
|
377
|
+
if (resolvedTarget === resolvedRequest) {
|
|
378
|
+
return patchedExports;
|
|
379
|
+
}
|
|
380
|
+
return previousLoad.call(this, targetRequest, parent, isMain);
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
return run();
|
|
385
|
+
} finally {
|
|
386
|
+
Module._load = previousLoad;
|
|
387
|
+
if (hadExisting) {
|
|
388
|
+
require.cache[resolvedRequest] = previousCacheEntry;
|
|
389
|
+
} else {
|
|
390
|
+
delete require.cache[resolvedRequest];
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function runComponentInteractionContract(sourceFile, exportName, props, interactionPlan = [], options = {}) {
|
|
396
|
+
const wrapRender = options && typeof options.wrapRender === 'function' ? options.wrapRender : null;
|
|
397
|
+
return loadModuleWithReactHarness(sourceFile, async ({ moduleExports, stateRecords, beginRender } = {}) => {
|
|
398
|
+
const component = readExportValue(moduleExports || require(sourceFile), exportName);
|
|
399
|
+
function render() {
|
|
400
|
+
beginRender();
|
|
401
|
+
const rendered = component(props);
|
|
402
|
+
return wrapRender ? wrapRender(rendered) : rendered;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let rendered = render();
|
|
406
|
+
const availableInteractions = collectElementInteractions(rendered);
|
|
407
|
+
const interactions = [];
|
|
408
|
+
|
|
409
|
+
for (const interaction of resolvePlannedElementInteractions(availableInteractions, interactionPlan)) {
|
|
410
|
+
const beforeState = normalizeBehaviorValue(stateRecords);
|
|
411
|
+
const beforeRendered = normalizeBehaviorValue(rendered);
|
|
412
|
+
const result = interaction.invoke();
|
|
413
|
+
rendered = render();
|
|
414
|
+
const immediateRendered = normalizeBehaviorValue(rendered);
|
|
415
|
+
const immediateDom = buildDomContractFromElement(rendered);
|
|
416
|
+
const settledResult = await settleComponentInteractionResult(result);
|
|
417
|
+
rendered = render();
|
|
418
|
+
interactions.push({
|
|
419
|
+
label: interaction.label,
|
|
420
|
+
eventName: interaction.eventName,
|
|
421
|
+
elementType: interaction.elementType,
|
|
422
|
+
syntheticEvent: interaction.syntheticEvent,
|
|
423
|
+
result: normalizeBehaviorValue(settledResult),
|
|
424
|
+
beforeState,
|
|
425
|
+
afterState: normalizeBehaviorValue(stateRecords),
|
|
426
|
+
beforeRendered,
|
|
427
|
+
immediateRendered,
|
|
428
|
+
afterRendered: normalizeBehaviorValue(rendered),
|
|
429
|
+
beforeDom: buildDomContractFromElement(interaction.beforeRenderedElement || beforeRendered),
|
|
430
|
+
immediateDom,
|
|
431
|
+
afterDom: buildDomContractFromElement(rendered)
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
rendered: normalizeBehaviorValue(rendered),
|
|
437
|
+
dom: buildDomContractFromElement(rendered),
|
|
438
|
+
state: normalizeBehaviorValue(stateRecords),
|
|
439
|
+
plan: normalizeBehaviorValue(interactionPlan),
|
|
440
|
+
interactions
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function runComponentBehaviorFlowContract(sourceFile, exportName, props, flowPlan = [], options = {}) {
|
|
446
|
+
const wrapRender = options && typeof options.wrapRender === 'function' ? options.wrapRender : null;
|
|
447
|
+
return loadModuleWithReactHarness(sourceFile, async ({ moduleExports, stateRecords, beginRender } = {}) => {
|
|
448
|
+
const component = readExportValue(moduleExports || require(sourceFile), exportName);
|
|
449
|
+
function render() {
|
|
450
|
+
beginRender();
|
|
451
|
+
const rendered = component(props);
|
|
452
|
+
return wrapRender ? wrapRender(rendered) : rendered;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let rendered = render();
|
|
456
|
+
const steps = [];
|
|
457
|
+
|
|
458
|
+
for (const step of normalizeComponentFlowPlan(flowPlan)) {
|
|
459
|
+
const availableInteractions = collectElementInteractions(rendered);
|
|
460
|
+
const interaction = findMatchingElementInteraction(availableInteractions, step);
|
|
461
|
+
if (!interaction) {
|
|
462
|
+
steps.push({
|
|
463
|
+
label: step.label || step.event || 'unresolved-step',
|
|
464
|
+
expected: normalizeBehaviorValue(step.expected || {}),
|
|
465
|
+
skipped: true,
|
|
466
|
+
reason: 'No matching interaction was available for this flow step.',
|
|
467
|
+
beforeDom: buildDomContractFromElement(rendered),
|
|
468
|
+
beforeState: normalizeBehaviorValue(stateRecords)
|
|
469
|
+
});
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const beforeState = normalizeBehaviorValue(stateRecords);
|
|
474
|
+
const beforeDom = buildDomContractFromElement(rendered);
|
|
475
|
+
const beforeRendered = normalizeBehaviorValue(rendered);
|
|
476
|
+
const result = interaction.invoke(step.syntheticEvent);
|
|
477
|
+
rendered = render();
|
|
478
|
+
const immediateDom = buildDomContractFromElement(rendered);
|
|
479
|
+
const immediateState = normalizeBehaviorValue(stateRecords);
|
|
480
|
+
const immediateRendered = normalizeBehaviorValue(rendered);
|
|
481
|
+
const settledResult = await settleComponentFlowStep(result, step);
|
|
482
|
+
rendered = render();
|
|
483
|
+
let settledDom = buildDomContractFromElement(rendered);
|
|
484
|
+
if (!isSatisfiedFlowExpectation(step.expected, settledDom)) {
|
|
485
|
+
const awaited = await awaitFlowExpectation(render, rendered, step);
|
|
486
|
+
rendered = awaited.rendered;
|
|
487
|
+
settledDom = awaited.dom;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
steps.push({
|
|
491
|
+
label: step.label || interaction.label,
|
|
492
|
+
eventName: interaction.eventName,
|
|
493
|
+
elementType: interaction.elementType,
|
|
494
|
+
expected: normalizeBehaviorValue(step.expected || {}),
|
|
495
|
+
syntheticEvent: normalizeBehaviorValue(step.syntheticEvent),
|
|
496
|
+
returnValue: normalizeBehaviorValue(settledResult),
|
|
497
|
+
beforeState,
|
|
498
|
+
immediateState,
|
|
499
|
+
settledState: normalizeBehaviorValue(stateRecords),
|
|
500
|
+
beforeRendered,
|
|
501
|
+
immediateRendered,
|
|
502
|
+
settledRendered: normalizeBehaviorValue(rendered),
|
|
503
|
+
beforeDom,
|
|
504
|
+
immediateDom,
|
|
505
|
+
settledDom
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return {
|
|
510
|
+
rendered: normalizeBehaviorValue(rendered),
|
|
511
|
+
dom: buildDomContractFromElement(rendered),
|
|
512
|
+
state: normalizeBehaviorValue(stateRecords),
|
|
513
|
+
plan: normalizeBehaviorValue(flowPlan),
|
|
514
|
+
steps
|
|
515
|
+
};
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function runHookInteractionContract(sourceFile, exportName, args, interactionPlan = []) {
|
|
520
|
+
return loadModuleWithReactHarness(sourceFile, ({ moduleExports, stateRecords, beginRender } = {}) => {
|
|
521
|
+
const hook = readExportValue(moduleExports || require(sourceFile), exportName);
|
|
522
|
+
function render() {
|
|
523
|
+
beginRender();
|
|
524
|
+
return hook(...args);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let result = render();
|
|
528
|
+
const availableInteractions = collectStatefulMethodInteractions(result);
|
|
529
|
+
const interactions = resolvePlannedHookInteractions(availableInteractions, interactionPlan).map((interaction) => {
|
|
530
|
+
const beforeState = normalizeBehaviorValue(stateRecords);
|
|
531
|
+
const beforeResult = normalizeBehaviorValue(result);
|
|
532
|
+
const returnValue = interaction.invoke();
|
|
533
|
+
result = render();
|
|
534
|
+
return {
|
|
535
|
+
label: interaction.label,
|
|
536
|
+
methodName: interaction.methodName,
|
|
537
|
+
returnValue: normalizeBehaviorValue(returnValue),
|
|
538
|
+
beforeState,
|
|
539
|
+
afterState: normalizeBehaviorValue(stateRecords),
|
|
540
|
+
beforeResult,
|
|
541
|
+
afterResult: normalizeBehaviorValue(result)
|
|
542
|
+
};
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
result: normalizeBehaviorValue(result),
|
|
547
|
+
state: normalizeBehaviorValue(stateRecords),
|
|
548
|
+
plan: normalizeBehaviorValue(interactionPlan),
|
|
549
|
+
interactions
|
|
550
|
+
};
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function resolvePlannedElementInteractions(interactions, plan) {
|
|
555
|
+
if (!Array.isArray(interactions) || interactions.length === 0) {
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const steps = normalizeComponentInteractionPlan(plan);
|
|
560
|
+
if (steps.length === 0) {
|
|
561
|
+
return interactions.slice(0, 4);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return materializeInteractionSteps(interactions, steps, findMatchingElementInteraction);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function resolvePlannedHookInteractions(interactions, plan) {
|
|
568
|
+
if (!Array.isArray(interactions) || interactions.length === 0) {
|
|
569
|
+
return [];
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const steps = normalizeHookInteractionPlan(plan);
|
|
573
|
+
if (steps.length === 0) {
|
|
574
|
+
return interactions.slice(0, 6);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return materializeInteractionSteps(interactions, steps, findMatchingHookInteraction);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function materializeInteractionSteps(interactions, steps, matcher) {
|
|
581
|
+
const resolved = [];
|
|
582
|
+
|
|
583
|
+
for (const step of steps) {
|
|
584
|
+
const matched = matcher(interactions, step);
|
|
585
|
+
if (!matched) {
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const repeat = Math.max(1, Number(step.repeat || 1));
|
|
590
|
+
for (let count = 0; count < repeat; count += 1) {
|
|
591
|
+
resolved.push(matched);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return resolved;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function normalizeComponentInteractionPlan(plan) {
|
|
599
|
+
if (!Array.isArray(plan)) {
|
|
600
|
+
return [];
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return plan
|
|
604
|
+
.map((step) => {
|
|
605
|
+
if (typeof step === 'string') {
|
|
606
|
+
return { event: step };
|
|
607
|
+
}
|
|
608
|
+
if (!step || typeof step !== 'object') {
|
|
609
|
+
return null;
|
|
610
|
+
}
|
|
611
|
+
return {
|
|
612
|
+
event: typeof step.event === 'string' ? step.event : null,
|
|
613
|
+
labelIncludes: typeof step.labelIncludes === 'string' ? step.labelIncludes : null,
|
|
614
|
+
elementType: typeof step.elementType === 'string' ? step.elementType : null,
|
|
615
|
+
repeat: Number(step.repeat || 1)
|
|
616
|
+
};
|
|
617
|
+
})
|
|
618
|
+
.filter(Boolean);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function normalizeHookInteractionPlan(plan) {
|
|
622
|
+
if (!Array.isArray(plan)) {
|
|
623
|
+
return [];
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return plan
|
|
627
|
+
.map((step) => {
|
|
628
|
+
if (typeof step === 'string') {
|
|
629
|
+
return { method: step };
|
|
630
|
+
}
|
|
631
|
+
if (!step || typeof step !== 'object') {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
method: typeof step.method === 'string' ? step.method : null,
|
|
636
|
+
repeat: Number(step.repeat || 1)
|
|
637
|
+
};
|
|
638
|
+
})
|
|
639
|
+
.filter(Boolean);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function normalizeComponentFlowPlan(plan) {
|
|
643
|
+
if (!Array.isArray(plan)) {
|
|
644
|
+
return [];
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
return plan
|
|
648
|
+
.map((step) => {
|
|
649
|
+
if (!step || typeof step !== 'object') {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const syntheticEvent = buildFlowSyntheticEvent(step);
|
|
654
|
+
return {
|
|
655
|
+
label: typeof step.label === 'string' ? step.label : null,
|
|
656
|
+
event: typeof step.event === 'string' ? step.event : null,
|
|
657
|
+
labelIncludes: typeof step.labelIncludes === 'string' ? step.labelIncludes : null,
|
|
658
|
+
elementType: typeof step.elementType === 'string' ? step.elementType : null,
|
|
659
|
+
syntheticEvent,
|
|
660
|
+
awaitResult: step.awaitResult === true,
|
|
661
|
+
flushMicrotasks: Number(step.flushMicrotasks || 0),
|
|
662
|
+
advanceTimersByTime: Number(step.advanceTimersByTime || 0),
|
|
663
|
+
runAllTimers: step.runAllTimers === true,
|
|
664
|
+
expected: isPlainObject(step.expected) ? clonePlainData(step.expected) : {}
|
|
665
|
+
};
|
|
666
|
+
})
|
|
667
|
+
.filter((step) => step && step.event);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function findMatchingElementInteraction(interactions, step) {
|
|
671
|
+
return interactions.find((interaction) => {
|
|
672
|
+
if (step.event && interaction.eventName !== step.event) {
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
if (step.elementType && interaction.elementType !== step.elementType) {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
if (step.labelIncludes && !interaction.label.includes(step.labelIncludes)) {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}) || null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function findMatchingHookInteraction(interactions, step) {
|
|
686
|
+
return interactions.find((interaction) => interaction.methodName === step.method) || null;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function settleComponentFlowStep(result, step) {
|
|
690
|
+
let settledResult = result;
|
|
691
|
+
|
|
692
|
+
if (step.awaitResult && settledResult && typeof settledResult.then === 'function') {
|
|
693
|
+
settledResult = await settledResult;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const flushCount = Math.max(0, Number(step.flushMicrotasks || 0));
|
|
697
|
+
for (let index = 0; index < flushCount; index += 1) {
|
|
698
|
+
await Promise.resolve();
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (step.runAllTimers && typeof globalThis.runAllTimers === 'function') {
|
|
702
|
+
globalThis.runAllTimers();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (Number(step.advanceTimersByTime || 0) > 0 && typeof globalThis.advanceTimersByTime === 'function') {
|
|
706
|
+
globalThis.advanceTimersByTime(Number(step.advanceTimersByTime));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (flushCount > 0) {
|
|
710
|
+
for (let index = 0; index < flushCount; index += 1) {
|
|
711
|
+
await Promise.resolve();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return settledResult;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async function settleComponentInteractionResult(result) {
|
|
719
|
+
let settledResult = result;
|
|
720
|
+
|
|
721
|
+
if (settledResult && typeof settledResult.then === 'function') {
|
|
722
|
+
settledResult = await settledResult;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
await flushFlowMicrotasks({ flushMicrotasks: 1 });
|
|
726
|
+
return settledResult;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function awaitFlowExpectation(render, rendered, step, maxAttempts = 4) {
|
|
730
|
+
let currentRendered = rendered;
|
|
731
|
+
let currentDom = buildDomContractFromElement(currentRendered);
|
|
732
|
+
|
|
733
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
734
|
+
if (isSatisfiedFlowExpectation(step.expected, currentDom)) {
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
await flushFlowMicrotasks(step);
|
|
739
|
+
currentRendered = render();
|
|
740
|
+
currentDom = buildDomContractFromElement(currentRendered);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
rendered: currentRendered,
|
|
745
|
+
dom: currentDom
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function isSatisfiedFlowExpectation(expected, dom) {
|
|
750
|
+
if (!expected || typeof expected !== 'object') {
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if (typeof expected.beforeTextIncludes === 'string' && !String(dom && dom.textContent || '').includes(expected.beforeTextIncludes)) {
|
|
755
|
+
return false;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (typeof expected.settledTextIncludes === 'string' && !String(dom && dom.textContent || '').includes(expected.settledTextIncludes)) {
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (typeof expected.textExcludes === 'string' && String(dom && dom.textContent || '').includes(expected.textExcludes)) {
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (!domContractMatchesAttributes(dom, expected.attributes)) {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (!domContractMatchesRoles(dom, expected.rolesInclude)) {
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function domContractMatchesAttributes(dom, expectedAttributes) {
|
|
778
|
+
if (!isPlainObject(expectedAttributes) || Object.keys(expectedAttributes).length === 0) {
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const nodes = dom && Array.isArray(dom.nodes) ? dom.nodes : [];
|
|
783
|
+
return nodes.some((node) => {
|
|
784
|
+
if (!node || node.kind !== 'element' || !isPlainObject(node.attributes)) {
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return Object.entries(expectedAttributes).every(([key, value]) => {
|
|
789
|
+
const actual = Object.prototype.hasOwnProperty.call(node.attributes, key) ? node.attributes[key] : undefined;
|
|
790
|
+
return valuesDeepEqual(normalizeBehaviorValue(actual), normalizeBehaviorValue(value));
|
|
791
|
+
});
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function valuesDeepEqual(left, right) {
|
|
796
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function domContractMatchesRoles(dom, rolesInclude) {
|
|
800
|
+
if (rolesInclude === undefined || rolesInclude === null) {
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const expectedRoles = Array.isArray(rolesInclude) ? rolesInclude : [rolesInclude];
|
|
805
|
+
if (expectedRoles.length === 0) {
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const roles = dom && Array.isArray(dom.roles) ? dom.roles : [];
|
|
810
|
+
return expectedRoles.every((expectedRole) => roles.some((entry) => entry && entry.role === expectedRole));
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
async function flushFlowMicrotasks(step) {
|
|
814
|
+
if (typeof globalThis.flushMicrotasks === 'function') {
|
|
815
|
+
await globalThis.flushMicrotasks();
|
|
816
|
+
} else {
|
|
817
|
+
await Promise.resolve();
|
|
818
|
+
await Promise.resolve();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const flushCount = Math.max(0, Number(step && step.flushMicrotasks || 0));
|
|
822
|
+
for (let index = 0; index < flushCount; index += 1) {
|
|
823
|
+
await Promise.resolve();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function collectElementInteractions(node, ancestry = []) {
|
|
828
|
+
const interactions = [];
|
|
829
|
+
visitElementInteractions(node, ancestry, interactions);
|
|
830
|
+
return interactions;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function buildFlowSyntheticEvent(step) {
|
|
834
|
+
const payload = isPlainObject(step.target) ? clonePlainData(step.target) : {};
|
|
835
|
+
const type = typeof step.event === 'string' ? step.event.replace(/^on/, '').toLowerCase() : 'event';
|
|
836
|
+
return {
|
|
837
|
+
type,
|
|
838
|
+
defaultPrevented: false,
|
|
839
|
+
preventDefault() {
|
|
840
|
+
this.defaultPrevented = true;
|
|
841
|
+
},
|
|
842
|
+
target: payload,
|
|
843
|
+
currentTarget: {
|
|
844
|
+
type: typeof step.elementType === 'string' ? step.elementType : null,
|
|
845
|
+
props: {}
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function visitElementInteractions(node, ancestry, interactions) {
|
|
851
|
+
if (!isReactLikeElement(node)) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
const elementType = normalizeElementType(node.type);
|
|
856
|
+
const currentPath = ancestry.concat([elementType]);
|
|
857
|
+
const props = node.props || {};
|
|
858
|
+
|
|
859
|
+
for (const eventName of ['onClick', 'onSubmit', 'onChange', 'onInput']) {
|
|
860
|
+
if (typeof props[eventName] !== 'function') {
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const syntheticEvent = buildSyntheticEvent(eventName, node);
|
|
865
|
+
interactions.push({
|
|
866
|
+
label: currentPath.join(' > ') + '.' + eventName,
|
|
867
|
+
eventName,
|
|
868
|
+
elementType,
|
|
869
|
+
syntheticEvent: normalizeBehaviorValue(syntheticEvent),
|
|
870
|
+
beforeRenderedElement: node,
|
|
871
|
+
invoke(overrideEvent) {
|
|
872
|
+
const eventValue = overrideEvent || syntheticEvent;
|
|
873
|
+
return props[eventName](eventValue);
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
for (const child of flattenChildren(props.children)) {
|
|
879
|
+
visitElementInteractions(child, currentPath, interactions);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function buildDomContractFromElement(node) {
|
|
884
|
+
const contract = {
|
|
885
|
+
textContent: collectElementText(node),
|
|
886
|
+
roles: [],
|
|
887
|
+
nodes: []
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
visitDomContract(node, contract, []);
|
|
891
|
+
return contract;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function visitDomContract(node, contract, ancestry) {
|
|
895
|
+
if (node === null || node === undefined || typeof node === 'boolean') {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
900
|
+
contract.nodes.push({
|
|
901
|
+
kind: 'text',
|
|
902
|
+
value: String(node),
|
|
903
|
+
path: ancestry.join(' > ')
|
|
904
|
+
});
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (Array.isArray(node)) {
|
|
909
|
+
for (const child of node) {
|
|
910
|
+
visitDomContract(child, contract, ancestry);
|
|
911
|
+
}
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (!isReactLikeElement(node)) {
|
|
916
|
+
contract.nodes.push({
|
|
917
|
+
kind: 'value',
|
|
918
|
+
value: normalizeBehaviorValue(node),
|
|
919
|
+
path: ancestry.join(' > ')
|
|
920
|
+
});
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const elementType = normalizeElementType(node.type);
|
|
925
|
+
const props = node.props || {};
|
|
926
|
+
const currentPath = ancestry.concat([elementType]);
|
|
927
|
+
const attributes = collectDomAttributes(props);
|
|
928
|
+
const role = inferElementRole(elementType, props);
|
|
929
|
+
const textContent = collectElementText(props.children);
|
|
930
|
+
|
|
931
|
+
contract.nodes.push({
|
|
932
|
+
kind: 'element',
|
|
933
|
+
type: elementType,
|
|
934
|
+
path: currentPath.join(' > '),
|
|
935
|
+
textContent,
|
|
936
|
+
attributes
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
if (role) {
|
|
940
|
+
contract.roles.push({
|
|
941
|
+
role,
|
|
942
|
+
name: props['aria-label'] || textContent,
|
|
943
|
+
path: currentPath.join(' > '),
|
|
944
|
+
type: elementType,
|
|
945
|
+
attributes
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
for (const child of flattenChildren(props.children)) {
|
|
950
|
+
visitDomContract(child, contract, currentPath);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
function collectElementText(node) {
|
|
955
|
+
if (node === null || node === undefined || typeof node === 'boolean') {
|
|
956
|
+
return '';
|
|
957
|
+
}
|
|
958
|
+
if (typeof node === 'string' || typeof node === 'number') {
|
|
959
|
+
return String(node);
|
|
960
|
+
}
|
|
961
|
+
if (Array.isArray(node)) {
|
|
962
|
+
return node.map((child) => collectElementText(child)).join('');
|
|
963
|
+
}
|
|
964
|
+
if (!isReactLikeElement(node)) {
|
|
965
|
+
return '';
|
|
966
|
+
}
|
|
967
|
+
return collectElementText((node.props || {}).children);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function collectDomAttributes(props) {
|
|
971
|
+
const attributes = {};
|
|
972
|
+
for (const [key, value] of Object.entries(props || {})) {
|
|
973
|
+
if (key === 'children' || key.startsWith('on') || value === undefined || value === null) {
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
attributes[key] = normalizeBehaviorValue(value);
|
|
977
|
+
}
|
|
978
|
+
return attributes;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function inferElementRole(type, props) {
|
|
982
|
+
if (props && typeof props.role === 'string') {
|
|
983
|
+
return props.role;
|
|
984
|
+
}
|
|
985
|
+
if (type === 'button') {
|
|
986
|
+
return 'button';
|
|
987
|
+
}
|
|
988
|
+
if (type === 'form') {
|
|
989
|
+
return 'form';
|
|
990
|
+
}
|
|
991
|
+
if (type === 'textarea') {
|
|
992
|
+
return 'textbox';
|
|
993
|
+
}
|
|
994
|
+
if (type === 'input') {
|
|
995
|
+
const inputType = props && typeof props.type === 'string' ? props.type.toLowerCase() : 'text';
|
|
996
|
+
if (inputType === 'checkbox') {
|
|
997
|
+
return 'checkbox';
|
|
998
|
+
}
|
|
999
|
+
if (inputType === 'radio') {
|
|
1000
|
+
return 'radio';
|
|
1001
|
+
}
|
|
1002
|
+
return 'textbox';
|
|
1003
|
+
}
|
|
1004
|
+
if (type === 'a' && props && props.href) {
|
|
1005
|
+
return 'link';
|
|
1006
|
+
}
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function flattenChildren(children) {
|
|
1011
|
+
if (children === null || children === undefined) {
|
|
1012
|
+
return [];
|
|
1013
|
+
}
|
|
1014
|
+
if (Array.isArray(children)) {
|
|
1015
|
+
const flattened = [];
|
|
1016
|
+
for (const child of children) {
|
|
1017
|
+
flattened.push(...flattenChildren(child));
|
|
1018
|
+
}
|
|
1019
|
+
return flattened;
|
|
1020
|
+
}
|
|
1021
|
+
return [children];
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function buildSyntheticEvent(eventName, element) {
|
|
1025
|
+
const props = element && element.props ? element.props : {};
|
|
1026
|
+
const target = {};
|
|
1027
|
+
|
|
1028
|
+
if (eventName === 'onChange' || eventName === 'onInput') {
|
|
1029
|
+
if (Object.prototype.hasOwnProperty.call(props, 'checked')) {
|
|
1030
|
+
target.checked = !Boolean(props.checked);
|
|
1031
|
+
}
|
|
1032
|
+
if (Object.prototype.hasOwnProperty.call(props, 'value')) {
|
|
1033
|
+
target.value = props.value;
|
|
1034
|
+
} else if (!Object.prototype.hasOwnProperty.call(target, 'checked')) {
|
|
1035
|
+
target.value = 'themis';
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const event = {
|
|
1040
|
+
type: eventName.slice(2).toLowerCase(),
|
|
1041
|
+
defaultPrevented: false,
|
|
1042
|
+
preventDefault() {
|
|
1043
|
+
this.defaultPrevented = true;
|
|
1044
|
+
},
|
|
1045
|
+
target,
|
|
1046
|
+
currentTarget: {
|
|
1047
|
+
type: normalizeElementType(element && element.type),
|
|
1048
|
+
props: normalizeBehaviorValue(props)
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
return event;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function collectStatefulMethodInteractions(value) {
|
|
1056
|
+
if (!value || (typeof value !== 'object' && typeof value !== 'function')) {
|
|
1057
|
+
return [];
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
const methods = Object.keys(value)
|
|
1061
|
+
.filter((key) => typeof value[key] === 'function' && value[key].length === 0)
|
|
1062
|
+
.sort((left, right) => {
|
|
1063
|
+
const leftIndex = ['toggle', 'enable', 'disable', 'increment', 'decrement', 'open', 'close', 'reset', 'submit'].indexOf(left);
|
|
1064
|
+
const rightIndex = ['toggle', 'enable', 'disable', 'increment', 'decrement', 'open', 'close', 'reset', 'submit'].indexOf(right);
|
|
1065
|
+
if (leftIndex !== -1 || rightIndex !== -1) {
|
|
1066
|
+
const safeLeft = leftIndex === -1 ? 9 : leftIndex;
|
|
1067
|
+
const safeRight = rightIndex === -1 ? 9 : rightIndex;
|
|
1068
|
+
if (safeLeft !== safeRight) {
|
|
1069
|
+
return safeLeft - safeRight;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
return left.localeCompare(right);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
return methods.map((methodName) => ({
|
|
1076
|
+
label: methodName,
|
|
1077
|
+
methodName,
|
|
1078
|
+
invoke() {
|
|
1079
|
+
return value[methodName]();
|
|
1080
|
+
}
|
|
1081
|
+
}));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function normalizeFunction(value) {
|
|
1085
|
+
const ownKeys = Object.keys(value).sort();
|
|
1086
|
+
const prototypeKeys = value.prototype && value.prototype !== Object.prototype
|
|
1087
|
+
? Object.getOwnPropertyNames(value.prototype).filter((key) => key !== 'constructor').sort()
|
|
1088
|
+
: [];
|
|
1089
|
+
|
|
1090
|
+
return {
|
|
1091
|
+
kind: isClassLike(value) ? 'class' : 'function',
|
|
1092
|
+
name: value.name || '(anonymous)',
|
|
1093
|
+
arity: value.length,
|
|
1094
|
+
ownKeys,
|
|
1095
|
+
prototypeKeys
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function classifyValue(value) {
|
|
1100
|
+
if (value === null) {
|
|
1101
|
+
return 'null';
|
|
1102
|
+
}
|
|
1103
|
+
if (Array.isArray(value)) {
|
|
1104
|
+
return 'array';
|
|
1105
|
+
}
|
|
1106
|
+
if (value instanceof Date) {
|
|
1107
|
+
return 'date';
|
|
1108
|
+
}
|
|
1109
|
+
if (value instanceof RegExp) {
|
|
1110
|
+
return 'regexp';
|
|
1111
|
+
}
|
|
1112
|
+
if (value instanceof Map) {
|
|
1113
|
+
return 'map';
|
|
1114
|
+
}
|
|
1115
|
+
if (value instanceof Set) {
|
|
1116
|
+
return 'set';
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const type = typeof value;
|
|
1120
|
+
if (type !== 'object') {
|
|
1121
|
+
return type;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
if (isPlainObject(value)) {
|
|
1125
|
+
return 'object';
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
return 'instance:' + getConstructorName(value);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function getConstructorName(value) {
|
|
1132
|
+
if (!value || !value.constructor || !value.constructor.name) {
|
|
1133
|
+
return 'Object';
|
|
1134
|
+
}
|
|
1135
|
+
return value.constructor.name;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
function isPlainObject(value) {
|
|
1139
|
+
if (!value || typeof value !== 'object') {
|
|
1140
|
+
return false;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const prototype = Object.getPrototypeOf(value);
|
|
1144
|
+
return prototype === Object.prototype || prototype === null;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function isClassLike(value) {
|
|
1148
|
+
return Function.prototype.toString.call(value).startsWith('class ');
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function isReactLikeElement(value) {
|
|
1152
|
+
return value && typeof value === 'object' && value.$$typeof === 'react.test.element';
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
module.exports = {
|
|
1156
|
+
listExportNames,
|
|
1157
|
+
buildModuleContract,
|
|
1158
|
+
readExportValue,
|
|
1159
|
+
normalizeBehaviorValue,
|
|
1160
|
+
normalizeRouteResult,
|
|
1161
|
+
createRequestFromSpec,
|
|
1162
|
+
assertSourceFreshness,
|
|
1163
|
+
runComponentInteractionContract,
|
|
1164
|
+
runComponentBehaviorFlowContract,
|
|
1165
|
+
runHookInteractionContract
|
|
1166
|
+
};
|