@letsrunit/playwright 0.7.1 → 0.9.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/dist/index.d.ts +17 -2
- package/dist/index.js +357 -262
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/fuzzy-locator.ts +22 -9
- package/src/index.ts +1 -0
- package/src/scrub-html.ts +40 -0
- package/src/snapshot.ts +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@letsrunit/playwright",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Playwright extensions and utilities for letsrunit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"testing",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"packageManager": "yarn@4.10.3",
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@letsrunit/utils": "0.
|
|
45
|
+
"@letsrunit/utils": "0.9.0",
|
|
46
46
|
"@playwright/test": "^1.57.0",
|
|
47
47
|
"case": "^1.6.3",
|
|
48
48
|
"diff": "^8.0.3",
|
package/src/fuzzy-locator.ts
CHANGED
|
@@ -1,26 +1,39 @@
|
|
|
1
1
|
import { Locator, Page } from '@playwright/test';
|
|
2
2
|
|
|
3
|
+
function debug(...args: unknown[]) {
|
|
4
|
+
if (process.env.LETSRUNIT_DEBUG_FUZZY_LOCATOR === '1') {
|
|
5
|
+
// eslint-disable-next-line no-console
|
|
6
|
+
console.log('[fuzzyLocator]', ...args);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
/**
|
|
4
11
|
* Locates an element using Playwright selectors, with lazy fallbacks.
|
|
5
12
|
*/
|
|
6
13
|
export async function fuzzyLocator(page: Page, selector: string): Promise<Locator> {
|
|
14
|
+
debug('input selector:', selector);
|
|
7
15
|
const primary = page.locator(selector);
|
|
8
|
-
const candidates = [
|
|
9
|
-
tryRelaxNameToHasText(page, selector),
|
|
10
|
-
tryTagInsteadOfRole(page, selector),
|
|
11
|
-
tryRoleNameProximity(page, selector),
|
|
12
|
-
tryFieldAlternative(page, selector),
|
|
13
|
-
tryAsField(page, selector),
|
|
16
|
+
const candidates: Array<{ name: string; locator: Locator | null }> = [
|
|
17
|
+
{ name: 'relaxNameToHasText', locator: tryRelaxNameToHasText(page, selector) },
|
|
18
|
+
{ name: 'tagInsteadOfRole', locator: tryTagInsteadOfRole(page, selector) },
|
|
19
|
+
{ name: 'roleNameProximity', locator: tryRoleNameProximity(page, selector) },
|
|
20
|
+
{ name: 'fieldAlternative', locator: tryFieldAlternative(page, selector) },
|
|
21
|
+
{ name: 'asField', locator: tryAsField(page, selector) },
|
|
14
22
|
];
|
|
15
23
|
|
|
16
24
|
let combined = primary;
|
|
25
|
+
const enabled: string[] = [];
|
|
17
26
|
|
|
18
27
|
for (const candidate of candidates) {
|
|
19
|
-
if (!candidate) continue;
|
|
20
|
-
|
|
28
|
+
if (!candidate.locator) continue;
|
|
29
|
+
enabled.push(candidate.name);
|
|
30
|
+
combined = combined.or(candidate.locator);
|
|
21
31
|
}
|
|
32
|
+
debug('enabled fallbacks:', enabled.length ? enabled.join(', ') : '(none)');
|
|
22
33
|
|
|
23
|
-
|
|
34
|
+
const result = combined.first();
|
|
35
|
+
debug('returning locator:', result.toString());
|
|
36
|
+
return result;
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
// Preserve the selector but relax [name="..."] to [has-text="..."]
|
package/src/index.ts
CHANGED
package/src/scrub-html.ts
CHANGED
|
@@ -31,6 +31,9 @@ export type ScrubHtmlOptions = {
|
|
|
31
31
|
replaceBrInHeadings?: boolean;
|
|
32
32
|
/** Limit lists to max items: -1 mean no limit. Default: -1 */
|
|
33
33
|
limitLists?: number;
|
|
34
|
+
/** Strip utility-framework classes (Tailwind, Bootstrap, UnoCSS, Windi) from class
|
|
35
|
+
* attributes. Removes the attribute entirely when all classes are stripped. Default: false */
|
|
36
|
+
dropUtilityClasses?: boolean;
|
|
34
37
|
};
|
|
35
38
|
|
|
36
39
|
const HTML_MIN_ATTR_THRESHOLD = 250_000; // ~70k tokens
|
|
@@ -48,6 +51,7 @@ function getDefaults(contentLength: number): Required<ScrubHtmlOptions> {
|
|
|
48
51
|
dropComments: true,
|
|
49
52
|
replaceBrInHeadings: true,
|
|
50
53
|
limitLists: contentLength >= HTML_LIMIT_LISTS_THRESHOLD ? 20 : -1,
|
|
54
|
+
dropUtilityClasses: false,
|
|
51
55
|
};
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -183,6 +187,7 @@ export async function realScrubHtml(
|
|
|
183
187
|
if (o.dropComments) dropHtmlComments(doc);
|
|
184
188
|
if (o.replaceBrInHeadings) replaceBrsInHeadings(doc);
|
|
185
189
|
if (o.limitLists >= 0) limitListsAndRows(doc, o.limitLists);
|
|
190
|
+
if (o.dropUtilityClasses) stripUtilityClasses(doc);
|
|
186
191
|
if (o.normalizeWhitespace) normalizeWhitespace(doc.body);
|
|
187
192
|
|
|
188
193
|
return doc.body.innerHTML;
|
|
@@ -314,6 +319,41 @@ function replaceBrsInHeadings(doc: Document) {
|
|
|
314
319
|
});
|
|
315
320
|
}
|
|
316
321
|
|
|
322
|
+
// ---- Utility class detection ----
|
|
323
|
+
|
|
324
|
+
// Any class containing ':' is a Tailwind/Windi/UnoCSS variant prefix (hover:, sm:, dark:focus:, …)
|
|
325
|
+
const UTILITY_VARIANT_RE = /:/;
|
|
326
|
+
|
|
327
|
+
// Prefix-based utility patterns (Tailwind + Bootstrap)
|
|
328
|
+
const UTILITY_PREFIX_RE = /^-?(?:p[xytblrse]?|m[xytblrse]?|gap|space-[xy]|w|h|min-w|min-h|max-w|max-h|size|basis|inset|top|right|bottom|left|start|end|z|text|bg|border|ring|shadow|outline|fill|stroke|divide|accent|caret|from|via|to|decoration|font|leading|tracking|indent|line-clamp|columns|aspect|object|opacity|rotate|scale|translate|skew|transition|duration|ease|delay|animate|rounded|overflow|overscroll|scroll|snap|touch|cursor|pointer-events|select|resize|flex|grid|col|row|order|auto-cols|auto-rows|items|justify|content|self|place|float|clear|list|whitespace|break|hyphens|mix-blend|bg-blend|backdrop|d|g|fs|fw|lh|align|position)-/i;
|
|
329
|
+
|
|
330
|
+
// Standalone keywords that are utilities on their own (no suffix)
|
|
331
|
+
const UTILITY_STANDALONE = new Set([
|
|
332
|
+
'flex', 'grid', 'block', 'hidden', 'inline', 'inline-block', 'inline-flex', 'inline-grid',
|
|
333
|
+
'contents', 'flow-root', 'list-item', 'table', 'container', 'truncate',
|
|
334
|
+
'grow', 'shrink', 'static', 'relative', 'absolute', 'fixed', 'sticky',
|
|
335
|
+
'visible', 'invisible', 'collapse', 'isolate',
|
|
336
|
+
'underline', 'overline', 'line-through', 'no-underline',
|
|
337
|
+
'uppercase', 'lowercase', 'capitalize', 'normal-case',
|
|
338
|
+
'italic', 'not-italic', 'antialiased', 'subpixel-antialiased',
|
|
339
|
+
'sr-only', 'not-sr-only', 'clearfix', 'row', 'col',
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
function isUtilityClass(token: string): boolean {
|
|
343
|
+
if (UTILITY_VARIANT_RE.test(token)) return true;
|
|
344
|
+
const base = token.startsWith('-') ? token.slice(1) : token;
|
|
345
|
+
if (UTILITY_STANDALONE.has(base)) return true;
|
|
346
|
+
return UTILITY_PREFIX_RE.test(token);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function stripUtilityClasses(doc: Document) {
|
|
350
|
+
for (const el of doc.body.querySelectorAll<HTMLElement>('[class]')) {
|
|
351
|
+
const kept = el.className.split(/\s+/).filter((t) => t && !isUtilityClass(t));
|
|
352
|
+
if (kept.length === 0) el.removeAttribute('class');
|
|
353
|
+
else el.className = kept.join(' ');
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
317
357
|
function limitListsAndRows(doc: Document, limit: number) {
|
|
318
358
|
// lists
|
|
319
359
|
doc.querySelectorAll('ul, ol').forEach((list) => {
|
package/src/snapshot.ts
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
import { sleep } from '@letsrunit/utils';
|
|
2
2
|
import type { Page } from '@playwright/test';
|
|
3
3
|
import { screenshot } from './screenshot';
|
|
4
|
+
import { realScrubHtml } from './scrub-html';
|
|
4
5
|
import type { Snapshot } from './types';
|
|
5
6
|
import { waitForDomIdle } from './wait';
|
|
6
7
|
|
|
7
|
-
export
|
|
8
|
+
export type SnapshotOptions = {
|
|
9
|
+
/** Strip utility-framework classes (Tailwind, Bootstrap, UnoCSS, Windi) from the captured HTML. */
|
|
10
|
+
dropUtilityClasses?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export async function snapshot(page: Page, opts: SnapshotOptions = {}): Promise<Snapshot> {
|
|
8
14
|
await sleep(500);
|
|
9
15
|
await waitForDomIdle(page);
|
|
10
16
|
|
|
11
17
|
const [url, html, file] = await Promise.all([page.url(), getContentWithMarkedHidden(page), screenshot(page)]);
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
const finalHtml = opts.dropUtilityClasses
|
|
20
|
+
? await realScrubHtml({ html, url }, {
|
|
21
|
+
dropHidden: false, dropHead: false, dropSvg: false, pickMain: false,
|
|
22
|
+
stripAttributes: 0, normalizeWhitespace: false, dropComments: false,
|
|
23
|
+
replaceBrInHeadings: false, limitLists: -1, dropUtilityClasses: true,
|
|
24
|
+
})
|
|
25
|
+
: html;
|
|
26
|
+
|
|
27
|
+
return { url, html: finalHtml, screenshot: file };
|
|
14
28
|
}
|
|
15
29
|
|
|
16
30
|
async function getContentWithMarkedHidden(page: Page): Promise<string> {
|