my-frontend-observer 0.3.0 → 0.5.0
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 +112 -0
- package/README.md +59 -7
- package/dist/application/comparisonService.d.ts +56 -0
- package/dist/application/comparisonService.js +77 -0
- package/dist/application/comparisonService.js.map +1 -0
- package/dist/application/frontendContractEvaluationService.d.ts +49 -0
- package/dist/application/frontendContractEvaluationService.js +112 -0
- package/dist/application/frontendContractEvaluationService.js.map +1 -0
- package/dist/application/frontendContractPersistenceService.d.ts +56 -0
- package/dist/application/frontendContractPersistenceService.js +91 -0
- package/dist/application/frontendContractPersistenceService.js.map +1 -0
- package/dist/artifacts/artifactReader.d.ts +19 -0
- package/dist/artifacts/artifactReader.js +36 -0
- package/dist/artifacts/artifactReader.js.map +1 -0
- package/dist/artifacts/comparisonArtifactReader.d.ts +18 -0
- package/dist/artifacts/comparisonArtifactReader.js +35 -0
- package/dist/artifacts/comparisonArtifactReader.js.map +1 -0
- package/dist/artifacts/comparisonArtifactWriter.d.ts +41 -0
- package/dist/artifacts/comparisonArtifactWriter.js +67 -0
- package/dist/artifacts/comparisonArtifactWriter.js.map +1 -0
- package/dist/artifacts/frontendContractArtifactReader.d.ts +24 -0
- package/dist/artifacts/frontendContractArtifactReader.js +47 -0
- package/dist/artifacts/frontendContractArtifactReader.js.map +1 -0
- package/dist/artifacts/frontendContractArtifactWriter.d.ts +34 -0
- package/dist/artifacts/frontendContractArtifactWriter.js +70 -0
- package/dist/artifacts/frontendContractArtifactWriter.js.map +1 -0
- package/dist/artifacts/frontendContractEvaluationArtifactReader.d.ts +17 -0
- package/dist/artifacts/frontendContractEvaluationArtifactReader.js +34 -0
- package/dist/artifacts/frontendContractEvaluationArtifactReader.js.map +1 -0
- package/dist/artifacts/frontendContractEvaluationArtifactWriter.d.ts +32 -0
- package/dist/artifacts/frontendContractEvaluationArtifactWriter.js +58 -0
- package/dist/artifacts/frontendContractEvaluationArtifactWriter.js.map +1 -0
- package/dist/cli.js +712 -3
- package/dist/cli.js.map +1 -1
- package/dist/domain/comparison.d.ts +198 -0
- package/dist/domain/comparison.js +324 -0
- package/dist/domain/comparison.js.map +1 -0
- package/dist/domain/comparisonEngine.d.ts +48 -0
- package/dist/domain/comparisonEngine.js +694 -0
- package/dist/domain/comparisonEngine.js.map +1 -0
- package/dist/domain/comparisonIdentity.d.ts +13 -0
- package/dist/domain/comparisonIdentity.js +46 -0
- package/dist/domain/comparisonIdentity.js.map +1 -0
- package/dist/domain/evidence.d.ts +2 -0
- package/dist/domain/evidence.js +4 -0
- package/dist/domain/evidence.js.map +1 -1
- package/dist/domain/frontendContractEvaluation.d.ts +57 -0
- package/dist/domain/frontendContractEvaluation.js +454 -0
- package/dist/domain/frontendContractEvaluation.js.map +1 -0
- package/dist/domain/frontendContractEvaluationArtifact.d.ts +65 -0
- package/dist/domain/frontendContractEvaluationArtifact.js +108 -0
- package/dist/domain/frontendContractEvaluationArtifact.js.map +1 -0
- package/dist/domain/frontendContractIdentity.d.ts +39 -0
- package/dist/domain/frontendContractIdentity.js +70 -0
- package/dist/domain/frontendContractIdentity.js.map +1 -0
- package/dist/domain/frontendContracts.d.ts +188 -0
- package/dist/domain/frontendContracts.js +260 -0
- package/dist/domain/frontendContracts.js.map +1 -0
- package/dist/domain/relationships.d.ts +168 -0
- package/dist/domain/relationships.js +343 -0
- package/dist/domain/relationships.js.map +1 -0
- package/dist/index.d.ts +36 -1
- package/dist/index.js +20 -1
- package/dist/index.js.map +1 -1
- package/docs/ARCHITECTURE.md +173 -3
- package/docs/CI_CD.md +76 -0
- package/docs/COMMANDS.md +299 -0
- package/docs/CONTRACTS.md +270 -6
- package/docs/CURRENT_STATE.md +183 -8
- package/docs/DEVELOPMENT.md +83 -12
- package/docs/PROJECT_OVERVIEW.md +18 -8
- package/docs/QUICKSTART.md +10 -0
- package/docs/RELEASE.md +17 -8
- package/docs/ROADMAP.md +10 -0
- package/docs/SECURITY.md +26 -1
- package/docs/WORKFLOWS.md +113 -6
- package/package.json +1 -1
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { evidenceValue } from './evidence.js';
|
|
2
|
+
import { isValidObservationArtifact, getProducerInfo } from './schema.js';
|
|
3
|
+
import { deriveOverflowEvidence } from './scrollEvidence.js';
|
|
4
|
+
import { deriveLayoutRelationships, deriveTargetClipping, isValidGeometryTolerancePx, GEOMETRY_TOLERANCE_DEFAULT_PX } from './relationships.js';
|
|
5
|
+
import { COMPARISON_ARTIFACT_KIND, COMPARISON_SCHEMA_VERSION, COMPARABILITY_REASON_SEVERITY, COMPARABILITY_REASON_CODES, isValidComparisonConfig, isValidComparisonArtifact, } from './comparison.js';
|
|
6
|
+
import { buildComparisonRequestIdentity, buildComparisonIdentity } from './comparisonIdentity.js';
|
|
7
|
+
import { DIAGNOSTIC_SEVERITY } from './diagnostics.js';
|
|
8
|
+
// --- small local helpers -----------------------------------------------------
|
|
9
|
+
/** `evidenceValue` for a field that may itself be absent from a dictionary lookup (e.g. `pageEvidence['someKey']`, an optional `containment` cast) - never fabricates a value for a missing field. */
|
|
10
|
+
function fieldValue(field) {
|
|
11
|
+
return field ? evidenceValue(field) : undefined;
|
|
12
|
+
}
|
|
13
|
+
function normalizedTargetMap(targets) {
|
|
14
|
+
const map = new Map();
|
|
15
|
+
for (const target of targets)
|
|
16
|
+
map.set(target.name.toLowerCase(), target);
|
|
17
|
+
return map;
|
|
18
|
+
}
|
|
19
|
+
/** Deterministic order: before's configured order, restricted to names also configured after (see domain/comparisonEngine.ts#compareTargetConfiguration for the matching addition/removal ordering). */
|
|
20
|
+
function commonTargets(before, after) {
|
|
21
|
+
const afterMap = normalizedTargetMap(after.requestConfig.targets);
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const target of before.requestConfig.targets) {
|
|
24
|
+
const afterTarget = afterMap.get(target.name.toLowerCase());
|
|
25
|
+
if (afterTarget)
|
|
26
|
+
results.push({ canonicalName: target.name, beforeName: target.name, afterName: afterTarget.name });
|
|
27
|
+
}
|
|
28
|
+
return results;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Configuration-change evidence: `removed`/`locator-changed` in before's
|
|
32
|
+
* configured order, then `added` in after's configured order (Batch 1's
|
|
33
|
+
* frozen ordering guidance) - never a source of appeared/disappeared
|
|
34
|
+
* rendered-state differences by itself.
|
|
35
|
+
*/
|
|
36
|
+
export function compareTargetConfiguration(before, after) {
|
|
37
|
+
const changes = [];
|
|
38
|
+
const beforeMap = normalizedTargetMap(before.requestConfig.targets);
|
|
39
|
+
const afterMap = normalizedTargetMap(after.requestConfig.targets);
|
|
40
|
+
for (const target of before.requestConfig.targets) {
|
|
41
|
+
const key = target.name.toLowerCase();
|
|
42
|
+
const afterTarget = afterMap.get(key);
|
|
43
|
+
if (!afterTarget) {
|
|
44
|
+
changes.push({ kind: 'removed', target: target.name });
|
|
45
|
+
}
|
|
46
|
+
else if (JSON.stringify(target.locators) !== JSON.stringify(afterTarget.locators)) {
|
|
47
|
+
changes.push({ kind: 'locator-changed', target: target.name });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const target of after.requestConfig.targets) {
|
|
51
|
+
if (!beforeMap.has(target.name.toLowerCase()))
|
|
52
|
+
changes.push({ kind: 'added', target: target.name });
|
|
53
|
+
}
|
|
54
|
+
return changes;
|
|
55
|
+
}
|
|
56
|
+
function isSameScrollScenario(before, after) {
|
|
57
|
+
if (before === undefined && after === undefined)
|
|
58
|
+
return true;
|
|
59
|
+
if (before === undefined || after === undefined)
|
|
60
|
+
return false;
|
|
61
|
+
const a = before.action;
|
|
62
|
+
const b = after.action;
|
|
63
|
+
if (a.kind !== b.kind)
|
|
64
|
+
return false;
|
|
65
|
+
if (a.deltaX !== b.deltaX || a.deltaY !== b.deltaY)
|
|
66
|
+
return false;
|
|
67
|
+
if (a.kind === 'target-scroll-by' && b.kind === 'target-scroll-by') {
|
|
68
|
+
return a.target.toLowerCase() === b.target.toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
// --- comparability -----------------------------------------------------------
|
|
73
|
+
/**
|
|
74
|
+
* Evaluates comparability before any rendered difference is calculated
|
|
75
|
+
* (Batch 1's frozen `ComparabilityResult`/reason vocabulary). Hard
|
|
76
|
+
* incompatibilities (`blocking`) force `incomparable`; producer/browser
|
|
77
|
+
* version and target-configuration mismatches are `warning`-only; the three
|
|
78
|
+
* state dimensions the observer never models (theme, authenticated-state,
|
|
79
|
+
* application-state) are always recorded as `unassessed` - never silently
|
|
80
|
+
* claimed identical.
|
|
81
|
+
*/
|
|
82
|
+
export function evaluateComparability(before, after) {
|
|
83
|
+
const reasons = [];
|
|
84
|
+
const add = (code, message) => {
|
|
85
|
+
reasons.push({ code, severity: COMPARABILITY_REASON_SEVERITY[code], message });
|
|
86
|
+
};
|
|
87
|
+
if (before.artifactKind !== after.artifactKind) {
|
|
88
|
+
add('artifact-kind-mismatch', `before artifactKind "${before.artifactKind}" differs from after artifactKind "${after.artifactKind}"`);
|
|
89
|
+
}
|
|
90
|
+
if (before.schemaVersion !== after.schemaVersion) {
|
|
91
|
+
add('schema-version-mismatch', `before schemaVersion "${before.schemaVersion}" differs from after schemaVersion "${after.schemaVersion}"`);
|
|
92
|
+
}
|
|
93
|
+
if (before.requestConfig.targetUrl !== after.requestConfig.targetUrl) {
|
|
94
|
+
add('page-url-mismatch', `before targetUrl "${before.requestConfig.targetUrl}" differs from after targetUrl "${after.requestConfig.targetUrl}"`);
|
|
95
|
+
}
|
|
96
|
+
if (before.requestConfig.viewport.width !== after.requestConfig.viewport.width || before.requestConfig.viewport.height !== after.requestConfig.viewport.height) {
|
|
97
|
+
add('viewport-mismatch', `before viewport ${before.requestConfig.viewport.width}x${before.requestConfig.viewport.height} differs from after viewport ${after.requestConfig.viewport.width}x${after.requestConfig.viewport.height}`);
|
|
98
|
+
}
|
|
99
|
+
const beforeBrowser = evidenceValue(before.browser);
|
|
100
|
+
const afterBrowser = evidenceValue(after.browser);
|
|
101
|
+
if (beforeBrowser && afterBrowser && beforeBrowser.engine !== afterBrowser.engine) {
|
|
102
|
+
add('browser-engine-mismatch', `before browser engine "${beforeBrowser.engine}" differs from after browser engine "${afterBrowser.engine}"`);
|
|
103
|
+
}
|
|
104
|
+
if (beforeBrowser && afterBrowser && beforeBrowser.version !== afterBrowser.version) {
|
|
105
|
+
add('browser-version-mismatch', `before browser version "${beforeBrowser.version}" differs from after browser version "${afterBrowser.version}"`);
|
|
106
|
+
}
|
|
107
|
+
if (!isSameScrollScenario(before.requestConfig.scrollScenario, after.requestConfig.scrollScenario)) {
|
|
108
|
+
add('scroll-scenario-mismatch', 'before and after requestConfig.scrollScenario are not the same semantic scroll scenario');
|
|
109
|
+
}
|
|
110
|
+
if (before.producer.version !== after.producer.version) {
|
|
111
|
+
add('producer-version-mismatch', `before producer version "${before.producer.version}" differs from after producer version "${after.producer.version}"`);
|
|
112
|
+
}
|
|
113
|
+
const configurationChanges = compareTargetConfiguration(before, after);
|
|
114
|
+
if (configurationChanges.some((change) => change.kind === 'added' || change.kind === 'removed')) {
|
|
115
|
+
add('target-set-mismatch', 'configured target sets differ between before and after');
|
|
116
|
+
}
|
|
117
|
+
if (configurationChanges.some((change) => change.kind === 'locator-changed')) {
|
|
118
|
+
add('target-locator-mismatch', 'a stable target name has a changed locator configuration between before and after');
|
|
119
|
+
}
|
|
120
|
+
add('theme-unassessed', 'theme identity is not modeled by the observer');
|
|
121
|
+
add('authenticated-state-unassessed', 'authenticated-state identity is not modeled by the observer');
|
|
122
|
+
add('application-state-unassessed', 'application-state identity is not modeled by the observer');
|
|
123
|
+
reasons.sort((a, b) => COMPARABILITY_REASON_CODES.indexOf(a.code) - COMPARABILITY_REASON_CODES.indexOf(b.code));
|
|
124
|
+
const hasBlocking = reasons.some((reason) => reason.severity === 'blocking');
|
|
125
|
+
const hasWarning = reasons.some((reason) => reason.severity === 'warning');
|
|
126
|
+
const state = hasBlocking ? 'incomparable' : hasWarning ? 'comparable-with-warnings' : 'comparable';
|
|
127
|
+
return { state, reasons };
|
|
128
|
+
}
|
|
129
|
+
function targetPresence(record) {
|
|
130
|
+
if (!record)
|
|
131
|
+
return 'unavailable';
|
|
132
|
+
const resolution = evidenceValue(record.resolution);
|
|
133
|
+
if (!resolution)
|
|
134
|
+
return 'unavailable';
|
|
135
|
+
if (resolution.selectionStatus === 'matched')
|
|
136
|
+
return 'matched';
|
|
137
|
+
if (resolution.selectionStatus === 'not-found')
|
|
138
|
+
return 'not-found';
|
|
139
|
+
if (resolution.selectionStatus === 'ambiguous')
|
|
140
|
+
return 'ambiguous';
|
|
141
|
+
return 'unavailable';
|
|
142
|
+
}
|
|
143
|
+
function geometryEvidenceRef(name) {
|
|
144
|
+
return [{ path: `targetEvidence.${name}.geometry` }];
|
|
145
|
+
}
|
|
146
|
+
function targetOverflow(record) {
|
|
147
|
+
const layout = evidenceValue(record.layout);
|
|
148
|
+
const style = evidenceValue(record.style);
|
|
149
|
+
if (!layout || !style)
|
|
150
|
+
return undefined;
|
|
151
|
+
return deriveOverflowEvidence(layout, style.overflowX, style.overflowY);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Direct per-target rendered differences (moved/resized/visibility/clipping/
|
|
155
|
+
* actual-overflow) plus appeared/disappeared, for one common (same stable
|
|
156
|
+
* name both sides) target. `diagnostics` records an honest inability to
|
|
157
|
+
* assess rendered state (ambiguous/unavailable on either side) rather than
|
|
158
|
+
* fabricating absence - see Batch 1's frozen "ambiguous/unavailable is not
|
|
159
|
+
* definitely absent" rule.
|
|
160
|
+
*/
|
|
161
|
+
function compareOneTarget(before, after, target, tolerance, diagnostics) {
|
|
162
|
+
const differences = [];
|
|
163
|
+
const beforeRecord = before.targetEvidence[target.beforeName];
|
|
164
|
+
const afterRecord = after.targetEvidence[target.afterName];
|
|
165
|
+
const beforeStatus = targetPresence(beforeRecord);
|
|
166
|
+
const afterStatus = targetPresence(afterRecord);
|
|
167
|
+
const subject = { type: 'target', target: target.canonicalName };
|
|
168
|
+
if (beforeStatus === 'not-found' && afterStatus === 'matched') {
|
|
169
|
+
differences.push({
|
|
170
|
+
kind: 'appeared',
|
|
171
|
+
subject,
|
|
172
|
+
before: 'not-found',
|
|
173
|
+
after: 'matched',
|
|
174
|
+
classification: 'presence',
|
|
175
|
+
beforeObservationId: before.observationId,
|
|
176
|
+
afterObservationId: after.observationId,
|
|
177
|
+
evidence: geometryEvidenceRef(target.afterName),
|
|
178
|
+
});
|
|
179
|
+
return differences;
|
|
180
|
+
}
|
|
181
|
+
if (beforeStatus === 'matched' && afterStatus === 'not-found') {
|
|
182
|
+
differences.push({
|
|
183
|
+
kind: 'disappeared',
|
|
184
|
+
subject,
|
|
185
|
+
before: 'matched',
|
|
186
|
+
after: 'not-found',
|
|
187
|
+
classification: 'presence',
|
|
188
|
+
beforeObservationId: before.observationId,
|
|
189
|
+
afterObservationId: after.observationId,
|
|
190
|
+
evidence: geometryEvidenceRef(target.beforeName),
|
|
191
|
+
});
|
|
192
|
+
return differences;
|
|
193
|
+
}
|
|
194
|
+
if (beforeStatus === 'not-found' && afterStatus === 'not-found') {
|
|
195
|
+
return differences;
|
|
196
|
+
}
|
|
197
|
+
if (beforeStatus !== 'matched' || afterStatus !== 'matched') {
|
|
198
|
+
const code = beforeStatus === 'ambiguous' || afterStatus === 'ambiguous' ? 'target-ambiguous' : 'browser-evidence-unavailable';
|
|
199
|
+
diagnostics.push({
|
|
200
|
+
code,
|
|
201
|
+
severity: DIAGNOSTIC_SEVERITY[code],
|
|
202
|
+
message: `target "${target.canonicalName}" rendered-state comparison unavailable (before: ${beforeStatus}, after: ${afterStatus})`,
|
|
203
|
+
targetName: target.canonicalName,
|
|
204
|
+
});
|
|
205
|
+
return differences;
|
|
206
|
+
}
|
|
207
|
+
// Both sides matched: compare rendered state directly.
|
|
208
|
+
const beforeGeometry = beforeRecord ? evidenceValue(beforeRecord.geometry) : undefined;
|
|
209
|
+
const afterGeometry = afterRecord ? evidenceValue(afterRecord.geometry) : undefined;
|
|
210
|
+
if (beforeGeometry && afterGeometry) {
|
|
211
|
+
const dx = afterGeometry.x - beforeGeometry.x;
|
|
212
|
+
const dy = afterGeometry.y - beforeGeometry.y;
|
|
213
|
+
if (Math.abs(dx) > tolerance || Math.abs(dy) > tolerance) {
|
|
214
|
+
differences.push({
|
|
215
|
+
kind: 'moved',
|
|
216
|
+
subject,
|
|
217
|
+
before: { x: beforeGeometry.x, y: beforeGeometry.y },
|
|
218
|
+
after: { x: afterGeometry.x, y: afterGeometry.y },
|
|
219
|
+
delta: { x: dx, y: dy },
|
|
220
|
+
classification: 'position',
|
|
221
|
+
beforeObservationId: before.observationId,
|
|
222
|
+
afterObservationId: after.observationId,
|
|
223
|
+
evidence: [...geometryEvidenceRef(target.beforeName), ...geometryEvidenceRef(target.afterName)],
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const dw = afterGeometry.width - beforeGeometry.width;
|
|
227
|
+
const dh = afterGeometry.height - beforeGeometry.height;
|
|
228
|
+
if (Math.abs(dw) > tolerance || Math.abs(dh) > tolerance) {
|
|
229
|
+
differences.push({
|
|
230
|
+
kind: 'resized',
|
|
231
|
+
subject,
|
|
232
|
+
before: { width: beforeGeometry.width, height: beforeGeometry.height },
|
|
233
|
+
after: { width: afterGeometry.width, height: afterGeometry.height },
|
|
234
|
+
delta: { width: dw, height: dh },
|
|
235
|
+
classification: 'size',
|
|
236
|
+
beforeObservationId: before.observationId,
|
|
237
|
+
afterObservationId: after.observationId,
|
|
238
|
+
evidence: [...geometryEvidenceRef(target.beforeName), ...geometryEvidenceRef(target.afterName)],
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const beforeVisible = beforeRecord ? evidenceValue(beforeRecord.visibility)?.visible : undefined;
|
|
243
|
+
const afterVisible = afterRecord ? evidenceValue(afterRecord.visibility)?.visible : undefined;
|
|
244
|
+
if (beforeVisible !== undefined && afterVisible !== undefined && beforeVisible !== afterVisible) {
|
|
245
|
+
differences.push({
|
|
246
|
+
kind: 'visibility-changed',
|
|
247
|
+
subject,
|
|
248
|
+
before: beforeVisible,
|
|
249
|
+
after: afterVisible,
|
|
250
|
+
classification: 'visibility',
|
|
251
|
+
beforeObservationId: before.observationId,
|
|
252
|
+
afterObservationId: after.observationId,
|
|
253
|
+
evidence: [{ path: `targetEvidence.${target.beforeName}.visibility` }, { path: `targetEvidence.${target.afterName}.visibility` }],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (beforeRecord && afterRecord) {
|
|
257
|
+
const beforeClip = deriveTargetClipping(beforeRecord);
|
|
258
|
+
const afterClip = deriveTargetClipping(afterRecord);
|
|
259
|
+
const clipEvidence = [{ path: `targetEvidence.${target.beforeName}.layout` }, { path: `targetEvidence.${target.afterName}.layout` }];
|
|
260
|
+
if (beforeClip.horizontal !== 'unavailable' && afterClip.horizontal !== 'unavailable' && beforeClip.horizontal !== afterClip.horizontal) {
|
|
261
|
+
differences.push({
|
|
262
|
+
kind: 'clipping-changed',
|
|
263
|
+
subject,
|
|
264
|
+
before: beforeClip.horizontal,
|
|
265
|
+
after: afterClip.horizontal,
|
|
266
|
+
classification: 'horizontal',
|
|
267
|
+
beforeObservationId: before.observationId,
|
|
268
|
+
afterObservationId: after.observationId,
|
|
269
|
+
evidence: clipEvidence,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (beforeClip.vertical !== 'unavailable' && afterClip.vertical !== 'unavailable' && beforeClip.vertical !== afterClip.vertical) {
|
|
273
|
+
differences.push({
|
|
274
|
+
kind: 'clipping-changed',
|
|
275
|
+
subject,
|
|
276
|
+
before: beforeClip.vertical,
|
|
277
|
+
after: afterClip.vertical,
|
|
278
|
+
classification: 'vertical',
|
|
279
|
+
beforeObservationId: before.observationId,
|
|
280
|
+
afterObservationId: after.observationId,
|
|
281
|
+
evidence: clipEvidence,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const beforeOverflow = targetOverflow(beforeRecord);
|
|
285
|
+
const afterOverflow = targetOverflow(afterRecord);
|
|
286
|
+
if (beforeOverflow && afterOverflow) {
|
|
287
|
+
if (beforeOverflow.horizontalOverflow !== afterOverflow.horizontalOverflow) {
|
|
288
|
+
differences.push({
|
|
289
|
+
kind: 'horizontal-overflow-changed',
|
|
290
|
+
subject,
|
|
291
|
+
before: beforeOverflow.horizontalOverflow,
|
|
292
|
+
after: afterOverflow.horizontalOverflow,
|
|
293
|
+
classification: 'horizontal',
|
|
294
|
+
beforeObservationId: before.observationId,
|
|
295
|
+
afterObservationId: after.observationId,
|
|
296
|
+
evidence: clipEvidence,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
if (beforeOverflow.verticalOverflow !== afterOverflow.verticalOverflow) {
|
|
300
|
+
differences.push({
|
|
301
|
+
kind: 'vertical-overflow-changed',
|
|
302
|
+
subject,
|
|
303
|
+
before: beforeOverflow.verticalOverflow,
|
|
304
|
+
after: afterOverflow.verticalOverflow,
|
|
305
|
+
classification: 'vertical',
|
|
306
|
+
beforeObservationId: before.observationId,
|
|
307
|
+
afterObservationId: after.observationId,
|
|
308
|
+
evidence: clipEvidence,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return differences;
|
|
314
|
+
}
|
|
315
|
+
/** Pairwise DOM-containment comparison among matched-both-sides common targets - reuses existing `TargetContainment` evidence directly, never re-derived from geometry (Batch 2's frozen distinction). Only evaluated when the specific pair was actually evaluated on both sides. */
|
|
316
|
+
function compareContainment(before, after, matched) {
|
|
317
|
+
const differences = [];
|
|
318
|
+
for (let i = 0; i < matched.length; i += 1) {
|
|
319
|
+
for (let j = 0; j < matched.length; j += 1) {
|
|
320
|
+
if (i === j)
|
|
321
|
+
continue;
|
|
322
|
+
const subjectTarget = matched[i];
|
|
323
|
+
const otherTarget = matched[j];
|
|
324
|
+
const beforeContainment = fieldValue(before.targetEvidence[subjectTarget.beforeName]?.containment);
|
|
325
|
+
const afterContainment = fieldValue(after.targetEvidence[subjectTarget.afterName]?.containment);
|
|
326
|
+
if (!beforeContainment || !afterContainment)
|
|
327
|
+
continue;
|
|
328
|
+
const beforeEvaluated = beforeContainment.evaluatedTargetIds.includes(otherTarget.beforeName);
|
|
329
|
+
const afterEvaluated = afterContainment.evaluatedTargetIds.includes(otherTarget.afterName);
|
|
330
|
+
if (!beforeEvaluated || !afterEvaluated)
|
|
331
|
+
continue;
|
|
332
|
+
const beforeContained = beforeContainment.containedByTargetIds.includes(otherTarget.beforeName);
|
|
333
|
+
const afterContained = afterContainment.containedByTargetIds.includes(otherTarget.afterName);
|
|
334
|
+
if (beforeContained === afterContained)
|
|
335
|
+
continue;
|
|
336
|
+
differences.push({
|
|
337
|
+
kind: 'containment-changed',
|
|
338
|
+
subject: { type: 'target', target: subjectTarget.canonicalName },
|
|
339
|
+
before: beforeContained,
|
|
340
|
+
after: afterContained,
|
|
341
|
+
classification: `contained-by:${otherTarget.canonicalName}`,
|
|
342
|
+
beforeObservationId: before.observationId,
|
|
343
|
+
afterObservationId: after.observationId,
|
|
344
|
+
evidence: [
|
|
345
|
+
{ path: `targetEvidence.${subjectTarget.beforeName}.containment` },
|
|
346
|
+
{ path: `targetEvidence.${subjectTarget.afterName}.containment` },
|
|
347
|
+
],
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return differences;
|
|
352
|
+
}
|
|
353
|
+
function comparePageSize(before, after, tolerance) {
|
|
354
|
+
const beforeWidth = fieldValue(before.pageEvidence.documentWidth);
|
|
355
|
+
const afterWidth = fieldValue(after.pageEvidence.documentWidth);
|
|
356
|
+
const beforeHeight = fieldValue(before.pageEvidence.documentHeight);
|
|
357
|
+
const afterHeight = fieldValue(after.pageEvidence.documentHeight);
|
|
358
|
+
if (typeof beforeWidth !== 'number' || typeof afterWidth !== 'number' || typeof beforeHeight !== 'number' || typeof afterHeight !== 'number') {
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
const dw = afterWidth - beforeWidth;
|
|
362
|
+
const dh = afterHeight - beforeHeight;
|
|
363
|
+
if (Math.abs(dw) <= tolerance && Math.abs(dh) <= tolerance)
|
|
364
|
+
return [];
|
|
365
|
+
return [
|
|
366
|
+
{
|
|
367
|
+
kind: 'page-size-changed',
|
|
368
|
+
subject: { type: 'page' },
|
|
369
|
+
before: { width: beforeWidth, height: beforeHeight },
|
|
370
|
+
after: { width: afterWidth, height: afterHeight },
|
|
371
|
+
delta: { width: dw, height: dh },
|
|
372
|
+
classification: 'document-size',
|
|
373
|
+
beforeObservationId: before.observationId,
|
|
374
|
+
afterObservationId: after.observationId,
|
|
375
|
+
evidence: [{ path: 'pageEvidence.documentWidth' }, { path: 'pageEvidence.documentHeight' }],
|
|
376
|
+
},
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
function scrollOwnerEqual(a, b) {
|
|
380
|
+
if (a.kind !== b.kind)
|
|
381
|
+
return false;
|
|
382
|
+
if (a.kind === 'target' && b.kind === 'target')
|
|
383
|
+
return a.target === b.target;
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
/** Only meaningful when both sides have actually-derived scroll-owner evidence (comparability already ensures the scenario *configuration* matches; this compares the *runtime result*). Never fabricated when either side's evidence is unavailable. */
|
|
387
|
+
function compareScrollOwner(before, after) {
|
|
388
|
+
const beforeOwner = before.scrollScenarioEvidence ? evidenceValue(before.scrollScenarioEvidence.scrollOwner) : undefined;
|
|
389
|
+
const afterOwner = after.scrollScenarioEvidence ? evidenceValue(after.scrollScenarioEvidence.scrollOwner) : undefined;
|
|
390
|
+
if (!beforeOwner || !afterOwner || scrollOwnerEqual(beforeOwner, afterOwner))
|
|
391
|
+
return [];
|
|
392
|
+
return [
|
|
393
|
+
{
|
|
394
|
+
kind: 'scroll-owner-changed',
|
|
395
|
+
subject: { type: 'page' },
|
|
396
|
+
before: beforeOwner,
|
|
397
|
+
after: afterOwner,
|
|
398
|
+
classification: 'scroll-owner',
|
|
399
|
+
beforeObservationId: before.observationId,
|
|
400
|
+
afterObservationId: after.observationId,
|
|
401
|
+
evidence: [{ path: 'scrollScenarioEvidence.scrollOwner' }],
|
|
402
|
+
},
|
|
403
|
+
];
|
|
404
|
+
}
|
|
405
|
+
// --- relationship-change derivation -------------------------------------------
|
|
406
|
+
const RELATIVE_POSITION_KINDS = [
|
|
407
|
+
'left-of',
|
|
408
|
+
'right-of',
|
|
409
|
+
'horizontally-overlapping',
|
|
410
|
+
'above',
|
|
411
|
+
'below',
|
|
412
|
+
'vertically-overlapping',
|
|
413
|
+
'overlaps',
|
|
414
|
+
'does-not-overlap',
|
|
415
|
+
];
|
|
416
|
+
function isRelativePositionFamily(kind) {
|
|
417
|
+
return RELATIVE_POSITION_KINDS.includes(kind);
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* A pair carries up to one record *per family* (see
|
|
421
|
+
* `deriveLayoutRelationships`), so looking up "the" record for
|
|
422
|
+
* (subjectTarget, relatedTarget) is ambiguous without also naming the
|
|
423
|
+
* family - matching by subject/related alone would silently return
|
|
424
|
+
* whichever family's record happens to appear first in the array.
|
|
425
|
+
*/
|
|
426
|
+
function findPairwiseInFamily(graph, subjectTarget, relatedTarget, family) {
|
|
427
|
+
return graph.pairwiseRelationships.find((r) => r.subjectTarget === subjectTarget && r.relatedTarget === relatedTarget && family.includes(r.kind));
|
|
428
|
+
}
|
|
429
|
+
const RELATIONSHIP_FAMILY_GROUPS = [
|
|
430
|
+
['left-of', 'right-of', 'horizontally-overlapping'],
|
|
431
|
+
['above', 'below', 'vertically-overlapping'],
|
|
432
|
+
['overlaps', 'does-not-overlap'],
|
|
433
|
+
['wider-than', 'narrower-than', 'equal-width-within-tolerance'],
|
|
434
|
+
['fits-inside', 'does-not-fit-inside'],
|
|
435
|
+
['follows-vertically'],
|
|
436
|
+
];
|
|
437
|
+
/**
|
|
438
|
+
* Matches relationship records by structural identity (family + subject +
|
|
439
|
+
* related target), never by array position. Direction is taken from
|
|
440
|
+
* `before`'s own configured order for each pair; if `after`'s graph placed
|
|
441
|
+
* the same pair in the reversed direction (or the pair became unresolved),
|
|
442
|
+
* the `after` side of the family is honestly reported `'unavailable'`
|
|
443
|
+
* (the frozen escape hatch) rather than guessed/inverted - relationship
|
|
444
|
+
* direction inversion under target-order changes is a known, documented
|
|
445
|
+
* scope limitation, not a fabricated result.
|
|
446
|
+
*/
|
|
447
|
+
function deriveRelationshipChanges(relationshipsBefore, relationshipsAfter, matched) {
|
|
448
|
+
const changes = [];
|
|
449
|
+
for (let i = 0; i < matched.length; i += 1) {
|
|
450
|
+
for (let j = i + 1; j < matched.length; j += 1) {
|
|
451
|
+
const x = matched[i];
|
|
452
|
+
const y = matched[j];
|
|
453
|
+
for (const family of RELATIONSHIP_FAMILY_GROUPS) {
|
|
454
|
+
const beforeKind = findPairwiseInFamily(relationshipsBefore, x.beforeName, y.beforeName, family)?.kind;
|
|
455
|
+
const afterKind = findPairwiseInFamily(relationshipsAfter, x.afterName, y.afterName, family)?.kind;
|
|
456
|
+
if (beforeKind === afterKind)
|
|
457
|
+
continue;
|
|
458
|
+
if (beforeKind === undefined && afterKind === undefined)
|
|
459
|
+
continue;
|
|
460
|
+
changes.push({
|
|
461
|
+
scope: 'pairwise',
|
|
462
|
+
kind: (beforeKind ?? afterKind),
|
|
463
|
+
subjectTarget: x.canonicalName,
|
|
464
|
+
relatedTarget: y.canonicalName,
|
|
465
|
+
before: beforeKind ?? 'unavailable',
|
|
466
|
+
after: afterKind ?? 'unavailable',
|
|
467
|
+
beforeObservationId: relationshipsBefore.observationId,
|
|
468
|
+
afterObservationId: relationshipsAfter.observationId,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
const beforePage = relationshipsBefore.pageRelationships[0];
|
|
474
|
+
const afterPage = relationshipsAfter.pageRelationships[0];
|
|
475
|
+
if (beforePage || afterPage) {
|
|
476
|
+
if (beforePage?.kind !== afterPage?.kind) {
|
|
477
|
+
changes.push({
|
|
478
|
+
scope: 'page',
|
|
479
|
+
kind: (beforePage?.kind ?? afterPage?.kind),
|
|
480
|
+
before: beforePage?.kind ?? 'unavailable',
|
|
481
|
+
after: afterPage?.kind ?? 'unavailable',
|
|
482
|
+
beforeObservationId: relationshipsBefore.observationId,
|
|
483
|
+
afterObservationId: relationshipsAfter.observationId,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return changes;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* One user-facing `ComparisonDifference` per relationship change -
|
|
491
|
+
* `relative-position-changed` for the horizontal-order/vertical-order/
|
|
492
|
+
* area-overlap families, `relationship-changed` for the rest (Batch 1's
|
|
493
|
+
* frozen distinction between absolute target movement and a relative
|
|
494
|
+
* relationship transition). Evidence is built directly from the change's own
|
|
495
|
+
* target/page identity - every family's supporting evidence for a given
|
|
496
|
+
* pair is the same two geometry references (see
|
|
497
|
+
* `deriveLayoutRelationships#geometryEvidence`), so no graph lookup (and no
|
|
498
|
+
* risk of picking a different family's record) is needed here.
|
|
499
|
+
*/
|
|
500
|
+
function relationshipChangesToDifferences(changes, beforeObservationId, afterObservationId) {
|
|
501
|
+
return changes.map((change) => {
|
|
502
|
+
const kind = change.scope === 'pairwise' && isRelativePositionFamily(change.kind) ? 'relative-position-changed' : 'relationship-changed';
|
|
503
|
+
const subject = change.scope === 'page' || !change.subjectTarget || !change.relatedTarget
|
|
504
|
+
? { type: 'relationship', kind: change.kind }
|
|
505
|
+
: { type: 'relationship', kind: change.kind, subjectTarget: change.subjectTarget, relatedTarget: change.relatedTarget };
|
|
506
|
+
const evidence = change.scope === 'pairwise' && change.subjectTarget && change.relatedTarget
|
|
507
|
+
? [{ path: `targetEvidence.${change.subjectTarget}.geometry` }, { path: `targetEvidence.${change.relatedTarget}.geometry` }]
|
|
508
|
+
: [{ path: 'pageEvidence.documentWidth' }, { path: 'pageEvidence.viewportWidth' }];
|
|
509
|
+
return {
|
|
510
|
+
kind,
|
|
511
|
+
subject,
|
|
512
|
+
before: change.before,
|
|
513
|
+
after: change.after,
|
|
514
|
+
classification: change.scope,
|
|
515
|
+
beforeObservationId,
|
|
516
|
+
afterObservationId,
|
|
517
|
+
evidence,
|
|
518
|
+
};
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
// --- explicit dependency evaluation -------------------------------------------
|
|
522
|
+
function extractTargetProperty(observation, targetName, property) {
|
|
523
|
+
const record = observation.targetEvidence[targetName];
|
|
524
|
+
if (!record)
|
|
525
|
+
return undefined;
|
|
526
|
+
const geometry = evidenceValue(record.geometry);
|
|
527
|
+
return geometry ? geometry[property] : undefined;
|
|
528
|
+
}
|
|
529
|
+
function evaluateTerm(before, after, direction, tolerance) {
|
|
530
|
+
const delta = after - before;
|
|
531
|
+
switch (direction) {
|
|
532
|
+
case 'increase':
|
|
533
|
+
return { matches: delta > tolerance, contradicts: delta < -tolerance };
|
|
534
|
+
case 'decrease':
|
|
535
|
+
return { matches: delta < -tolerance, contradicts: delta > tolerance };
|
|
536
|
+
case 'change':
|
|
537
|
+
return { matches: Math.abs(delta) > tolerance, contradicts: Math.abs(delta) <= tolerance };
|
|
538
|
+
case 'unchanged':
|
|
539
|
+
return { matches: Math.abs(delta) <= tolerance, contradicts: Math.abs(delta) > tolerance };
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Evaluates only explicitly-declared dependencies (`config.expectedDependencies`,
|
|
544
|
+
* in declared order) - never synthesizes a declaration from observed
|
|
545
|
+
* co-change (Batch 1's hard non-causal boundary). Each declared side is
|
|
546
|
+
* evaluated independently from the target's own before/after geometry;
|
|
547
|
+
* `unavailable` when either side's required property evidence cannot be
|
|
548
|
+
* read, `contradictory-to-declaration` when either side's actual direction
|
|
549
|
+
* is the strict opposite of what was declared, `consistent` when both sides
|
|
550
|
+
* match their declared direction, `not-observed` otherwise. No causal claim
|
|
551
|
+
* is ever produced.
|
|
552
|
+
*/
|
|
553
|
+
export function evaluateExpectedDependencies(before, after, declarations, tolerance) {
|
|
554
|
+
return declarations.map((declaration) => {
|
|
555
|
+
const causeBefore = extractTargetProperty(before, declaration.cause.target, declaration.cause.property);
|
|
556
|
+
const causeAfter = extractTargetProperty(after, declaration.cause.target, declaration.cause.property);
|
|
557
|
+
const effectBefore = extractTargetProperty(before, declaration.effect.target, declaration.effect.property);
|
|
558
|
+
const effectAfter = extractTargetProperty(after, declaration.effect.target, declaration.effect.property);
|
|
559
|
+
const supportingEvidence = [
|
|
560
|
+
{ path: `targetEvidence.${declaration.cause.target}.geometry` },
|
|
561
|
+
{ path: `targetEvidence.${declaration.effect.target}.geometry` },
|
|
562
|
+
];
|
|
563
|
+
if (causeBefore === undefined || causeAfter === undefined || effectBefore === undefined || effectAfter === undefined) {
|
|
564
|
+
return { declaration, outcome: 'unavailable', supportingEvidence };
|
|
565
|
+
}
|
|
566
|
+
const causeEval = evaluateTerm(causeBefore, causeAfter, declaration.cause.direction, tolerance);
|
|
567
|
+
const effectEval = evaluateTerm(effectBefore, effectAfter, declaration.effect.direction, tolerance);
|
|
568
|
+
let outcome;
|
|
569
|
+
if (causeEval.contradicts || effectEval.contradicts) {
|
|
570
|
+
outcome = 'contradictory-to-declaration';
|
|
571
|
+
}
|
|
572
|
+
else if (causeEval.matches && effectEval.matches) {
|
|
573
|
+
outcome = 'consistent';
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
outcome = 'not-observed';
|
|
577
|
+
}
|
|
578
|
+
return { declaration, outcome, supportingEvidence };
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
// --- comparison identity / source references ----------------------------------
|
|
582
|
+
function sourceReference(observation) {
|
|
583
|
+
const screenshot = evidenceValue(observation.screenshot);
|
|
584
|
+
if (!screenshot)
|
|
585
|
+
return undefined;
|
|
586
|
+
return {
|
|
587
|
+
observationId: observation.observationId,
|
|
588
|
+
requestId: observation.requestId,
|
|
589
|
+
producer: observation.producer,
|
|
590
|
+
observationSchemaVersion: observation.schemaVersion,
|
|
591
|
+
screenshot: { path: screenshot.path },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
function normalizeConfig(config) {
|
|
595
|
+
return {
|
|
596
|
+
geometryTolerancePx: config.geometryTolerancePx ?? GEOMETRY_TOLERANCE_DEFAULT_PX,
|
|
597
|
+
...(config.expectedDependencies !== undefined ? { expectedDependencies: config.expectedDependencies } : {}),
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The one canonical pure comparison entry point (real Chromium ->
|
|
602
|
+
* ObservationArtifact -> relationship derivation -> **comparability ->
|
|
603
|
+
* comparison** -> ComparisonArtifact, see docs/ARCHITECTURE.md). Comparison
|
|
604
|
+
* is ordered: `compareObservations(a, b)` and `compareObservations(b, a)`
|
|
605
|
+
* produce different `comparisonRequestId`s and reversed-sign
|
|
606
|
+
* moved/resized deltas - `before`/`after` are never treated as an
|
|
607
|
+
* unordered pair.
|
|
608
|
+
*/
|
|
609
|
+
export function compareObservations(before, after, configInput = {}) {
|
|
610
|
+
const beforeValidation = isValidObservationArtifact(before);
|
|
611
|
+
if (!beforeValidation.valid)
|
|
612
|
+
return { ok: false, reason: `before ObservationArtifact is invalid: ${beforeValidation.reason}` };
|
|
613
|
+
const afterValidation = isValidObservationArtifact(after);
|
|
614
|
+
if (!afterValidation.valid)
|
|
615
|
+
return { ok: false, reason: `after ObservationArtifact is invalid: ${afterValidation.reason}` };
|
|
616
|
+
const config = normalizeConfig(configInput);
|
|
617
|
+
if (!isValidComparisonConfig(config))
|
|
618
|
+
return { ok: false, reason: 'ComparisonConfig is invalid' };
|
|
619
|
+
if (!isValidGeometryTolerancePx(config.geometryTolerancePx))
|
|
620
|
+
return { ok: false, reason: 'ComparisonConfig.geometryTolerancePx is out of range' };
|
|
621
|
+
const beforeRef = sourceReference(before);
|
|
622
|
+
const afterRef = sourceReference(after);
|
|
623
|
+
if (!beforeRef)
|
|
624
|
+
return { ok: false, reason: 'before ObservationArtifact has no available screenshot evidence; cannot build a comparison source reference' };
|
|
625
|
+
if (!afterRef)
|
|
626
|
+
return { ok: false, reason: 'after ObservationArtifact has no available screenshot evidence; cannot build a comparison source reference' };
|
|
627
|
+
const tolerance = config.geometryTolerancePx;
|
|
628
|
+
const beforeRelResult = deriveLayoutRelationships(before, { geometryTolerancePx: tolerance });
|
|
629
|
+
if (!beforeRelResult.ok)
|
|
630
|
+
return { ok: false, reason: `relationship derivation failed for the before observation: ${beforeRelResult.reason}` };
|
|
631
|
+
const afterRelResult = deriveLayoutRelationships(after, { geometryTolerancePx: tolerance });
|
|
632
|
+
if (!afterRelResult.ok)
|
|
633
|
+
return { ok: false, reason: `relationship derivation failed for the after observation: ${afterRelResult.reason}` };
|
|
634
|
+
const relationshipsBefore = beforeRelResult.graph;
|
|
635
|
+
const relationshipsAfter = afterRelResult.graph;
|
|
636
|
+
const comparability = evaluateComparability(before, after);
|
|
637
|
+
const configurationChanges = compareTargetConfiguration(before, after);
|
|
638
|
+
const comparisonRequestId = buildComparisonRequestIdentity(before.observationId, after.observationId, config);
|
|
639
|
+
const comparisonId = buildComparisonIdentity(comparisonRequestId);
|
|
640
|
+
const shared = {
|
|
641
|
+
artifactKind: COMPARISON_ARTIFACT_KIND,
|
|
642
|
+
schemaVersion: COMPARISON_SCHEMA_VERSION,
|
|
643
|
+
comparisonId,
|
|
644
|
+
comparisonRequestId,
|
|
645
|
+
producer: getProducerInfo(),
|
|
646
|
+
provenance: { comparedAt: new Date().toISOString() },
|
|
647
|
+
before: beforeRef,
|
|
648
|
+
after: afterRef,
|
|
649
|
+
config,
|
|
650
|
+
comparability,
|
|
651
|
+
configurationChanges,
|
|
652
|
+
relationshipsBefore,
|
|
653
|
+
relationshipsAfter,
|
|
654
|
+
limits: { truncated: false, omittedFields: [], omittedTargetPairs: [] },
|
|
655
|
+
};
|
|
656
|
+
if (comparability.state === 'incomparable') {
|
|
657
|
+
const artifact = {
|
|
658
|
+
...shared,
|
|
659
|
+
differences: [],
|
|
660
|
+
relationshipChanges: [],
|
|
661
|
+
expectedDependencyEvidence: [],
|
|
662
|
+
diagnostics: [],
|
|
663
|
+
};
|
|
664
|
+
const validation = isValidComparisonArtifact(artifact);
|
|
665
|
+
if (!validation.valid)
|
|
666
|
+
return { ok: false, reason: `assembled incomparable ComparisonArtifact failed structural validation: ${validation.reason}` };
|
|
667
|
+
return { ok: true, artifact };
|
|
668
|
+
}
|
|
669
|
+
const diagnostics = [];
|
|
670
|
+
const common = commonTargets(before, after);
|
|
671
|
+
const differences = [];
|
|
672
|
+
for (const target of common) {
|
|
673
|
+
differences.push(...compareOneTarget(before, after, target, tolerance, diagnostics));
|
|
674
|
+
}
|
|
675
|
+
const matched = common.filter((target) => targetPresence(before.targetEvidence[target.beforeName]) === 'matched' && targetPresence(after.targetEvidence[target.afterName]) === 'matched');
|
|
676
|
+
differences.push(...compareContainment(before, after, matched));
|
|
677
|
+
differences.push(...comparePageSize(before, after, tolerance));
|
|
678
|
+
differences.push(...compareScrollOwner(before, after));
|
|
679
|
+
const relationshipChanges = deriveRelationshipChanges(relationshipsBefore, relationshipsAfter, matched);
|
|
680
|
+
differences.push(...relationshipChangesToDifferences(relationshipChanges, before.observationId, after.observationId));
|
|
681
|
+
const expectedDependencyEvidence = evaluateExpectedDependencies(before, after, config.expectedDependencies ?? [], tolerance);
|
|
682
|
+
const artifact = {
|
|
683
|
+
...shared,
|
|
684
|
+
differences,
|
|
685
|
+
relationshipChanges,
|
|
686
|
+
expectedDependencyEvidence,
|
|
687
|
+
diagnostics,
|
|
688
|
+
};
|
|
689
|
+
const validation = isValidComparisonArtifact(artifact);
|
|
690
|
+
if (!validation.valid)
|
|
691
|
+
return { ok: false, reason: `assembled ComparisonArtifact failed structural validation: ${validation.reason}` };
|
|
692
|
+
return { ok: true, artifact };
|
|
693
|
+
}
|
|
694
|
+
//# sourceMappingURL=comparisonEngine.js.map
|