PRACTICAL GUIDE / Playwright artifactsDir worker project partitioning

Stop parallel Playwright workers from sharing artifact paths

Use Playwright's test-owned output paths, isolate custom worker logs by run and project, and diagnose collisions that only appear in parallel CI.

By The Testing AcademyUpdated August 7, 202621 min read
All field guides
In this guide7 sections
  1. Use Playwright's test-owned output directory first
  2. Partition only artifacts that outlive one test
  3. Tell collisions from cleanup and upload loss
  4. A short worker log is not always an overwritten worker log
  5. Carry the same partition through CI
  6. Migrate one artifact producer at a time
  7. Route ownership with the artifact evidence
  8. Avoid partitioning when Playwright already owns the boundary

What you will learn

  • Use Playwright's test-owned output directory first
  • Partition only artifacts that outlive one test
  • Tell collisions from cleanup and upload loss
  • Carry the same partition through CI

Two Playwright workers fail within seconds, but the uploaded bundle contains one network log. Both tests wrote to artifacts/network.ndjson, and the last process to close the file won. Adding a worker number appears to fix it until a worker restarts or the same indexes run on another shard.

Playwright already solves the common version of this problem. Each test run receives its own output directory, and testInfo.outputPath() returns a path inside it that parallel tests can use safely. Manual worker and project partitioning belongs only around artifacts whose lifetime is genuinely wider than one test.

Use Playwright's test-owned output directory first

There is no documented Playwright Test configuration property named artifactsDir. A repository may have a constant, environment variable, or wrapper option with that name, but it is team-owned code. Passing artifactsDir into defineConfig() is not the supported way to control test output.

The supported project property is outputDir. It defaults to test-results relative to the package directory. Playwright cleans that directory at the start of a run, then creates a unique subdirectory for each test run. The per-test absolute directory is exposed as testInfo.outputDir. The testInfo.outputPath(...segments) method returns a path below it and rejects a path that would escape that boundary.

That guarantee is stronger than a filename assembled from the title and worker index. It accounts for project, repeat, and retry execution in Playwright's own output layout. The first attempt and a retry each have a distinct test run. Two tests with the same title in different files do not need to negotiate a shared destination.

Use the method whenever the file belongs to one test attempt:

  • A screenshot captured by test code
  • A JSON dump of application state
  • A downloaded fixture copied for diagnosis
  • A HAR fragment or console excerpt produced for that attempt
  • A manifest describing the test's external resources

Create any nested directory before writing. outputPath() computes and validates the path; it does not promise that every parent directory you add already exists. Attach files that should appear in reporters with testInfo.attach() rather than asking a custom reporter to guess their locations.

This example writes a create-only diagnostic record into the current test's output directory and attaches it. The record contains identity fields that are useful when the file is later removed from its original tree.

TypeScript
import { test, expect } from '@playwright/test';
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';

test('submits a refund for review', async ({ page }, testInfo) => {
  await page.goto('/refunds/new');
  await page.getByLabel('Order ID').fill('order-for-this-test');
  await page.getByRole('button', { name: 'Submit for review' }).click();

  const diagnosticPath = testInfo.outputPath('diagnostics', 'identity.json');
  await mkdir(dirname(diagnosticPath), { recursive: true });

  const diagnostic = {
    testId: testInfo.testId,
    project: testInfo.project.name,
    retry: testInfo.retry,
    repeatEachIndex: testInfo.repeatEachIndex,
    workerIndex: testInfo.workerIndex,
    parallelIndex: testInfo.parallelIndex,
    pageUrl: page.url(),
  };

  await writeFile(
    diagnosticPath,
    JSON.stringify(diagnostic, null, 2),
    { encoding: 'utf8', flag: 'wx' },
  );

  await testInfo.attach('execution identity', {
    path: diagnosticPath,
    contentType: 'application/json',
  });

  await expect(page.getByText('Refund submitted')).toBeVisible();
});

The wx flag is not required for Playwright's uniqueness guarantee, but it is useful during framework migration. A duplicate write within the same test attempt becomes an EEXIST error rather than silently truncating the first diagnostic. Once ownership is proven, you can decide whether a particular artifact is allowed to be updated.

A common migration starts with a global failure hook that writes artifacts/failure.png. Move only the destination first. The hook below retains its existing behavior but lets Playwright choose the attempt-owned directory.

TypeScript
import { test } from '@playwright/test';

test.afterEach(async ({ page }, testInfo) => {
  if (testInfo.status === testInfo.expectedStatus) return;

  const screenshot = testInfo.outputPath('failure.png');
  await page.screenshot({ path: screenshot });
  await testInfo.attach('failure screenshot', {
    path: screenshot,
    contentType: 'image/png',
  });
});

This fixes three collisions at once: tests in different files, projects with the same title, and retries of one case no longer share a leaf path. It does not make the hook universally necessary. The built-in screenshot: 'only-on-failure' option is simpler when an ordinary failure screenshot is all you need. Keep custom capture when it records a deliberate page state, uses a product-specific name, or must run at a particular hook boundary.

The hook also exposes a lifecycle near-miss. A test that closes its page before failing leaves no live page for page.screenshot(). Changing directories will not fix that error. Read the failure stack and page lifecycle before classifying every missing PNG as parallel overwrite.

Project-level outputDir is still useful. It sets the root and lets CI keep separate Playwright invocations away from each other. The value must be stable every time the configuration is evaluated. Accept a slot assigned by the caller, sanitize it, and avoid generating a random value inside the config.

TypeScript
import { defineConfig, devices } from '@playwright/test';
import path from 'node:path';

function safeSlot(value: string): string {
  const safe = value.replace(/[^a-zA-Z0-9._-]+/g, '-');
  if (!safe || safe === '.' || safe === '..') {
    throw new Error(`Invalid PW_OUTPUT_SLOT: ${value}`);
  }
  return safe;
}

const slot = safeSlot(process.env.PW_OUTPUT_SLOT ?? 'local');

export default defineConfig({
  use: {
    screenshot: 'only-on-failure',
    trace: 'retain-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      outputDir: path.join('test-results', slot, 'chromium'),
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'webkit',
      outputDir: path.join('test-results', slot, 'webkit'),
      use: { ...devices['Desktop Safari'] },
    },
  ],
});

Separate project roots make the filesystem easier to inspect and prevent two projects from cleaning the same explicit directory. The slot handles a second process running in the same workspace. In isolated CI jobs, each job may already have its own filesystem, but keeping run ownership explicit helps reusable runners and combined upload steps.

The trade-off is cleanup. Playwright cleans each project output directory at startup. If PW_OUTPUT_SLOT=local is used by two concurrent local commands, they still share a root and can interfere. A launcher that permits concurrent commands must assign distinct slots before either command starts.

Partition only artifacts that outlive one test

Some files really are worker-scoped. A proxy launched once per worker may write one log. A worker-scoped account fixture may need an audit record covering several tests. Capturing those through one test's outputPath() would make the first test appear to own activity from the whole process.

For these cases, include dimensions in this order:

CI run / CI shard / project / parallel slot / worker process / artifact kind

The run and shard come from CI. Project comes from workerInfo.project.name. The parallelIndex identifies a worker slot from zero up to the configured worker count minus one. When Playwright restarts a worker after a failure, the replacement keeps the same parallel index. The new process receives a new workerIndex.

That distinction determines the name. If one logical lane should have one record assembled after the run, collect process files separately and merge them later. Do not let a restarted worker reopen the old lane file for uncoordinated append. If every process owns its own log, include both indexes and create the file once.

This worker-scoped fixture creates an NDJSON log with a unique path for the process. It requires explicit CI values when CI is set, and it closes the stream before fixture teardown completes.

TypeScript
import { test as base } from '@playwright/test';
import { createHash } from 'node:crypto';
import { once } from 'node:events';
import { mkdir } from 'node:fs/promises';
import { createWriteStream, type WriteStream } from 'node:fs';
import path from 'node:path';

type WorkerLog = {
  path: string;
  write(event: Record<string, unknown>): void;
};

type WorkerFixtures = {
  workerLog: WorkerLog;
};

function requiredInCi(name: string, fallback: string): string {
  const value = process.env[name];
  if (value) return value;
  if (process.env.CI) throw new Error(`Missing ${name} in CI`);
  return fallback;
}

function safe(value: string): string {
  const result = value.replace(/[^a-zA-Z0-9._-]+/g, '-');
  if (!result || result === '.' || result === '..') {
    throw new Error(`Unsafe artifact component: ${value}`);
  }
  return result.slice(0, 48);
}

function ownedPart(value: string): string {
  const digest = createHash('sha256').update(value).digest('hex').slice(0, 12);
  return `${safe(value)}--${digest}`;
}

export const test = base.extend<{}, WorkerFixtures>({
  workerLog: [
    async ({}, use, workerInfo) => {
      const run = ownedPart(requiredInCi('CI_RUN_KEY', 'local'));
      const shard = ownedPart(requiredInCi('CI_SHARD_KEY', 'single'));
      const project = ownedPart(workerInfo.project.name);

      const directory = path.resolve(
        'worker-artifacts',
        run,
        shard,
        project,
      );
      await mkdir(directory, { recursive: true });

      const file = path.join(
        directory,
        `parallel-${workerInfo.parallelIndex}-worker-${workerInfo.workerIndex}.ndjson`,
      );
      const stream: WriteStream = createWriteStream(file, {
        encoding: 'utf8',
        flags: 'wx',
      });
      await once(stream, 'open');

      await use({
        path: file,
        write(event) {
          stream.write(JSON.stringify({
            workerIndex: workerInfo.workerIndex,
            parallelIndex: workerInfo.parallelIndex,
            ...event,
          }) + '\n');
        },
      });

      stream.end();
      await once(stream, 'close');
    },
    { scope: 'worker' },
  ],
});

This fixture is not a replacement for test-owned output. If each test calls workerLog.write({ result: ... }), the combined file is ordered by the sequence in that worker process, but it does not become an attachment for one test automatically. Include testInfo.testId and testInfo.retry in each event when test correlation matters.

A single worker normally executes one test at a time, but worker logs can also receive messages from fixtures and hooks. Node streams provide write ordering within that process. They do not coordinate separate processes. The unique process path is what avoids cross-process writes.

Project name alone is insufficient. The same named project exists on each shard, and the same parallel indexes repeat on different machines. Worker index alone is also insufficient because each Playwright invocation starts its own index space. CI dimensions complete the ownership boundary.

Sanitization needs identity preservation too. Two legal project labels can collapse to the same filesystem text after punctuation is replaced. The fixture keeps a readable prefix and appends a digest of the original value, so mobile/chrome and mobile:chrome do not become one owner. The same rule protects unusually formatted run and shard values. A digest here shortens identity; it is not a checksum of the worker log.

Run-scoped summaries need one more boundary. Coverage totals, accessibility aggregates, and custom counters should not be updated by every worker through one JSON file. Give each process an immutable fragment, close those fragments at teardown, then combine them in a serial post-test step. The merger can reject a missing worker fragment and duplicate process identity. Direct shared writes would require a real inter-process lock and recovery rules for a worker killed mid-write, which is far more machinery than a folder prefix.

Tell collisions from cleanup and upload loss

Three incidents can end with "one file is missing" while requiring different fixes.

A writer collision happens during the test run. Two owners resolve the same path, and one truncates or appends to the other's file. With create-only writes, the second writer gets an EEXIST-style filesystem error. Logs from both owners show the same absolute destination. Fix the identity tuple or lifecycle.

Startup cleanup happens between Playwright invocations. The first process writes valid output. A second process points at the same project outputDir and starts while the first is still running or before its uploader finishes. Playwright documents that the output directory is cleaned at the start. The file exists in a mid-run inventory and disappears when another invocation begins. Give each process a caller-supplied output slot or isolate workspaces.

Upload loss occurs after local execution. Every expected local relative path and checksum exists, but the downloaded CI artifact contains fewer entries. The uploader may have flattened directory structures, matched only one project glob, or published before teardown closed a worker file. Package or upload the root recursively after Playwright exits, and inspect the archive entry names.

A fourth near-miss is retention policy. The config above keeps screenshots only for failures and retains traces and video on failure. A passing test will not produce the same built-in files as a failure. Before calling that deletion, check the test's final status and the configured mode. Do not broaden retention just because one engineer expected a video for a pass.

Worker restart evidence is especially revealing. A failed test causes Playwright to discard its worker process and continue in a new one; with retries, the retry runs in that replacement. If logs show one parallelIndex but two workerIndex values, the restart is expected. A custom filename based only on parallel index invited the replacement to reuse the original path.

Run this inventory after the test command and before upload. It prints paths with sizes, rejects relative paths that collide once the two roots are combined, verifies that each worker log ends with a newline, and records a SHA-256 manifest.

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

roots=(test-results worker-artifacts)
manifest="artifact-sha256.txt"
: > "${manifest}"

for root in "${roots[@]}"; do
  [[ -d "${root}" ]] || continue
  find "${root}" -type f -printf '%p\t%s bytes\n' | sort

  while IFS= read -r file; do
    sha256sum "${file}" >> "${manifest}"
  done < <(find "${root}" -type f -print | sort)
done

duplicates=$(
  for root in "${roots[@]}"; do
    [[ -d "${root}" ]] || continue
    find "${root}" -type f -printf '%P\n'
  done |
    sort |
    uniq -d
)
if [[ -n "${duplicates}" ]]; then
  echo "Relative paths collide once the roots are combined:" >&2
  printf '%s\n' "${duplicates}" >&2
  exit 30
fi

while IFS= read -r log; do
  if [[ -s "${log}" ]] && [[ "$(tail -c 1 "${log}" | wc -l)" -eq 0 ]]; then
    echo "Worker log was not cleanly terminated: ${log}" >&2
    exit 31
  fi
done < <(find worker-artifacts -type f -name '*.ndjson' -print 2>/dev/null)

sort -k2 "${manifest}" -o "${manifest}"
test -s "${manifest}" || { echo "No artifacts found" >&2; exit 32; }

The duplicate check compares relative paths, so it is only meaningful because two roots are involved. GNU find's %P removes the starting point it was found under, which makes test-results/chromium/log.ndjson and worker-artifacts/chromium/log.ndjson both reduce to chromium/log.ndjson. A single tree cannot collide with itself, but an uploader or a download step that merges the two roots into one destination can, and that is the case the exit code protects.

The size values are observations from the current run, not performance measurements. Save the output with the job log. When an archive later lacks a path that appeared here, investigation moves to packaging and upload rather than Playwright workers.

If files are present but attached to the wrong test in a report, compare testInfo.testId in the diagnostic JSON with the report's test ID. The filesystem may be correct while a custom reporter groups attachments by title. Titles are not unique identifiers. Fix the reporter grouping rather than adding more path segments.

A short worker log is not always an overwritten worker log

A worker can own a unique path and still leave an incomplete file. The process may be terminated before fixture teardown closes its stream, or the filesystem may reject a later write after the file was created successfully. The resulting archive can look like an overwrite because the last operations are absent and the file is smaller than an engineer expects. Adding another directory component does nothing for this failure. Ownership was already unique; completion was not observed.

Separate the two cases at the point where the file is opened. A collision produces two owner tuples that resolve to the same absolute path. With the create-only mode used above, one open succeeds and another reports an error whose filesystem code is EEXIST; the error also identifies the contested path. An incomplete unique file has a different shape: each run, shard, project, parallel index, and worker index tuple resolves to one path, each create-only open succeeds, but one producer never records a clean close or its file fails the final structural check. Preserve the open and close messages in the job log so an archive can be interpreted without recreating the worker schedule.

The NDJSON newline check is deliberately quiet when a nonempty file ends on a record boundary. Its broken output names the worker log and says that it was not cleanly terminated. That message is evidence of an incomplete final record, not proof of why the process stopped. A worker can be killed immediately after writing a complete line, so a final newline is necessary for this format but cannot prove that every intended event was emitted. Pair it with the CI termination reason, the last Playwright test event, and the presence or absence of fixture teardown output.

Read sizes as routing evidence, not as a quality threshold. The inventory prints a path followed by a byte count. A healthy quiet worker may have a much smaller log than a busy worker, and two correct logs will normally have different SHA-256 values. The useful comparison is structural: the path is under the expected run and shard, the process indexes in the filename agree with those inside its records, every line parses, and the file was closed before inventory began. A positive size alone is misleading because a truncated JSON object still occupies bytes.

The identity attachment provides a similar diagnostic for test-owned files. Read testId, project, retry, and repeatEachIndex together. A retry should have a different attempt directory even if its title and project are unchanged. workerIndex may also change because Playwright replaced the process, while parallelIndex can remain the same logical lane. A report attachment whose embedded testId names another case indicates association or consumer grouping, not a path collision. A different worker index by itself is also misleading: it proves that two processes existed, but it says nothing about whether custom naming included enough identity to keep their destinations apart.

This distinction changes the repair. A contested open goes to the code that constructs paths. A unique but unclosed stream goes to fixture lifecycle or runner termination. A complete local file missing from the archive goes to CI packaging. Send the evidence from the earliest boundary that failed instead of assigning every short file to the parallelism backlog.

Carry the same partition through CI

The output slot and custom artifact run key should come from one pipeline identity. A shard matrix must also give each job a shard key. This workflow fragment assigns those values, runs one shard, then uploads both Playwright-owned and worker-owned roots without flattening them into a shared live directory.

YAML
name: Playwright matrix

on:
  push:
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        shard: [1, 2, 3, 4]
        total: [4]
    env:
      CI_RUN_KEY: ${{ github.run_id }}-attempt-${{ github.run_attempt }}
      CI_SHARD_KEY: shard-${{ matrix.shard }}-of-${{ matrix.total }}
      PW_OUTPUT_SLOT: ${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard }}
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - name: Run shard
        run: npx playwright test --shard=${{ matrix.shard }}/${{ matrix.total }}
      - name: Inventory artifacts
        if: always()
        run: ./ci/inventory-playwright-artifacts.sh
      - name: Upload this shard
        if: always()
        uses: actions/upload-artifact@v5
        with:
          name: pw-${{ env.CI_RUN_KEY }}-${{ env.CI_SHARD_KEY }}
          path: |
            test-results
            worker-artifacts
            artifact-sha256.txt
          if-no-files-found: error

The values in env are fixed before Playwright starts, so repeated config evaluation sees the same slot. The archive name includes the run attempt and shard, preventing artifact-service collisions. Each uploaded root retains its internal project and worker structure.

The example installs dependencies because it is a complete job. In a repository that already has a standard bootstrap action, keep that action and add only the ownership fields, inventory, and uploader paths. Artifact partitioning should not create a second dependency-install strategy.

For monorepos, add the package identity before project if separate packages can use the same output root. Prefer separate working directories or explicit package roots over deriving a package name from process.cwd() after scripts change directories.

Migrate one artifact producer at a time

Inventory every current writer before changing the directory tree. Search for writeFile, createWriteStream, page.screenshot with explicit paths, download copies, custom reporter output, and service logs. Classify each file as test-scoped, worker-scoped, process-scoped, or run-scoped.

Move test-scoped files to testInfo.outputPath() first. This usually removes most manual naming code. Add testInfo.attach() where report visibility is required. Keep create-only mode during a staged rollout to expose duplicate writes inside a test.

Next, isolate project roots with outputDir only if operators or concurrent invocations need it. Do not change output root and retention modes in the same rollout. If file counts change, you want to know whether naming or retention caused it.

Then move genuine worker files to a worker-scoped fixture. Include run, shard, project, parallel index, and worker index. Add a forced worker-restart test in a non-release job: fail one test, enable one retry, and verify that two process logs exist for the logical parallel slot rather than one replaced file.

Update upload patterns last. Capture can be perfect while a legacy glob such as artifacts/*.png ignores nested paths. Inventory locally, upload recursively, download in a verification job, and compare the saved checksum manifest. Remove the old flat root after every consumer reads the new hierarchy.

Keep dashboards tolerant during the transition. Resolve attachments through report metadata or manifests, not by reconstructing a filesystem path from a title. Path reconstruction duplicates the exact logic the migration is trying to centralize.

The first compatibility break usually appears after capture. A legacy uploader matches only flat children, a report link assumes a title-derived filename, or a cleanup task treats the new nested root as abandoned data. Land the inventory and recursive consumer support in shadow mode before enabling a migrated producer in the required job. The consumer should accept both layouts during the canary, while its verification rejects a missing manifest entry. After one producer moves, download its archive and prove that the attachment still opens before moving the next producer. Remove the flat-path fallback only after searches show that no uploader, dashboard, or cleanup task requests it.

This ordering has a maintenance cost. During the overlap, consumers carry two layouts and the archive may contain both old and new artifact classes. Keep that period bounded by a named migration owner. An indefinite dual reader makes a later collision harder to diagnose because either path can appear authoritative.

Route ownership with the artifact evidence

The test-framework owner fixes test and worker destination construction. That team controls testInfo.outputPath() helpers, worker fixtures, identity fields, and close behavior. The CI platform owner fixes run and shard keys, workspace isolation, upload timing, archive layout, and cleanup. The reporting or developer-tools owner fixes attachment association and links. Product test authors own only the artifact's content and the point in the test lifecycle when it is captured.

A useful handoff contains the pipeline run and attempt, shard, project, testId, retry, both worker indexes, the resolved absolute path from the writer log, and the matching manifest entry. Include the create or open result, whether teardown completed, and the archive entry name. If the report is wrong, include the attachment's embedded identity and the test identity shown by the report. This payload lets the receiving team reproduce its boundary without access to the original runner.

Do not hand off a screenshot of a directory listing with the claim that parallelism deleted a file. A listing taken after cleanup cannot locate the deletion boundary, and a title cannot identify a test attempt. The framework team needs owner and path evidence; the CI team needs pre-upload and post-download inventories; the report team needs attachment metadata. When those three views agree, the missing file lies outside this partitioning technique, such as a producer that never ran or a product hook that closed its page before capture.

Avoid partitioning when Playwright already owns the boundary

Do not add worker or project prefixes to every testInfo.outputPath() call. Playwright guarantees those paths are inside unique test-run directories. Extra identity makes paths longer and couples test code to scheduler details without increasing isolation.

Do not use parallelIndex as a global ID. It is a slot inside one invocation and deliberately survives a worker restart. That behavior is useful for one-account-per-worker fixtures, but separate shards and projects can repeat the number.

Do not use workerIndex when you need continuity across retries. It identifies the process, so a restart changes it. A test-owned path and retry field are the correct boundary for attempt evidence.

Avoid a shared worker log if per-test attachments are the real requirement. A combined stream reduces file count but makes retention and access control coarser. One sensitive event can force the whole worker log to be restricted, and one malformed line can affect several tests.

Skip custom worker artifacts when built-in traces, screenshots, and videos answer the incident. Every extra producer needs teardown, upload wiring, retention, and a consumer. Diagnostics that nobody reads are storage and privacy liabilities.

The custom design also costs startup and teardown time. Each worker creates directories and opens a stream, and CI calculates checksums over additional files. Measure that cost in your own suite before applying a retention or file-count budget.

The useful rule is ownership, not maximum partitioning. A test artifact goes under testInfo.outputPath(). A process artifact includes both worker indexes. A cross-machine artifact adds pipeline run and shard identity. Once each file has one writer and one lifecycle, a missing artifact points to a specific boundary instead of a vague parallelism problem.

// 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 7, 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

Is artifactsDir a Playwright Test config option?

No documented Playwright Test option is named `artifactsDir`. Teams often use that name for a custom directory; the supported test-output option is `outputDir`, with `testInfo.outputDir` and `testInfo.outputPath()` providing a unique directory for each test run.

Do I need the worker index in every screenshot filename?

Usually not. Files created through `testInfo.outputPath()` already live in a per-test-run directory that Playwright guarantees will not conflict with parallel tests. Worker identity matters for genuinely worker-scoped output such as one process log.

What is the difference between workerIndex and parallelIndex?

A restarted Playwright worker gets a new `workerIndex`, while its `parallelIndex` remains the same logical slot. Include the process index when every worker process needs a separate file; use the parallel index when modeling a stable lane.

Why are artifacts still missing when every test has a unique directory?

Check later boundaries. A second Playwright invocation may clean the same project output directory at startup, a retention setting may never keep the expected file, or the CI uploader may flatten paths after capture.

How should separate CI shards partition custom artifacts?

Add a pipeline-provided run key and shard key before project and worker fields. Worker indexes repeat on separate machines, so they cannot identify a file globally without the CI dimensions.