@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.
Files changed (61) hide show
  1. package/__tests__/.gitkeep +0 -0
  2. package/__tests__/config-integration.spec.ts +102 -0
  3. package/__tests__/snapshot.spec.d.ts +1 -0
  4. package/__tests__/snapshot.spec.js +81 -0
  5. package/__tests__/snapshot.spec.ts +58 -0
  6. package/__tests__/unit/ci.spec.d.ts +1 -0
  7. package/__tests__/unit/ci.spec.js +35 -0
  8. package/__tests__/unit/ci.spec.ts +40 -0
  9. package/__tests__/unit/reporter.spec.d.ts +1 -0
  10. package/__tests__/unit/reporter.spec.js +37 -0
  11. package/__tests__/unit/reporter.spec.ts +43 -0
  12. package/__tests__/unit/structureAnalyzer.spec.js +212 -0
  13. package/__tests__/unit/types.spec.ts +179 -0
  14. package/dist/__tests__/unit/ci.spec.d.ts +1 -0
  15. package/dist/__tests__/unit/ci.spec.js +226 -0
  16. package/dist/__tests__/unit/compression.spec.d.ts +4 -0
  17. package/dist/__tests__/unit/compression.spec.js +46 -0
  18. package/dist/ci.d.ts +30 -0
  19. package/dist/ci.js +117 -0
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.js +47 -0
  22. package/dist/cli/init.d.ts +3 -0
  23. package/dist/cli/init.js +158 -0
  24. package/dist/config/loader.d.ts +29 -0
  25. package/dist/config/loader.js +251 -0
  26. package/dist/domAnalyzer.d.ts +10 -0
  27. package/dist/domAnalyzer.js +285 -0
  28. package/dist/index.d.ts +7 -0
  29. package/dist/index.js +11 -0
  30. package/dist/reporter-entry.d.ts +2 -0
  31. package/dist/reporter-entry.js +5 -0
  32. package/dist/reporter-types.d.ts +2 -0
  33. package/dist/reporter-types.js +2 -0
  34. package/dist/reporter.d.ts +21 -0
  35. package/dist/reporter.js +249 -0
  36. package/dist/snapshot.d.ts +12 -0
  37. package/dist/snapshot.js +601 -0
  38. package/dist/structureAnalyzer.d.ts +12 -0
  39. package/dist/structureAnalyzer.js +288 -0
  40. package/dist/types.d.ts +368 -0
  41. package/dist/types.js +10 -0
  42. package/examples/structure-analysis-example.spec.ts +118 -0
  43. package/examples/structure-analysis.config.ts +159 -0
  44. package/jest.config.js +8 -0
  45. package/package.json +51 -0
  46. package/playwright.config.ts +11 -0
  47. package/src/__tests__/unit/ci.spec.ts +257 -0
  48. package/src/__tests__/unit/compression.spec.ts +52 -0
  49. package/src/ci.ts +140 -0
  50. package/src/cli/index.ts +49 -0
  51. package/src/cli/init.ts +131 -0
  52. package/src/config/loader.ts +238 -0
  53. package/src/index.ts +14 -0
  54. package/src/reporter-entry.ts +6 -0
  55. package/src/reporter-types.ts +5 -0
  56. package/src/reporter.ts +251 -0
  57. package/src/snapshot.ts +632 -0
  58. package/src/structureAnalyzer.ts +338 -0
  59. package/src/types.ts +388 -0
  60. package/tsconfig.jest.json +7 -0
  61. 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
+ }