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