@sungen/driver-ui 3.2.30 → 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 +2 -2
- package/src/patterns/assertion-patterns.ts +7 -4
- package/src/patterns/capture-patterns.ts +49 -1
- package/src/patterns/interaction-patterns.ts +4 -18
- package/src/patterns/navigation-patterns.ts +5 -4
- package/src/patterns/table-patterns.ts +121 -9
- package/src/utils/url-path-regex.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sungen/driver-ui",
|
|
3
|
-
"version": "3.2.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
566
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
)
|
|
520
|
-
|
|
521
|
-
|
|
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(
|
|
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
|
+
}
|