Skip to content

Commit c7a22d5

Browse files
committed
feat: add provider-aware generated dom contracts
1 parent 50f002e commit c7a22d5

File tree

5 files changed

+262
-13
lines changed

5 files changed

+262
-13
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ All notable changes to this project are documented in this file.
1717
- Added first-party snapshots, mocks, run-diff artifacts, and watch mode for tighter agent rerun loops.
1818
- Added a lightweight DOM-oriented `jsdom` UI test layer with `render`, `screen`, `fireEvent`, `waitFor`, `cleanup`, and UI matchers for text, attributes, and document presence.
1919
- Added deterministic async UI test controls with fake timers, microtask flushing, and first-party fetch mocking for `jsdom` tests.
20+
- Expanded generated React and Next component adapters with DOM-state contract snapshots and provider-aware `wrapRender(...)` support in `themis.generate.js` / `themis.generate.cjs`.
2021
- Added an in-repo VS Code extension scaffold for artifact-driven result viewing, reruns, and HTML report opening.
2122
- Expanded the VS Code extension scaffold with generated-review navigation for source/test/hint mappings and unresolved generation backlog.
2223
- Refreshed README, AGENTS, and supporting docs to match the current package scope, JS/TS feature set, artifact contracts, and extension surface.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,12 @@ Generated files land under `tests/generated` by default. Each generated test:
104104
- snapshots a normalized runtime contract for the module surface
105105
- adds scenario adapters for React components/hooks, Next app/router files, route handlers, and service functions when Themis can infer or read useful inputs
106106
- captures React interaction and hook state-transition contracts when event handlers or stateful methods are available
107+
- snapshots DOM-state contracts for generated React and Next component adapters so humans and agents can review visible structure, roles, attributes, and interaction-driven UI changes
107108
- fails with a regeneration hint when the source drifts after the scan
108109

109110
Themis also supports per-file generation hints with sidecars like `src/components/Button.themis.json` so humans and agents can provide props, args, route requests, and route context. When those sidecars do not exist yet, `--write-hints` can scaffold them automatically from the current source analysis.
110111

111-
For repo-wide generation defaults, add `themis.generate.js` or `themis.generate.cjs` at the project root. Providers in that file can match source paths, supply shared props/args/interaction plans, and register runtime mocks for generated React, route, and service adapters.
112+
For repo-wide generation defaults, add `themis.generate.js` or `themis.generate.cjs` at the project root. Providers in that file can match source paths, supply shared props/args/interaction plans, register runtime mocks, and wrap generated component renders so DOM-state snapshots include the same provider shells humans use in app tests.
112113

113114
For CI and agent loops, Themis can also enforce generation quality instead of only writing files. Strict runs emit a structured backlog, fail on unresolved scan debt, and hand back exact remediation commands.
114115

docs/api.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ Default behavior:
3131
- generated tests snapshot normalized runtime export contracts
3232
- scenario adapters cover React components, React hooks, Next app components, Next route handlers, generic route handlers, and Node service functions when inputs can be inferred or hinted
3333
- React component and hook adapters also snapshot inferred interaction/state contracts when event handlers or zero-argument stateful methods are available
34-
- project-level providers from `themis.generate.js` / `themis.generate.cjs` can match source files, inject shared fixture data, and register runtime mocks for generated adapters
34+
- React and Next component adapters also emit DOM-state snapshots that capture visible text, inferred roles, non-event attributes, and interaction-driven UI changes
35+
- project-level providers from `themis.generate.js` / `themis.generate.cjs` can match source files, inject shared fixture data, register runtime mocks, and wrap generated component renders for provider-aware DOM contracts
3536
- `.themis/generate-map.json` records source-to-generated-test mappings plus scenario metadata
3637
- `.themis/generate-last.json` stores the full machine-readable generate payload
3738
- `.themis/generate-handoff.json` stores a compact prompt-ready handoff payload for agents
@@ -82,6 +83,7 @@ Project-level provider modules are supported via `themis.generate.js` or `themis
8283
- `include` / `exclude` / `files`: source matching rules
8384
- any of the same static fixture keys as sidecars (`componentProps`, `componentInteractions`, `hookArgs`, `hookInteractions`, `serviceArgs`, `routeRequests`, `routeContext`, `scenarios`)
8485
- `applyMocks(context)`: runtime mock registration for generated tests
86+
- `wrapRender(context)`: provider-aware render wrapping for generated React and Next component adapters
8587

8688
`applyMocks(context)` receives:
8789

@@ -92,6 +94,14 @@ Project-level provider modules are supported via `themis.generate.js` or `themis
9294
- `mock`
9395
- `fn`
9496

97+
`wrapRender(context)` receives:
98+
99+
- `sourceFile`
100+
- `sourcePath`
101+
- `exportName`
102+
- `scenario`
103+
- `element`
104+
95105
## `themis test` options
96106

97107
| Option | Type | Description |

src/generate.js

Lines changed: 230 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -415,12 +415,14 @@ function withPatchedModule(sourceFile, request, buildExports, run) {
415415
}
416416
}
417417
418-
function runComponentInteractionContract(sourceFile, exportName, props, interactionPlan = []) {
418+
function runComponentInteractionContract(sourceFile, exportName, props, interactionPlan = [], options = {}) {
419+
const wrapRender = options && typeof options.wrapRender === 'function' ? options.wrapRender : null;
419420
return loadModuleWithReactHarness(sourceFile, ({ moduleExports, stateRecords, beginRender } = {}) => {
420421
const component = readExportValue(moduleExports || require(sourceFile), exportName);
421422
function render() {
422423
beginRender();
423-
return component(props);
424+
const rendered = component(props);
425+
return wrapRender ? wrapRender(rendered) : rendered;
424426
}
425427
426428
let rendered = render();
@@ -439,12 +441,15 @@ function runComponentInteractionContract(sourceFile, exportName, props, interact
439441
beforeState,
440442
afterState: normalizeBehaviorValue(stateRecords),
441443
beforeRendered,
442-
afterRendered: normalizeBehaviorValue(rendered)
444+
afterRendered: normalizeBehaviorValue(rendered),
445+
beforeDom: buildDomContractFromElement(interaction.beforeRenderedElement || beforeRendered),
446+
afterDom: buildDomContractFromElement(rendered)
443447
};
444448
});
445449
446450
return {
447451
rendered: normalizeBehaviorValue(rendered),
452+
dom: buildDomContractFromElement(rendered),
448453
state: normalizeBehaviorValue(stateRecords),
449454
plan: normalizeBehaviorValue(interactionPlan),
450455
interactions
@@ -620,6 +625,7 @@ function visitElementInteractions(node, ancestry, interactions) {
620625
eventName,
621626
elementType,
622627
syntheticEvent: normalizeBehaviorValue(syntheticEvent),
628+
beforeRenderedElement: node,
623629
invoke() {
624630
return props[eventName](syntheticEvent);
625631
}
@@ -631,6 +637,133 @@ function visitElementInteractions(node, ancestry, interactions) {
631637
}
632638
}
633639
640+
function buildDomContractFromElement(node) {
641+
const contract = {
642+
textContent: collectElementText(node),
643+
roles: [],
644+
nodes: []
645+
};
646+
647+
visitDomContract(node, contract, []);
648+
return contract;
649+
}
650+
651+
function visitDomContract(node, contract, ancestry) {
652+
if (node === null || node === undefined || typeof node === 'boolean') {
653+
return;
654+
}
655+
656+
if (typeof node === 'string' || typeof node === 'number') {
657+
contract.nodes.push({
658+
kind: 'text',
659+
value: String(node),
660+
path: ancestry.join(' > ')
661+
});
662+
return;
663+
}
664+
665+
if (Array.isArray(node)) {
666+
for (const child of node) {
667+
visitDomContract(child, contract, ancestry);
668+
}
669+
return;
670+
}
671+
672+
if (!isReactLikeElement(node)) {
673+
contract.nodes.push({
674+
kind: 'value',
675+
value: normalizeBehaviorValue(node),
676+
path: ancestry.join(' > ')
677+
});
678+
return;
679+
}
680+
681+
const elementType = normalizeElementType(node.type);
682+
const props = node.props || {};
683+
const currentPath = ancestry.concat([elementType]);
684+
const attributes = collectDomAttributes(props);
685+
const role = inferElementRole(elementType, props);
686+
const textContent = collectElementText(props.children);
687+
688+
contract.nodes.push({
689+
kind: 'element',
690+
type: elementType,
691+
path: currentPath.join(' > '),
692+
textContent,
693+
attributes
694+
});
695+
696+
if (role) {
697+
contract.roles.push({
698+
role,
699+
name: props['aria-label'] || textContent,
700+
path: currentPath.join(' > '),
701+
type: elementType,
702+
attributes
703+
});
704+
}
705+
706+
for (const child of flattenChildren(props.children)) {
707+
visitDomContract(child, contract, currentPath);
708+
}
709+
}
710+
711+
function collectElementText(node) {
712+
if (node === null || node === undefined || typeof node === 'boolean') {
713+
return '';
714+
}
715+
if (typeof node === 'string' || typeof node === 'number') {
716+
return String(node);
717+
}
718+
if (Array.isArray(node)) {
719+
return node.map((child) => collectElementText(child)).join('');
720+
}
721+
if (!isReactLikeElement(node)) {
722+
return '';
723+
}
724+
return collectElementText((node.props || {}).children);
725+
}
726+
727+
function collectDomAttributes(props) {
728+
const attributes = {};
729+
for (const [key, value] of Object.entries(props || {})) {
730+
if (key === 'children' || key.startsWith('on') || value === undefined || value === null) {
731+
continue;
732+
}
733+
attributes[key] = normalizeBehaviorValue(value);
734+
}
735+
return attributes;
736+
}
737+
738+
function inferElementRole(type, props) {
739+
if (props && typeof props.role === 'string') {
740+
return props.role;
741+
}
742+
if (type === 'button') {
743+
return 'button';
744+
}
745+
if (type === 'form') {
746+
return 'form';
747+
}
748+
if (type === 'textarea') {
749+
return 'textbox';
750+
}
751+
if (type === 'input') {
752+
const inputType = props && typeof props.type === 'string' ? props.type.toLowerCase() : 'text';
753+
if (inputType === 'checkbox') {
754+
return 'checkbox';
755+
}
756+
if (inputType === 'radio') {
757+
return 'radio';
758+
}
759+
return 'textbox';
760+
}
761+
if (type === 'a' && props && props.href) {
762+
return 'link';
763+
}
764+
return null;
765+
}
766+
634767
function flattenChildren(children) {
635768
if (children === null || children === undefined) {
636769
return [];
@@ -826,7 +959,7 @@ function generateTestsFromSource(cwd, options = {}) {
826959
analysis.projectProviders = matchedProviders;
827960
analysis.projectProviderFile = projectProviders ? projectProviders.file : null;
828961
analysis.providerRuntimeIndexes = matchedProviders
829-
.filter((provider) => typeof provider.applyMocks === 'function')
962+
.filter((provider) => typeof provider.applyMocks === 'function' || typeof provider.wrapRender === 'function')
830963
.map((provider) => provider.index);
831964
analysis.hints = mergeHintSources(buildProviderHints(matchedProviders), sidecarHints);
832965
analysis.hintsFile = sidecarHints ? resolveHintFilePath(file) : null;
@@ -3835,6 +3968,43 @@ function applyProjectProviderMocks(exportName, scenarioName) {
38353968
}
38363969
}
38373970
3971+
function applyProjectProviderRender(element, exportName, scenarioName) {
3972+
if (!PROJECT_PROVIDER_IMPORT) {
3973+
return element;
3974+
}
3975+
3976+
const loaded = require(PROJECT_PROVIDER_IMPORT);
3977+
const providers = Array.isArray(loaded)
3978+
? loaded
3979+
: Array.isArray(loaded && loaded.providers)
3980+
? loaded.providers
3981+
: loaded
3982+
? [loaded]
3983+
: [];
3984+
3985+
let current = element;
3986+
for (const providerIndex of PROJECT_PROVIDER_INDEXES) {
3987+
const provider = providers[providerIndex];
3988+
if (!provider || typeof provider.wrapRender !== 'function') {
3989+
continue;
3990+
}
3991+
3992+
const nextValue = provider.wrapRender({
3993+
sourceFile: SOURCE_FILE,
3994+
sourcePath: SOURCE_PATH,
3995+
exportName,
3996+
scenario: scenarioName,
3997+
element: current
3998+
});
3999+
4000+
if (nextValue !== undefined) {
4001+
current = nextValue;
4002+
}
4003+
}
4004+
4005+
return current;
4006+
}
4007+
38384008
describe(${JSON.stringify(suiteName)}, () => {
38394009
${exactExportBlock}
38404010
@@ -3854,7 +4024,7 @@ describe(${JSON.stringify(suiteName)}, () => {
38544024
applyProjectProviderMocks(testCase.exportName, 'next-app-component');
38554025
const moduleExports = loadModuleExports();
38564026
const component = readExportValue(moduleExports, testCase.exportName);
3857-
const rendered = component(testCase.props);
4027+
const rendered = applyProjectProviderRender(component(testCase.props), testCase.exportName, 'next-app-component');
38584028
expect({
38594029
source: SOURCE_PATH,
38604030
scenario: 'next-app-component',
@@ -3873,7 +4043,33 @@ describe(${JSON.stringify(suiteName)}, () => {
38734043
confidence: testCase.confidence,
38744044
exportName: testCase.exportName,
38754045
props: testCase.props,
3876-
interaction: runComponentInteractionContract(SOURCE_FILE, testCase.exportName, testCase.props, testCase.interactions)
4046+
interaction: runComponentInteractionContract(SOURCE_FILE, testCase.exportName, testCase.props, testCase.interactions, {
4047+
wrapRender(element) {
4048+
return applyProjectProviderRender(element, testCase.exportName, 'next-app-component');
4049+
}
4050+
})
4051+
}).toMatchSnapshot();
4052+
});
4053+
4054+
test(testCase.exportName + ' next dom state contract', () => {
4055+
applyProjectProviderMocks(testCase.exportName, 'next-app-component');
4056+
const contract = runComponentInteractionContract(SOURCE_FILE, testCase.exportName, testCase.props, testCase.interactions, {
4057+
wrapRender(element) {
4058+
return applyProjectProviderRender(element, testCase.exportName, 'next-app-component');
4059+
}
4060+
});
4061+
expect({
4062+
source: SOURCE_PATH,
4063+
scenario: 'next-app-component',
4064+
confidence: testCase.confidence,
4065+
exportName: testCase.exportName,
4066+
props: testCase.props,
4067+
dom: contract.dom,
4068+
interactionDom: contract.interactions.map((entry) => ({
4069+
label: entry.label,
4070+
beforeDom: entry.beforeDom,
4071+
afterDom: entry.afterDom
4072+
}))
38774073
}).toMatchSnapshot();
38784074
});
38794075
}
@@ -3887,7 +4083,7 @@ describe(${JSON.stringify(suiteName)}, () => {
38874083
applyProjectProviderMocks(testCase.exportName, 'react-component');
38884084
const moduleExports = loadModuleExports();
38894085
const component = readExportValue(moduleExports, testCase.exportName);
3890-
const rendered = component(testCase.props);
4086+
const rendered = applyProjectProviderRender(component(testCase.props), testCase.exportName, 'react-component');
38914087
expect({
38924088
source: SOURCE_PATH,
38934089
scenario: 'react-component',
@@ -3906,7 +4102,33 @@ describe(${JSON.stringify(suiteName)}, () => {
39064102
confidence: testCase.confidence,
39074103
exportName: testCase.exportName,
39084104
props: testCase.props,
3909-
interaction: runComponentInteractionContract(SOURCE_FILE, testCase.exportName, testCase.props, testCase.interactions)
4105+
interaction: runComponentInteractionContract(SOURCE_FILE, testCase.exportName, testCase.props, testCase.interactions, {
4106+
wrapRender(element) {
4107+
return applyProjectProviderRender(element, testCase.exportName, 'react-component');
4108+
}
4109+
})
4110+
}).toMatchSnapshot();
4111+
});
4112+
4113+
test(testCase.exportName + ' dom state contract', () => {
4114+
applyProjectProviderMocks(testCase.exportName, 'react-component');
4115+
const contract = runComponentInteractionContract(SOURCE_FILE, testCase.exportName, testCase.props, testCase.interactions, {
4116+
wrapRender(element) {
4117+
return applyProjectProviderRender(element, testCase.exportName, 'react-component');
4118+
}
4119+
});
4120+
expect({
4121+
source: SOURCE_PATH,
4122+
scenario: 'react-component',
4123+
confidence: testCase.confidence,
4124+
exportName: testCase.exportName,
4125+
props: testCase.props,
4126+
dom: contract.dom,
4127+
interactionDom: contract.interactions.map((entry) => ({
4128+
label: entry.label,
4129+
beforeDom: entry.beforeDom,
4130+
afterDom: entry.afterDom
4131+
}))
39104132
}).toMatchSnapshot();
39114133
});
39124134
}

0 commit comments

Comments
 (0)