Skip to content

Commit 37a6f23

Browse files
authored
Align Page getBy* locators with Playwrights JS API (#76)
This PR makes some changes to the Page Locator API to align it closer to the Playwright JS API - Added Regex support for name fields and text selectors - Added `PagePlaywrightApiTest` integration test suite to test that the getBy* selectors align with the Playwright API - Updated existing tests to adhere to Regex support
1 parent 201d8fa commit 37a6f23

File tree

12 files changed

+702
-186
lines changed

12 files changed

+702
-186
lines changed

src/Locator/Options/GetByRoleOptions.php

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
namespace Playwright\Locator\Options;
1616

1717
use Playwright\Exception\RuntimeException;
18+
use Playwright\Regex;
1819

1920
/**
2021
* @phpstan-type GetByRoleOptionsArray array{
@@ -23,7 +24,7 @@
2324
* expanded?: bool,
2425
* includeHidden?: bool,
2526
* level?: int,
26-
* name?: string,
27+
* name?: string|Regex,
2728
* pressed?: bool,
2829
* selected?: bool
2930
* }
@@ -39,7 +40,7 @@ public function __construct(
3940
public ?bool $expanded = null,
4041
public ?bool $includeHidden = null,
4142
public ?int $level = null,
42-
public ?string $name = null,
43+
public string|Regex|null $name = null,
4344
public bool|string|null $pressed = null,
4445
public ?bool $selected = null,
4546
public LocatorOptions $locatorOptions = new LocatorOptions(),
@@ -165,7 +166,7 @@ private static function extractPressed(array $options): bool|string|null
165166
/**
166167
* @param array<string, mixed> $options
167168
*/
168-
private static function extractName(array $options): ?string
169+
private static function extractName(array $options): string|Regex|null
169170
{
170171
if (!array_key_exists('name', $options)) {
171172
return null;
@@ -176,11 +177,15 @@ private static function extractName(array $options): ?string
176177
return null;
177178
}
178179

180+
if ($value instanceof Regex) {
181+
return $value;
182+
}
183+
179184
if (is_scalar($value) || $value instanceof \Stringable) {
180185
return (string) $value;
181186
}
182187

183-
throw new RuntimeException('getByRole option "name" must be stringable.');
188+
throw new RuntimeException('getByRole option "name" must be a string or Regex instance.');
184189
}
185190

186191
/**

src/Locator/RoleSelectorBuilder.php

Lines changed: 12 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
namespace Playwright\Locator;
1616

17+
use Playwright\Regex;
18+
1719
/**
1820
* Helper for generating Playwright role selectors with accessibility focused options.
1921
*
@@ -24,7 +26,6 @@ final class RoleSelectorBuilder
2426
/** @var array<int, string> */
2527
private const ROLE_SPECIFIC_KEYS = [
2628
'name',
27-
'nameRegex',
2829
'exact',
2930
'checked',
3031
'disabled',
@@ -43,15 +44,11 @@ public static function buildSelector(string $role, array $options = []): string
4344
$normalizedRole = self::normalizeRole($role);
4445
$selector = 'internal:role='.$normalizedRole;
4546

46-
$nameFragment = self::buildNameAttribute($options);
47+
$nameFragment = self::buildNameAttribute($options, !empty($options['exact']));
4748
if (null !== $nameFragment) {
4849
$selector .= $nameFragment;
4950
}
5051

51-
if (!empty($options['exact'])) {
52-
$selector .= '[exact]';
53-
}
54-
5552
$selector .= self::buildBooleanAttribute('checked', $options['checked'] ?? null);
5653
$selector .= self::buildBooleanAttribute('disabled', $options['disabled'] ?? null);
5754
$selector .= self::buildBooleanAttribute('expanded', $options['expanded'] ?? null);
@@ -93,27 +90,20 @@ private static function normalizeRole(string $role): string
9390
/**
9491
* @param array<string, mixed> $options
9592
*/
96-
private static function buildNameAttribute(array $options): ?string
93+
private static function buildNameAttribute(array $options, bool $exact = false): ?string
9794
{
98-
if (array_key_exists('nameRegex', $options)) {
99-
$regexFragment = self::formatRegexAttribute('name', $options['nameRegex']);
100-
if (null !== $regexFragment) {
101-
return $regexFragment;
102-
}
103-
}
104-
10595
if (!array_key_exists('name', $options)) {
10696
return null;
10797
}
10898

10999
$nameOption = $options['name'];
110100

111-
if (is_array($nameOption) && array_key_exists('regex', $nameOption)) {
112-
return self::formatRegexAttribute('name', $nameOption);
101+
if ($nameOption instanceof Regex) {
102+
return '[name='.$nameOption->pattern.']';
113103
}
114104

115105
if ($nameOption instanceof \Stringable) {
116-
return '[name="'.self::escapeAttributeValue((string) $nameOption).'"]';
106+
$nameOption = (string) $nameOption;
117107
}
118108

119109
if (is_string($nameOption)) {
@@ -122,7 +112,11 @@ private static function buildNameAttribute(array $options): ?string
122112
return null;
123113
}
124114

125-
return '[name="'.self::escapeAttributeValue($nameOption).'"]';
115+
if ($exact) {
116+
return '[name="'.self::escapeAttributeValue($nameOption).'"]';
117+
}
118+
119+
return '[name=/'.preg_quote($nameOption, '/').'/i]';
126120
}
127121

128122
return null;
@@ -160,71 +154,4 @@ private static function escapeAttributeValue(string $value): string
160154
{
161155
return addcslashes($value, '\\"');
162156
}
163-
164-
private static function escapeRegexPattern(string $pattern): string
165-
{
166-
return addcslashes($pattern, '/');
167-
}
168-
169-
private static function formatRegexAttribute(string $attribute, mixed $value): ?string
170-
{
171-
$pattern = null;
172-
$flags = '';
173-
174-
if (is_string($value) || $value instanceof \Stringable) {
175-
$pattern = (string) $value;
176-
} elseif (is_array($value)) {
177-
$patternValue = $value['pattern'] ?? $value['regex'] ?? null;
178-
if (is_string($patternValue) || $patternValue instanceof \Stringable) {
179-
$pattern = (string) $patternValue;
180-
}
181-
182-
$flagsValue = $value['flags'] ?? null;
183-
if (is_string($flagsValue)) {
184-
$flags = $flagsValue;
185-
}
186-
187-
$ignoreCase = $value['ignoreCase'] ?? $value['ignore_case'] ?? null;
188-
if (true === $ignoreCase && !str_contains($flags, 'i')) {
189-
$flags .= 'i';
190-
}
191-
}
192-
193-
if (null === $pattern) {
194-
return null;
195-
}
196-
197-
$pattern = trim($pattern);
198-
if ('' === $pattern) {
199-
return null;
200-
}
201-
202-
if ('/' !== $pattern[0]) {
203-
$pattern = '/'.self::escapeRegexPattern($pattern).'/';
204-
}
205-
206-
if ('' !== $flags) {
207-
$pattern .= self::sanitizeRegexFlags($flags);
208-
}
209-
210-
return '['.$attribute.'='.$pattern.']';
211-
}
212-
213-
private static function sanitizeRegexFlags(string $flags): string
214-
{
215-
$valid = ['d', 'g', 'i', 'm', 's', 'u', 'y'];
216-
$unique = [];
217-
218-
foreach (str_split($flags) as $flag) {
219-
if (!in_array($flag, $valid, true)) {
220-
continue;
221-
}
222-
if (in_array($flag, $unique, true)) {
223-
continue;
224-
}
225-
$unique[] = $flag;
226-
}
227-
228-
return implode('', $unique);
229-
}
230157
}

src/Page/Page.php

Lines changed: 88 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
use Playwright\Locator\LocatorInterface;
4242
use Playwright\Locator\Options\GetByRoleOptions;
4343
use Playwright\Locator\Options\LocatorOptions;
44+
use Playwright\Locator\RoleSelectorBuilder;
4445
use Playwright\Network\Request;
4546
use Playwright\Network\Response;
4647
use Playwright\Network\ResponseInterface;
@@ -61,6 +62,7 @@
6162
use Playwright\Page\Options\WaitForResponseOptions;
6263
use Playwright\Page\Options\WaitForSelectorOptions;
6364
use Playwright\Page\Options\WaitForUrlOptions;
65+
use Playwright\Regex;
6466
use Playwright\Screenshot\ScreenshotHelper;
6567
use Playwright\Transport\TransportInterface;
6668
use Psr\Log\LoggerInterface;
@@ -210,33 +212,50 @@ public function locator(string $selector, array|LocatorOptions $options = []): L
210212
/**
211213
* @param array<string, mixed>|LocatorOptions $options
212214
*/
213-
public function getByAltText(string $text, array|LocatorOptions $options = []): LocatorInterface
215+
public function getByAltText(string|Regex $text, array|LocatorOptions $options = []): LocatorInterface
214216
{
215-
return $this->locator(\sprintf('[alt="%s"]', $text), $this->normalizeLocatorOptions($options));
217+
$opts = $this->normalizeLocatorOptions($options);
218+
$exact = self::extractExact($opts);
219+
$selector = self::buildAttrSelector('alt', $text, $exact);
220+
221+
return $this->locator($selector, $opts);
216222
}
217223

218224
/**
219225
* @param array<string, mixed>|LocatorOptions $options
220226
*/
221-
public function getByLabel(string $text, array|LocatorOptions $options = []): LocatorInterface
227+
public function getByLabel(string|Regex $text, array|LocatorOptions $options = []): LocatorInterface
222228
{
223-
return $this->locator(\sprintf('label:text-is("%s") >> nth=0', $text), $this->normalizeLocatorOptions($options));
229+
$opts = $this->normalizeLocatorOptions($options);
230+
$exact = self::extractExact($opts);
231+
$selector = self::buildLabelSelector($text, $exact);
232+
233+
return $this->locator($selector, $opts);
224234
}
225235

226236
/**
227237
* @param array<string, mixed>|LocatorOptions $options
228238
*/
229-
public function getByPlaceholder(string $text, array|LocatorOptions $options = []): LocatorInterface
239+
public function getByPlaceholder(string|Regex $text, array|LocatorOptions $options = []): LocatorInterface
230240
{
231-
return $this->locator(\sprintf('[placeholder="%s"]', $text), $this->normalizeLocatorOptions($options));
241+
$opts = $this->normalizeLocatorOptions($options);
242+
$exact = self::extractExact($opts);
243+
$selector = self::buildAttrSelector('placeholder', $text, $exact);
244+
245+
return $this->locator($selector, $opts);
232246
}
233247

234248
/**
235249
* @param array<string, mixed>|GetByRoleOptions $options
236250
*/
237251
public function getByRole(string $role, array|GetByRoleOptions $options = []): LocatorInterface
238252
{
239-
return $this->locator($role, $this->normalizeGetByRoleOptions($options));
253+
$options = GetByRoleOptions::from($options);
254+
$optionsArray = $options->toArray();
255+
$selector = RoleSelectorBuilder::buildSelector($role, $optionsArray);
256+
$locatorOptions = RoleSelectorBuilder::filterLocatorOptions($optionsArray);
257+
258+
return $this->locator($selector, $locatorOptions);
240259
}
241260

242261
/**
@@ -250,17 +269,25 @@ public function getByTestId(string $testId, array|LocatorOptions $options = []):
250269
/**
251270
* @param array<string, mixed>|LocatorOptions $options
252271
*/
253-
public function getByText(string $text, array|LocatorOptions $options = []): LocatorInterface
272+
public function getByText(string|Regex $text, array|LocatorOptions $options = []): LocatorInterface
254273
{
255-
return $this->locator(\sprintf('text="%s"', $text), $this->normalizeLocatorOptions($options));
274+
$opts = $this->normalizeLocatorOptions($options);
275+
$exact = self::extractExact($opts);
276+
$selector = self::buildTextSelector($text, $exact);
277+
278+
return $this->locator($selector, $opts);
256279
}
257280

258281
/**
259282
* @param array<string, mixed>|LocatorOptions $options
260283
*/
261-
public function getByTitle(string $text, array|LocatorOptions $options = []): LocatorInterface
284+
public function getByTitle(string|Regex $text, array|LocatorOptions $options = []): LocatorInterface
262285
{
263-
return $this->locator(\sprintf('[title="%s"]', $text), $this->normalizeLocatorOptions($options));
286+
$opts = $this->normalizeLocatorOptions($options);
287+
$exact = self::extractExact($opts);
288+
$selector = self::buildAttrSelector('title', $text, $exact);
289+
290+
return $this->locator($selector, $opts);
264291
}
265292

266293
/**
@@ -581,16 +608,6 @@ private function normalizeLocatorOptions(array|LocatorOptions $options): array
581608
return LocatorOptions::from($options)->toArray();
582609
}
583610

584-
/**
585-
* @param array<string, mixed>|GetByRoleOptions $options
586-
*
587-
* @return array<string, mixed>
588-
*/
589-
private function normalizeGetByRoleOptions(array|GetByRoleOptions $options): array
590-
{
591-
return GetByRoleOptions::from($options)->toArray();
592-
}
593-
594611
/**
595612
* @param array<string, mixed> $params
596613
*
@@ -1074,6 +1091,56 @@ private static function normalizeForPage(string $expression): string
10741091
return $expression;
10751092
}
10761093

1094+
/**
1095+
* @param array<string, mixed> $options
1096+
*/
1097+
private static function extractExact(array &$options): bool
1098+
{
1099+
$exact = (bool) ($options['exact'] ?? false);
1100+
unset($options['exact']);
1101+
1102+
return $exact;
1103+
}
1104+
1105+
private static function buildTextSelector(string|Regex $text, bool $exact): string
1106+
{
1107+
if ($text instanceof Regex) {
1108+
return \sprintf('internal:text=%s', $text->pattern);
1109+
}
1110+
1111+
if ($exact) {
1112+
return \sprintf('internal:text="%s"', addcslashes($text, '\\"'));
1113+
}
1114+
1115+
return \sprintf('internal:text=/%s/i', preg_quote($text, '/'));
1116+
}
1117+
1118+
private static function buildAttrSelector(string $attr, string|Regex $text, bool $exact): string
1119+
{
1120+
if ($text instanceof Regex) {
1121+
return \sprintf('internal:attr=[%s=%s]', $attr, $text->pattern);
1122+
}
1123+
1124+
if ($exact) {
1125+
return \sprintf('internal:attr=[%s="%s"]', $attr, addcslashes($text, '\\"'));
1126+
}
1127+
1128+
return \sprintf('internal:attr=[%s=/%s/i]', $attr, preg_quote($text, '/'));
1129+
}
1130+
1131+
private static function buildLabelSelector(string|Regex $text, bool $exact): string
1132+
{
1133+
if ($text instanceof Regex) {
1134+
return \sprintf('internal:label=%s', $text->pattern);
1135+
}
1136+
1137+
if ($exact) {
1138+
return \sprintf('internal:label="%s"', addcslashes($text, '\\"'));
1139+
}
1140+
1141+
return \sprintf('internal:label=/%s/i', preg_quote($text, '/'));
1142+
}
1143+
10771144
private static function isFunctionLike(string $s): bool
10781145
{
10791146
return (bool) preg_match('/^((async\s+)?function\b|\([^)]*\)\s*=>|[A-Za-z_$][A-Za-z0-9_$]*\s*=>|async\s*\([^)]*\)\s*=>)/', $s);

0 commit comments

Comments
 (0)