@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/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 = path.join('.themis', 'generate-map.json');
10
- const GENERATED_RESULT_ARTIFACT = path.join('.themis', 'generate-last.json');
11
- const GENERATED_HANDOFF_ARTIFACT = path.join('.themis', 'generate-handoff.json');
12
- const GENERATED_BACKLOG_ARTIFACT = path.join('.themis', 'generate-backlog.json');
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 GENERATED_HELPER_SOURCE = `${GENERATED_MARKER}
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 || path.join('tests', 'generated'));
1218
- const helperFile = path.join(outputDir, GENERATED_HELPER_NAME);
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: formatPathForDisplay(projectRoot, summary.artifacts.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.js`);
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 = normalizeRelativeModule(path.relative(path.dirname(outputFile), helperFile));
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 (filePath.endsWith('.generated.test.js')) {
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,