add --list and npm run list

This commit is contained in:
robby 2025-01-12 13:48:57 +00:00
parent 6179b5b1d7
commit 27b5ad546d
2 changed files with 229 additions and 195 deletions

View file

@ -45,7 +45,8 @@
"roll": "node utils/roll_browser.js", "roll": "node utils/roll_browser.js",
"check-deps": "node utils/check_deps.js", "check-deps": "node utils/check_deps.js",
"build-android-driver": "./utils/build_android_driver.sh", "build-android-driver": "./utils/build_android_driver.sh",
"innerloop": "playwright run-server --reuse-browser" "innerloop": "playwright run-server --reuse-browser",
"list": "playwright install --list"
}, },
"workspaces": [ "workspaces": [
"packages/*" "packages/*"

View file

@ -39,37 +39,37 @@ import { isTargetClosedError } from '../client/errors';
const packageJSON = require('../../package.json'); const packageJSON = require('../../package.json');
program program
.version('Version ' + (process.env.PW_CLI_DISPLAY_VERSION || packageJSON.version)) .version('Version ' + (process.env.PW_CLI_DISPLAY_VERSION || packageJSON.version))
.name(buildBasePlaywrightCLICommand(process.env.PW_LANG_NAME)); .name(buildBasePlaywrightCLICommand(process.env.PW_LANG_NAME));
program program
.command('mark-docker-image [dockerImageNameTemplate]', { hidden: true }) .command('mark-docker-image [dockerImageNameTemplate]', { hidden: true })
.description('mark docker image') .description('mark docker image')
.allowUnknownOption(true) .allowUnknownOption(true)
.action(function(dockerImageNameTemplate) { .action(function (dockerImageNameTemplate) {
assert(dockerImageNameTemplate, 'dockerImageNameTemplate is required'); assert(dockerImageNameTemplate, 'dockerImageNameTemplate is required');
writeDockerVersion(dockerImageNameTemplate).catch(logErrorAndExit); writeDockerVersion(dockerImageNameTemplate).catch(logErrorAndExit);
}); });
commandWithOpenOptions('open [url]', 'open page in browser specified via -b, --browser', []) commandWithOpenOptions('open [url]', 'open page in browser specified via -b, --browser', [])
.action(function(url, options) { .action(function (url, options) {
open(options, url, codegenId()).catch(logErrorAndExit); open(options, url, codegenId()).catch(logErrorAndExit);
}) })
.addHelpText('afterAll', ` .addHelpText('afterAll', `
Examples: Examples:
$ open $ open
$ open -b webkit https://example.com`); $ open -b webkit https://example.com`);
commandWithOpenOptions('codegen [url]', 'open page and generate code for user actions', commandWithOpenOptions('codegen [url]', 'open page and generate code for user actions',
[ [
['-o, --output <file name>', 'saves the generated script to a file'], ['-o, --output <file name>', 'saves the generated script to a file'],
['--target <language>', `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, java, java-junit`, codegenId()], ['--target <language>', `language to generate, one of javascript, playwright-test, python, python-async, python-pytest, csharp, csharp-mstest, csharp-nunit, java, java-junit`, codegenId()],
['--save-trace <filename>', 'record a trace for the session and save it to a file'], ['--save-trace <filename>', 'record a trace for the session and save it to a file'],
['--test-id-attribute <attributeName>', 'use the specified attribute to generate data test ID selectors'], ['--test-id-attribute <attributeName>', 'use the specified attribute to generate data test ID selectors'],
]).action(function(url, options) { ]).action(function (url, options) {
codegen(options, url).catch(logErrorAndExit); codegen(options, url).catch(logErrorAndExit);
}).addHelpText('afterAll', ` }).addHelpText('afterAll', `
Examples: Examples:
$ codegen $ codegen
@ -126,68 +126,68 @@ function checkBrowsersToInstall(args: string[], options: { noShell?: boolean, on
program program
.command('install [browser...]') .command('install [browser...]')
.description('ensure browsers necessary for this version of Playwright are installed') .description('ensure browsers necessary for this version of Playwright are installed')
.option('--with-deps', 'install system dependencies for browsers') .option('--with-deps', 'install system dependencies for browsers')
.option('--dry-run', 'do not execute installation, only print information') .option('--dry-run', 'do not execute installation, only print information')
.option('--force', 'force reinstall of stable browser channels') .option('--force', 'force reinstall of stable browser channels')
.option('--only-shell', 'only install headless shell when installing chromium') .option('--only-shell', 'only install headless shell when installing chromium')
.option('--no-shell', 'do not install chromium headless shell') .option('--no-shell', 'do not install chromium headless shell')
.action(async function(args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean }) { .action(async function (args: string[], options: { withDeps?: boolean, force?: boolean, dryRun?: boolean, shell?: boolean, noShell?: boolean, onlyShell?: boolean }) {
// For '--no-shell' option, commander sets `shell: false` instead. // For '--no-shell' option, commander sets `shell: false` instead.
if (options.shell === false) if (options.shell === false)
options.noShell = true; options.noShell = true;
if (isLikelyNpxGlobal()) { if (isLikelyNpxGlobal()) {
console.error(wrapInASCIIBox([ console.error(wrapInASCIIBox([
`WARNING: It looks like you are running 'npx playwright install' without first`, `WARNING: It looks like you are running 'npx playwright install' without first`,
`installing your project's dependencies.`, `installing your project's dependencies.`,
``, ``,
`To avoid unexpected behavior, please install your dependencies first, and`, `To avoid unexpected behavior, please install your dependencies first, and`,
`then run Playwright's install command:`, `then run Playwright's install command:`,
``, ``,
` npm install`, ` npm install`,
` npx playwright install`, ` npx playwright install`,
``, ``,
`If your project does not yet depend on Playwright, first install the`, `If your project does not yet depend on Playwright, first install the`,
`applicable npm package (most commonly @playwright/test), and`, `applicable npm package (most commonly @playwright/test), and`,
`then run Playwright's install command to download the browsers:`, `then run Playwright's install command to download the browsers:`,
``, ``,
` npm install @playwright/test`, ` npm install @playwright/test`,
` npx playwright install`, ` npx playwright install`,
``, ``,
].join('\n'), 1)); ].join('\n'), 1));
} }
try { try {
const hasNoArguments = !args.length; const hasNoArguments = !args.length;
const executables = hasNoArguments ? defaultBrowsersToInstall(options) : checkBrowsersToInstall(args, options); const executables = hasNoArguments ? defaultBrowsersToInstall(options) : checkBrowsersToInstall(args, options);
if (options.withDeps) if (options.withDeps)
await registry.installDeps(executables, !!options.dryRun); await registry.installDeps(executables, !!options.dryRun);
if (options.dryRun) { if (options.dryRun) {
for (const executable of executables) { for (const executable of executables) {
const version = executable.browserVersion ? `version ` + executable.browserVersion : ''; const version = executable.browserVersion ? `version ` + executable.browserVersion : '';
console.log(`browser: ${executable.name}${version ? ' ' + version : ''}`); console.log(`browser: ${executable.name}${version ? ' ' + version : ''}`);
console.log(` Install location: ${executable.directory ?? '<system>'}`); console.log(` Install location: ${executable.directory ?? '<system>'}`);
if (executable.downloadURLs?.length) { if (executable.downloadURLs?.length) {
const [url, ...fallbacks] = executable.downloadURLs; const [url, ...fallbacks] = executable.downloadURLs;
console.log(` Download url: ${url}`); console.log(` Download url: ${url}`);
for (let i = 0; i < fallbacks.length; ++i) for (let i = 0; i < fallbacks.length; ++i)
console.log(` Download fallback ${i + 1}: ${fallbacks[i]}`); console.log(` Download fallback ${i + 1}: ${fallbacks[i]}`);
}
console.log(``);
} }
} else { console.log(``);
const forceReinstall = hasNoArguments ? false : !!options.force;
await registry.install(executables, forceReinstall);
await registry.validateHostRequirementsForExecutablesIfNeeded(executables, process.env.PW_LANG_NAME || 'javascript').catch((e: Error) => {
e.name = 'Playwright Host validation warning';
console.error(e);
});
} }
} catch (e) { } else {
console.log(`Failed to install browsers\n${e}`); const forceReinstall = hasNoArguments ? false : !!options.force;
gracefullyProcessExitDoNotHang(1); await registry.install(executables, forceReinstall);
await registry.validateHostRequirementsForExecutablesIfNeeded(executables, process.env.PW_LANG_NAME || 'javascript').catch((e: Error) => {
e.name = 'Playwright Host validation warning';
console.error(e);
});
} }
}).addHelpText('afterAll', ` } catch (e) {
console.log(`Failed to install browsers\n${e}`);
gracefullyProcessExitDoNotHang(1);
}
}).addHelpText('afterAll', `
Examples: Examples:
- $ install - $ install
@ -197,34 +197,34 @@ Examples:
Install custom browsers, supports ${suggestedBrowsersToInstall()}.`); Install custom browsers, supports ${suggestedBrowsersToInstall()}.`);
program program
.command('uninstall') .command('uninstall')
.description('Removes browsers used by this installation of Playwright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.') .description('Removes browsers used by this installation of Playwright from the system (chromium, firefox, webkit, ffmpeg). This does not include branded channels.')
.option('--all', 'Removes all browsers used by any Playwright installation from the system.') .option('--all', 'Removes all browsers used by any Playwright installation from the system.')
.action(async (options: { all?: boolean }) => { .action(async (options: { all?: boolean }) => {
delete process.env.PLAYWRIGHT_SKIP_BROWSER_GC; delete process.env.PLAYWRIGHT_SKIP_BROWSER_GC;
await registry.uninstall(!!options.all).then(({ numberOfBrowsersLeft }) => { await registry.uninstall(!!options.all).then(({ numberOfBrowsersLeft }) => {
if (!options.all && numberOfBrowsersLeft > 0) { if (!options.all && numberOfBrowsersLeft > 0) {
console.log('Successfully uninstalled Playwright browsers for the current Playwright installation.'); console.log('Successfully uninstalled Playwright browsers for the current Playwright installation.');
console.log(`There are still ${numberOfBrowsersLeft} browsers left, used by other Playwright installations.\nTo uninstall Playwright browsers for all installations, re-run with --all flag.`); console.log(`There are still ${numberOfBrowsersLeft} browsers left, used by other Playwright installations.\nTo uninstall Playwright browsers for all installations, re-run with --all flag.`);
} }
}).catch(logErrorAndExit); }).catch(logErrorAndExit);
}); });
program program
.command('install-deps [browser...]') .command('install-deps [browser...]')
.description('install dependencies necessary to run browsers (will ask for sudo permissions)') .description('install dependencies necessary to run browsers (will ask for sudo permissions)')
.option('--dry-run', 'Do not execute installation commands, only print them') .option('--dry-run', 'Do not execute installation commands, only print them')
.action(async function(args: string[], options: { dryRun?: boolean }) { .action(async function (args: string[], options: { dryRun?: boolean }) {
try { try {
if (!args.length) if (!args.length)
await registry.installDeps(defaultBrowsersToInstall({}), !!options.dryRun); await registry.installDeps(defaultBrowsersToInstall({}), !!options.dryRun);
else else
await registry.installDeps(checkBrowsersToInstall(args, {}), !!options.dryRun); await registry.installDeps(checkBrowsersToInstall(args, {}), !!options.dryRun);
} catch (e) { } catch (e) {
console.log(`Failed to install browser dependencies\n${e}`); console.log(`Failed to install browser dependencies\n${e}`);
gracefullyProcessExitDoNotHang(1); gracefullyProcessExitDoNotHang(1);
} }
}).addHelpText('afterAll', ` }).addHelpText('afterAll', `
Examples: Examples:
- $ install-deps - $ install-deps
Install dependencies for default browsers. Install dependencies for default browsers.
@ -232,6 +232,39 @@ Examples:
- $ install-deps chrome firefox - $ install-deps chrome firefox
Install dependencies for specific browsers, supports ${suggestedBrowsersToInstall()}.`); Install dependencies for specific browsers, supports ${suggestedBrowsersToInstall()}.`);
program
.command('show-browsers') // Changed from 'install --list' to avoid conflicts
.alias('list-browsers') // Added alias for convenience
.description('lists installed browsers and their paths')
.action(async function () {
try {
const browsers = new Set();
// Filter once instead of twice
const installableBrowsers = registry.executables().filter(e =>
e.installType !== 'none' &&
e.type !== 'tool'
);
if (installableBrowsers.length === 0) {
console.log('No browsers installed. To install them use: npx playwright install');
return;
}
for (const executable of installableBrowsers) {
try {
const version = executable.browserVersion ? `v${executable.browserVersion}` : '';
const location = executable.directory || '<system>';
console.log(`${executable.name} ${version ? version + ' ' : ''}${location ? `(${location})` : ''}`);
browsers.add(executable.name);
} catch (e) {
console.error(`Failed to get info for browser ${executable.name}:`, e);
}
}
} catch (e) {
logErrorAndExit(e);
}
});
const browsers = [ const browsers = [
{ alias: 'cr', name: 'Chromium', type: 'chromium' }, { alias: 'cr', name: 'Chromium', type: 'chromium' },
{ alias: 'ff', name: 'Firefox', type: 'firefox' }, { alias: 'ff', name: 'Firefox', type: 'firefox' },
@ -240,100 +273,100 @@ const browsers = [
for (const { alias, name, type } of browsers) { for (const { alias, name, type } of browsers) {
commandWithOpenOptions(`${alias} [url]`, `open page in ${name}`, []) commandWithOpenOptions(`${alias} [url]`, `open page in ${name}`, [])
.action(function(url, options) { .action(function (url, options) {
open({ ...options, browser: type }, url, options.target).catch(logErrorAndExit); open({ ...options, browser: type }, url, options.target).catch(logErrorAndExit);
}).addHelpText('afterAll', ` }).addHelpText('afterAll', `
Examples: Examples:
$ ${alias} https://example.com`); $ ${alias} https://example.com`);
} }
commandWithOpenOptions('screenshot <url> <filename>', 'capture a page screenshot', commandWithOpenOptions('screenshot <url> <filename>', 'capture a page screenshot',
[ [
['--wait-for-selector <selector>', 'wait for selector before taking a screenshot'], ['--wait-for-selector <selector>', 'wait for selector before taking a screenshot'],
['--wait-for-timeout <timeout>', 'wait for timeout in milliseconds before taking a screenshot'], ['--wait-for-timeout <timeout>', 'wait for timeout in milliseconds before taking a screenshot'],
['--full-page', 'whether to take a full page screenshot (entire scrollable area)'], ['--full-page', 'whether to take a full page screenshot (entire scrollable area)'],
]).action(function(url, filename, command) { ]).action(function (url, filename, command) {
screenshot(command, command, url, filename).catch(logErrorAndExit); screenshot(command, command, url, filename).catch(logErrorAndExit);
}).addHelpText('afterAll', ` }).addHelpText('afterAll', `
Examples: Examples:
$ screenshot -b webkit https://example.com example.png`); $ screenshot -b webkit https://example.com example.png`);
commandWithOpenOptions('pdf <url> <filename>', 'save page as pdf', commandWithOpenOptions('pdf <url> <filename>', 'save page as pdf',
[ [
['--wait-for-selector <selector>', 'wait for given selector before saving as pdf'], ['--wait-for-selector <selector>', 'wait for given selector before saving as pdf'],
['--wait-for-timeout <timeout>', 'wait for given timeout in milliseconds before saving as pdf'], ['--wait-for-timeout <timeout>', 'wait for given timeout in milliseconds before saving as pdf'],
]).action(function(url, filename, options) { ]).action(function (url, filename, options) {
pdf(options, options, url, filename).catch(logErrorAndExit); pdf(options, options, url, filename).catch(logErrorAndExit);
}).addHelpText('afterAll', ` }).addHelpText('afterAll', `
Examples: Examples:
$ pdf https://example.com example.pdf`); $ pdf https://example.com example.pdf`);
program program
.command('run-driver', { hidden: true }) .command('run-driver', { hidden: true })
.action(function(options) { .action(function (options) {
runDriver(); runDriver();
}); });
program program
.command('run-server') .command('run-server')
.option('--port <port>', 'Server port') .option('--port <port>', 'Server port')
.option('--host <host>', 'Server host') .option('--host <host>', 'Server host')
.option('--path <path>', 'Endpoint Path', '/') .option('--path <path>', 'Endpoint Path', '/')
.option('--max-clients <maxClients>', 'Maximum clients') .option('--max-clients <maxClients>', 'Maximum clients')
.option('--mode <mode>', 'Server mode, either "default" or "extension"') .option('--mode <mode>', 'Server mode, either "default" or "extension"')
.action(function(options) { .action(function (options) {
runServer({ runServer({
port: options.port ? +options.port : undefined, port: options.port ? +options.port : undefined,
host: options.host, host: options.host,
path: options.path, path: options.path,
maxConnections: options.maxClients ? +options.maxClients : Infinity, maxConnections: options.maxClients ? +options.maxClients : Infinity,
extension: options.mode === 'extension' || !!process.env.PW_EXTENSION_MODE, extension: options.mode === 'extension' || !!process.env.PW_EXTENSION_MODE,
}).catch(logErrorAndExit); }).catch(logErrorAndExit);
}); });
program program
.command('print-api-json', { hidden: true }) .command('print-api-json', { hidden: true })
.action(function(options) { .action(function (options) {
printApiJson(); printApiJson();
}); });
program program
.command('launch-server', { hidden: true }) .command('launch-server', { hidden: true })
.requiredOption('--browser <browserName>', 'Browser name, one of "chromium", "firefox" or "webkit"') .requiredOption('--browser <browserName>', 'Browser name, one of "chromium", "firefox" or "webkit"')
.option('--config <path-to-config-file>', 'JSON file with launchServer options') .option('--config <path-to-config-file>', 'JSON file with launchServer options')
.action(function(options) { .action(function (options) {
launchBrowserServer(options.browser, options.config); launchBrowserServer(options.browser, options.config);
}); });
program program
.command('show-trace [trace...]') .command('show-trace [trace...]')
.option('-b, --browser <browserType>', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium') .option('-b, --browser <browserType>', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium')
.option('-h, --host <host>', 'Host to serve trace on; specifying this option opens trace in a browser tab') .option('-h, --host <host>', 'Host to serve trace on; specifying this option opens trace in a browser tab')
.option('-p, --port <port>', 'Port to serve trace on, 0 for any free port; specifying this option opens trace in a browser tab') .option('-p, --port <port>', 'Port to serve trace on, 0 for any free port; specifying this option opens trace in a browser tab')
.option('--stdin', 'Accept trace URLs over stdin to update the viewer') .option('--stdin', 'Accept trace URLs over stdin to update the viewer')
.description('show trace viewer') .description('show trace viewer')
.action(function(traces, options) { .action(function (traces, options) {
if (options.browser === 'cr') if (options.browser === 'cr')
options.browser = 'chromium'; options.browser = 'chromium';
if (options.browser === 'ff') if (options.browser === 'ff')
options.browser = 'firefox'; options.browser = 'firefox';
if (options.browser === 'wk') if (options.browser === 'wk')
options.browser = 'webkit'; options.browser = 'webkit';
const openOptions: TraceViewerServerOptions = { const openOptions: TraceViewerServerOptions = {
host: options.host, host: options.host,
port: +options.port, port: +options.port,
isServer: !!options.stdin, isServer: !!options.stdin,
}; };
if (options.port !== undefined || options.host !== undefined) if (options.port !== undefined || options.host !== undefined)
runTraceInBrowser(traces, openOptions).catch(logErrorAndExit); runTraceInBrowser(traces, openOptions).catch(logErrorAndExit);
else else
runTraceViewerApp(traces, options.browser, openOptions, true).catch(logErrorAndExit); runTraceViewerApp(traces, options.browser, openOptions, true).catch(logErrorAndExit);
}).addHelpText('afterAll', ` }).addHelpText('afterAll', `
Examples: Examples:
$ show-trace https://example.com/trace.zip`); $ show-trace https://example.com/trace.zip`);
@ -518,13 +551,13 @@ async function launchContext(options: Options, extraOptions: LaunchOptions): Pro
} }
context.on('page', page => { context.on('page', page => {
page.on('dialog', () => {}); // Prevent dialogs from being automatically dismissed. page.on('dialog', () => { }); // Prevent dialogs from being automatically dismissed.
page.on('close', () => { page.on('close', () => {
const hasPage = browser.contexts().some(context => context.pages().length > 0); const hasPage = browser.contexts().some(context => context.pages().length > 0);
if (hasPage) if (hasPage)
return; return;
// Avoid the error when the last page is closed because the browser has been closed. // Avoid the error when the last page is closed because the browser has been closed.
closeBrowser().catch(() => {}); closeBrowser().catch(() => { });
}); });
}); });
process.on('SIGINT', async () => { process.on('SIGINT', async () => {
@ -686,24 +719,24 @@ function commandWithOpenOptions(command: string, description: string, options: a
for (const option of options) for (const option of options)
result = result.option(option[0], ...option.slice(1)); result = result.option(option[0], ...option.slice(1));
return result return result
.option('-b, --browser <browserType>', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium') .option('-b, --browser <browserType>', 'browser to use, one of cr, chromium, ff, firefox, wk, webkit', 'chromium')
.option('--block-service-workers', 'block service workers') .option('--block-service-workers', 'block service workers')
.option('--channel <channel>', 'Chromium distribution channel, "chrome", "chrome-beta", "msedge-dev", etc') .option('--channel <channel>', 'Chromium distribution channel, "chrome", "chrome-beta", "msedge-dev", etc')
.option('--color-scheme <scheme>', 'emulate preferred color scheme, "light" or "dark"') .option('--color-scheme <scheme>', 'emulate preferred color scheme, "light" or "dark"')
.option('--device <deviceName>', 'emulate device, for example "iPhone 11"') .option('--device <deviceName>', 'emulate device, for example "iPhone 11"')
.option('--geolocation <coordinates>', 'specify geolocation coordinates, for example "37.819722,-122.478611"') .option('--geolocation <coordinates>', 'specify geolocation coordinates, for example "37.819722,-122.478611"')
.option('--ignore-https-errors', 'ignore https errors') .option('--ignore-https-errors', 'ignore https errors')
.option('--load-storage <filename>', 'load context storage state from the file, previously saved with --save-storage') .option('--load-storage <filename>', 'load context storage state from the file, previously saved with --save-storage')
.option('--lang <language>', 'specify language / locale, for example "en-GB"') .option('--lang <language>', 'specify language / locale, for example "en-GB"')
.option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"') .option('--proxy-server <proxy>', 'specify proxy server, for example "http://myproxy:3128" or "socks5://myproxy:8080"')
.option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"') .option('--proxy-bypass <bypass>', 'comma-separated domains to bypass proxy, for example ".com,chromium.org,.domain.com"')
.option('--save-har <filename>', 'save HAR file with all network activity at the end') .option('--save-har <filename>', 'save HAR file with all network activity at the end')
.option('--save-har-glob <glob pattern>', 'filter entries in the HAR by matching url against this glob pattern') .option('--save-har-glob <glob pattern>', 'filter entries in the HAR by matching url against this glob pattern')
.option('--save-storage <filename>', 'save context storage state at the end, for later use with --load-storage') .option('--save-storage <filename>', 'save context storage state at the end, for later use with --load-storage')
.option('--timezone <time zone>', 'time zone to emulate, for example "Europe/Rome"') .option('--timezone <time zone>', 'time zone to emulate, for example "Europe/Rome"')
.option('--timeout <timeout>', 'timeout for Playwright actions in milliseconds, no timeout by default') .option('--timeout <timeout>', 'timeout for Playwright actions in milliseconds, no timeout by default')
.option('--user-agent <ua string>', 'specify user agent string') .option('--user-agent <ua string>', 'specify user agent string')
.option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"'); .option('--viewport-size <size>', 'specify browser viewport size in pixels, for example "1280, 720"');
} }
function buildBasePlaywrightCLICommand(cliTargetLang: string | undefined): string { function buildBasePlaywrightCLICommand(cliTargetLang: string | undefined): string {