diff --git a/packages/html-reporter/src/headerView.spec.tsx b/packages/html-reporter/src/headerView.spec.tsx
index 5c05ec1e29..707ba73cc1 100644
--- a/packages/html-reporter/src/headerView.spec.tsx
+++ b/packages/html-reporter/src/headerView.spec.tsx
@@ -28,7 +28,7 @@ test('should render counters', async ({ mount }) => {
skipped: 10,
ok: false,
duration: 100000
- }} filterText='' setFilterText={() => {}} projectNames={[]}>);
+ }} filterText='' setFilterText={() => {}}>);
await expect(component.locator('a', { hasText: 'All' }).locator('.counter')).toHaveText('100');
await expect(component.locator('a', { hasText: 'Passed' }).locator('.counter')).toHaveText('42');
await expect(component.locator('a', { hasText: 'Failed' }).locator('.counter')).toHaveText('31');
diff --git a/packages/playwright-core/src/server/recorder.ts b/packages/playwright-core/src/server/recorder.ts
index cc9227241a..d2e53e9d62 100644
--- a/packages/playwright-core/src/server/recorder.ts
+++ b/packages/playwright-core/src/server/recorder.ts
@@ -25,6 +25,7 @@ import { Frame } from './frames';
import { BrowserContext } from './browserContext';
import { JavaLanguageGenerator } from './recorder/java';
import { JavaScriptLanguageGenerator } from './recorder/javascript';
+import { JsonlLanguageGenerator } from './recorder/jsonl';
import { CSharpLanguageGenerator } from './recorder/csharp';
import { PythonLanguageGenerator } from './recorder/python';
import * as recorderSource from '../generated/recorderSource';
@@ -406,6 +407,7 @@ class ContextRecorder extends EventEmitter {
new CSharpLanguageGenerator('mstest'),
new CSharpLanguageGenerator('nunit'),
new CSharpLanguageGenerator('library'),
+ new JsonlLanguageGenerator(),
]);
const primaryLanguage = [...languages].find(l => l.id === codegenId);
if (!primaryLanguage)
diff --git a/packages/playwright-core/src/server/recorder/jsonl.ts b/packages/playwright-core/src/server/recorder/jsonl.ts
new file mode 100644
index 0000000000..636aaae70f
--- /dev/null
+++ b/packages/playwright-core/src/server/recorder/jsonl.ts
@@ -0,0 +1,44 @@
+/**
+ * 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 { asLocator } from '../../utils/isomorphic/locatorGenerators';
+import type { ActionInContext } from './codeGenerator';
+import type { Language, LanguageGenerator, LanguageGeneratorOptions } from './language';
+
+export class JsonlLanguageGenerator implements LanguageGenerator {
+ id = 'jsonl';
+ groupName = '';
+ name = 'JSONL';
+ highlighter = 'javascript' as Language;
+
+ generateAction(actionInContext: ActionInContext): string {
+ const locator = (actionInContext.action as any).selector ? JSON.parse(asLocator('jsonl', (actionInContext.action as any).selector)) : undefined;
+ const entry = {
+ ...actionInContext.action,
+ pageAlias: actionInContext.frame.pageAlias,
+ locator,
+ };
+ return JSON.stringify(entry);
+ }
+
+ generateHeader(options: LanguageGeneratorOptions): string {
+ return JSON.stringify(options);
+ }
+
+ generateFooter(saveStorage: string | undefined): string {
+ return '';
+ }
+}
diff --git a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts
index 6f0920299e..50d0595f3d 100644
--- a/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts
+++ b/packages/playwright-core/src/utils/isomorphic/locatorGenerators.ts
@@ -18,7 +18,7 @@ import { escapeWithQuotes, toSnakeCase, toTitleCase } from './stringUtils';
import { type NestedSelectorBody, parseAttributeSelector, parseSelector, stringifySelector } from './selectorParser';
import type { ParsedSelector } from './selectorParser';
-export type Language = 'javascript' | 'python' | 'java' | 'csharp';
+export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
export type LocatorType = 'default' | 'role' | 'text' | 'label' | 'placeholder' | 'alt' | 'title' | 'test-id' | 'nth' | 'first' | 'last' | 'has-text' | 'has-not-text' | 'has' | 'hasNot' | 'frame' | 'and' | 'or';
export type LocatorBase = 'page' | 'locator' | 'frame-locator';
@@ -31,6 +31,7 @@ type LocatorOptions = {
};
export interface LocatorFactory {
generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options?: LocatorOptions): string;
+ chainLocators(locators: string[]): string;
}
export function asLocator(lang: Language, selector: string, isFrameLocator: boolean = false, playSafe: boolean = false): string {
@@ -191,7 +192,7 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram
// Two options:
// - locator('div').filter({ hasText: 'foo' })
// - locator('div', { hasText: 'foo' })
- tokens.push([locatorPart + '.' + nextLocatorPart, combinedPart]);
+ tokens.push([factory.chainLocators([locatorPart, nextLocatorPart]), combinedPart]);
index++;
continue;
}
@@ -200,16 +201,16 @@ function innerAsLocators(factory: LocatorFactory, parsed: ParsedSelector, isFram
tokens.push([locatorPart]);
}
- return combineTokens(tokens, maxOutputSize);
+ return combineTokens(factory, tokens, maxOutputSize);
}
-function combineTokens(tokens: string[][], maxOutputSize: number): string[] {
+function combineTokens(factory: LocatorFactory, tokens: string[][], maxOutputSize: number): string[] {
const currentTokens = tokens.map(() => '');
const result: string[] = [];
const visit = (index: number) => {
if (index === tokens.length) {
- result.push(currentTokens.join('.'));
+ result.push(factory.chainLocators(currentTokens));
return currentTokens.length < maxOutputSize;
}
for (const taken of tokens[index]) {
@@ -301,6 +302,10 @@ export class JavaScriptLocatorFactory implements LocatorFactory {
}
}
+ chainLocators(locators: string[]): string {
+ return locators.join('.');
+ }
+
private toCallWithExact(method: string, body: string | RegExp, exact?: boolean) {
if (isRegExp(body))
return `${method}(${body})`;
@@ -381,6 +386,10 @@ export class PythonLocatorFactory implements LocatorFactory {
}
}
+ chainLocators(locators: string[]): string {
+ return locators.join('.');
+ }
+
private regexToString(body: RegExp) {
const suffix = body.flags.includes('i') ? ', re.IGNORECASE' : '';
return `re.compile(r"${body.source.replace(/\\\//, '/').replace(/"/g, '\\"')}"${suffix})`;
@@ -470,6 +479,10 @@ export class JavaLocatorFactory implements LocatorFactory {
}
}
+ chainLocators(locators: string[]): string {
+ return locators.join('.');
+ }
+
private regexToString(body: RegExp) {
const suffix = body.flags.includes('i') ? ', Pattern.CASE_INSENSITIVE' : '';
return `Pattern.compile(${this.quote(body.source)}${suffix})`;
@@ -553,6 +566,10 @@ export class CSharpLocatorFactory implements LocatorFactory {
}
}
+ chainLocators(locators: string[]): string {
+ return locators.join('.');
+ }
+
private regexToString(body: RegExp): string {
const suffix = body.flags.includes('i') ? ', RegexOptions.IgnoreCase' : '';
return `new Regex(${this.quote(body.source)}${suffix})`;
@@ -583,11 +600,29 @@ export class CSharpLocatorFactory implements LocatorFactory {
}
}
+export class JsonlLocatorFactory implements LocatorFactory {
+ generateLocator(base: LocatorBase, kind: LocatorType, body: string | RegExp, options: LocatorOptions = {}): string {
+ return JSON.stringify({
+ kind,
+ body,
+ options,
+ });
+ }
+
+ chainLocators(locators: string[]): string {
+ const objects = locators.map(l => JSON.parse(l));
+ for (let i = 0; i < objects.length - 1; ++i)
+ objects[i].next = objects[i + 1];
+ return JSON.stringify(objects[0]);
+ }
+}
+
const generators: Record = {
javascript: new JavaScriptLocatorFactory(),
python: new PythonLocatorFactory(),
java: new JavaLocatorFactory(),
csharp: new CSharpLocatorFactory(),
+ jsonl: new JsonlLocatorFactory(),
};
function isRegExp(obj: any): obj is RegExp {
diff --git a/packages/recorder/src/recorder.tsx b/packages/recorder/src/recorder.tsx
index 055073bb39..ba6d217ff9 100644
--- a/packages/recorder/src/recorder.tsx
+++ b/packages/recorder/src/recorder.tsx
@@ -165,7 +165,7 @@ function renderSourceOptions(sources: Source[]): React.ReactNode {
const hasGroup = sources.some(s => s.group);
if (hasGroup) {
const groups = new Set(sources.map(s => s.group));
- return Array.from(groups).map(group => (
+ return [...groups].filter(Boolean).map(group => (
diff --git a/packages/recorder/src/recorderTypes.ts b/packages/recorder/src/recorderTypes.ts
index 302164321c..4820a8abee 100644
--- a/packages/recorder/src/recorderTypes.ts
+++ b/packages/recorder/src/recorderTypes.ts
@@ -29,7 +29,7 @@ export type UIState = {
mode: Mode;
actionPoint?: Point;
actionSelector?: string;
- language: 'javascript' | 'python' | 'java' | 'csharp';
+ language: 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
testIdAttributeName: string;
};
diff --git a/packages/web/src/components/codeMirrorWrapper.tsx b/packages/web/src/components/codeMirrorWrapper.tsx
index f072530f48..c8c96b2fd5 100644
--- a/packages/web/src/components/codeMirrorWrapper.tsx
+++ b/packages/web/src/components/codeMirrorWrapper.tsx
@@ -26,7 +26,7 @@ export type SourceHighlight = {
message?: string;
};
-export type Language = 'javascript' | 'python' | 'java' | 'csharp';
+export type Language = 'javascript' | 'python' | 'java' | 'csharp' | 'jsonl';
export interface SourceProps {
text: string;
diff --git a/tests/library/inspector/inspectorTest.ts b/tests/library/inspector/inspectorTest.ts
index 04111a39d6..2b4b790c1d 100644
--- a/tests/library/inspector/inspectorTest.ts
+++ b/tests/library/inspector/inspectorTest.ts
@@ -32,6 +32,7 @@ type CLITestArgs = {
};
const codegenLang2Id: Map = new Map([
+ ['JSON', 'jsonl'],
['JavaScript', 'javascript'],
['Java', 'java'],
['Python', 'python'],