PRACTICAL GUIDE / Playwright connectOverCDP artifactsDir

Keep evidence when Playwright attaches to an existing Chrome

Configure a safe artifact directory for CDP connections, save remote downloads and traces correctly, and prevent teardown or parallel runs losing evidence.

By The Testing AcademyUpdated August 4, 202618 min read
All field guides
In this guide6 sections
  1. Separate connection, recording, and retention
  2. Give each CDP run a writable evidence directory
  3. Save remote downloads without relying on download.path
  4. Diagnose empty directories from lifecycle evidence
  5. Roll the directory into CI without creating new collisions
  6. Prefer a normal Playwright launch when CDP is not the requirement

What you will learn

  • Separate connection, recording, and retention
  • Give each CDP run a writable evidence directory
  • Save remote downloads without relying on download.path
  • Diagnose empty directories from lifecycle evidence

The test fails after attaching to a long-running Chrome process, and the trace you expected is nowhere in the CI artifacts. A retry passes, but it overwrites the only downloaded file from the first attempt. The browser connection worked. Evidence ownership did not.

Playwright 1.61 added artifactsDir to chromium.connectOverCDP(). The option gives an attached Chromium session a deliberate place for browser artifacts such as traces and downloads instead of relying on temporary storage. It does not start a trace, name files, upload them to a reporter, or isolate two workers that share one Chrome profile. Those responsibilities still belong to the test harness.

Separate connection, recording, and retention

connectOverCDP() attaches Playwright to a Chromium-based browser that already exposes a Chrome DevTools Protocol endpoint. The endpoint can be an HTTP URL such as http://127.0.0.1:9222 or a browser WebSocket URL. The returned Browser exposes the existing default context through browser.contexts()[0].

The option added in 1.61 has this exact placement:

TypeScript
const browser = await chromium.connectOverCDP(endpointURL, {
  artifactsDir: absoluteDirectory,
});

Putting artifactsDir in browser.newContext() is a different API shape and will not configure the connection. Passing it as a third argument is also wrong. Do not cast an options object to any to make an older Playwright version accept the property. Upgrade the installed package and its declarations instead.

This runnable smoke script creates a unique directory, passes it at connection time, and exports a trace before disconnecting from the dedicated browser:

TypeScript
import { chromium } from 'playwright';
import { mkdir } from 'node:fs/promises';
import { join, resolve } from 'node:path';

async function main(): Promise<void> {
  const endpoint = process.env.CDP_ENDPOINT;
  if (!endpoint) throw new Error('CDP_ENDPOINT is required');

  const artifactsDir = resolve(
    process.env.CDP_ARTIFACTS_DIR ?? `test-results/cdp-${process.pid}`
  );
  await mkdir(artifactsDir, { recursive: true });

  const browser = await chromium.connectOverCDP(endpoint, { artifactsDir });
  const context = browser.contexts()[0];
  if (!context) {
    await browser.close();
    throw new Error('The CDP browser has no default context');
  }

  const tracePath = join(artifactsDir, 'smoke-trace.zip');
  let tracingStarted = false;

  try {
    await context.tracing.start({ screenshots: true, snapshots: true });
    tracingStarted = true;
    const page = await context.newPage();
    await page.goto('data:text/html,<title>cdp-evidence</title>');
    if (await page.title() !== 'cdp-evidence') {
      throw new Error('The CDP smoke navigation failed');
    }
    await page.close();
  } finally {
    try {
      if (tracingStarted) await context.tracing.stop({ path: tracePath });
    } finally {
      await browser.close();
    }
  }
}

main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

Three lifecycles are involved:

  1. Connection setup attaches to the existing browser.
  2. Recording or download activity creates artifact data.
  3. Finalization and CI upload make that data durable and visible.

A directory solves only the storage location inside those lifecycles. context.tracing.start() is still required before a trace can capture actions. context.tracing.stop({ path }) exports the final trace file. A download event must occur before a download exists. testInfo.attach() or the CI system's upload step must still retain the file after the job finishes.

An explicit directory is useful because attached browsers do not have the same launch-time defaults as a browser created for one Playwright Test worker. The browser may have been started by another process, may outlive the test, and may use a profile with existing pages. Temporary artifact locations are hard to reason about in that arrangement.

CDP itself has a boundary. Playwright documents that connectOverCDP is Chromium-only and lower fidelity than the Playwright protocol connection through browserType.connect(). An artifact directory does not improve protocol fidelity. If route handling, context isolation, downloads, or another advanced feature behaves differently under CDP, record that as a CDP constraint rather than assuming the directory is broken.

artifactsDir does not mean every artifact should be consumed by its internal name. Download files can have generated names. Trace recording uses intermediate data and still benefits from an explicit final path. A reliable harness creates a caller-owned run directory, passes it to the connection, and then writes named outputs under that same directory.

Use absolute paths in CI. Relative paths depend on the current working directory of the Node process. A test launched from the repository root and a helper launched from a package subdirectory can otherwise write to two different folders that share the same printed relative name.

The path is also not a report attachment. A local file can exist and still disappear when the CI container exits. Decide which layer uploads it and under what retention policy. Traces contain DOM snapshots and network information. Downloads may contain customer-like test data. Restricted storage and short retention can be more important than keeping every passing run.

Give each CDP run a writable evidence directory

The first example is a standalone TypeScript script. Start a dedicated Chromium-based browser with a remote debugging port and a non-default user data directory, then provide its endpoint through CDP_ENDPOINT. Modern Chrome policies require a separate automation profile rather than the user's normal profile.

A typical dedicated launch looks like this, with the executable name adjusted for the installed browser:

Shell
google-chrome \
  --remote-debugging-port=9222 \
  --user-data-dir=/tmp/qa-cdp-profile \
  about:blank

Do not expose port 9222 on an untrusted network. Anyone who can reach an unauthenticated debugging endpoint can control the browser and access its session.

The script creates the artifact root before connecting, starts tracing on the default context, performs a navigation, and stops tracing before disconnecting.

TypeScript
import { chromium } from 'playwright';
import { constants } from 'node:fs';
import { access, mkdir, stat } from 'node:fs/promises';
import { join, resolve } from 'node:path';

async function main() {
  const endpoint = process.env.CDP_ENDPOINT;
  if (!endpoint)
    throw new Error('CDP_ENDPOINT is required');

  const configured = process.env.CDP_ARTIFACTS_DIR ??
    'test-results/cdp-manual';
  const artifactsDir = resolve(configured);

  await mkdir(artifactsDir, { recursive: true });
  await access(artifactsDir, constants.W_OK);

  const browser = await chromium.connectOverCDP(endpoint, {
    artifactsDir,
    timeout: 30_000,
  });

  const context = browser.contexts()[0];
  if (!context)
    throw new Error('CDP browser did not expose a default context');

  const tracePath = join(artifactsDir, 'trace.zip');
  let tracingStarted = false;

  try {
    await context.tracing.start({
      screenshots: true,
      snapshots: true,
      sources: true,
      title: 'CDP smoke check',
    });
    tracingStarted = true;

    const existingPage = context.pages()[0];
    const page = existingPage ?? await context.newPage();

    await page.goto('https://example.com');
    const heading = await page.locator('h1').textContent();

    if (heading !== 'Example Domain')
      throw new Error('Unexpected heading: ' + heading);
  } finally {
    try {
      if (tracingStarted)
        await context.tracing.stop({ path: tracePath });
    } finally {
      await browser.close({
        reason: 'CDP evidence collection finished',
      });
    }
  }

  const traceStats = await stat(tracePath);
  if (traceStats.size === 0)
    throw new Error('Trace file was empty');

  console.log(JSON.stringify({
    artifactsDir,
    tracePath,
    traceBytes: traceStats.size,
  }));
}

main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

This ordering is intentional. tracing.stop() runs before browser.close(). If the assertion throws, finally still attempts to export the trace. A nested finally disconnects even if trace export fails. One error can mask another in ordinary cleanup, so a production harness may collect both failures and report them separately, but it must not skip disconnect.

browser.close() behaves differently for a launched browser and a connected browser. Playwright documents that a connected Browser clears contexts created through that connection and disconnects from the browser server. It also disposes the Browser object. Do not keep using the object after close.

The script reuses the first existing page when one is present. That is appropriate only for a dedicated automation browser. Attaching to a developer's daily browser introduces cookies, extensions, open tabs, downloads, and private data into the test. A separate user data directory is safer and more reproducible.

The explicit trace path is not redundant. artifactsDir controls the browser artifact area for the CDP attachment. tracing.stop({ path }) gives the final archive a stable name that the CI uploader can target. The postcondition on file size catches a no-op upload of a zero-byte placeholder.

The write-access check fails before the browser performs useful work. It is still only an early diagnostic because permissions, quotas, and mounts can change after the check. The actual trace export and file-size postcondition remain the proof that evidence was written.

The example prints paths only after success. Avoid printing the full CDP WebSocket URL because providers sometimes embed tokens in it. Redact endpoint credentials and unpredictable WebSocket path segments in logs.

Save remote downloads without relying on download.path

Downloads expose the most common CDP artifact mistake. Playwright documents that download.path() throws when connected remotely. The browser may hold the source file in a location that the Node process cannot access directly. download.saveAs() is the supported way to copy it to a caller-selected path.

The next Playwright Test case attaches to a dedicated browser, triggers a data download, copies it into the test's unique output directory, verifies its contents, and attaches it to the report.

TypeScript
import { chromium, test, expect } from '@playwright/test';
import { mkdir, readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';

test('retains a download from an attached Chrome', async ({}, testInfo) => {
  const endpoint = process.env.CDP_ENDPOINT;
  if (!endpoint)
    throw new Error('CDP_ENDPOINT is required');

  const runDir = testInfo.outputPath('cdp-artifacts');
  await mkdir(runDir, { recursive: true });

  const browser = await chromium.connectOverCDP(endpoint, {
    artifactsDir: runDir,
    timeout: 30_000,
  });

  try {
    const context = browser.contexts()[0];
    if (!context)
      throw new Error('CDP browser did not expose a default context');

    const page = await context.newPage();

    await page.setContent([
      '<a id="report"',
      ' download="quarterly-report.txt"',
      ' href="data:text/plain,orders%3D17">',
      'Download report',
      '</a>',
    ].join(''));

    const downloadPromise = page.waitForEvent('download');
    await page.getByRole('link', { name: 'Download report' }).click();
    const download = await downloadPromise;

    expect(await download.failure()).toBeNull();

    const safeName = basename(download.suggestedFilename());
    const savedPath = join(runDir, safeName);
    await download.saveAs(savedPath);

    expect(await readFile(savedPath, 'utf8')).toBe('orders=17');

    await testInfo.attach('downloaded-report', {
      path: savedPath,
      contentType: 'text/plain',
    });

    await page.close();
  } finally {
    await browser.close({
      reason: 'CDP download test completed',
    });
  }
});

basename() treats the suggested filename as untrusted input. Browsers normally sanitize Content-Disposition filenames, but the test harness should not allow ../ or a platform-specific path to escape runDir. For stronger policies, replace characters outside a small allowlist and generate the storage name independently of the server suggestion.

testInfo.outputPath() gives the attempt a location under Playwright Test's output tree. That prevents a retry from writing into the same generic downloads/report.txt path as the first attempt. The reporter attachment then makes the copied file visible according to reporter and CI retention settings.

download.saveAs() waits for the download to finish if necessary. Calling download.failure() first makes the failure reason explicit, although saveAs() would also fail for an unsuccessful download. The extra call earns its place when diagnostic output distinguishes cancellation, browser policy, and filesystem problems.

The context's download acceptance still matters. connectOverCDP() has a noDefaults option in recent Playwright versions. When noDefaults is true, Playwright leaves acceptDownloads at the existing default context's browser setting. If downloads do not emit or are denied, inspect that choice and how Chrome was launched. artifactsDir cannot override a browser policy that rejects the download.

The example creates a new page inside the existing default context and closes only that page. It avoids closing the default context, which may be owned by the external browser. A dedicated browser per worker is still the cleaner design. Shared contexts allow cookies and storage from one test to affect another.

Large downloads have a concrete cost. The browser writes the download, and saveAs() copies it to the retained path. Remote connections can transfer bytes across the connection rather than exposing a local source path. Retaining a 2 GB file can double storage traffic and lengthen teardown. For large payload tests, verify a bounded sample or checksum through an approved streaming approach and keep only the evidence the defect workflow needs.

Diagnose empty directories from lifecycle evidence

An empty artifact directory does not identify one cause. Add markers for each completed stage:

When CDP_ENDPOINT is an HTTP debugging URL, this shell diagnostic separates endpoint reachability, file creation, and ZIP corruption without printing the endpoint response:

Shell
#!/usr/bin/env bash
set -euo pipefail

: "${CDP_ENDPOINT:?Set CDP_ENDPOINT to the HTTP debugging URL}"
: "${CDP_ARTIFACTS_DIR:?Set CDP_ARTIFACTS_DIR to the run directory}"

mkdir -p "$CDP_ARTIFACTS_DIR"
if [[ ! -w "$CDP_ARTIFACTS_DIR" ]]; then
  printf 'artifact directory is not writable: %s\n' "$CDP_ARTIFACTS_DIR" >&2
  exit 1
fi

curl --fail --silent --show-error \
  --output /dev/null \
  "${CDP_ENDPOINT%/}/json/version"

trace_path="$CDP_ARTIFACTS_DIR/smoke-trace.zip"
if [[ ! -s "$trace_path" ]]; then
  printf 'trace is missing or empty: %s\n' "$trace_path" >&2
  exit 2
fi

unzip -tqq "$trace_path"
printf 'CDP endpoint, trace size, and ZIP integrity passed\n'
Example
cdp_evidence={
  "directoryCreated":true,
  "connected":true,
  "defaultContextFound":true,
  "traceStarted":true,
  "actionCompleted":false,
  "traceStopped":true,
  "traceAttached":true,
  "browserDisconnected":true
}

Store this as a small JSON attachment rather than relying on interleaved console lines from parallel workers. It contains no endpoint token.

If directoryCreated is false, the failure is filesystem setup. Check the absolute path, container mount, parent permissions, read-only workspace, disk quota, and inode exhaustion. A connection timeout cannot repair a directory that the test process cannot write.

If connected is false, inspect the CDP endpoint and browser process. A typical failure occurs before any context exists:

Example
browserType.connectOverCDP: connect ECONNREFUSED 127.0.0.1:9222

That points to Chrome startup, port publishing, endpoint address, or firewall. artifactsDir is not involved yet. Confirm the debugging endpoint from the same network namespace as the test process, without printing embedded credentials.

If defaultContextFound is false, the endpoint may not represent the expected browser or the connection may have dropped during discovery. Playwright's documented connectOverCDP example accesses browser.contexts()[0]. Fail clearly instead of indexing into undefined and producing an unrelated error at context.pages().

If traceStarted is false after connection, the setup branch skipped context.tracing.start() or tracing itself threw. Check whether another client already controls tracing on the same context. Shared CDP clients can interfere with session-wide instrumentation.

If traceStarted is true and traceStopped is false, teardown order is the prime suspect. A process exit, test timeout, browser disconnect, or cleanup exception can skip finalization. Put stop in finally and give the test enough teardown budget. Do not call process.exit() immediately after an assertion failure because pending artifact writes will be abandoned.

If the trace file exists locally but traceAttached is false, the problem is reporter retention. Inspect the exact path passed to testInfo.attach(), whether attachment occurred before deletion, and the CI upload glob. A shell uploader looking for test-results/**/*.zip will not find files written under another working directory.

Open a retained trace directly:

Shell
npx playwright show-trace test-results/path-to-attempt/cdp-artifacts/trace.zip

An archive that opens but lacks assertions is expected when tracing was started through context.tracing rather than Playwright Test configuration. The tracing API captures browser operations and network activity, but Playwright's documentation notes that it does not record test assertions. Use test steps and attachments to preserve the claim, or prefer configured Playwright Test tracing when CDP attachment is not required.

Downloads have different evidence. The download event fires when a download starts, while path availability and saveAs completion wait for it to finish. A missing event points to page behavior or browser download policy. A saveAs filesystem error points to the destination. A call to download.path() that throws on a remote connection is an API-choice problem.

Version mismatch appears at compile or runtime. Verify:

Shell
npx playwright --version

The artifactsDir option for connectOverCDP requires 1.61 or newer. An older declaration may report that the property is not part of the options object. Do not suppress that error with a cast. A stale CI image can still run an older package even when the repository manifest requests a newer range.

Another near-miss is an explicit path outside artifactsDir. tracing.stop({ path: '/tmp/trace.zip' }) writes the final archive to /tmp because that method received an explicit destination. The connection directory can be empty or contain only intermediate files. Search the path actually passed to the finalizing API before concluding that artifactsDir was ignored.

Roll the directory into CI without creating new collisions

Start with a single dedicated CDP project and one worker. CDP attachment uses an existing browser and default context, so ordinary Playwright Test isolation assumptions may not hold. Cookies, local storage, open pages, service workers, extensions, and downloads can be shared.

Make the output path unique by run, worker, test, retry, and repeat index. testInfo.outputPath() already scopes output to the test result, which is safer than manually concatenating only a title. If an external runner needs a deterministic tree, sanitize every component and include retry.

Do not solve filesystem collisions while leaving browser collisions. Two workers can write separate artifact directories and still drive the same page or mutate the same default context. Assign one CDP endpoint per worker, run the project serially, or create a reviewed isolation scheme. Separate directories protect files, not browser state.

Add a small manifest after finalization:

TypeScript
import { stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

async function writeManifest(
  runDir: string,
  tracePath: string,
  testId: string
) {
  const trace = await stat(tracePath);

  await writeFile(
    join(runDir, 'manifest.json'),
    JSON.stringify({
      testId,
      trace: {
        file: 'trace.zip',
        bytes: trace.size,
      },
      finalizedAt: new Date().toISOString(),
    }, null, 2),
    'utf8'
  );
}

The manifest proves that finalization completed and gives retention tooling a stable inventory. It should not contain cookies, CDP credentials, internal page content, or full absolute paths from developer machines.

Retain failure and retry attempts separately. The first failure often has the only useful trace. A passing retry should not overwrite it or cause cleanup to delete it. Configure CI upload to run even when the test command fails.

If the smoke script above is saved as scripts/cdp-evidence.ts and the job has started a dedicated Chrome at the configured endpoint, these GitHub Actions steps keep the failed command's status, upload the attempt-specific directory, and then restore the failure after evidence retention:

YAML
- name: Run the dedicated CDP test
  id: cdp_test
  continue-on-error: true
  env:
    CDP_ENDPOINT: http://127.0.0.1:9222
    CDP_ARTIFACTS_DIR: test-results/cdp-${{ github.run_id }}-${{ github.run_attempt }}
  run: npx tsx scripts/cdp-evidence.ts

- name: Verify retained CDP evidence
  if: ${{ always() }}
  env:
    CDP_ARTIFACTS_DIR: test-results/cdp-${{ github.run_id }}-${{ github.run_attempt }}
  run: test -s "$CDP_ARTIFACTS_DIR/smoke-trace.zip"

- name: Upload CDP evidence
  if: ${{ always() }}
  uses: actions/upload-artifact@v4
  with:
    name: cdp-evidence-${{ github.run_attempt }}
    path: test-results/cdp-${{ github.run_id }}-${{ github.run_attempt }}
    if-no-files-found: error

- name: Fail after evidence upload
  if: ${{ steps.cdp_test.outcome == 'failure' }}
  run: exit 1

Set quotas. Traces with screenshots and DOM snapshots grow quickly, and downloads can dwarf them. Keep all evidence for failed attempts, sample passing traces, and delete expired run directories through CI retention rather than an unbounded cleanup script inside tests. The test should never recursively delete a shared output root.

Check permissions in the same container or host that runs the Node Playwright client. A directory writable on the Chrome host is not automatically writable to the connecting job, and the reverse is also true. The isLocal option tells Playwright that browser and client share a host for certain filesystem optimizations; do not set it merely to make a path error disappear.

Security review belongs in rollout. Traces can include DOM text, request URLs, headers, and response information. Downloads are the product data. Use a restricted artifact store, redact test accounts, and avoid attaching daily-browser evidence. CDP endpoints themselves should be private and authenticated where the provider supports it.

The operational costs are real: more disk, upload time, reporter processing, and retention administration. A 100 MB trace uploaded from 20 retries can delay a pipeline more than the test did. Match artifact richness to defect triage needs. Screenshots and snapshots help UI failures; a download checksum and request log may be enough for export validation.

Prefer a normal Playwright launch when CDP is not the requirement

Do not use connectOverCDP solely to choose an artifact directory. A normal Playwright Test project already owns browser lifecycle, context isolation, traces, screenshots, videos, and per-test output. Configure those native facilities instead.

Choose browserType.connect() when a remote Playwright browser server is under your control and protocol fidelity matters. Playwright explicitly describes the CDP connection as lower fidelity. The Playwright protocol also supports Chromium, Firefox, and WebKit, while CDP attachment is Chromium-only.

Avoid attaching to a person's daily Chrome profile for CI evidence. The trace can capture private pages, cookies can alter application behavior, extensions can inject scripts, and closing or changing tabs can disrupt the user. A separate user data directory costs startup time but creates a reviewable boundary.

Do not expect artifactsDir to enable videos on an existing default context. Video recording is normally configured when a context is created. Attaching after that lifecycle point cannot retroactively record earlier activity. If video is required, launch or create the context with supported recording options and explicit ownership.

Skip large retained downloads when the test only needs to prove HTTP headers, size, or a checksum. Copying remote files adds bandwidth and disk pressure. Keep the smallest evidence that can reproduce or explain the failure.

Do not use artifact output as a substitute for assertions. A trace showing the right page does not make a test pass correctly, and a downloaded file in a directory does not prove its content. Assert the behavior first, then retain evidence that explains a failure.

Finally, do not reach for the directory option when the missing file is caused by teardown. A browser closed before tracing.stop(), a process killed at timeout, or an uploader skipped on failure will lose evidence regardless of directory naming. Fix ownership and finalization first. The path becomes useful once every layer agrees who creates, closes, verifies, and retains each artifact.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

How do I set an artifact directory when using connectOverCDP?

Pass artifactsDir in the second argument to chromium.connectOverCDP(), using Playwright 1.61 or newer. Create the directory first, prefer an absolute path, and give each CI attempt its own location.

Does artifactsDir automatically start a Playwright trace?

No recording is enabled merely by choosing a storage directory. Start context tracing explicitly and stop it with a final output path, or use the relevant download and artifact APIs for the evidence the run must retain.

Why does download.path fail after connecting to Chrome remotely?

Playwright documents that download.path() throws for remote connections. Use download.saveAs() to copy the completed download into a caller-owned path, and sanitize the suggested filename before joining it to that directory.

Can connectOverCDP attach to Firefox or WebKit?

Only Chromium-based browsers support Playwright's CDP connection. Use the Playwright protocol through browserType.connect() when the suite needs Firefox, WebKit, or higher-fidelity Playwright features.

Why is my CDP artifact folder empty after a failed test?

An empty folder often means tracing never started, tracing.stop() was skipped, the browser disconnected before finalization, or the reporter never uploaded the file. Check lifecycle markers and filesystem permissions before changing the connection timeout.