惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

I
InfoQ
H
Heimdal Security Blog
罗磊的独立博客
B
Blog RSS Feed
WordPress大学
WordPress大学
The Register - Security
The Register - Security
N
Netflix TechBlog - Medium
美团技术团队
量子位
GbyAI
GbyAI
Recent Announcements
Recent Announcements
博客园 - 叶小钗
D
DataBreaches.Net
S
SegmentFault 最新的问题
Hacker News - Newest:
Hacker News - Newest: "LLM"
T
Troy Hunt's Blog
The Last Watchdog
The Last Watchdog
O
OpenAI News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
酷 壳 – CoolShell
酷 壳 – CoolShell
Webroot Blog
Webroot Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Last Week in AI
Last Week in AI
V
V2EX
N
News and Events Feed by Topic
Jina AI
Jina AI
Y
Y Combinator Blog
T
The Blog of Author Tim Ferriss
IT之家
IT之家
C
Check Point Blog
H
Hacker News: Front Page
爱范儿
爱范儿
Schneier on Security
Schneier on Security
Apple Machine Learning Research
Apple Machine Learning Research
P
Privacy & Cybersecurity Law Blog
L
LINUX DO - 最新话题
Forbes - Security
Forbes - Security
人人都是产品经理
人人都是产品经理
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Microsoft Azure Blog
Microsoft Azure Blog
C
Cyber Attacks, Cyber Crime and Cyber Security
D
Darknet – Hacking Tools, Hacker News & Cyber Security
S
Secure Thoughts
The Cloudflare Blog
Simon Willison's Weblog
Simon Willison's Weblog
Stack Overflow Blog
Stack Overflow Blog
腾讯CDC
MongoDB | Blog
MongoDB | Blog
V2EX - 技术
V2EX - 技术
AI
AI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
The Playwright Playbook — Part 2: Network Interception — The Complete Guide
Faizal · 2026-06-16 · via DEV Community

The Playwright Playbook — Part 2: Network Interception — The Complete Guide

"Your tests should own the network — not be at its mercy."

In Part 1, we fixed the foundation — proper selectors, storageState, POM, and a clean playwright.config.ts. If you haven't read that, start there. The project structure we built in Part 1 is what we're building on today.

Now we go to the feature that separates intermediate Playwright users from advanced ones.

Network interception.

Most people know page.route() exists. Most people use it once, mock one response, and move on.

But network interception is an entire testing strategy — not just a utility. When you fully understand it, you can:

  • Test your frontend completely independently of the backend
  • Simulate error states, timeouts, and edge cases in seconds
  • Assert on exactly what API calls your app is making — and what it's sending
  • Record real network traffic and replay it in tests without a live server
  • Catch silent API failures that your UI swallows gracefully

This is the part where Playwright stops feeling like a UI testing tool and starts feeling like a full testing platform. 🚀

Let's build it. 🎯


🏗️ Where We Left Off

At the end of Part 1, our project looked like this:

playwright-playbook/
├── tests/
│   ├── auth/
│   │   └── login.spec.ts
│   └── tasks/
│       └── task-management.spec.ts
├── pages/
│   ├── LoginPage.ts
│   └── TaskPage.ts
├── .auth/
│   ├── admin.json
│   └── user.json
├── global-setup.ts
├── playwright.config.ts
├── .env
└── package.json

By the end of Part 2, we're adding a fixtures/ folder for mock data and a new network/ test folder:

playwright-playbook/
├── tests/
│   ├── auth/
│   ├── tasks/
│   └── network/                         ← NEW
│       ├── api-mocking.spec.ts
│       ├── error-simulation.spec.ts
│       └── network-assertions.spec.ts
├── pages/
├── fixtures/                            ← NEW
│   ├── tasks.json
│   └── empty-tasks.json
├── .auth/
├── global-setup.ts
├── playwright.config.ts
└── .env


🔍 How Playwright Network Interception Works

Before we write code — let's understand the architecture.

Every time your browser makes a network request, Playwright can intercept it at the network layer — before it ever reaches the server.

Browser Action (click "Load Tasks")
        │
        ▼
Playwright Route Handler ← you control this
        │
   ┌────┴────┐
   │         │
   ▼         ▼
Fulfill    Continue
(mock)    (real server)

Three things you can do with an intercepted request:

  • Fulfill — return a fake response you define (mock)
  • Abort — kill the request entirely (simulate network failure)
  • Continue — let it go through to the real server (passthrough, optionally modified)

That's the whole mental model. Everything we do in this part is a variation of those three options.


🧪 The Basic: page.route() — Your First Mock

Let's say our Task Manager app calls GET /api/tasks to load the task list. In most test setups, this requires a running backend with seeded data.

With network interception — we don't need that.

First, create your mock data file:

// fixtures/tasks.json
[
  { "id": 1, "title": "Write unit tests", "status": "pending", "assignee": "admin" },
  { "id": 2, "title": "Review pull request", "status": "completed", "assignee": "user" },
  { "id": 3, "title": "Fix flaky test in CI", "status": "pending", "assignee": "admin" }
]

Now intercept and mock the API call in your test:

// tests/network/api-mocking.spec.ts
import { test, expect } from '@playwright/test';
import tasks from '../../fixtures/tasks.json';

test('task list renders mocked data correctly', async ({ page }) => {
  // Intercept BEFORE navigating to the page
  await page.route('**/api/tasks', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify(tasks),
    });
  });

  await page.goto('/tasks');

  // Assert the UI rendered the mocked data
  await expect(page.getByRole('listitem')).toHaveCount(3);
  await expect(page.getByText('Write unit tests')).toBeVisible();
  await expect(page.getByText('Review pull request')).toBeVisible();
});

Key rule: Always set up page.route() BEFORE page.goto(). The route handler needs to be registered before the page makes any requests. 🎯


❌ Simulating Error States

This is where network interception becomes genuinely powerful.

How do you test what happens when your API returns a 500? Or a 401? Or a timeout?

Without interception — you need to break your backend. With interception — one line.

// tests/network/error-simulation.spec.ts
import { test, expect } from '@playwright/test';

test('shows error message when API returns 500', async ({ page }) => {
  await page.route('**/api/tasks', async route => {
    await route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal Server Error' }),
    });
  });

  await page.goto('/tasks');

  // Assert the app handles the error gracefully
  await expect(page.getByTestId('error-banner')).toBeVisible();
  await expect(page.getByText('Something went wrong')).toBeVisible();
  // Task list should NOT render
  await expect(page.getByTestId('task-list')).not.toBeVisible();
});

test('shows empty state when API returns 404', async ({ page }) => {
  await page.route('**/api/tasks', async route => {
    await route.fulfill({
      status: 404,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Not found' }),
    });
  });

  await page.goto('/tasks');

  await expect(page.getByTestId('empty-state')).toBeVisible();
  await expect(page.getByText('No tasks found')).toBeVisible();
});

test('shows loading skeleton when API is slow', async ({ page }) => {
  await page.route('**/api/tasks', async route => {
    // Delay the response by 3 seconds
    await new Promise(resolve => setTimeout(resolve, 3000));
    await route.continue();
  });

  await page.goto('/tasks');

  // Loading skeleton should be visible while request is in flight
  await expect(page.getByTestId('loading-skeleton')).toBeVisible();
});

test('handles network failure gracefully', async ({ page }) => {
  await page.route('**/api/tasks', async route => {
    // Completely abort the request — simulates no network
    await route.abort('failed');
  });

  await page.goto('/tasks');

  await expect(page.getByTestId('network-error')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

Think about what you just did. You tested four different failure scenarios — without touching a single line of backend code. Without spinning up a special error environment. Without asking a developer to temporarily break something. 💪


🔁 Modifying Requests on the Fly

Sometimes you don't want to fully mock a response. You want to intercept a real request, modify it slightly, and let it continue.

test('request modification — inject test headers', async ({ page }) => {
  await page.route('**/api/tasks', async route => {
    // Modify the request before it goes to the server
    const headers = {
      ...route.request().headers(),
      'x-test-mode': 'true',
      'x-test-user': 'automation',
    };

    await route.continue({ headers });
  });

  await page.goto('/tasks');
  // Test proceeds with modified headers going to real server
});

test('inject different query params', async ({ page }) => {
  await page.route('**/api/tasks', async route => {
    const url = new URL(route.request().url());
    url.searchParams.set('status', 'completed');

    await route.continue({ url: url.toString() });
  });

  await page.goto('/tasks');

  // Only completed tasks should show — filtered at the API level
  await expect(page.getByText('Review pull request')).toBeVisible();
  await expect(page.getByText('Write unit tests')).not.toBeVisible();
});


📡 Asserting on Real Network Calls — waitForResponse

Everything so far has been about controlling the network. Now let's talk about observing it.

This is the pattern I use most in API-heavy applications — asserting that the correct API calls are made, with the correct payloads, at the correct times.

// tests/network/network-assertions.spec.ts
import { test, expect } from '@playwright/test';
import { TaskPage } from '../../pages/TaskPage';

test('creating a task makes the correct API call', async ({ page }) => {
  const taskPage = new TaskPage(page);
  await taskPage.goto();

  // Set up the response listener BEFORE triggering the action
  const createTaskResponse = page.waitForResponse(
    response =>
      response.url().includes('/api/tasks') &&
      response.request().method() === 'POST' &&
      response.status() === 201
  );

  // Trigger the action
  await taskPage.createTask('Write integration tests');

  // Wait for and capture the actual API response
  const response = await createTaskResponse;
  const responseBody = await response.json();

  // Assert on the API response directly
  expect(responseBody.title).toBe('Write integration tests');
  expect(responseBody.status).toBe('pending');
  expect(responseBody.id).toBeDefined();

  // Also assert the UI updated
  await expect(taskPage.getTaskLocator('Write integration tests')).toBeVisible();
});

test('deleting a task sends DELETE request with correct ID', async ({ page }) => {
  const taskPage = new TaskPage(page);
  await taskPage.goto();

  // First create a task to get its ID
  const createResponse = await page.waitForResponse(
    resp => resp.url().includes('/api/tasks') && resp.request().method() === 'POST'
  );
  await taskPage.createTask('Task to be deleted');
  const { id } = await (await createResponse).json();

  // Now watch for the DELETE call
  const deleteResponse = page.waitForResponse(
    resp =>
      resp.url().includes(`/api/tasks/${id}`) &&
      resp.request().method() === 'DELETE'
  );

  await taskPage.deleteTask('Task to be deleted');
  const deleted = await deleteResponse;

  expect(deleted.status()).toBe(200);
});

test('assert request payload on task creation', async ({ page }) => {
  const taskPage = new TaskPage(page);
  await taskPage.goto();

  let capturedRequestBody: Record<string, unknown> = {};

  // Intercept to capture the request body
  await page.route('**/api/tasks', async route => {
    if (route.request().method() === 'POST') {
      capturedRequestBody = JSON.parse(route.request().postData() || '{}');
    }
    await route.continue();
  });

  await taskPage.createTask('Validate request payload');

  // Assert what was actually sent to the API
  expect(capturedRequestBody.title).toBe('Validate request payload');
  expect(capturedRequestBody.status).toBe('pending');
  expect(capturedRequestBody.assignee).toBeDefined();
});

This pattern is invaluable when testing forms — you can assert the correct payload is being sent without depending on what the backend does with it. 🎯


🎬 HAR Files — Record Real Traffic, Replay It in Tests

HAR (HTTP Archive) is the most underused feature in Playwright's network toolkit.

The idea: Record real network traffic from your app once — save it to a file — replay it in tests without ever hitting the server again.

Perfect for:

  • Third-party APIs you don't control (payment gateways, analytics, maps)
  • Expensive API calls you don't want to make in every test run
  • Flaky external dependencies you want to stabilize

Step 1 — Record the HAR file

// scripts/record-har.ts
import { chromium } from '@playwright/test';

(async () => {
  const browser = await chromium.launch();
  const context = await browser.newContext();

  // Start recording
  await context.routeFromHAR('./fixtures/tasks-har.har', { update: true });

  const page = await context.newPage();
  await page.goto('http://localhost:3000/tasks');

  // Interact with the app — all network calls get recorded
  await page.getByRole('button', { name: 'New Task' }).click();
  await page.getByLabel('Task title').fill('Recorded task');
  await page.getByRole('button', { name: 'Save task' }).click();

  await browser.close();
  console.log('HAR recorded to fixtures/tasks-har.har');
})();

Run this once: npx ts-node scripts/record-har.ts

Step 2 — Replay it in your tests

test('task flow using recorded HAR — no live server needed', async ({ page }) => {
  // All network calls will be served from the HAR file
  await page.routeFromHAR('./fixtures/tasks-har.har', {
    // Strict mode — fail if a request isn't in the HAR
    notFound: 'abort',
    // Match by URL only (ignore method, headers)
    url: '**/api/**',
  });

  await page.goto('/tasks');

  // Test runs against recorded responses — fully deterministic
  await expect(page.getByRole('listitem')).toHaveCount(3);
});

Your tests are now completely decoupled from any live server. CI runs in half the time. Zero flakiness from external APIs. ✅


🧩 Putting It All Together — A Real-World Scenario

Here's a scenario you'll actually face: testing a dashboard that loads data from three different APIs, one of which is a flaky third-party service.

test('dashboard renders correctly even when analytics API is down', async ({ page }) => {
  // Let the core task API work normally
  await page.route('**/api/tasks', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, title: 'Write unit tests', status: 'pending' },
        { id: 2, title: 'Deploy to staging', status: 'completed' },
      ]),
    });
  });

  // Let the user API work normally
  await page.route('**/api/users/me', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({ name: 'Faizal', role: 'admin' }),
    });
  });

  // Simulate the flaky third-party analytics API being down
  await page.route('**/analytics.thirdparty.com/**', async route => {
    await route.abort('failed');
  });

  await page.goto('/dashboard');

  // Core content should still render
  await expect(page.getByText('Write unit tests')).toBeVisible();
  await expect(page.getByText('Hello, Faizal')).toBeVisible();

  // Analytics widget should degrade gracefully — not crash the page
  await expect(page.getByTestId('analytics-widget')).toContainText('Analytics unavailable');

  // No error banner for the overall page
  await expect(page.getByTestId('error-banner')).not.toBeVisible();
});

This is the kind of test that catches real production bugs. Most teams never write it — because without network interception, it's too hard to set up. With Playwright — it's 10 lines. 🔥


📁 Updated Project Structure After Part 2

playwright-playbook/
├── tests/
│   ├── auth/
│   │   └── login.spec.ts
│   ├── tasks/
│   │   └── task-management.spec.ts
│   └── network/                              ← NEW
│       ├── api-mocking.spec.ts
│       ├── error-simulation.spec.ts
│       └── network-assertions.spec.ts
├── pages/
│   ├── LoginPage.ts
│   └── TaskPage.ts
├── fixtures/                                 ← NEW
│   ├── tasks.json
│   ├── empty-tasks.json
│   └── tasks-har.har
├── scripts/                                  ← NEW
│   └── record-har.ts
├── .auth/
│   ├── admin.json
│   └── user.json
├── global-setup.ts
├── playwright.config.ts
├── .env
└── package.json


🗺️ What's Coming in This Series

Part 1 — Stop Writing Tests Like a Beginner              ✅ Done
Part 2 — Network Interception: The Complete Guide        ← You are here
Part 3 — Multi-User, Multi-Tab & Context Testing
Part 4 — API Testing (The Underrated Superpower)
Part 5 — Visual Regression Testing
Part 6 — Debugging Like a Pro: Trace Viewer & Inspector
Part 7 — The CI/CD Setup Nobody Shows You
Part 8 — Playwright Meets AI: Agents, MCP & Self-Healing Tests

In Part 3, we tackle the scenarios most automation frameworks can't handle at all — testing with multiple users simultaneously, multi-tab flows, and real-time features like live notifications. Playwright's browser context architecture makes this surprisingly clean.


🔖 Before You Go

Network interception is the feature that makes Playwright genuinely powerful — not just as a UI testing tool, but as a full-stack testing platform.

Once you start owning the network layer in your tests:

  • You stop depending on backend availability
  • Your tests stop failing because of external APIs
  • You start catching frontend bugs that only appear under specific API conditions
  • Your test suite becomes fast, deterministic, and actually reliable

That's the goal. Not just tests that run — tests that you can trust. 💪


Follow me so you don't miss Part 3 — where we go deep on multi-user and multi-tab testing. Two browser contexts. One test. Real-time feature validation without a live websocket server.

Drop a comment below 👇

  • Are you using page.route() in your current test suite?
  • What's the most painful external dependency in your tests right now?
  • Have you ever used HAR files before — or is that new to you?

Let's talk in the comments. 🙌


Faizal Shaikh | Senior Automation Engineer | Playwright & AI Testing
Connect with me on LinkedIn