From 28f3fe8e48625285af4f1166519ce849996b4faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?An=C5=BEe=20Vodovnik?= Date: Fri, 26 Feb 2021 18:04:03 +0100 Subject: [PATCH] chore(dotnet): generate dotnet API from Markdown (#5089) Introduces the generator for the .NET API surface to be used by the .NET language port to ensure greater consistency with other language ports. --- docs/src/api/class-browsercontext.md | 2 + utils/doclint/generateDotnetApi.js | 685 +++++++++++++++++++++++++++ utils/doclint/templates/interface.cs | 49 ++ 3 files changed, 736 insertions(+) create mode 100644 utils/doclint/generateDotnetApi.js create mode 100644 utils/doclint/templates/interface.cs diff --git a/docs/src/api/class-browsercontext.md b/docs/src/api/class-browsercontext.md index 271de36735..e1928deb50 100644 --- a/docs/src/api/class-browsercontext.md +++ b/docs/src/api/class-browsercontext.md @@ -249,6 +249,8 @@ The default browser context cannot be closed. ::: ## async method: BrowserContext.cookies +* langs: + - alias-csharp: GetCookiesAsync - returns: <[Array]<[Object]>> - `name` <[string]> - `value` <[string]> diff --git a/utils/doclint/generateDotnetApi.js b/utils/doclint/generateDotnetApi.js new file mode 100644 index 0000000000..0afedd8e76 --- /dev/null +++ b/utils/doclint/generateDotnetApi.js @@ -0,0 +1,685 @@ +/** + * 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. + */ + +// @ts-check + +const path = require('path'); +const Documentation = require('./documentation'); +const XmlDoc = require('./xmlDocumentation') +const PROJECT_DIR = path.join(__dirname, '..', '..'); +const fs = require('fs'); +const { parseApi } = require('./api_parser'); +const { Type } = require('./documentation'); +const { args } = require('commander'); +const { EOL } = require('os'); + +const maxDocumentationColumnWidth = 80; + +/** @type {Map} */ +const additionalTypes = new Map(); // this will hold types that we discover, because of .NET specifics, like results +/** @type {Map} */ +const enumTypes = new Map(); + +let documentation; +/** @type {Map} */ +let classNameMap; + +{ + const typesDir = process.argv[2] || '../generate_types/csharp/'; + let checkAndMakeDir = (path) => { + if (!fs.existsSync(path)) + fs.mkdirSync(path, { recursive: true }); + }; + + const modelsDir = path.join(typesDir, "models"); + const enumsDir = path.join(typesDir, "enums"); + + checkAndMakeDir(typesDir); + checkAndMakeDir(modelsDir); + checkAndMakeDir(enumsDir); + + documentation = parseApi(path.join(PROJECT_DIR, 'docs', 'src', 'api')); + documentation.filterForLanguage('csharp'); + + documentation.setLinkRenderer(item => { + if (item.clazz) + return ``; + else if (item.member) + return ``; + else if (item.option) + return ``; + else if (item.param) + return ``; + else + throw new Error('Unknown link format.'); + }); + + // get the template for a class + const template = fs.readFileSync("./templates/interface.cs", 'utf-8') + .replace('[PW_TOOL_VERSION]', `${__filename.substring(path.join(__dirname, '..', '..').length).split(path.sep).join(path.posix.sep)}`); + + // we have some "predefined" types, like the mixed state enum, that we can map in advance + enumTypes.set("MixedState", ["On", "Off", "Mixed"]); + + // map the name to a C# friendly one (we prepend an I to denote an interface) + classNameMap = new Map(documentation.classesArray.map(x => [x.name, translateMemberName('interface', x.name, null)])); + + // map some types that we know of + classNameMap.set('Error', 'Exception'); + classNameMap.set('TimeoutError', 'TimeoutException'); + classNameMap.set('EvaluationArgument', 'object'); + classNameMap.set('boolean', 'bool'); + classNameMap.set('Serializable', 'T'); + classNameMap.set('any', 'object'); + classNameMap.set('Buffer', 'byte[]'); + classNameMap.set('path', 'string'); + classNameMap.set('URL', 'string'); + classNameMap.set('RegExp', 'Regex'); + + // this are types that we don't explicility render even if we get the specs + const ignoredTypes = ['TimeoutException']; + + let writeFile = (name, out, folder) => { + let content = template.replace('[CONTENT]', out.join(`${EOL}\t`)); + fs.writeFileSync(`${path.join(folder, name)}.cs`, content); + } + + /** + * + * @param {string} kind + * @param {string} name + * @param {Documentation.MarkdownNode[]} spec + * @param {function(string[]): void} callback + * @param {string} folder + * @param {string} extendsName + */ + let innerRenderElement = (kind, name, spec, callback, folder = typesDir, extendsName = null) => { + const out = []; + console.log(`Generating ${name}`); + + if (spec) + out.push(...XmlDoc.renderXmlDoc(spec, maxDocumentationColumnWidth)); + + if (extendsName === 'IEventEmitter') + extendsName = null; + + out.push(`public ${kind} ${name}${extendsName ? ` : ${extendsName}` : ''}`); + out.push('{'); + + callback(out); + + // we want to separate the items with a space and this is nicer, than holding + // an index in each iterator down the line + const lastLine = out.pop(); + if (lastLine !== '') + out.push(lastLine); + + out.push('}'); + + writeFile(name, out, folder); + }; + + for (const element of documentation.classesArray) { + const name = classNameMap.get(element.name); + if (ignoredTypes.includes(name)) + continue; + + innerRenderElement('partial interface', name, element.spec, (out) => { + for (const member of element.membersArray) { + renderMember(member, element, out); + } + }, typesDir, translateMemberName('interface', element.extends, null)); + } + + additionalTypes.forEach((type, name) => + innerRenderElement('class', name, null, (out) => { + // TODO: consider how this could be merged with the `translateType` check + if (type.union + && type.union[0].name === 'null' + && type.union.length == 2) { + type = type.union[1]; + } + + if (type.name === 'Array') { + throw new Error('Array at this stage is unexpected.'); + } else if (type.properties) { + for (const member of type.properties) { + let fakeType = new Type(name, null); + renderMember(member, fakeType, out); + } + } else { + console.log(type); + throw new Error(`Not sure what to do in this case.`); + } + }, modelsDir)); + + enumTypes.forEach((values, name) => + innerRenderElement('enum', name, null, (out) => { + out.push('\tUndefined = 0,'); + values.forEach((v, i) => { + // strip out the quotes + v = v.replace(/[\"]/g, ``) + let escapedName = v.replace(/[-]/g, ' ') + .split(' ') + .map(word => word[0].toUpperCase() + word.substring(1)).join(''); + + out.push(`\t[EnumMember(Value = "${v}")]`); + out.push(`\t${escapedName},`); + }); + }, enumsDir)); +} + +/** + * @param {string} memberKind + * @param {string} name + * @param {Documentation.Member} member + */ +function translateMemberName(memberKind, name, member = null) { + if (!name) return name; + + // we strip it for special chars, like @ because we might get called back with it in some special cases + // like, when generating classes inside methods for params + name = name.replace(/[@-]/g, ''); + + // we sanitize some common abbreviations to ensure consistency + name = name.replace(/(HTTP[S]?)/g, (m, g) => { + return g[0].toUpperCase() + g.substring(1).toLowerCase(); + }); + + if (memberKind === 'argument') { + if (['params', 'event'].includes(name)) { // just in case we want to add others + return `@${name}`; + } else { + return name; + } + } + // check if there's an alias in the docs, in which case + // we return that, otherwise, we apply our dotnet magic to it + if (member) { + if (member.alias !== name) { + return member.alias; + } + } + + let assumedName = name.charAt(0).toUpperCase() + name.substring(1); + + switch (memberKind) { + case "interface": + // apply name mapping if the map exists + let mappedName = classNameMap ? classNameMap.get(assumedName) : null; + if (mappedName) + return mappedName; + return `I${assumedName}`; + case "method": + if (member && member.async) + return `${assumedName}Async`; + return assumedName; + case "event": + return `${assumedName}`; + case "enum": + return `${assumedName}`; + default: + return `${assumedName}`; + } +} + +/** + * + * @param {Documentation.Member} member + * @param {Documentation.Class|Documentation.Type} parent + * @param {string[]} out + */ +function renderMember(member, parent, out) { + let output = line => { + if (typeof (line) === 'string') + out.push(`\t${line}`); + else + out.push(...line.map(x => `\t${x}`)); + } + + let name = translateMemberName(member.kind, member.name, member); + if (member.kind === 'method') { + renderMethod(member, parent, output, name); + } else { + let type = translateType(member.type, parent, t => generateNameDefault(member, name, t, parent)); + if (member.kind === 'event') { + if (!member.type) + throw new Error(`No Event Type for ${name} in ${parent.name}`); + if (member.spec) + output(XmlDoc.renderXmlDoc(member.spec, maxDocumentationColumnWidth)/*.map(x => `\t${x}`)*/); + if (parent && (classNameMap.get(parent.name) === type)) + output(`event EventHandler ${name};`); // event sender will be the type, so we're fine to ignore + else + output(`event EventHandler<${type}> ${name};`); + } else if (member.kind === 'property') { + if (member.spec) + output(XmlDoc.renderXmlDoc(member.spec, maxDocumentationColumnWidth)/*.map(x => `\t${x}`)*/); + output(`${type} ${name} { get; set; }`); + } else { + throw new Error(`Problem rendering a member: ${type} - ${name} (${member.kind})`); + } + } + + // we're separating each entry and removing the final blank line when rendering + out.push(''); +} + +/** + * + * @param {Documentation.Member} member + * @param {string} name + * @param {Documentation.Type} t + * @param {*} parent + */ +function generateNameDefault(member, name, t, parent) { + if (!t.properties + && !t.templates + && !t.union + && t.expression === '[Object]') + return 'object'; + + // we'd get this call for enums, primarily + let enumName = generateEnumNameIfApplicable(member, name, t, parent); + if (!enumName && member) { + if (member.kind === 'method' || member.kind === 'property') { + // this should be easy to name... let's call it the same as the argument (eternal optimist) + let probableName = `${parent.name}${translateMemberName(``, name, null)}`; + let probableType = additionalTypes.get(probableName); + if (probableType) { + // compare it with what? + if (probableType.expression != t.expression) { + throw new Error(`Non-matching types with the same name. Panic.`); + } + } else { + additionalTypes.set(probableName, t); + } + + return probableName; + } + + if (member.kind === 'event') { + return `${name}Payload`; + } + } + + return enumName || t.name; +} + +function generateEnumNameIfApplicable(member, name, type, parent) { + if (!type.union) + return null; + + const potentialValues = type.union.filter(u => u.name.startsWith('"')); + if ((potentialValues.length !== type.union.length) + && !(type.union[0].name === 'null' && potentialValues.length === type.union.length - 1)) + return null; // this isn't an enum, so we don't care, we let the caller generate the name + + if (type && type.name) + return type.name; + + // our enum naming policy leaves a few bits to be desired, but it'll do for now + // however, with the recent changes, this almost never gets called anymore + return translateMemberName('enum', name, type); +} + +/** + * Rendering a method is so _special_, with so many weird edge cases, that it + * makes sense to put it separate from the other logic. + * @param {Documentation.Member} member + * @param {Documentation.Class|Documentation.Type} parent + * @param {Function} output + */ +function renderMethod(member, parent, output, name) { + const typeResolve = (type) => translateType(type, parent, (t) => { + return `${parent.name}${translateMemberName(member.kind, member.name, null)}Result`; + }); + + /** @type {Map} */ + const paramDocs = new Map(); + const addParamsDoc = (paramName, docs) => { + if (paramName.startsWith('@')) + paramName = paramName.substring(1); + if (paramDocs.get(paramName)) + throw new Error(`Parameter ${paramName} already exists in the docs.`); + paramDocs.set(paramName, docs); + }; + + /** @type {string} */ + let type = null; + // need to check the original one + if (member.type.name === 'Object' || member.type.name === 'Array') { + let innerType = member.type; + let isArray = false; + if (innerType.name === 'Array') { + // we want to influence the name, but also change the object type + innerType = member.type.templates[0]; + isArray = true; + } + + if (innerType.expression === '[Object]<[string], [string]>') { + // do nothing, because this is handled down the road + } else if (!isArray && !innerType.properties) { + type = `dynamic`; + } else { + type = classNameMap.get(innerType.name); + if (!type) { + type = typeResolve(innerType); + } + + if (isArray) + type = `IReadOnlyCollection<${type}>`; + } + } + + type = type || typeResolve(member.type); //translateType(member.type, parent); + // TODO: this is something that will probably go into the docs + if (member.args.size == 0 + && type !== 'void' + && !name.startsWith('Is') + && !name.startsWith('Get')) { + if (!member.async) { + if (member.spec) + output(XmlDoc.renderXmlDoc(member.spec, maxDocumentationColumnWidth)); + output(`${type} ${name} { get; }`); + return; + } + name = `Get${name}`; + } + + // HACK: special case for generics handling! + if (type === 'T') { + name = `${name}`; + } + + // adjust the return type for async methods + if (member.async) { + if (type === 'void') + type = `Task`; + else + type = `Task<${type}>`; + } + + // render args + let args = []; + /** + * + * @param {string} innerArgType + * @param {string} innerArgName + * @param {Documentation.Member} argument + */ + const pushArg = (innerArgType, innerArgName, argument) => { + let isEnum = enumTypes.has(innerArgType); + let isNullable = ['int', 'bool', 'decimal', 'float'].includes(innerArgType); + const requiredPrefix = argument.required ? "" : isNullable ? "?" : ""; + const requiredSuffix = argument.required ? "" : isEnum ? " = default" : " = null"; + args.push(`${innerArgType}${requiredPrefix} ${innerArgName}${requiredSuffix}`); + }; + + let parseArg = (/** @type {Documentation.Member} */ arg) => { + if (arg.name === "options") { + arg.type.properties.forEach(prop => { + parseArg(prop); + }); + return; + } + + if (arg.type.expression === '[string]|[path]') { + let argName = translateMemberName('argument', arg.name, null); + pushArg("string", argName, arg); + pushArg("string", `${argName}Path`, arg); + if (arg.spec) { + addParamsDoc(argName, XmlDoc.renderTextOnly(arg.spec, maxDocumentationColumnWidth)); + addParamsDoc(`${argName}Path`, [`Instead of specifying , gives the file name to load from.`]); + } + return; + } else if (arg.type.expression === '[boolean]|[Array]<[string]>') { + // HACK: this hurts my brain too + // we split this into two args, one boolean, with the logical name + let argName = translateMemberName('argument', arg.name, null); + let leftArgType = translateType(arg.type.union[0], parent, (t) => { throw new Error('Not supported'); }); + let rightArgType = translateType(arg.type.union[1], parent, (t) => { throw new Error('Not supported'); }); + + pushArg(leftArgType, argName, arg); + pushArg(rightArgType, `${argName}Values`, arg); + + addParamsDoc(argName, XmlDoc.renderTextOnly(arg.spec, maxDocumentationColumnWidth)); + addParamsDoc(`${argName}Values`, [`The values to take into account when is true.`]); + + return; + } + + const argName = translateMemberName('argument', arg.alias || arg.name, null); + const argType = translateType(arg.type, parent, (t) => generateNameDefault(member, argName, t, parent)); + + if (argType === null && arg.type.union) { + // we might have to split this into multiple arguments + let translatedArguments = arg.type.union.map(t => translateType(t, parent, (x) => generateNameDefault(member, argName, x, parent))); + if (translatedArguments.includes(null)) + throw new Error('Unexpected null in translated argument types. Aborting.'); + + let argDocumentation = XmlDoc.renderTextOnly(arg.spec, maxDocumentationColumnWidth); + for (const newArg of translatedArguments) { + const sanitizedArgName = newArg.match(/(?<=^[\s"']*)(\w+)/g, '')[0] || newArg; + const newArgName = `${argName}${sanitizedArgName[0].toUpperCase() + sanitizedArgName.substring(1)}`; + pushArg(newArg, newArgName, arg); + addParamsDoc(newArgName, argDocumentation); + } + return; + } + + addParamsDoc(argName, XmlDoc.renderTextOnly(arg.spec, maxDocumentationColumnWidth)); + + if (argName === 'timeout' && argType === 'decimal') { + args.push(`int timeout = 0`); // a special argument, we ignore our convention + return; + } + + pushArg(argType, argName, arg); + }; + + member.args.forEach(parseArg); + + output(XmlDoc.renderXmlDoc(member.spec, maxDocumentationColumnWidth)); + paramDocs.forEach((val, ind) => { + if (val && val.length === 1) + output(`/// ${val}`); + else { + output(`/// `); + output(val.map(l => `/// ${l}`)); + output(`/// `); + } + }); + output(`${type} ${name}(${args.join(', ')});`); +} + +/** + * + * @callback generateNameCallback + * @param {Documentation.Type} t + * @returns {string} + */ + +/** + * @param {Documentation.Type} type + * @param {Documentation.Class|Documentation.Type} parent + * @param {generateNameCallback} generateNameCallback +*/ +function translateType(type, parent, generateNameCallback = t => t.name) { + // a few special cases we can fix automatically + if (type.expression === '[null]|[Error]') + return 'void'; + else if (type.expression === '[boolean]|"mixed"') + return 'MixedState'; + + let isNullableEnum = false; + if (type.union) { + if (type.union[0].name === 'null') { + // for dotnet, this is a nullable type + // if the other side is a primitive type + if (type.union.length > 2) { + if (type.union.filter(x => x.name.startsWith('"')).length == type.union.length - 1) + isNullableEnum = true; + else + throw new Error(`Union (${parent.name}) with null is too long.`); + } else { + const innerTypeName = translateType(type.union[1], parent, generateNameCallback); + // if type is primitive, or an enum, then it's nullable + if (innerTypeName === 'bool' + || innerTypeName === 'int') { + return `${innerTypeName}?`; + } + + // if it's not a value type, it'll be nullable by default, so we can ignore it + return `${innerTypeName}`; + } + } + + if (type.union.filter(u => u.name.startsWith(`"`)).length == type.union.length + || isNullableEnum) { + // this is an enum + let enumName = generateNameCallback(type); + if (!enumName) + throw new Error(`This was supposed to be an enum, but it failed generating a name, ${type.name} ${parent ? parent.name : ""}.`); + + // make sure we map the enum, or invalidate the name, in case it doesn't match well + const potentialEnum = enumTypes.get(enumName); + let enumValues = type.union.filter(x => x.name !== 'null').map(x => x.name); + if (potentialEnum) { + // compare values + if (potentialEnum.join(',') !== enumValues.join(',')) { + // for now, we'll merge the two enums, if they have the same name, and we'll go from there + potentialEnum.concat(enumValues.filter(x => !potentialEnum.includes(x))); // merge & de-dupe + // TODO: think about doing global type annotation, where we can add comments, such as this? + enumTypes.set(enumName, potentialEnum); + } + } else { + enumTypes.set(enumName, enumValues); + } + if (isNullableEnum) + return `${enumName}?`; + return enumName; + } + + if (type.expression === '[string]|[Buffer]') + return `byte[]`; // TODO: make sure we implement extension methods for this! + else if (type.expression === '[string]|[float]' + || type.expression === '[string]|[float]|[boolean]') { + console.warn(`${type.name} should be a 'string', but was a ${type.expression}`); + throw new Error(`The type ${type.name} was not marked as string, but we expect it to be.`); + } else if (type.union.length == 2 && type.union[1].name === 'Array' && type.union[1].templates[0].name === type.union[0].name) + return `IEnumerable<${type.union[0].name}>`; // an example of this is [string]|[Array]<[string]> + else if (type.union[0].name === 'path') + // we don't support path, but we know it's usually an object on the other end, and we expect + // the dotnet folks to use [NameOfTheObject].LoadFromPath(); method which we can provide separately + return translateType(type.union[1], parent, generateNameCallback); + else if (type.expression === '[float]|"raf"') + return `Polling`; // hardcoded because there's no other way to denote this + + return null; + } + + if (type.name === 'Array') { + if (type.templates.length != 1) + throw new Error(`Array (${type.name} from ${parent.name}) has more than 1 dimension. Panic.`); + + let innerType = translateType(type.templates[0], parent, generateNameCallback); + return `IEnumerable<${innerType}>`; + } + + if (type.name === 'Object') { + // take care of some common cases + // TODO: this can be genericized + if (type.templates && type.templates.length == 2) { + // get the inner types of both templates, and if they're strings, it's a keyvaluepair string, string, + let keyType = translateType(type.templates[0], parent, generateNameCallback); + let valueType = translateType(type.templates[1], parent, generateNameCallback); + return `IEnumerable>`; + } + + if ((type.name === 'Object') + && !type.properties + && !type.union) { + return 'object'; + } + // this is an additional type that we need to generate + let objectName = generateNameCallback(type); + if (objectName === 'Object') { + throw new Error('Object unexpected'); + } else if (type.name === 'Object') { + registerAdditionalType(objectName, type); + } + return objectName; + } + + if (type.name === 'Map') { + if (type.templates && type.templates.length == 2) { + // we map to a dictionary + let keyType = translateType(type.templates[0], parent, generateNameCallback); + let valueType = translateType(type.templates[1], parent, generateNameCallback); + return `Dictionary<${keyType}, ${valueType}>`; + } else { + throw 'Map has invalid number of templates.'; + } + } + + if (type.name === 'function') { + if (type.expression === '[function]' || !type.args) + return 'Action'; // super simple mapping + + let argsList = ''; + if (type.args) { + let translatedCallbackArguments = type.args.map(t => translateType(t, parent, generateNameCallback)); + if (translatedCallbackArguments.includes(null)) + throw new Error('There was an argument we could not parse. Aborting.'); + + argsList = translatedCallbackArguments.join(', '); + } + + if (!type.returnType) { + // this is an Action + return `Action<${argsList}>`; + } else { + let returnType = translateType(type.returnType, parent, generateNameCallback); + if (returnType == null) + throw new Error('Unexpected null as return type.'); + + return `Func<${argsList}, ${returnType}>`; + } + } + + // there's a chance this is a name we've already seen before, so check + // this is also where we map known types, like boolean -> bool, etc. + let name = classNameMap.get(type.name) || type.name; + return `${name}`; +} + +/** + * + * @param {string} typeName + * @param {Documentation.Type} type + */ +function registerAdditionalType(typeName, type) { + if (['object', 'string', 'int'].includes(typeName)) + return; + + let potentialType = additionalTypes.get(typeName); + if (potentialType) { + console.log(`Type ${typeName} already exists, so skipping...`); + return; + } + + additionalTypes.set(typeName, type); +} \ No newline at end of file diff --git a/utils/doclint/templates/interface.cs b/utils/doclint/templates/interface.cs new file mode 100644 index 0000000000..74b72f9035 --- /dev/null +++ b/utils/doclint/templates/interface.cs @@ -0,0 +1,49 @@ +/* + * MIT License + * + * Copyright (c) Microsoft Corporation. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * + * ------------------------------------------------------------------------------ + * + * This code was generated by a tool at: + * [PW_TOOL_VERSION] + * + * Changes to this file may cause incorrect behavior and will be lost if + * the code is regenerated. + * + * ------------------------------------------------------------------------------ + */ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Globalization; +using System.IO; +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace PlaywrightSharp +{ + [CONTENT] +} \ No newline at end of file