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