diff --git a/docs/src/api/params.md b/docs/src/api/params.md index d5272fbed202c..14ab34518b2fe 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1152,6 +1152,11 @@ Logger sink for Playwright logging. Maximum time in milliseconds to wait for the browser instance to start. Defaults to `30000` (30 seconds). Pass `0` to disable timeout. +## browser-option-artifactsdir +- `artifactsDir` <[path]> + +If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the browser closes. + ## browser-option-tracesdir - `tracesDir` <[path]> @@ -1164,6 +1169,7 @@ Slows down Playwright operations by the specified amount of milliseconds. Useful ## shared-browser-options-list-v1.8 - %%-browser-option-args-%% +- %%-browser-option-artifactsdir-%% - %%-browser-option-channel-%% - %%-browser-option-chromiumsandbox-%% - %%-browser-option-downloadspath-%% diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 604208ad14b25..db283f4217641 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -15302,6 +15302,13 @@ export interface BrowserType { */ args?: Array; + /** + * If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory + * is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the + * browser closes. + */ + artifactsDir?: string; + /** * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), @@ -15826,6 +15833,13 @@ export interface BrowserType { */ args?: Array; + /** + * If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory + * is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the + * browser closes. + */ + artifactsDir?: string; + /** * Browser distribution channel. * @@ -22228,6 +22242,13 @@ export interface LaunchOptions { */ args?: Array; + /** + * If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory + * is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the + * browser closes. + */ + artifactsDir?: string; + /** * Browser distribution channel. * diff --git a/packages/playwright-core/src/cli/driver.ts b/packages/playwright-core/src/cli/driver.ts index f716d8efa7aaa..28e0bee702f04 100644 --- a/packages/playwright-core/src/cli/driver.ts +++ b/packages/playwright-core/src/cli/driver.ts @@ -69,6 +69,7 @@ export type RunServerOptions = { maxConnections?: number, browserProxyMode?: 'client' | 'tether', ownedByTetherClient?: boolean, + artifactsDir?: string, }; export async function runServer(options: RunServerOptions) { @@ -78,8 +79,9 @@ export async function runServer(options: RunServerOptions) { path = '/', maxConnections = Infinity, extension, + artifactsDir, } = options; - const server = new PlaywrightServer({ mode: extension ? 'extension' : 'default', path, maxConnections }); + const server = new PlaywrightServer({ mode: extension ? 'extension' : 'default', path, maxConnections, artifactsDir }); const wsEndpoint = await server.listen(port, host); process.on('exit', () => server.close().catch(console.error)); console.log('Listening on ' + wsEndpoint); diff --git a/packages/playwright-core/src/cli/program.ts b/packages/playwright-core/src/cli/program.ts index 25e56ca44697a..cbd18e242e371 100644 --- a/packages/playwright-core/src/cli/program.ts +++ b/packages/playwright-core/src/cli/program.ts @@ -296,6 +296,7 @@ program .option('--path ', 'Endpoint Path', '/') .option('--max-clients ', 'Maximum clients') .option('--mode ', 'Server mode, either "default" or "extension"') + .option('--artifacts-dir ', 'Artifacts directory') .action(function(options) { runServer({ port: options.port ? +options.port : undefined, @@ -303,6 +304,7 @@ program path: options.path, maxConnections: options.maxClients ? +options.maxClients : Infinity, extension: options.mode === 'extension' || !!process.env.PW_EXTENSION_MODE, + artifactsDir: options.artifactsDir, }).catch(logErrorAndExit); }); diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 86bd0a197b8e1..ad440997298ea 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -526,6 +526,7 @@ scheme.BrowserTypeLaunchParams = tObject({ })), downloadsPath: tOptional(tString), tracesDir: tOptional(tString), + artifactsDir: tOptional(tString), chromiumSandbox: tOptional(tBoolean), firefoxUserPrefs: tOptional(tAny), cdpPort: tOptional(tInt), @@ -555,6 +556,7 @@ scheme.BrowserTypeLaunchPersistentContextParams = tObject({ })), downloadsPath: tOptional(tString), tracesDir: tOptional(tString), + artifactsDir: tOptional(tString), chromiumSandbox: tOptional(tBoolean), firefoxUserPrefs: tOptional(tAny), cdpPort: tOptional(tInt), diff --git a/packages/playwright-core/src/remote/playwrightServer.ts b/packages/playwright-core/src/remote/playwrightServer.ts index 1f3994422c5d1..26e90c70c2c15 100644 --- a/packages/playwright-core/src/remote/playwrightServer.ts +++ b/packages/playwright-core/src/remote/playwrightServer.ts @@ -40,6 +40,7 @@ type ServerOptions = { preLaunchedBrowser?: Browser; preLaunchedAndroidDevice?: AndroidDevice; preLaunchedSocksProxy?: SocksProxy; + artifactsDir?: string; }; export class PlaywrightServer { @@ -102,6 +103,8 @@ export class PlaywrightServer { launchOptions.timeout = DEFAULT_PLAYWRIGHT_LAUNCH_TIMEOUT; } catch (e) { } + if (this._options.artifactsDir) + launchOptions.artifactsDir = this._options.artifactsDir; const isExtension = this._options.mode === 'extension'; const allowFSPaths = isExtension; @@ -367,6 +370,7 @@ function filterLaunchOptions(options: LaunchOptionsWithTimeout, allowFSPaths: bo slowMo: options.slowMo, executablePath: (isUnderTest() || allowFSPaths) ? options.executablePath : undefined, downloadsPath: allowFSPaths ? options.downloadsPath : undefined, + artifactsDir: (isUnderTest() || allowFSPaths) ? options.artifactsDir : undefined, }; } @@ -382,4 +386,5 @@ const optionsThatAllowBrowserReuse: (keyof LaunchOptionsWithTimeout)[] = [ 'headless', 'timeout', 'tracesDir', + 'artifactsDir', ]; diff --git a/packages/playwright-core/src/server/browserType.ts b/packages/playwright-core/src/server/browserType.ts index 2f7a89fa3d203..fd54a8a6d8aad 100644 --- a/packages/playwright-core/src/server/browserType.ts +++ b/packages/playwright-core/src/server/browserType.ts @@ -153,8 +153,13 @@ export abstract class BrowserType extends SdkObject { executablePath = null, } = options; const tempDirectories: string[] = []; - const artifactsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-artifacts-')); - tempDirectories.push(artifactsDir); + let artifactsDir: string; + if (options.artifactsDir) { + artifactsDir = options.artifactsDir; + } else { + artifactsDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'playwright-artifacts-')); + tempDirectories.push(artifactsDir); + } if (userDataDir) { assert(path.isAbsolute(userDataDir), 'userDataDir must be an absolute path'); diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 604208ad14b25..db283f4217641 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -15302,6 +15302,13 @@ export interface BrowserType { */ args?: Array; + /** + * If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory + * is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the + * browser closes. + */ + artifactsDir?: string; + /** * When using [page.goto(url[, options])](https://playwright.dev/docs/api/class-page#page-goto), * [page.route(url, handler[, options])](https://playwright.dev/docs/api/class-page#page-route), @@ -15826,6 +15833,13 @@ export interface BrowserType { */ args?: Array; + /** + * If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory + * is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the + * browser closes. + */ + artifactsDir?: string; + /** * Browser distribution channel. * @@ -22228,6 +22242,13 @@ export interface LaunchOptions { */ args?: Array; + /** + * If specified, artifacts (traces, videos, downloads, HAR files, etc.) are saved into this directory. The directory + * is not cleaned up when the browser closes. If not specified, a temporary directory is used and cleaned up when the + * browser closes. + */ + artifactsDir?: string; + /** * Browser distribution channel. * diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 6b9bc44596d58..3f4356d15f779 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -89,6 +89,7 @@ const playwrightFixtures: Fixtures = ({ handleSIGINT: false, ...launchOptions, tracesDir: tracing().tracesDir(), + artifactsDir: tracing().artifactsDir(), }; if (headless !== undefined) options.headless = headless; diff --git a/packages/playwright/src/runner/workerHost.ts b/packages/playwright/src/runner/workerHost.ts index 348257b8fc03a..bd8dcc756334c 100644 --- a/packages/playwright/src/runner/workerHost.ts +++ b/packages/playwright/src/runner/workerHost.ts @@ -68,10 +68,6 @@ export class WorkerHost extends ProcessHost { }; } - artifactsDir() { - return this._params.artifactsDir; - } - async start() { await fs.promises.mkdir(this._params.artifactsDir, { recursive: true }); return await this.startRunner(this._params, { diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 3561d9b2df93e..13f74e5af8bae 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -915,6 +915,7 @@ export type BrowserTypeLaunchParams = { }, downloadsPath?: string, tracesDir?: string, + artifactsDir?: string, chromiumSandbox?: boolean, firefoxUserPrefs?: any, cdpPort?: number, @@ -940,6 +941,7 @@ export type BrowserTypeLaunchOptions = { }, downloadsPath?: string, tracesDir?: string, + artifactsDir?: string, chromiumSandbox?: boolean, firefoxUserPrefs?: any, cdpPort?: number, @@ -969,6 +971,7 @@ export type BrowserTypeLaunchPersistentContextParams = { }, downloadsPath?: string, tracesDir?: string, + artifactsDir?: string, chromiumSandbox?: boolean, firefoxUserPrefs?: any, cdpPort?: number, @@ -1051,6 +1054,7 @@ export type BrowserTypeLaunchPersistentContextOptions = { }, downloadsPath?: string, tracesDir?: string, + artifactsDir?: string, chromiumSandbox?: boolean, firefoxUserPrefs?: any, cdpPort?: number, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 57027a0e743cb..d07e9f4d43741 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -488,6 +488,7 @@ LaunchOptions: password: string? downloadsPath: string? tracesDir: string? + artifactsDir: string? chromiumSandbox: boolean? firefoxUserPrefs: json? cdpPort: int? diff --git a/tests/config/browserTest.ts b/tests/config/browserTest.ts index c464fa7d7972f..8f4c4b20c751e 100644 --- a/tests/config/browserTest.ts +++ b/tests/config/browserTest.ts @@ -44,7 +44,7 @@ export type BrowserTestWorkerFixtures = PageWorkerFixtures & { }; interface StartRemoteServer { - (kind: 'run-server' | 'launchServer'): Promise; + (kind: 'run-server' | 'launchServer', options?: RemoteServerOptions): Promise; (kind: 'launchServer', options?: RemoteServerOptions): Promise; } @@ -163,7 +163,7 @@ const test = baseTest.extend server = remoteServer; } else { const runServer = new RunServer(); - await runServer.start(childProcess); + await runServer.start(childProcess, { artifactsDir: options?.artifactsDir }); server = runServer; } return server; diff --git a/tests/config/remoteServer.ts b/tests/config/remoteServer.ts index 4c25934581abc..1e04c0c8db610 100644 --- a/tests/config/remoteServer.ts +++ b/tests/config/remoteServer.ts @@ -27,15 +27,17 @@ export class RunServer implements PlaywrightServer { private _process!: TestChildProcess; _wsEndpoint!: string; - async start(childProcess: CommonFixtures['childProcess'], mode?: 'extension' | 'default', env?: NodeJS.ProcessEnv) { + async start(childProcess: CommonFixtures['childProcess'], options?: { mode?: 'extension' | 'default', env?: NodeJS.ProcessEnv, artifactsDir?: string }) { const command = ['node', path.join(__dirname, '..', '..', 'packages', 'playwright-core', 'cli.js'), 'run-server']; - if (mode === 'extension') + if (options?.mode === 'extension') command.push('--mode=extension'); + if (options?.artifactsDir) + command.push(`--artifacts-dir=${options.artifactsDir}`); this._process = childProcess({ command, env: { ...process.env, - ...env, + ...options?.env, }, }); @@ -69,6 +71,7 @@ export type RemoteServerOptions = { url?: string; startStopAndRunHttp?: boolean; sharedBrowser?: boolean; + artifactsDir?: string; }; export class RemoteServer implements PlaywrightServer { @@ -97,6 +100,8 @@ export class RemoteServer implements PlaywrightServer { }; if (remoteServerOptions.sharedBrowser) (launchOptions as any)._sharedBrowser = true; + if (remoteServerOptions.artifactsDir) + launchOptions.artifactsDir = remoteServerOptions.artifactsDir; const options = { browserTypeName: browserType.name(), channel, diff --git a/tests/library/browsertype-connect.spec.ts b/tests/library/browsertype-connect.spec.ts index a295198bd3d0c..89c795002be66 100644 --- a/tests/library/browsertype-connect.spec.ts +++ b/tests/library/browsertype-connect.spec.ts @@ -512,6 +512,27 @@ for (const kind of ['launchServer', 'run-server'] as const) { expect(error.message).toContain('Path is not available when connecting remotely. Use saveAs() to save a local copy.'); }); + test('should save videos to artifactsDir', async ({ connect, startRemoteServer }, testInfo) => { + const artifactsDir = testInfo.outputPath('artifacts'); + const remoteServer = await startRemoteServer(kind, { artifactsDir }); + const browser = await connect(remoteServer.wsEndpoint()); + const localDir = testInfo.outputPath('random-dir'); + const context = await browser.newContext({ + recordVideo: { dir: localDir, size: { width: 320, height: 240 } }, + }); + const page = await context.newPage(); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await rafraf(page, 100); + await context.close(); + + const savedAsPath = testInfo.outputPath('my-video.webm'); + await page.video().saveAs(savedAsPath); + expect(fs.existsSync(savedAsPath)).toBeTruthy(); + expect(fs.existsSync(localDir)).toBeFalsy(); + + await browser.close(); + }); + test('should be able to connect 20 times to a single server without warnings', async ({ connect, startRemoteServer, platform }) => { test.skip(platform !== 'linux', 'Testing non-platform specific code'); @@ -1085,7 +1106,7 @@ test.describe('launchServer only', () => { test('should refuse connecting when versions do not match', async ({ connect, childProcess }) => { const server = new RunServer(); - await server.start(childProcess, 'default', { PW_VERSION_OVERRIDE: '1.2.3' }); + await server.start(childProcess, { mode: 'default', env: { PW_VERSION_OVERRIDE: '1.2.3' } }); const error = await connect(server.wsEndpoint()).catch(e => e); await server.close(); expect(error.message).toContain('Playwright version mismatch'); diff --git a/tests/library/download.spec.ts b/tests/library/download.spec.ts index 2ad642b9ccf2e..c45a2a6b3616e 100644 --- a/tests/library/download.spec.ts +++ b/tests/library/download.spec.ts @@ -375,7 +375,7 @@ it.describe('download event', () => { }); it('should delete downloads on browser gone', async ({ server, browserType }) => { - const browser = await browserType.launch(); + const browser = await browserType.launch({ artifactsDir: undefined }); const page = await browser.newPage(); await page.setContent(`download`); const [download1] = await Promise.all([ @@ -396,6 +396,23 @@ it.describe('download event', () => { expect(fs.existsSync(path.join(path1, '..'))).toBeFalsy(); }); + it('should save downloads to artifactsDir', async ({ server, browserType }, testInfo) => { + const artifactsDir = testInfo.outputPath('artifacts'); + const browser = await browserType.launch({ artifactsDir }); + const page = await browser.newPage(); + await page.setContent(`download`); + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('a') + ]); + const downloadPath = await download.path(); + expect(downloadPath.startsWith(artifactsDir)).toBeTruthy(); + expect(fs.existsSync(downloadPath)).toBeTruthy(); + await browser.close(); + // User-provided artifactsDir should not be cleaned up. + expect(fs.existsSync(artifactsDir)).toBeTruthy(); + }); + it('should close the context without awaiting the failed download', async ({ browser, server, httpsServer, browserName, headless }, testInfo) => { it.skip(browserName !== 'chromium', 'Only Chromium downloads on alt-click'); diff --git a/tests/playwright-test/playwright.reuse.browser.spec.ts b/tests/playwright-test/playwright.reuse.browser.spec.ts index d172658b9423f..3f8ad95789ddc 100644 --- a/tests/playwright-test/playwright.reuse.browser.spec.ts +++ b/tests/playwright-test/playwright.reuse.browser.spec.ts @@ -23,7 +23,7 @@ const test = baseTest.extend<{ runServer: () => Promise }>({ let server: PlaywrightServer | undefined; await use(async () => { const runServer = new RunServer(); - await runServer.start(childProcess, 'extension'); + await runServer.start(childProcess, { mode: 'extension' }); server = runServer; return server; });