chore: make locators generator isomorphic (#17850)
This commit is contained in:
parent
30179d4d78
commit
3ecaa36e25
|
|
@ -1,2 +1,3 @@
|
||||||
# Files in this folder are used both in Node.js and injected environments, they are isomorphic and can't have dependencies.
|
# Files in this folder are used both in Node.js and injected environments, they are isomorphic and can't have dependencies.
|
||||||
[*]
|
[*]
|
||||||
|
../../utils/isomorphic
|
||||||
|
|
@ -0,0 +1,330 @@
|
||||||
|
/**
|
||||||
|
* Copyright (c) Microsoft Corporation.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { escapeWithQuotes, toSnakeCase, toTitleCase } from '../../utils/isomorphic/stringUtils';
|
||||||
|
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
|
||||||
|
import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
||||||
|
import type { ParsedSelector } from '../isomorphic/selectorParser';
|
||||||
|
|
||||||
|
type Language = 'javascript' | 'python' | 'java' | 'csharp';
|
||||||
|
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text';
|
||||||
|
export type LocatorBase = 'page' | 'locator' | 'frame-locator';
|
||||||
|
|
||||||
|
export interface LocatorFactory {
|
||||||
|
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options?: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean }): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false): string {
|
||||||
|
return innerAsLocator(generators[lang], selector, isFrameLocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
function innerAsLocator(factory: LocatorFactory, selector: string, isFrameLocator: boolean = false): string {
|
||||||
|
const parsed = parseSelector(selector);
|
||||||
|
const tokens: string[] = [];
|
||||||
|
for (const part of parsed.parts) {
|
||||||
|
const base = part === parsed.parts[0] ? (isFrameLocator ? 'frame-locator' : 'page') : 'locator';
|
||||||
|
if (part.name === 'nth') {
|
||||||
|
if (part.body === '0')
|
||||||
|
tokens.push(factory.generateLocator(base, 'first', ''));
|
||||||
|
else if (part.body === '-1')
|
||||||
|
tokens.push(factory.generateLocator(base, 'last', ''));
|
||||||
|
else
|
||||||
|
tokens.push(factory.generateLocator(base, 'nth', part.body as string));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part.name === 'text') {
|
||||||
|
const { exact, text } = detectExact(part.body as string);
|
||||||
|
tokens.push(factory.generateLocator(base, 'text', text, { exact }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part.name === 'internal:label') {
|
||||||
|
const { exact, text } = detectExact(part.body as string);
|
||||||
|
tokens.push(factory.generateLocator(base, 'label', text, { exact }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part.name === 'role') {
|
||||||
|
const attrSelector = parseAttributeSelector(part.body as string, true);
|
||||||
|
const attrs: Record<string, boolean | string> = {};
|
||||||
|
for (const attr of attrSelector.attributes!)
|
||||||
|
attrs[attr.name === 'include-hidden' ? 'includeHidden' : attr.name] = attr.value;
|
||||||
|
tokens.push(factory.generateLocator(base, 'role', attrSelector.name, { attrs }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part.name === 'css') {
|
||||||
|
const parsed = part.body as CSSComplexSelectorList;
|
||||||
|
if (parsed[0].simples.length === 1 && parsed[0].simples[0].selector.functions.length === 1 && parsed[0].simples[0].selector.functions[0].name === 'hasText') {
|
||||||
|
const hasText = parsed[0].simples[0].selector.functions[0].args[0] as string;
|
||||||
|
tokens.push(factory.generateLocator(base, 'has-text', parsed[0].simples[0].selector.css!, { hasText }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.name === 'internal:attr') {
|
||||||
|
const attrSelector = parseAttributeSelector(part.body as string, true);
|
||||||
|
const { name, value } = attrSelector.attributes[0];
|
||||||
|
if (name === 'data-testid') {
|
||||||
|
tokens.push(factory.generateLocator(base, 'test-id', value));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { exact, text } = detectExact(value);
|
||||||
|
if (name === 'placeholder') {
|
||||||
|
tokens.push(factory.generateLocator(base, 'placeholder', text, { exact }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name === 'alt') {
|
||||||
|
tokens.push(factory.generateLocator(base, 'alt', text, { exact }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name === 'title') {
|
||||||
|
tokens.push(factory.generateLocator(base, 'title', text, { exact }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (name === 'label') {
|
||||||
|
tokens.push(factory.generateLocator(base, 'label', text, { exact }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const p: ParsedSelector = { parts: [part] };
|
||||||
|
tokens.push(factory.generateLocator(base, 'default', stringifySelector(p)));
|
||||||
|
}
|
||||||
|
return tokens.join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectExact(text: string): { exact: boolean, text: string } {
|
||||||
|
let exact = false;
|
||||||
|
if (text.startsWith('"') && text.endsWith('"')) {
|
||||||
|
text = JSON.parse(text);
|
||||||
|
exact = true;
|
||||||
|
}
|
||||||
|
return { exact, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JavaScriptLocatorFactory implements LocatorFactory {
|
||||||
|
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'default':
|
||||||
|
return `locator(${this.quote(body)})`;
|
||||||
|
case 'nth':
|
||||||
|
return `nth(${body})`;
|
||||||
|
case 'first':
|
||||||
|
return `first()`;
|
||||||
|
case 'last':
|
||||||
|
return `last()`;
|
||||||
|
case 'role':
|
||||||
|
const attrs: string[] = [];
|
||||||
|
for (const [name, value] of Object.entries(options.attrs!))
|
||||||
|
attrs.push(`${name}: ${typeof value === 'string' ? this.quote(value) : value}`);
|
||||||
|
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
|
||||||
|
return `getByRole(${this.quote(body)}${attrString})`;
|
||||||
|
case 'has-text':
|
||||||
|
return `locator(${this.quote(body)}, { hasText: ${this.quote(options.hasText!)} })`;
|
||||||
|
case 'test-id':
|
||||||
|
return `getByTestId(${this.quote(body)})`;
|
||||||
|
case 'text':
|
||||||
|
return this.toCallWithExact('getByText', body, !!options.exact);
|
||||||
|
case 'alt':
|
||||||
|
return this.toCallWithExact('getByAltText', body, !!options.exact);
|
||||||
|
case 'placeholder':
|
||||||
|
return this.toCallWithExact('getByPlaceholder', body, !!options.exact);
|
||||||
|
case 'label':
|
||||||
|
return this.toCallWithExact('getByLabel', body, !!options.exact);
|
||||||
|
case 'title':
|
||||||
|
return this.toCallWithExact('getByTitle', body, !!options.exact);
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown selector kind ' + kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCallWithExact(method: string, body: string, exact: boolean) {
|
||||||
|
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i')))
|
||||||
|
return `${method}(${body})`;
|
||||||
|
return exact ? `${method}(${this.quote(body)}, { exact: true })` : `${method}(${this.quote(body)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private quote(text: string) {
|
||||||
|
return escapeWithQuotes(text, '\'');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PythonLocatorFactory implements LocatorFactory {
|
||||||
|
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'default':
|
||||||
|
return `locator(${this.quote(body)})`;
|
||||||
|
case 'nth':
|
||||||
|
return `nth(${body})`;
|
||||||
|
case 'first':
|
||||||
|
return `first`;
|
||||||
|
case 'last':
|
||||||
|
return `last`;
|
||||||
|
case 'role':
|
||||||
|
const attrs: string[] = [];
|
||||||
|
for (const [name, value] of Object.entries(options.attrs!))
|
||||||
|
attrs.push(`${toSnakeCase(name)}=${typeof value === 'string' ? this.quote(value) : value}`);
|
||||||
|
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
|
||||||
|
return `get_by_role(${this.quote(body)}${attrString})`;
|
||||||
|
case 'has-text':
|
||||||
|
return `locator(${this.quote(body)}, has_text=${this.quote(options.hasText!)})`;
|
||||||
|
case 'test-id':
|
||||||
|
return `get_by_test_id(${this.quote(body)})`;
|
||||||
|
case 'text':
|
||||||
|
return this.toCallWithExact('get_by_text', body, !!options.exact);
|
||||||
|
case 'alt':
|
||||||
|
return this.toCallWithExact('get_by_alt_text', body, !!options.exact);
|
||||||
|
case 'placeholder':
|
||||||
|
return this.toCallWithExact('get_by_placeholder', body, !!options.exact);
|
||||||
|
case 'label':
|
||||||
|
return this.toCallWithExact('get_by_label', body, !!options.exact);
|
||||||
|
case 'title':
|
||||||
|
return this.toCallWithExact('get_by_title', body, !!options.exact);
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown selector kind ' + kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCallWithExact(method: string, body: string, exact: boolean) {
|
||||||
|
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
|
||||||
|
const regex = body.substring(1, body.lastIndexOf('/'));
|
||||||
|
const suffix = body.endsWith('i') ? ', re.IGNORECASE' : '';
|
||||||
|
return `${method}(re.compile(r${this.quote(regex)}${suffix}))`;
|
||||||
|
}
|
||||||
|
if (exact)
|
||||||
|
return `${method}(${this.quote(body)}, exact=true)`;
|
||||||
|
return `${method}(${this.quote(body)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private quote(text: string) {
|
||||||
|
return escapeWithQuotes(text, '\"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JavaLocatorFactory implements LocatorFactory {
|
||||||
|
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
||||||
|
let clazz: string;
|
||||||
|
switch (base) {
|
||||||
|
case 'page': clazz = 'Page'; break;
|
||||||
|
case 'frame-locator': clazz = 'FrameLocator'; break;
|
||||||
|
case 'locator': clazz = 'Locator'; break;
|
||||||
|
}
|
||||||
|
switch (kind) {
|
||||||
|
case 'default':
|
||||||
|
return `locator(${this.quote(body)})`;
|
||||||
|
case 'nth':
|
||||||
|
return `nth(${body})`;
|
||||||
|
case 'first':
|
||||||
|
return `first()`;
|
||||||
|
case 'last':
|
||||||
|
return `last()`;
|
||||||
|
case 'role':
|
||||||
|
const attrs: string[] = [];
|
||||||
|
for (const [name, value] of Object.entries(options.attrs!))
|
||||||
|
attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? this.quote(value) : value})`);
|
||||||
|
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
|
||||||
|
return `getByRole(${this.quote(body)}${attrString})`;
|
||||||
|
case 'has-text':
|
||||||
|
return `locator(${this.quote(body)}, new ${clazz}.LocatorOptions().setHasText(${this.quote(options.hasText!)}))`;
|
||||||
|
case 'test-id':
|
||||||
|
return `getByTestId(${this.quote(body)})`;
|
||||||
|
case 'text':
|
||||||
|
return this.toCallWithExact(clazz, 'getByText', body, !!options.exact);
|
||||||
|
case 'alt':
|
||||||
|
return this.toCallWithExact(clazz, 'getByAltText', body, !!options.exact);
|
||||||
|
case 'placeholder':
|
||||||
|
return this.toCallWithExact(clazz, 'getByPlaceholder', body, !!options.exact);
|
||||||
|
case 'label':
|
||||||
|
return this.toCallWithExact(clazz, 'getByLabel', body, !!options.exact);
|
||||||
|
case 'title':
|
||||||
|
return this.toCallWithExact(clazz, 'getByTitle', body, !!options.exact);
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown selector kind ' + kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCallWithExact(clazz: string, method: string, body: string, exact: boolean) {
|
||||||
|
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
|
||||||
|
const regex = body.substring(1, body.lastIndexOf('/'));
|
||||||
|
const suffix = body.endsWith('i') ? ', Pattern.CASE_INSENSITIVE' : '';
|
||||||
|
return `${method}(Pattern.compile(${this.quote(regex)}${suffix}))`;
|
||||||
|
}
|
||||||
|
if (exact)
|
||||||
|
return `${method}(${this.quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(exact))`;
|
||||||
|
return `${method}(${this.quote(body)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private quote(text: string) {
|
||||||
|
return escapeWithQuotes(text, '\"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CSharpLocatorFactory implements LocatorFactory {
|
||||||
|
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
||||||
|
switch (kind) {
|
||||||
|
case 'default':
|
||||||
|
return `Locator(${this.quote(body)})`;
|
||||||
|
case 'nth':
|
||||||
|
return `Nth(${body})`;
|
||||||
|
case 'first':
|
||||||
|
return `First`;
|
||||||
|
case 'last':
|
||||||
|
return `Last`;
|
||||||
|
case 'role':
|
||||||
|
const attrs: string[] = [];
|
||||||
|
for (const [name, value] of Object.entries(options.attrs!))
|
||||||
|
attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? this.quote(value) : value}`);
|
||||||
|
const attrString = attrs.length ? `, new () { ${attrs.join(', ')} }` : '';
|
||||||
|
return `GetByRole(${this.quote(body)}${attrString})`;
|
||||||
|
case 'has-text':
|
||||||
|
return `Locator(${this.quote(body)}, new () { HasTextString: ${this.quote(options.hasText!)} })`;
|
||||||
|
case 'test-id':
|
||||||
|
return `GetByTestId(${this.quote(body)})`;
|
||||||
|
case 'text':
|
||||||
|
return this.toCallWithExact('GetByText', body, !!options.exact);
|
||||||
|
case 'alt':
|
||||||
|
return this.toCallWithExact('GetByAltText', body, !!options.exact);
|
||||||
|
case 'placeholder':
|
||||||
|
return this.toCallWithExact('GetByPlaceholder', body, !!options.exact);
|
||||||
|
case 'label':
|
||||||
|
return this.toCallWithExact('GetByLabel', body, !!options.exact);
|
||||||
|
case 'title':
|
||||||
|
return this.toCallWithExact('GetByTitle', body, !!options.exact);
|
||||||
|
default:
|
||||||
|
throw new Error('Unknown selector kind ' + kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCallWithExact(method: string, body: string, exact: boolean) {
|
||||||
|
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
|
||||||
|
const regex = body.substring(1, body.lastIndexOf('/'));
|
||||||
|
const suffix = body.endsWith('i') ? ', RegexOptions.IgnoreCase' : '';
|
||||||
|
return `${method}(new Regex(${this.quote(regex)}${suffix}))`;
|
||||||
|
}
|
||||||
|
if (exact)
|
||||||
|
return `${method}(${this.quote(body)}, new () { Exact: true })`;
|
||||||
|
return `${method}(${this.quote(body)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private quote(text: string) {
|
||||||
|
return escapeWithQuotes(text, '\"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const generators: Record<Language, LocatorFactory> = {
|
||||||
|
javascript: new JavaScriptLocatorFactory(),
|
||||||
|
python: new PythonLocatorFactory(),
|
||||||
|
java: new JavaLocatorFactory(),
|
||||||
|
csharp: new CSharpLocatorFactory(),
|
||||||
|
};
|
||||||
|
|
@ -15,15 +15,15 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../..';
|
import type { BrowserContextOptions } from '../../..';
|
||||||
import { asLocator } from './language';
|
import type { LanguageGenerator, LanguageGeneratorOptions } from './language';
|
||||||
import type { LanguageGenerator, LanguageGeneratorOptions, LocatorBase, LocatorType } from './language';
|
|
||||||
import { sanitizeDeviceOptions, toSignalMap } from './language';
|
import { sanitizeDeviceOptions, toSignalMap } from './language';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext } from './codeGenerator';
|
||||||
import type { Action } from './recorderActions';
|
import type { Action } from './recorderActions';
|
||||||
import type { MouseClickOptions } from './utils';
|
import type { MouseClickOptions } from './utils';
|
||||||
import { toModifiers } from './utils';
|
import { toModifiers } from './utils';
|
||||||
import { escapeWithQuotes, toTitleCase } from '../../utils/isomorphic/stringUtils';
|
import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils';
|
||||||
import deviceDescriptors from '../deviceDescriptors';
|
import deviceDescriptors from '../deviceDescriptors';
|
||||||
|
import { asLocator } from '../isomorphic/locatorGenerators';
|
||||||
|
|
||||||
type CSharpLanguageMode = 'library' | 'mstest' | 'nunit';
|
type CSharpLanguageMode = 'library' | 'mstest' | 'nunit';
|
||||||
|
|
||||||
|
|
@ -165,7 +165,7 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _asLocator(selector: string) {
|
private _asLocator(selector: string) {
|
||||||
return asLocator(this, selector);
|
return asLocator('csharp', selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateHeader(options: LanguageGeneratorOptions): string {
|
generateHeader(options: LanguageGeneratorOptions): string {
|
||||||
|
|
@ -221,52 +221,6 @@ export class CSharpLanguageGenerator implements LanguageGenerator {
|
||||||
return `${storageStateLine} }
|
return `${storageStateLine} }
|
||||||
}\n`;
|
}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
|
||||||
switch (kind) {
|
|
||||||
case 'default':
|
|
||||||
return `Locator(${quote(body)})`;
|
|
||||||
case 'nth':
|
|
||||||
return `Nth(${body})`;
|
|
||||||
case 'first':
|
|
||||||
return `First`;
|
|
||||||
case 'last':
|
|
||||||
return `Last`;
|
|
||||||
case 'role':
|
|
||||||
const attrs: string[] = [];
|
|
||||||
for (const [name, value] of Object.entries(options.attrs!))
|
|
||||||
attrs.push(`${toTitleCase(name)} = ${typeof value === 'string' ? quote(value) : value}`);
|
|
||||||
const attrString = attrs.length ? `, new () { ${attrs.join(', ')} }` : '';
|
|
||||||
return `GetByRole(${quote(body)}${attrString})`;
|
|
||||||
case 'has-text':
|
|
||||||
return `Locator(${quote(body)}, new () { HasTextString: ${quote(options.hasText!)} })`;
|
|
||||||
case 'test-id':
|
|
||||||
return `GetByTestId(${quote(body)})`;
|
|
||||||
case 'text':
|
|
||||||
return toCallWithExact('GetByText', body, !!options.exact);
|
|
||||||
case 'alt':
|
|
||||||
return toCallWithExact('GetByAltText', body, !!options.exact);
|
|
||||||
case 'placeholder':
|
|
||||||
return toCallWithExact('GetByPlaceholder', body, !!options.exact);
|
|
||||||
case 'label':
|
|
||||||
return toCallWithExact('GetByLabel', body, !!options.exact);
|
|
||||||
case 'title':
|
|
||||||
return toCallWithExact('GetByTitle', body, !!options.exact);
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown selector kind ' + kind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCallWithExact(method: string, body: string, exact: boolean) {
|
|
||||||
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
|
|
||||||
const regex = body.substring(1, body.lastIndexOf('/'));
|
|
||||||
const suffix = body.endsWith('i') ? ', RegexOptions.IgnoreCase' : '';
|
|
||||||
return `${method}(new Regex(${quote(regex)}${suffix}))`;
|
|
||||||
}
|
|
||||||
if (exact)
|
|
||||||
return `${method}(${quote(body)}, new () { Exact: true })`;
|
|
||||||
return `${method}(${quote(body)})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatObject(value: any, indent = ' ', name = ''): string {
|
function formatObject(value: any, indent = ' ', name = ''): string {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../..';
|
import type { BrowserContextOptions } from '../../..';
|
||||||
import { asLocator } from './language';
|
import type { LanguageGenerator, LanguageGeneratorOptions } from './language';
|
||||||
import type { LanguageGenerator, LanguageGeneratorOptions, LocatorBase, LocatorType } from './language';
|
|
||||||
import { toSignalMap } from './language';
|
import { toSignalMap } from './language';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext } from './codeGenerator';
|
||||||
import type { Action } from './recorderActions';
|
import type { Action } from './recorderActions';
|
||||||
|
|
@ -24,7 +23,8 @@ import type { MouseClickOptions } from './utils';
|
||||||
import { toModifiers } from './utils';
|
import { toModifiers } from './utils';
|
||||||
import deviceDescriptors from '../deviceDescriptors';
|
import deviceDescriptors from '../deviceDescriptors';
|
||||||
import { JavaScriptFormatter } from './javascript';
|
import { JavaScriptFormatter } from './javascript';
|
||||||
import { escapeWithQuotes, toTitleCase } from '../../utils/isomorphic/stringUtils';
|
import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils';
|
||||||
|
import { asLocator } from '../isomorphic/locatorGenerators';
|
||||||
|
|
||||||
export class JavaLanguageGenerator implements LanguageGenerator {
|
export class JavaLanguageGenerator implements LanguageGenerator {
|
||||||
id = 'java';
|
id = 'java';
|
||||||
|
|
@ -134,7 +134,7 @@ export class JavaLanguageGenerator implements LanguageGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _asLocator(selector: string, inFrameLocator: boolean) {
|
private _asLocator(selector: string, inFrameLocator: boolean) {
|
||||||
return asLocator(this, selector, inFrameLocator);
|
return asLocator('java', selector, inFrameLocator);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateHeader(options: LanguageGeneratorOptions): string {
|
generateHeader(options: LanguageGeneratorOptions): string {
|
||||||
|
|
@ -159,58 +159,6 @@ export class JavaLanguageGenerator implements LanguageGenerator {
|
||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
|
||||||
let clazz: string;
|
|
||||||
switch (base) {
|
|
||||||
case 'page': clazz = 'Page'; break;
|
|
||||||
case 'frame-locator': clazz = 'FrameLocator'; break;
|
|
||||||
case 'locator': clazz = 'Locator'; break;
|
|
||||||
}
|
|
||||||
switch (kind) {
|
|
||||||
case 'default':
|
|
||||||
return `locator(${quote(body)})`;
|
|
||||||
case 'nth':
|
|
||||||
return `nth(${body})`;
|
|
||||||
case 'first':
|
|
||||||
return `first()`;
|
|
||||||
case 'last':
|
|
||||||
return `last()`;
|
|
||||||
case 'role':
|
|
||||||
const attrs: string[] = [];
|
|
||||||
for (const [name, value] of Object.entries(options.attrs!))
|
|
||||||
attrs.push(`.set${toTitleCase(name)}(${typeof value === 'string' ? quote(value) : value})`);
|
|
||||||
const attrString = attrs.length ? `, new ${clazz}.GetByRoleOptions()${attrs.join('')}` : '';
|
|
||||||
return `getByRole(${quote(body)}${attrString})`;
|
|
||||||
case 'has-text':
|
|
||||||
return `locator(${quote(body)}, new ${clazz}.LocatorOptions().setHasText(${quote(options.hasText!)}))`;
|
|
||||||
case 'test-id':
|
|
||||||
return `getByTestId(${quote(body)})`;
|
|
||||||
case 'text':
|
|
||||||
return toCallWithExact(clazz, 'getByText', body, !!options.exact);
|
|
||||||
case 'alt':
|
|
||||||
return toCallWithExact(clazz, 'getByAltText', body, !!options.exact);
|
|
||||||
case 'placeholder':
|
|
||||||
return toCallWithExact(clazz, 'getByPlaceholder', body, !!options.exact);
|
|
||||||
case 'label':
|
|
||||||
return toCallWithExact(clazz, 'getByLabel', body, !!options.exact);
|
|
||||||
case 'title':
|
|
||||||
return toCallWithExact(clazz, 'getByTitle', body, !!options.exact);
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown selector kind ' + kind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCallWithExact(clazz: string, method: string, body: string, exact: boolean) {
|
|
||||||
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
|
|
||||||
const regex = body.substring(1, body.lastIndexOf('/'));
|
|
||||||
const suffix = body.endsWith('i') ? ', Pattern.CASE_INSENSITIVE' : '';
|
|
||||||
return `${method}(Pattern.compile(${quote(regex)}${suffix}))`;
|
|
||||||
}
|
|
||||||
if (exact)
|
|
||||||
return `${method}(${quote(body)}, new ${clazz}.${toTitleCase(method)}Options().setExact(exact))`;
|
|
||||||
return `${method}(${quote(body)})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPath(files: string | string[]): string {
|
function formatPath(files: string | string[]): string {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../..';
|
import type { BrowserContextOptions } from '../../..';
|
||||||
import { asLocator } from './language';
|
import type { LanguageGenerator, LanguageGeneratorOptions } from './language';
|
||||||
import type { LanguageGenerator, LanguageGeneratorOptions, LocatorBase, LocatorType } from './language';
|
|
||||||
import { sanitizeDeviceOptions, toSignalMap } from './language';
|
import { sanitizeDeviceOptions, toSignalMap } from './language';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext } from './codeGenerator';
|
||||||
import type { Action } from './recorderActions';
|
import type { Action } from './recorderActions';
|
||||||
|
|
@ -24,6 +23,7 @@ import type { MouseClickOptions } from './utils';
|
||||||
import { toModifiers } from './utils';
|
import { toModifiers } from './utils';
|
||||||
import deviceDescriptors from '../deviceDescriptors';
|
import deviceDescriptors from '../deviceDescriptors';
|
||||||
import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils';
|
import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils';
|
||||||
|
import { asLocator } from '../isomorphic/locatorGenerators';
|
||||||
|
|
||||||
export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -155,7 +155,7 @@ export class JavaScriptLanguageGenerator implements LanguageGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _asLocator(selector: string) {
|
private _asLocator(selector: string) {
|
||||||
return asLocator(this, selector);
|
return asLocator('javascript', selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateHeader(options: LanguageGeneratorOptions): string {
|
generateHeader(options: LanguageGeneratorOptions): string {
|
||||||
|
|
@ -202,47 +202,6 @@ ${useText ? '\ntest.use(' + useText + ');\n' : ''}
|
||||||
await browser.close();
|
await browser.close();
|
||||||
})();`;
|
})();`;
|
||||||
}
|
}
|
||||||
|
|
||||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
|
||||||
switch (kind) {
|
|
||||||
case 'default':
|
|
||||||
return `locator(${quote(body)})`;
|
|
||||||
case 'nth':
|
|
||||||
return `nth(${body})`;
|
|
||||||
case 'first':
|
|
||||||
return `first()`;
|
|
||||||
case 'last':
|
|
||||||
return `last()`;
|
|
||||||
case 'role':
|
|
||||||
const attrs: string[] = [];
|
|
||||||
for (const [name, value] of Object.entries(options.attrs!))
|
|
||||||
attrs.push(`${name}: ${typeof value === 'string' ? quote(value) : value}`);
|
|
||||||
const attrString = attrs.length ? `, { ${attrs.join(', ')} }` : '';
|
|
||||||
return `getByRole(${quote(body)}${attrString})`;
|
|
||||||
case 'has-text':
|
|
||||||
return `locator(${quote(body)}, { hasText: ${quote(options.hasText!)} })`;
|
|
||||||
case 'test-id':
|
|
||||||
return `getByTestId(${quote(body)})`;
|
|
||||||
case 'text':
|
|
||||||
return toCallWithExact('getByText', body, !!options.exact);
|
|
||||||
case 'alt':
|
|
||||||
return toCallWithExact('getByAltText', body, !!options.exact);
|
|
||||||
case 'placeholder':
|
|
||||||
return toCallWithExact('getByPlaceholder', body, !!options.exact);
|
|
||||||
case 'label':
|
|
||||||
return toCallWithExact('getByLabel', body, !!options.exact);
|
|
||||||
case 'title':
|
|
||||||
return toCallWithExact('getByTitle', body, !!options.exact);
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown selector kind ' + kind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCallWithExact(method: string, body: string, exact: boolean) {
|
|
||||||
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i')))
|
|
||||||
return `${method}(${body})`;
|
|
||||||
return exact ? `${method}(${quote(body)}, { exact: true })` : `${method}(${quote(body)})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatOptions(value: any, hasArguments: boolean): string {
|
function formatOptions(value: any, hasArguments: boolean): string {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions, LaunchOptions } from '../../..';
|
import type { BrowserContextOptions, LaunchOptions } from '../../..';
|
||||||
import type { CSSComplexSelectorList } from '../isomorphic/cssParser';
|
|
||||||
import { parseAttributeSelector, parseSelector, stringifySelector } from '../isomorphic/selectorParser';
|
|
||||||
import type { ParsedSelector } from '../isomorphic/selectorParser';
|
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext } from './codeGenerator';
|
||||||
import type { Action, DialogSignal, DownloadSignal, NavigationSignal, PopupSignal } from './recorderActions';
|
import type { Action, DialogSignal, DownloadSignal, NavigationSignal, PopupSignal } from './recorderActions';
|
||||||
|
|
||||||
|
|
@ -40,7 +37,6 @@ export interface LanguageGenerator {
|
||||||
generateHeader(options: LanguageGeneratorOptions): string;
|
generateHeader(options: LanguageGeneratorOptions): string;
|
||||||
generateAction(actionInContext: ActionInContext): string;
|
generateAction(actionInContext: ActionInContext): string;
|
||||||
generateFooter(saveStorage: string | undefined): string;
|
generateFooter(saveStorage: string | undefined): string;
|
||||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options?: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean }): string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions {
|
export function sanitizeDeviceOptions(device: any, options: BrowserContextOptions): BrowserContextOptions {
|
||||||
|
|
@ -75,85 +71,3 @@ export function toSignalMap(action: Action) {
|
||||||
dialog,
|
dialog,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function detectExact(text: string): { exact: boolean, text: string } {
|
|
||||||
let exact = false;
|
|
||||||
if (text.startsWith('"') && text.endsWith('"')) {
|
|
||||||
text = JSON.parse(text);
|
|
||||||
exact = true;
|
|
||||||
}
|
|
||||||
return { exact, text };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asLocator(generator: LanguageGenerator, selector: string, isFrameLocator: boolean = false): string {
|
|
||||||
const parsed = parseSelector(selector);
|
|
||||||
const tokens: string[] = [];
|
|
||||||
for (const part of parsed.parts) {
|
|
||||||
const base = part === parsed.parts[0] ? (isFrameLocator ? 'frame-locator' : 'page') : 'locator';
|
|
||||||
if (part.name === 'nth') {
|
|
||||||
if (part.body === '0')
|
|
||||||
tokens.push(generator.generateLocator(base, 'first', ''));
|
|
||||||
else if (part.body === '-1')
|
|
||||||
tokens.push(generator.generateLocator(base, 'last', ''));
|
|
||||||
else
|
|
||||||
tokens.push(generator.generateLocator(base, 'nth', part.body as string));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (part.name === 'text') {
|
|
||||||
const { exact, text } = detectExact(part.body as string);
|
|
||||||
tokens.push(generator.generateLocator(base, 'text', text, { exact }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (part.name === 'internal:label') {
|
|
||||||
const { exact, text } = detectExact(part.body as string);
|
|
||||||
tokens.push(generator.generateLocator(base, 'label', text, { exact }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (part.name === 'role') {
|
|
||||||
const attrSelector = parseAttributeSelector(part.body as string, true);
|
|
||||||
const attrs: Record<string, boolean | string> = {};
|
|
||||||
for (const attr of attrSelector.attributes!)
|
|
||||||
attrs[attr.name === 'include-hidden' ? 'includeHidden' : attr.name] = attr.value;
|
|
||||||
tokens.push(generator.generateLocator(base, 'role', attrSelector.name, { attrs }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (part.name === 'css') {
|
|
||||||
const parsed = part.body as CSSComplexSelectorList;
|
|
||||||
if (parsed[0].simples.length === 1 && parsed[0].simples[0].selector.functions.length === 1 && parsed[0].simples[0].selector.functions[0].name === 'hasText') {
|
|
||||||
const hasText = parsed[0].simples[0].selector.functions[0].args[0] as string;
|
|
||||||
tokens.push(generator.generateLocator(base, 'has-text', parsed[0].simples[0].selector.css!, { hasText }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (part.name === 'internal:attr') {
|
|
||||||
const attrSelector = parseAttributeSelector(part.body as string, true);
|
|
||||||
const { name, value } = attrSelector.attributes[0];
|
|
||||||
if (name === 'data-testid') {
|
|
||||||
tokens.push(generator.generateLocator(base, 'test-id', value));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { exact, text } = detectExact(value);
|
|
||||||
if (name === 'placeholder') {
|
|
||||||
tokens.push(generator.generateLocator(base, 'placeholder', text, { exact }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (name === 'alt') {
|
|
||||||
tokens.push(generator.generateLocator(base, 'alt', text, { exact }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (name === 'title') {
|
|
||||||
tokens.push(generator.generateLocator(base, 'title', text, { exact }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (name === 'label') {
|
|
||||||
tokens.push(generator.generateLocator(base, 'label', text, { exact }));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const p: ParsedSelector = { parts: [part] };
|
|
||||||
tokens.push(generator.generateLocator(base, 'default', stringifySelector(p)));
|
|
||||||
}
|
|
||||||
return tokens.join('.');
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -15,15 +15,15 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { BrowserContextOptions } from '../../..';
|
import type { BrowserContextOptions } from '../../..';
|
||||||
import { asLocator } from './language';
|
import type { LanguageGenerator, LanguageGeneratorOptions } from './language';
|
||||||
import type { LanguageGenerator, LanguageGeneratorOptions, LocatorBase, LocatorType } from './language';
|
|
||||||
import { sanitizeDeviceOptions, toSignalMap } from './language';
|
import { sanitizeDeviceOptions, toSignalMap } from './language';
|
||||||
import type { ActionInContext } from './codeGenerator';
|
import type { ActionInContext } from './codeGenerator';
|
||||||
import type { Action } from './recorderActions';
|
import type { Action } from './recorderActions';
|
||||||
import type { MouseClickOptions } from './utils';
|
import type { MouseClickOptions } from './utils';
|
||||||
import { toModifiers } from './utils';
|
import { toModifiers } from './utils';
|
||||||
import { escapeWithQuotes } from '../../utils/isomorphic/stringUtils';
|
import { escapeWithQuotes, toSnakeCase } from '../../utils/isomorphic/stringUtils';
|
||||||
import deviceDescriptors from '../deviceDescriptors';
|
import deviceDescriptors from '../deviceDescriptors';
|
||||||
|
import { asLocator } from '../isomorphic/locatorGenerators';
|
||||||
|
|
||||||
export class PythonLanguageGenerator implements LanguageGenerator {
|
export class PythonLanguageGenerator implements LanguageGenerator {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -150,7 +150,7 @@ export class PythonLanguageGenerator implements LanguageGenerator {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _asLocator(selector: string) {
|
private _asLocator(selector: string) {
|
||||||
return asLocator(this, selector);
|
return asLocator('python', selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
generateHeader(options: LanguageGeneratorOptions): string {
|
generateHeader(options: LanguageGeneratorOptions): string {
|
||||||
|
|
@ -220,52 +220,6 @@ with sync_playwright() as playwright:
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
generateLocator(base: LocatorBase, kind: LocatorType, body: string, options: { attrs?: Record<string, string | boolean>, hasText?: string, exact?: boolean } = {}): string {
|
|
||||||
switch (kind) {
|
|
||||||
case 'default':
|
|
||||||
return `locator(${quote(body)})`;
|
|
||||||
case 'nth':
|
|
||||||
return `nth(${body})`;
|
|
||||||
case 'first':
|
|
||||||
return `first`;
|
|
||||||
case 'last':
|
|
||||||
return `last`;
|
|
||||||
case 'role':
|
|
||||||
const attrs: string[] = [];
|
|
||||||
for (const [name, value] of Object.entries(options.attrs!))
|
|
||||||
attrs.push(`${toSnakeCase(name)}=${typeof value === 'string' ? quote(value) : value}`);
|
|
||||||
const attrString = attrs.length ? `, ${attrs.join(', ')}` : '';
|
|
||||||
return `get_by_role(${quote(body)}${attrString})`;
|
|
||||||
case 'has-text':
|
|
||||||
return `locator(${quote(body)}, has_text=${quote(options.hasText!)})`;
|
|
||||||
case 'test-id':
|
|
||||||
return `get_by_test_id(${quote(body)})`;
|
|
||||||
case 'text':
|
|
||||||
return toCallWithExact('get_by_text', body, !!options.exact);
|
|
||||||
case 'alt':
|
|
||||||
return toCallWithExact('get_by_alt_text', body, !!options.exact);
|
|
||||||
case 'placeholder':
|
|
||||||
return toCallWithExact('get_by_placeholder', body, !!options.exact);
|
|
||||||
case 'label':
|
|
||||||
return toCallWithExact('get_by_label', body, !!options.exact);
|
|
||||||
case 'title':
|
|
||||||
return toCallWithExact('get_by_title', body, !!options.exact);
|
|
||||||
default:
|
|
||||||
throw new Error('Unknown selector kind ' + kind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCallWithExact(method: string, body: string, exact: boolean) {
|
|
||||||
if (body.startsWith('/') && (body.endsWith('/') || body.endsWith('/i'))) {
|
|
||||||
const regex = body.substring(1, body.lastIndexOf('/'));
|
|
||||||
const suffix = body.endsWith('i') ? ', re.IGNORECASE' : '';
|
|
||||||
return `${method}(re.compile(r${quote(regex)}${suffix}))`;
|
|
||||||
}
|
|
||||||
if (exact)
|
|
||||||
return `${method}(${quote(body)}, exact=true)`;
|
|
||||||
return `${method}(${quote(body)})`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatValue(value: any): string {
|
function formatValue(value: any): string {
|
||||||
|
|
@ -284,11 +238,6 @@ function formatValue(value: any): string {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSnakeCase(name: string): string {
|
|
||||||
const toSnakeCaseRegex = /((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))/g;
|
|
||||||
return name.replace(toSnakeCaseRegex, `_$1`).toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatOptions(value: any, hasArguments: boolean, asDict?: boolean): string {
|
function formatOptions(value: any, hasArguments: boolean, asDict?: boolean): string {
|
||||||
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
|
const keys = Object.keys(value).filter(key => value[key] !== undefined).sort();
|
||||||
if (!keys.length)
|
if (!keys.length)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,11 @@ export function toTitleCase(name: string) {
|
||||||
return name.charAt(0).toUpperCase() + name.substring(1);
|
return name.charAt(0).toUpperCase() + name.substring(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toSnakeCase(name: string): string {
|
||||||
|
const toSnakeCaseRegex = /((?<=[a-z0-9])[A-Z]|(?!^)[A-Z](?=[a-z]))/g;
|
||||||
|
return name.replace(toSnakeCaseRegex, `_$1`).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function cssEscape(s: string): string {
|
export function cssEscape(s: string): string {
|
||||||
let result = '';
|
let result = '';
|
||||||
for (let i = 0; i < s.length; i++)
|
for (let i = 0; i < s.length; i++)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue