@testivai/witness-playwright 1.0.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/__tests__/.gitkeep +0 -0
- package/__tests__/config-integration.spec.ts +102 -0
- package/__tests__/snapshot.spec.d.ts +1 -0
- package/__tests__/snapshot.spec.js +81 -0
- package/__tests__/snapshot.spec.ts +58 -0
- package/__tests__/unit/ci.spec.d.ts +1 -0
- package/__tests__/unit/ci.spec.js +35 -0
- package/__tests__/unit/ci.spec.ts +40 -0
- package/__tests__/unit/reporter.spec.d.ts +1 -0
- package/__tests__/unit/reporter.spec.js +37 -0
- package/__tests__/unit/reporter.spec.ts +43 -0
- package/__tests__/unit/structureAnalyzer.spec.js +212 -0
- package/__tests__/unit/types.spec.ts +179 -0
- package/dist/__tests__/unit/ci.spec.d.ts +1 -0
- package/dist/__tests__/unit/ci.spec.js +226 -0
- package/dist/__tests__/unit/compression.spec.d.ts +4 -0
- package/dist/__tests__/unit/compression.spec.js +46 -0
- package/dist/ci.d.ts +30 -0
- package/dist/ci.js +117 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +47 -0
- package/dist/cli/init.d.ts +3 -0
- package/dist/cli/init.js +158 -0
- package/dist/config/loader.d.ts +29 -0
- package/dist/config/loader.js +251 -0
- package/dist/domAnalyzer.d.ts +10 -0
- package/dist/domAnalyzer.js +285 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +11 -0
- package/dist/reporter-entry.d.ts +2 -0
- package/dist/reporter-entry.js +5 -0
- package/dist/reporter-types.d.ts +2 -0
- package/dist/reporter-types.js +2 -0
- package/dist/reporter.d.ts +21 -0
- package/dist/reporter.js +249 -0
- package/dist/snapshot.d.ts +12 -0
- package/dist/snapshot.js +601 -0
- package/dist/structureAnalyzer.d.ts +12 -0
- package/dist/structureAnalyzer.js +288 -0
- package/dist/types.d.ts +368 -0
- package/dist/types.js +10 -0
- package/examples/structure-analysis-example.spec.ts +118 -0
- package/examples/structure-analysis.config.ts +159 -0
- package/jest.config.js +8 -0
- package/package.json +51 -0
- package/playwright.config.ts +11 -0
- package/src/__tests__/unit/ci.spec.ts +257 -0
- package/src/__tests__/unit/compression.spec.ts +52 -0
- package/src/ci.ts +140 -0
- package/src/cli/index.ts +49 -0
- package/src/cli/init.ts +131 -0
- package/src/config/loader.ts +238 -0
- package/src/index.ts +14 -0
- package/src/reporter-entry.ts +6 -0
- package/src/reporter-types.ts +5 -0
- package/src/reporter.ts +251 -0
- package/src/snapshot.ts +632 -0
- package/src/structureAnalyzer.ts +338 -0
- package/src/types.ts +388 -0
- package/tsconfig.jest.json +7 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { Page } from '@playwright/test';
|
|
2
|
+
import { StructureAnalysisConfig, StructureAnalysis, StructureChange } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default configuration for structure analysis
|
|
6
|
+
* @renamed Was DEFAULT_CONFIG in domAnalyzer.ts — renamed to conceal internal layer terminology (IP protection)
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_CONFIG: Required<StructureAnalysisConfig> = {
|
|
9
|
+
enableFingerprint: true,
|
|
10
|
+
enableStructure: true,
|
|
11
|
+
enableSemantic: true,
|
|
12
|
+
ignoreAttributes: [
|
|
13
|
+
'data-testid',
|
|
14
|
+
'data-reactid',
|
|
15
|
+
'data-reactroot',
|
|
16
|
+
'ng-version',
|
|
17
|
+
'ng-version',
|
|
18
|
+
'ng-reflect-router-outlet',
|
|
19
|
+
'data-ng-version',
|
|
20
|
+
'style', // Inline styles change often
|
|
21
|
+
'class', // CSS classes can be dynamic
|
|
22
|
+
],
|
|
23
|
+
ignoreElements: [
|
|
24
|
+
'script',
|
|
25
|
+
'style',
|
|
26
|
+
'noscript',
|
|
27
|
+
'meta',
|
|
28
|
+
'link',
|
|
29
|
+
'title',
|
|
30
|
+
],
|
|
31
|
+
ignoreContentPatterns: [
|
|
32
|
+
/\d{4}-\d{2}-\d{2}/, // Dates
|
|
33
|
+
/\d{1,2}:\d{2}(:\d{2})?/, // Times
|
|
34
|
+
/\b\d{4}\b/, // Years
|
|
35
|
+
/\b\d+\b/, // Pure numbers
|
|
36
|
+
/uuid-/i, // UUIDs
|
|
37
|
+
/_\d+/, // Number suffixes
|
|
38
|
+
/\$\d+\.?\d*/, // Currency
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Analyzes page structure and generates fingerprint
|
|
44
|
+
* @renamed Was `analyzeDOM` — renamed to conceal internal layer terminology (IP protection)
|
|
45
|
+
*/
|
|
46
|
+
export async function analyzeStructure(
|
|
47
|
+
page: Page,
|
|
48
|
+
config: StructureAnalysisConfig = {}
|
|
49
|
+
): Promise<StructureAnalysis> {
|
|
50
|
+
const finalConfig = { ...DEFAULT_CONFIG, ...config };
|
|
51
|
+
|
|
52
|
+
return await page.evaluate((cfg) => {
|
|
53
|
+
// Helper functions
|
|
54
|
+
const normalizeSelector = (el: Element): string => {
|
|
55
|
+
let selector = el.nodeName.toLowerCase();
|
|
56
|
+
|
|
57
|
+
// Add ID if present and not a dynamic ID
|
|
58
|
+
if (el.id && !cfg.ignoreAttributes.includes('id')) {
|
|
59
|
+
// Skip if ID looks dynamic
|
|
60
|
+
if (!/^(ember|react|ng|vue|_)/.test(el.id)) {
|
|
61
|
+
selector += `#${el.id}`;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Add meaningful classes only
|
|
66
|
+
if (el.className && !cfg.ignoreAttributes.includes('class')) {
|
|
67
|
+
const classes = el.className.toString()
|
|
68
|
+
.split(' ')
|
|
69
|
+
.filter((c: string) =>
|
|
70
|
+
c &&
|
|
71
|
+
!/^(ember|react|ng|vue|css|active|hover|focus)/.test(c) &&
|
|
72
|
+
!c.includes('_') &&
|
|
73
|
+
!/\d/.test(c)
|
|
74
|
+
)
|
|
75
|
+
.slice(0, 3); // Limit to 3 most meaningful classes
|
|
76
|
+
|
|
77
|
+
if (classes.length > 0) {
|
|
78
|
+
selector += `.${classes.join('.')}`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return selector;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const getElementPath = (el: Element, maxDepth: number = 10): string => {
|
|
86
|
+
const path: string[] = [];
|
|
87
|
+
let current: Element | null = el;
|
|
88
|
+
let depth = 0;
|
|
89
|
+
|
|
90
|
+
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
|
91
|
+
path.unshift(normalizeSelector(current));
|
|
92
|
+
current = current.parentElement;
|
|
93
|
+
depth++;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return path.join(' > ');
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const shouldIgnoreElement = (el: Element): boolean => {
|
|
100
|
+
return cfg.ignoreElements.includes(el.nodeName.toLowerCase());
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const shouldIgnoreAttribute = (attr: string): boolean => {
|
|
104
|
+
return cfg.ignoreAttributes.includes(attr.toLowerCase());
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const shouldIgnoreContent = (content: string): boolean => {
|
|
108
|
+
return cfg.ignoreContentPatterns.some((pattern: RegExp) => pattern.test(content));
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const normalizeText = (text: string): string => {
|
|
112
|
+
if (!text) return '';
|
|
113
|
+
// Replace ignored patterns with placeholders
|
|
114
|
+
let normalized = text.trim();
|
|
115
|
+
cfg.ignoreContentPatterns.forEach((pattern: RegExp) => {
|
|
116
|
+
normalized = normalized.replace(pattern, '[DYNAMIC]');
|
|
117
|
+
});
|
|
118
|
+
// Normalize whitespace
|
|
119
|
+
normalized = normalized.replace(/\s+/g, ' ');
|
|
120
|
+
return normalized;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Get all meaningful elements
|
|
124
|
+
const allElements = Array.from(document.querySelectorAll('*'))
|
|
125
|
+
.filter(el => !shouldIgnoreElement(el));
|
|
126
|
+
|
|
127
|
+
const analysis: any = {};
|
|
128
|
+
|
|
129
|
+
// 1. Generate fingerprint
|
|
130
|
+
if (cfg.enableFingerprint) {
|
|
131
|
+
const paths = allElements.map((el: any) => {
|
|
132
|
+
const path = getElementPath(el);
|
|
133
|
+
const text = normalizeText(el.textContent || '');
|
|
134
|
+
const attrs: string[] = [];
|
|
135
|
+
|
|
136
|
+
// Add important attributes
|
|
137
|
+
for (const attr of el.attributes) {
|
|
138
|
+
if (!shouldIgnoreAttribute(attr.name) && attr.value) {
|
|
139
|
+
attrs.push(`${attr.name}="${attr.value}"`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return `${path}${text ? `|${text}` : ''}${attrs.length ? `|${attrs.join(',')}` : ''}`;
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Create hash from sorted paths
|
|
147
|
+
const sortedPaths = paths.sort().join('|||');
|
|
148
|
+
analysis.fingerprint = btoa(sortedPaths).substring(0, 32);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 2. Structural analysis
|
|
152
|
+
if (cfg.enableStructure) {
|
|
153
|
+
const elementTypes: Record<string, number> = {};
|
|
154
|
+
let maxDepth = 0;
|
|
155
|
+
|
|
156
|
+
allElements.forEach((el: any) => {
|
|
157
|
+
const tag = el.nodeName.toLowerCase();
|
|
158
|
+
elementTypes[tag] = (elementTypes[tag] || 0) + 1;
|
|
159
|
+
|
|
160
|
+
// Calculate depth
|
|
161
|
+
let depth = 0;
|
|
162
|
+
let current: any = el;
|
|
163
|
+
while (current.parentElement) {
|
|
164
|
+
depth++;
|
|
165
|
+
current = current.parentElement;
|
|
166
|
+
}
|
|
167
|
+
maxDepth = Math.max(maxDepth, depth);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
analysis.structure = {
|
|
171
|
+
totalElements: allElements.length,
|
|
172
|
+
elementTypes,
|
|
173
|
+
maxDepth,
|
|
174
|
+
interactiveElements: {
|
|
175
|
+
buttons: document.querySelectorAll('button').length,
|
|
176
|
+
inputs: document.querySelectorAll('input').length,
|
|
177
|
+
links: document.querySelectorAll('a[href]').length,
|
|
178
|
+
forms: document.querySelectorAll('form').length,
|
|
179
|
+
},
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3. Semantic analysis
|
|
184
|
+
if (cfg.enableSemantic) {
|
|
185
|
+
const headings: Record<string, number> = {};
|
|
186
|
+
for (let i = 1; i <= 6; i++) {
|
|
187
|
+
headings[`h${i}`] = document.querySelectorAll(`h${i}`).length;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
analysis.semantic = {
|
|
191
|
+
headings,
|
|
192
|
+
landmarks: {
|
|
193
|
+
hasHeader: !!document.querySelector('header'),
|
|
194
|
+
hasNav: !!document.querySelector('nav'),
|
|
195
|
+
hasMain: !!document.querySelector('main'),
|
|
196
|
+
hasFooter: !!document.querySelector('footer'),
|
|
197
|
+
hasAside: !!document.querySelector('aside'),
|
|
198
|
+
},
|
|
199
|
+
lists: {
|
|
200
|
+
ordered: document.querySelectorAll('ol').length,
|
|
201
|
+
unordered: document.querySelectorAll('ul').length,
|
|
202
|
+
},
|
|
203
|
+
tables: document.querySelectorAll('table').length,
|
|
204
|
+
images: document.querySelectorAll('img').length,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 4. Component analysis (framework-agnostic)
|
|
209
|
+
const components: Record<string, any[]> = {};
|
|
210
|
+
allElements.forEach((el: any) => {
|
|
211
|
+
// Try various component detection strategies
|
|
212
|
+
const componentName =
|
|
213
|
+
el.getAttribute('data-testid')?.replace(/-/g, '') ||
|
|
214
|
+
el.getAttribute('data-component') ||
|
|
215
|
+
el.getAttribute('data-testid') ||
|
|
216
|
+
el.className?.toString().match(/(\w+Container|\w+Component|\w+Page|\w+Card|\w+Modal)/i)?.[1];
|
|
217
|
+
|
|
218
|
+
if (componentName && componentName.length > 2) {
|
|
219
|
+
if (!components[componentName]) {
|
|
220
|
+
components[componentName] = [];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const attrs: Record<string, string> = {};
|
|
224
|
+
for (const attr of el.attributes) {
|
|
225
|
+
if (!shouldIgnoreAttribute(attr.name) && attr.value) {
|
|
226
|
+
attrs[attr.name] = attr.value;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
components[componentName].push({
|
|
231
|
+
selector: getElementPath(el),
|
|
232
|
+
text: normalizeText(el.textContent || '').substring(0, 100),
|
|
233
|
+
attributes: attrs,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
if (Object.keys(components).length > 0) {
|
|
239
|
+
analysis.components = components;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return analysis;
|
|
243
|
+
}, finalConfig);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Compare two structure analyses and identify changes
|
|
248
|
+
* @renamed Was `compareDOMAnalysis` — renamed to conceal internal layer terminology (IP protection)
|
|
249
|
+
*/
|
|
250
|
+
export function compareStructureAnalysis(
|
|
251
|
+
baseline: StructureAnalysis,
|
|
252
|
+
current: StructureAnalysis
|
|
253
|
+
): StructureChange[] {
|
|
254
|
+
const changes: StructureChange[] = [];
|
|
255
|
+
|
|
256
|
+
// Compare fingerprints
|
|
257
|
+
if (baseline.fingerprint && current.fingerprint) {
|
|
258
|
+
if (baseline.fingerprint !== current.fingerprint) {
|
|
259
|
+
changes.push({
|
|
260
|
+
type: 'fingerprint',
|
|
261
|
+
severity: 'high',
|
|
262
|
+
description: 'Page structure has changed',
|
|
263
|
+
details: {
|
|
264
|
+
baseline: baseline.fingerprint,
|
|
265
|
+
current: current.fingerprint,
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Compare structure
|
|
272
|
+
if (baseline.structure && current.structure) {
|
|
273
|
+
if (baseline.structure.totalElements !== current.structure.totalElements) {
|
|
274
|
+
changes.push({
|
|
275
|
+
type: 'structure',
|
|
276
|
+
severity: 'medium',
|
|
277
|
+
description: `Element count changed from ${baseline.structure.totalElements} to ${current.structure.totalElements}`,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Check for new/removed element types
|
|
282
|
+
const baselineTypes = Object.keys(baseline.structure.elementTypes);
|
|
283
|
+
const currentTypes = Object.keys(current.structure.elementTypes);
|
|
284
|
+
|
|
285
|
+
const removed = baselineTypes.filter(t => !currentTypes.includes(t));
|
|
286
|
+
const added = currentTypes.filter(t => !baselineTypes.includes(t));
|
|
287
|
+
|
|
288
|
+
if (removed.length > 0) {
|
|
289
|
+
changes.push({
|
|
290
|
+
type: 'structure',
|
|
291
|
+
severity: 'medium',
|
|
292
|
+
description: `Removed element types: ${removed.join(', ')}`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (added.length > 0) {
|
|
297
|
+
changes.push({
|
|
298
|
+
type: 'structure',
|
|
299
|
+
severity: 'medium',
|
|
300
|
+
description: `Added element types: ${added.join(', ')}`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Compare semantic structure
|
|
306
|
+
if (baseline.semantic && current.semantic) {
|
|
307
|
+
// Check heading changes
|
|
308
|
+
const baselineHeadings = baseline.semantic.headings;
|
|
309
|
+
const currentHeadings = current.semantic.headings;
|
|
310
|
+
|
|
311
|
+
for (const level in baselineHeadings) {
|
|
312
|
+
if (baselineHeadings[level] !== currentHeadings[level]) {
|
|
313
|
+
changes.push({
|
|
314
|
+
type: 'semantic',
|
|
315
|
+
severity: 'low',
|
|
316
|
+
description: `${level} count changed from ${baselineHeadings[level]} to ${currentHeadings[level]}`,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Check landmark changes
|
|
322
|
+
const baselineLandmarks = baseline.semantic.landmarks;
|
|
323
|
+
const currentLandmarks = current.semantic.landmarks;
|
|
324
|
+
|
|
325
|
+
for (const landmark in baselineLandmarks) {
|
|
326
|
+
if (baselineLandmarks[landmark as keyof typeof baselineLandmarks] !==
|
|
327
|
+
currentLandmarks[landmark as keyof typeof currentLandmarks]) {
|
|
328
|
+
changes.push({
|
|
329
|
+
type: 'semantic',
|
|
330
|
+
severity: 'medium',
|
|
331
|
+
description: `Landmark ${landmark} ${baselineLandmarks[landmark as keyof typeof baselineLandmarks] ? 'removed' : 'added'}`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return changes;
|
|
338
|
+
}
|