@sungen/driver-ui 3.2.31 → 3.2.32

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-ui",
3
- "version": "3.2.31",
3
+ "version": "3.2.32",
4
4
  "description": "Sungen UI capability (Playwright) — step patterns, viewpoint gate, and phase hooks. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "author": "eqe team (engineer & quality) — Sun Asterisk",
14
14
  "license": "MIT",
15
15
  "dependencies": {
16
- "@sun-asterisk/sungen": "3.2.31"
16
+ "@sun-asterisk/sungen": "3.2.32"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "@playwright/test": "^1.57.0"
@@ -2,7 +2,7 @@ import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { formatToRegexLiteral } from '@sun-asterisk/sungen';
3
3
  import { StepPattern, StepTemplateData, expandRecipe } from '@sun-asterisk/sungen';
4
4
  import { LIST_MEMBER_ROLES } from '@sun-asterisk/sungen';
5
- import { pathToRegexSource } from '../utils/url-path-regex';
5
+ import { urlToPatternParts } from '../utils/url-path-regex';
6
6
  import { resolveColumnHeaderName } from '../utils/column-header';
7
7
 
8
8
  /**
@@ -173,14 +173,17 @@ export const assertionPatterns: StepPattern[] = [
173
173
  query = `\${${step.dataRef}}`;
174
174
  }
175
175
  }
176
- // Convert path to regex-safe string (dynamic :id segments → [^/]+ wildcard)
177
- const pathRegex = pathToRegexSource(path);
176
+ // Convert path to regex-safe string (dynamic :id segments → [^/]+ wildcard). An ABSOLUTE
177
+ // page value also yields a host pattern: matching only the path let this assertion pass on
178
+ // any host, so `see [Admin Jobs] page` declared as https://admin.example.com/jobs was
179
+ // satisfied by the public site's /jobs.
180
+ const { pathRegex, hostRegex } = urlToPatternParts(path);
178
181
  const withSuffix = step.dataRef ? ` with ${step.dataRef}` : '';
179
182
  return {
180
183
  templateName: 'page-assertion',
181
184
  // `dataRef` rides along so a runtime failure can name the test-data key an author must go
182
185
  // fix — an empty/absent value is otherwise the hardest kind of red to trace.
183
- data: { pathRegex, query, dataRef: step.dataRef || '', anchor },
186
+ data: { pathRegex, hostRegex, query, dataRef: step.dataRef || '', anchor },
184
187
  comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert on page${withSuffix}`,
185
188
  };
186
189
  },
@@ -71,10 +71,58 @@ export const capturePatterns: StepPattern[] = [
71
71
  step.selectorRef!, undefined, step.elementType, step.nth,
72
72
  );
73
73
  const varName = step.dataRef!;
74
- const isValue = /\bvalue\b/i.test(step.text);
74
+ // What to capture is stated BETWEEN the reference and `as` — nowhere else (#736). Scanning the
75
+ // whole sentence also saw the word inside the reference name and inside the variable name, so
76
+ // `remember [Name Value] text as {{v}}` and `remember [Label] text as {{value}}` both compiled
77
+ // to .inputValue() and died on a <span> with "Node is not an <input>".
78
+ //
79
+ // `step.elementType` alone is NOT enough: it holds only the FIRST token after `]`, so the
80
+ // documented `remember [Email] field value as {{v}}` reads as 'field' and would lose its value
81
+ // capture. Slicing after `]` first is also what makes the `as` split safe — a reference like
82
+ // `[As Of Date]` never reaches it.
83
+ const descriptor = step.text.slice(step.text.indexOf(']') + 1).split(/\bas\b/i)[0];
84
+ const isValue = /\bvalue\b/i.test(descriptor);
75
85
  // Register so later `{{varName}}` references resolve to testData.get(varName)
76
86
  // and skip YAML validation.
77
87
  context.dataResolver.registerCaptured(varName);
88
+ // A capture reads ONE element, and the template now takes the first match rather than dying
89
+ // in strict mode. That is the right default — the documented workaround for the failure was
90
+ // `nth: 0`, i.e. this — but taking the first of many is a decision, and nothing else would
91
+ // ever say so: a capture feeds later steps, so a value read off the wrong row travels
92
+ // silently into whatever consumes it, possibly in another file.
93
+ //
94
+ // Reported on what the entry PROVES about cardinality, not on its strategy name. An id, a
95
+ // testid, a role WITH an accessible name, a label or a placeholder each address one thing by
96
+ // construction. `getByText` does not — it matches every element containing the text, which is
97
+ // exactly a list — and neither does a bare role or a class selector. The earlier reading of
98
+ // this rule ("warn for locator/css") got both halves wrong: it fired on `#order-id`, which is
99
+ // singular by definition, and stayed silent on `type: text`, which the `.first()` above
100
+ // silently narrows.
101
+ const value = String(resolved.value ?? '');
102
+ // Strategies that address ONE element by construction. The mobile native four are here for the
103
+ // same reason `testid` is: they are hand-written targeting expressions naming an element by an
104
+ // identifying attribute, so warning on them would fire on most captures in a mobile suite.
105
+ const IDENTIFYING = new Set([
106
+ 'id', 'testid', 'label', 'placeholder',
107
+ 'accessibility-id', 'xpath', 'android-uiautomator', 'ios-predicate',
108
+ ]);
109
+ const singular =
110
+ resolved.nth !== undefined
111
+ || IDENTIFYING.has(String(resolved.strategy))
112
+ || (resolved.strategy === 'role' && !!resolved.name)
113
+ || ((resolved.strategy === 'locator' || resolved.strategy === 'css') && value.includes('#'));
114
+ if (!singular) {
115
+ const fix = resolved.strategy === 'role'
116
+ ? 'give the role an accessible name, or declare the index you want ("nth: 0")'
117
+ : 'declare the index you want ("nth: 0"), or narrow the selector to the one element';
118
+ context.diagnostics?.add('SG-W036',
119
+ `"[${step.selectorRef}]" is captured through a ${resolved.strategy} selector `
120
+ + `("${value}") that can match several elements and declares no "nth:", so this step reads `
121
+ + `whichever one the page yields FIRST. If it describes every item in a list, the captured `
122
+ + `{{${varName}}} is the first row's value rather than the one the scenario means. `
123
+ + `To say the first one is meant, ${fix}.`,
124
+ { feature: context.featureName, step: step.text });
125
+ }
78
126
  return {
79
127
  templateName: 'capture-variable',
80
128
  data: { ...resolved, varName, capture: isValue ? 'inputValue' : 'textContent' },
@@ -1,6 +1,6 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { StepPattern } from '@sun-asterisk/sungen';
3
- import { escapeRegex, pathToRegexSource } from '../utils/url-path-regex';
3
+ import { urlToPatternParts } from '../utils/url-path-regex';
4
4
 
5
5
  /**
6
6
  * Extract Playwright waitFor state from step text.
@@ -562,25 +562,11 @@ export const interactionPatterns: StepPattern[] = [
562
562
  }
563
563
  }
564
564
 
565
- const isAbsoluteUrl = /^https?:\/\//.test(path);
566
- let pathRegex: string;
567
- let hostRegex: string | undefined;
568
- if (isAbsoluteUrl) {
569
- const url = new URL(path);
570
- // Host and path are matched SEPARATELY since #677: the wait tests `u.hostname` and
571
- // `u.pathname`, and neither string contains the other. Concatenating them into one regex
572
- // only worked while the match ran unanchored across the whole URL — the very looseness
573
- // that let the wait resolve on a transient URL. Dynamic :id segments still wildcard;
574
- // the host stays a literal.
575
- hostRegex = escapeRegex(url.hostname);
576
- pathRegex = pathToRegexSource(url.pathname);
577
- } else {
578
- pathRegex = pathToRegexSource(path);
579
- }
580
-
565
+ // Host and path are matched SEPARATELY since #677 — see urlToPatternParts, which now serves
566
+ // every URL pattern in the compiler so the two assertion forms cannot drift from this wait.
581
567
  return {
582
568
  templateName: 'wait-for-page',
583
- data: { pathRegex, hostRegex },
569
+ data: urlToPatternParts(path),
584
570
  comment: step.selectorRef ? `Wait for ${step.selectorRef} page` : `Wait for page`,
585
571
  };
586
572
  },
@@ -1,7 +1,7 @@
1
1
  import { ParsedStep } from '@sun-asterisk/sungen';
2
2
  import { StepPattern } from '@sun-asterisk/sungen';
3
3
  import { inferPath, resolvePathVariables, getPathCode } from '@sun-asterisk/sungen';
4
- import { pathToRegexSource } from '../utils/url-path-regex';
4
+ import { urlToPatternParts } from '../utils/url-path-regex';
5
5
 
6
6
  /**
7
7
  * `is on` names a NAVIGATION only for a page — or for an untyped / data-bound reference, the
@@ -90,7 +90,8 @@ export const navigationPatterns: StepPattern[] = [
90
90
  return {
91
91
  templateName: 'route-assertion',
92
92
  // `dataRef` rides along so a runtime failure names the test-data key (see the `see` twin).
93
- data: { pathRegex: pathToRegexSource(path), query, dataRef: step.dataRef || '' },
93
+ // An absolute page value also carries a host pattern — see urlToPatternParts.
94
+ data: { ...urlToPatternParts(path), query, dataRef: step.dataRef || '' },
94
95
  comment: step.selectorRef ? `Assert on ${step.selectorRef} page${withSuffix}` : `Assert page route`,
95
96
  };
96
97
  }
@@ -157,7 +158,7 @@ export const navigationPatterns: StepPattern[] = [
157
158
  // Same rule as above — assert against the pre-resolution path so `:id` wildcards.
158
159
  return {
159
160
  templateName: 'route-assertion',
160
- data: { pathRegex: pathToRegexSource(inferredPath) },
161
+ data: urlToPatternParts(inferredPath),
161
162
  comment: `Assert on ${route}`,
162
163
  };
163
164
  }
@@ -187,7 +188,7 @@ export const navigationPatterns: StepPattern[] = [
187
188
  });
188
189
  return {
189
190
  templateName: 'route-assertion',
190
- data: { pathRegex: pathToRegexSource(inferredPath) },
191
+ data: urlToPatternParts(inferredPath),
191
192
  comment: `Assert current route is ${route}`,
192
193
  };
193
194
  },
@@ -63,7 +63,17 @@ function warnIfRowsLikelyMissing(
63
63
  ): void {
64
64
  const sink = context.diagnostics;
65
65
  if (!sink || resolved.rows) return;
66
- if (!['css', 'locator', 'testid', 'id'].includes(String(resolved.strategy))) return;
66
+ const strategy = String(resolved.strategy);
67
+ if (strategy === 'role') {
68
+ // A ROLE-located table was exempt outright, which read the exemption as "the author chose a
69
+ // role, so they thought about it". They did — but `role: list` or `role: region` contains no
70
+ // role=row either, so those tables hit the very failure this warning exists to name while
71
+ // staying completely silent. Only the roles that DO hold rows are evidence of anything.
72
+ const role = String(resolved.value ?? '').trim().toLowerCase();
73
+ if (role === 'table' || role === 'grid' || role === 'treegrid') return;
74
+ } else if (!['css', 'locator', 'testid', 'id'].includes(strategy)) {
75
+ return;
76
+ }
67
77
  if (/\btable\b/i.test(String(resolved.value ?? ''))) return;
68
78
  const key = `${context.featureName ?? ''}::${tableName}`;
69
79
  let seen = warnedTables.get(sink);
@@ -71,10 +81,29 @@ function warnIfRowsLikelyMissing(
71
81
  if (seen.has(key)) return;
72
82
  seen.add(key);
73
83
  sink.add('SG-W016',
74
- `table "[${tableName}]" is located by ${resolved.strategy} ("${resolved.value}") and declares no "rows:" — if its rows are not role="row" (a <div> list), every table step resolves 0 rows: "is hidden" and "is empty" pass without checking anything, counts and clicks time out. Declare the row shape on the table entry, e.g. "rows: 'div.job-row'", or locate the table by role: table.`,
84
+ `table "[${tableName}]" is located by ${resolved.strategy} ("${resolved.value}") and declares no "rows:" — if its rows are not role="row" (a <div> list, or a role that holds no rows such as list/region), every table step resolves 0 rows: "is hidden" and "is empty" pass without checking anything, counts and clicks time out. Declare the row shape on the table entry, e.g. "rows: 'div.job-row'", or locate the table by role: table.`,
75
85
  { feature: context.featureName, step: stepText });
76
86
  }
77
87
 
88
+ /**
89
+ * Does a declared `rows:` SHAPE select `<tr>` elements?
90
+ *
91
+ * `rows:` says what a row LOOKS like; it does not say the markup is not a table. `rows: 'tbody tr'`
92
+ * on a real <table> is a documented, working combination — it pins the row universe without
93
+ * changing what a row IS, so ARIA rows and `td, th` cells are still there. Only a shape built from
94
+ * something else (`div.job-row`, `li.card`) loses them, and that is the case the cell-reading steps
95
+ * cannot serve.
96
+ *
97
+ * Keying those steps on "a shape is declared" instead turned a correct table red: the refusal below
98
+ * would tell an author with a real <table> that their rows "are not ARIA rows", which is false, and
99
+ * a compile-time throw has to be right.
100
+ *
101
+ * Read as an element token so `div.tray` and `[data-tr]` are not mistaken for `tr`.
102
+ */
103
+ function rowShapeKeepsTableRoles(rows: string | undefined): boolean {
104
+ return !!rows && /(^|[\s>+~,(])tr\b/i.test(rows);
105
+ }
106
+
78
107
  /** Helper: check if current step is in a Given context */
79
108
  function isGivenContext(context: PatternContext): boolean {
80
109
  return context.effectiveKeyword === 'Given';
@@ -391,8 +420,16 @@ export const tablePatterns: StepPattern[] = [
391
420
  platformAlternative:
392
421
  'there is no HTML table on a native screen - tap the control on the row element itself (`When User tap [Row 1 Delete] button`)',
393
422
  matcher: (step) => {
423
+ // The VERB is required, exactly as the `-nth` twin above requires it. Without this test the
424
+ // pattern claimed every `in [T] table with {{v}}` sentence, verb or not, and the resolver's
425
+ // `|| 'click'` default then turned an ASSERTION into a click: `Then User see [Status] text in
426
+ // [Job Table] table with {{v}}` compiled to `.click()`, so the scenario went green having
427
+ // checked nothing. An action pattern must not be reachable from a sentence that names no
428
+ // action. `remember` is excluded for the same reason it is on the twin — it reads a value.
394
429
  return /\bin\b.*\btable\b/i.test(step.text) &&
395
430
  /\bwith\b/i.test(step.text) &&
431
+ /\b(clicks?|unchecks?|checks?)\b/i.test(step.text) &&
432
+ !/\bremember\b/i.test(step.text) &&
396
433
  !!step.dataRef &&
397
434
  step.elementType !== 'column' &&
398
435
  step.elementType !== 'row' &&
@@ -497,6 +534,58 @@ export const tablePatterns: StepPattern[] = [
497
534
  const resolvedRows = dataTable.rows.map(row => ({ cells: row.cells.map(resolveCell) }));
498
535
  const esc = (s: string) => s.replace(/'/g, "\\'");
499
536
 
537
+ // Where a CELL lives, decided at compile time from the table's own declaration.
538
+ //
539
+ // The runtime header read below only works on a real <table>: it reads `thead tr` and then
540
+ // addresses cells as `td, th`. A list whose rows are declared with a `rows:` SHAPE is built
541
+ // from <div>s, where `thead tr` is 0 elements forever — not a render race — and `td, th` is
542
+ // 0 elements too, so the step can never pass however long it waits. Declaring `columns:`
543
+ // replaces the read entirely: the index is known at compile time, and a column that names
544
+ // its own `elements.cell` addresses the cell without any table role at all.
545
+ const declaredColumns = resolved.columns ?? {};
546
+ const columnFor = (header: string) => {
547
+ const key = header.trim().toLowerCase().normalize('NFC');
548
+ const direct = declaredColumns[key];
549
+ if (direct) return direct;
550
+ return Object.values(declaredColumns).find(
551
+ (c) => String(c?.header ?? '').trim().toLowerCase().normalize('NFC') === key,
552
+ );
553
+ };
554
+ // A shape that still selects <tr> keeps its ARIA rows and its `td, th` cells, so the runtime
555
+ // header read below serves it exactly as it always did. Only a shape built from something else
556
+ // needs — and can use — the compile-time map.
557
+ const shapeHidesTableRoles = !!resolved.rows && !rowShapeKeepsTableRoles(resolved.rows);
558
+ // Usable means the column names the selector that matches its cell INSIDE a row. An `index:`
559
+ // alone cannot help here: it indexes the `td, th` set, which is empty on this markup.
560
+ const unmapped = headers.filter((h) => !columnFor(h)?.elements?.cell);
561
+ const mappedByCellSelector = shapeHidesTableRoles && unmapped.length === 0;
562
+
563
+ // A row shape that hides the table roles, with no cell selector to reach the cells by, cannot
564
+ // be served at all — refuse now, loudly, rather than emit an assertion that waits out every
565
+ // timeout before failing on a header row that will never exist.
566
+ if (shapeHidesTableRoles && unmapped.length > 0) {
567
+ const reason = `step "${step.text}" runs \`table match data:\` against table `
568
+ + `"[${step.selectorRef}]", whose rows are declared with a "rows:" shape — so the rows are `
569
+ + `not ARIA rows, their cells are not "td, th", and there is no "thead tr" to read the `
570
+ + `header row from. This assertion cannot pass on that markup however long it waits. `
571
+ + `Declare the columns it needs (${unmapped.map((h) => `"${h}"`).join(', ')}) on the table `
572
+ + `entry, each with the selector that matches its cell inside a row: "columns:\\n `
573
+ + `${(unmapped[0] ?? 'title').trim().toLowerCase()}:\\n index: 0\\n header: `
574
+ + `'${unmapped[0] ?? 'Title'}'\\n elements:\\n cell:\\n type: locator\\n `
575
+ + ` value: '.your-cell-class'".`;
576
+ context.diagnostics?.add('SG-E035', reason,
577
+ { feature: context.featureName, step: step.text });
578
+ return {
579
+ templateName: 'table-match-data',
580
+ data: {
581
+ ...resolved,
582
+ assertions: [`throw new Error(${JSON.stringify(`[sungen] ${reason}`)});`],
583
+ isGiven: isGivenContext(context),
584
+ },
585
+ comment: `Unsupported table match data on a declared row shape — see SG-E035: ${step.text}`,
586
+ };
587
+ }
588
+
500
589
  // Header → cell mapping (field report: the header row was parsed and then IGNORED —
501
590
  // rows were asserted as chained row-level hasText filters, so column-swapped data and
502
591
  // duplicate values like '-' still passed). Now:
@@ -514,12 +603,35 @@ export const tablePatterns: StepPattern[] = [
514
603
  assertions.push(
515
604
  `await expect(table.locator('[rowspan]:not([rowspan="1"]), [colspan]:not([colspan="1"])'), 'table match data: rowspan/colspan tables are not supported yet — header-to-cell mapping would be wrong').toHaveCount(0);`
516
605
  );
517
- assertions.push(
518
- `const __headers = (await table.locator('thead tr').first().locator('th, td').allTextContents()).map((t) => t.trim());`
519
- );
520
- assertions.push(
521
- `const __colIdx = (h: string): number => { const i = __headers.indexOf(h); if (i !== -1) return i; const c = __headers.filter((t) => t.includes(h)); if (c.length === 1) return __headers.indexOf(c[0]); throw new Error(\`table match data: header "\${h}" \${c.length ? 'is ambiguous' : 'not found'} in table header row [\${__headers.join(' | ')}]\`); };`
522
- );
606
+ // The cell expression for one DataTable column, scoped to `row`.
607
+ let cellExpr: (header: string) => string;
608
+ if (mappedByCellSelector) {
609
+ // Declared cell selectors: no header row is read at run time, so there is nothing to race
610
+ // with. Taken ONLY when the shape hides the table roles and every header has one — on a real
611
+ // table the runtime lookup is strictly better, because it is robust to extra DOM columns (a
612
+ // leading checkbox) that a compile-time index cannot see. Switching a working table onto
613
+ // compile-time indexes because it happens to declare `columns:` for some other step would
614
+ // silently re-point its assertions at different cells.
615
+ cellExpr = (header: string): string => context.renderLocator({
616
+ ...context.selectorResolver.resolveNestedEntry(columnFor(header)!.elements!.cell!, header),
617
+ parentLocator: 'row',
618
+ });
619
+ } else {
620
+ // The historical runtime mapping, for a table that declares nothing. The span guard above
621
+ // is NOT a wait: `toHaveCount(0)` is already true of an empty DOM, so it returns on the
622
+ // first poll and the one-shot read below then sees an empty header row. This assertion
623
+ // waits for the header row to actually hold cells before reading it.
624
+ assertions.push(
625
+ `await expect(table.locator('thead tr').first().locator('th, td'), 'table match data: the header row is still empty — the table had not rendered by the time this assertion read it').not.toHaveCount(0);`
626
+ );
627
+ assertions.push(
628
+ `const __headers = (await table.locator('thead tr').first().locator('th, td').allTextContents()).map((t) => t.trim());`
629
+ );
630
+ assertions.push(
631
+ `const __colIdx = (h: string): number => { const i = __headers.indexOf(h); if (i !== -1) return i; const c = __headers.filter((t) => t.includes(h)); if (c.length === 1) return __headers.indexOf(c[0]); throw new Error(\`table match data: header "\${h}" \${c.length ? 'is ambiguous' : 'not found'} in table header row [\${__headers.join(' | ')}]\`); };`
632
+ );
633
+ cellExpr = (header: string): string => `row.locator('td, th').nth(__colIdx('${esc(header)}'))`;
634
+ }
523
635
  for (const row of resolvedRows) {
524
636
  const filters = row.cells
525
637
  .map(cell => `.filter({ hasText: '${esc(cell)}' })`)
@@ -530,7 +642,7 @@ export const tablePatterns: StepPattern[] = [
530
642
  const header = headers[i];
531
643
  if (header === undefined) return;
532
644
  assertions.push(
533
- ` await expect(row.locator('td, th').nth(__colIdx('${esc(header)}'))).toHaveText('${esc(cell)}');`
645
+ ` await expect(${cellExpr(header)}).toHaveText('${esc(cell)}');`
534
646
  );
535
647
  });
536
648
  assertions.push(`}`);
@@ -37,3 +37,38 @@ export function pathToRegexSource(path: string): string {
37
37
  )
38
38
  .join('\\/');
39
39
  }
40
+
41
+ /** A page value split into the two patterns the runtime matches SEPARATELY. */
42
+ export interface UrlPatternParts {
43
+ /** Pattern for `u.pathname`. Always present. */
44
+ pathRegex: string;
45
+ /** Pattern for `u.hostname` — set only when the page value named an absolute URL. */
46
+ hostRegex?: string;
47
+ }
48
+
49
+ /**
50
+ * Split a page value into the host and path patterns the runtime matches separately.
51
+ *
52
+ * `u.hostname` never appears in `u.pathname`, so one concatenated regex cannot carry both — it
53
+ * only ever "worked" while the match ran unanchored across the whole URL, which is the looseness
54
+ * that let a wait resolve on a transient URL (#677). A page value that names an absolute URL
55
+ * therefore yields two patterns; a relative one yields the path pattern alone, exactly as before.
56
+ *
57
+ * Every caller that builds a pathname pattern for a URL assertion or wait goes through here, so
58
+ * the host half cannot be honoured on one step form and silently dropped on another: before this,
59
+ * only `wait for [X] page` split the host, while both assertion forms matched the path against
60
+ * ANY host — `see [X] page` with `https://admin.example.com/jobs` passed on the public site.
61
+ *
62
+ * Dynamic `:id` segments still wildcard; the host stays a literal. A value that starts like an
63
+ * absolute URL but does not parse falls back to the path-only reading rather than throwing — the
64
+ * pattern is then wrong in a way the author can read, which beats losing the step.
65
+ */
66
+ export function urlToPatternParts(path: string): UrlPatternParts {
67
+ if (!/^https?:\/\//i.test(path)) return { pathRegex: pathToRegexSource(path) };
68
+ try {
69
+ const url = new URL(path);
70
+ return { hostRegex: escapeRegex(url.hostname), pathRegex: pathToRegexSource(url.pathname) };
71
+ } catch {
72
+ return { pathRegex: pathToRegexSource(path) };
73
+ }
74
+ }