Proxy Playwright : configuration du navigateur et du contexte

15 min de lecture

Posez un proxy Playwright sur le navigateur ou un contexte, passez les identifiants, confirmez le CONNECT HTTPS, et corrigez les 407 et les erreurs de certificat sans couper le TLS.

Ce guide s'adresse aux développeurs qui ont besoin que le trafic Playwright sorte par un proxy HTTP ou SOCKS5, soit pour tout un navigateur, soit pour un seul contexte. Le laboratoire a utilisé Playwright 1.64.0 contre une origine locale et un proxy factice capable de CONNECT. Pas de clés gratuites, et Top Proxy ne vend pas d'IP. Un montage local qui réussit n'est pas un compte de production ni une région.

01Poser un proxy sur le navigateur ou sur un contexte

Playwright documente un objet proxy à deux endroits. Network: HTTP proxy (consulté le 14 octobre 2026) dit que le proxy peut être posé globalement pour tout le navigateur, ou pour chaque contexte de navigateur. browser.newContext() répète les mêmes champs : server, bypass optionnel, username optionnel, password optionnel. Le guide réseau Python utilise la même forme sur chromium.launch(proxy=...) et browser.new_context(proxy=...). Ce tutoriel exécute la bibliothèque Node. Python est d'API similaire dans cette documentation, ce n'est pas un second laboratoire.

Posez le proxy sur chromium.launch quand chaque contexte de ce processus doit partager une sortie. Posez-le sur browser.newContext quand un processus doit tenir deux sorties, deux pots de cookies, ou un défaut plus une surcharge.

  1. Décidez si la tâche est « une sortie pour l'exécution » ou « deux identités dans un processus ».
  2. Si la tâche est une sortie pour tout le processus navigateur, posez proxy sur chromium.launch. Dans Playwright Test, use.proxy dans playwright.config.ts est une fixture de contexte : il s'applique au contexte de ce test et à ses pages, pas comme un défaut de lancement partagé par chaque contexte du navigateur. Voir Configuration (use).
  3. Si la tâche est deux sorties, lancez sans épingler la seconde identité, puis passez un proxy différent dans chaque newContext.
  4. Fermez chaque contexte que vous avez créé avant browser.close(), qui est la note de nettoyage de browser.newContext().

Le laboratoire a lancé Chromium 156.0.8078.4 avec un proxy HTTP au niveau navigateur vers 127.0.0.1 et a récupéré une origine locale. La page a renvoyé HTTP 200. Le journal du proxy a montré GET http://127.0.0.1:<port>/. Après avoir retiré HTTP_PROXY, HTTPS_PROXY, ALL_PROXY et NO_PROXY du processus, un lancement direct sans option proxy a touché la même origine avec zéro événement proxy. C'est un témoin propre sans proxy. Cela ne mesure pas ce que Playwright ferait si ces variables étaient encore posées.

Un second navigateur a utilisé le premier proxy comme défaut de lancement, puis a ouvert un contexte dont proxy.server pointait vers un autre relais local. La requête du contexte a renvoyé 200. Le trafic est arrivé sur le second relais. Le premier relais n'a pas vu l'URL d'origine du contexte. Le proxy de contexte a remplacé le défaut du navigateur pour ce contexte. Il ne s'est pas fusionné avec lui.

use.proxy de Playwright Test configure le contexte de test et ses pages. chromium.launch({ proxy }) est un défaut de navigateur séparé. Ce n'est pas une API de rotation par test. Il n'y a pas de champ rotateSession sur launch ou newContext dans l'API 1.64.0 que ce laboratoire a exécutée.

02Passer hôte, port et identifiants sans les divulguer

server est une URL. Le texte de l'API Browser est explicite : http://myproxy.com:3128 et socks5://myproxy.com:3128 sont pris en charge. Une forme courte myproxy.com:3128 est traitée comme HTTP. Si le fournisseur a donné un point d'accès SOCKS5, le schéma socks5:// est obligatoire. Identifiant et mot de passe sont documentés comme identifiants pour un proxy HTTP qui exige une authentification. Ils ne sont pas documentés comme userpass SOCKS5.

Gardez le mot de passe dans l'environnement ou un magasin de secrets. Ne le collez pas dans une config versionnée. Playwright doit quand même envoyer Proxy-Authorization sur le fil. Le laboratoire a utilisé l'utilisateur factice lab-user et un mot de passe factice qui inclut @, :, /, # et %. Les journaux de résultats ont masqué cette valeur.

L'auth proxy HTTP de Chromium est bavarde. Contre un proxy Basic local :

  1. Posez server sur http://127.0.0.1:<port> sans identifiant. page.goto sur l'origine HTTP a renvoyé le statut 407. Le journal du proxy a enregistré authReason: missing. Playwright n'a pas levé d'exception. Le statut sur l'objet réponse était la preuve.
  2. Posez l'identifiant lab-user et un mauvais mot de passe. Statut 407 encore. Le journal a montré une première sonde sans identifiants, puis authReason: wrong.
  3. Posez le mot de passe factice correctement. Statut 200. Le journal a montré la même sonde manquante, puis authorized: true.

De mauvais identifiants ne sont pas une socket qui pend dans cette compilation Chromium. C'est un HTTP 407 du proxy, remonté comme statut de navigation. Si un wrapper lève ensuite une exception, gardez le 407 et la classe d'exception. Ne fusionnez pas les deux en « le proxy ne marche pas ».

HTTP Authentication sur la même page Network est httpCredentials pour l'origine, pas proxy.username. Les mélanger est une erreur fréquente : 401 d'origine et 407 de proxy sont des sauts différents.

03Confirmer que le trafic HTTPS utilise CONNECT à travers un proxy HTTP

Le schéma de l'URL cible et le schéma de l'URL du proxy sont des boutons différents. Une origine https://example.com/ utilise encore un proxy HTTP sur http://127.0.0.1:<port>. La méthode proxy est CONNECT, cible example.com:443. Le laboratoire a journalisé ce CONNECT, puis Chromium a terminé le TLS contre le certificat public et page.goto a renvoyé 200. C'est du HTTPS à travers un proxy HTTP avec la vérification laissée activée.

Une origine HTTPS locale signée par une AC de laboratoire est l'autre moitié. Chromium 156.0.8078.4, ignoreHTTPSErrors laissé au défaut documenté false, a navigué vers https://127.0.0.1:<https-port>/ à travers le même proxy HTTP. Le proxy a journalisé CONNECT 127.0.0.1:<https-port>. Puis Playwright a levé PlaywrightError avec net::ERR_CERT_AUTHORITY_INVALID. CONNECT s'est produit. Le TLS a quand même échoué. Que la cible soit https:// n'a pas transformé le proxy en « proxy https », et le fait que le proxy accepte CONNECT n'a pas fait confiance à Chromium une AC privée.

https.request de Node vers cette même origine avec rejectUnauthorized: true et sans AC a échoué avec unable to verify the first certificate. Le même client avec le PEM de l'AC du lab a renvoyé HTTP 200. C'est le chemin de confiance Node documenté. L'option Playwright clientCertificates, ajoutée en v1.46 sur newContext, est un certificat client pour une origine nommée, pas un moyen d'installer une AC privée. L'interrupteur Playwright documenté qui sauterait l'échec est ignoreHTTPSErrors: true. Cet article ne l'utilise pas. Ne posez pas non plus rejectUnauthorized: false.

Si la tâche est « prouver CONNECT », lisez le journal du proxy pour CONNECT host:port. Si la tâche est « prouver que la page s'est chargée », vous avez encore besoin d'un certificat auquel Chromium fait déjà confiance, ou d'une AC installée par un processus hors de ce guide. Une page de vérificateur dans un navigateur personnel est la sortie de ce navigateur, pas la route de Playwright. Utilisez Mon IP seulement comme aide de sortie navigateur. Utilisez le vérificateur de proxies pour voir si un hôte accepte HTTP ou SOCKS5 avant d'y pointer Playwright.

Tableau d'assertions du laboratoire loopback Playwright 1.64.0 du 14 octobre 2026

La figure est un tableau de résultats de cette exécution, pas une image de couverture 16:9 et pas une capture du vérificateur.

04N'utiliser SOCKS5 que là où le moteur le prend vraiment en charge

Le guide Network dit que les pages peuvent se charger via un proxy HTTP(S) ou SOCKSv5. Chromium 156.0.8078.4 avec server: "socks5://127.0.0.1:<port>" et sans identifiant a récupéré l'origine HTTP avec le statut 200. Le montage SOCKS a journalisé CONNECT 127.0.0.1:<origin-port>. SOCKS5 non authentifié est dans le tutoriel pour ce Chromium figé.

SOCKS5 authentifié ne l'est pas. chromium.launch, firefox.launch et webkit.launch dans Playwright 1.64.0 ont tous levé PlaywrightError : Browser does not support socks5 proxy authentication. L'exception s'est produite au lancement, avant page.goto. Playwright 1.64.0 a téléchargé Firefox 157.0 et WebKit 27.2 pour ces lancements. Le message correspond à la demande produit ouverte de longue date microsoft/playwright#10567, encore Open avec le libellé P3-collecting-feedback le 14 octobre 2026. Un commentaire de mainteneur de 2021 y enregistre que Chromium et Firefox n'ont pas de userpass SOCKS5 dans les moteurs eux-mêmes.

Si un fournisseur n'offre que du SOCKS5 avec un identifiant, cette compilation Playwright figée n'enverra pas cet identifiant. Utilisez un proxy HTTP avec username et password, ou un point d'accès SOCKS5 non authentifié que le moteur a réellement composé dans un test comme celui ci-dessous. La comparaison de protocoles vit dans SOCKS5 ou HTTP. Python Requests est un client différent et un article différent : Proxy Python requests.

05Contourner des hôtes choisis sans traiter bypass comme une échappatoire universelle

bypass est une liste de domaines séparés par des virgules. L'exemple d'API est ".com, chromium.org, .domain.com". Ce n'est pas un interrupteur général pour chaque hôte, et ce n'est pas une fonctionnalité de confidentialité.

  1. Lancez Chromium avec proxy.server pointé vers le relais ouvert et bypass: "127.0.0.1".
  2. Naviguez vers http://127.0.0.1:<origin>/. La page a renvoyé 200. Le journal du proxy pour cette fenêtre était vide. L'hôte listé a sauté le proxy.
  3. Relancez avec bypass: "example.com" et naviguez vers la même origine 127.0.0.1. Statut 200, et le proxy a journalisé GET http://127.0.0.1:<origin>/. Un hôte non listé a encore utilisé le proxy.

C'est toute la promesse. bypass ne veut pas dire « ce proxy ne peut pas me voir en général ». Cela veut dire « ces hôtes, dans ce contexte, ne sont pas envoyés à ce proxy ». Les règles localhost de Chromium peuvent surprendre dans d'autres compilations. Mesurez le journal du proxy. Ne supposez pas.

06Isoler deux contextes pour que cookies et sorties ne se mélangent pas

browser.newContext() crée un pot qui ne partage pas les cookies avec les autres contextes. Combinez cela avec un proxy par contexte quand deux tests ne doivent pas fuiter une session sur la mauvaise sortie.

  1. Lancez Chromium sans proxy au niveau navigateur.
  2. Ouvrez le contexte A avec le proxy A, le contexte B avec le proxy B.
  3. Naviguez les deux vers la même origine qui pose un cookie lab.
  4. Lisez context.cookies().

Le laboratoire a obtenu le cookie t-origin-a-67a984 dans le contexte A et t-origin-a-ff4919 dans le contexte B. Les pots n'étaient pas égaux. Les deux navigations ont renvoyé 200. L'isolation ici est l'isolation de contexte Playwright plus deux serveurs proxy. Ce n'est pas du marketing sticky-session de fournisseur, et ce n'est pas l'affirmation qu'un pool résidentiel gardera la même IP.

Fermez les contextes que vous avez ouverts. Un contexte qui fuit garde cookies et identifiants proxy en vie jusqu'à la mort du navigateur.

07Diagnostiquer connexions refusées, 407 et échecs de certificat sans désactiver le TLS

Quand Playwright « ne marche pas », nommez le saut qui a échoué.

OùProtocoleCe que le lab 1.64.0 a observéLimite du moteur
chromium.launch() directaucunOrigine HTTP 200, zéro événement proxyaucune
proxy.server HTTP au niveau navigateurHTTP GET200, le journal du proxy montre un GET en forme absolueaucune
proxy de contexte qui surcharge le lancementHTTP GET200 sur le proxy du contexte, pas le proxy de lancementaucune
Proxy HTTP, Basic manquant ou fauxHTTP 407statut page.goto 407, pas d'exceptionaucune
Proxy HTTP, Basic correctHTTP GET200 après la sonde sans auth de Chromiumaucune
Proxy HTTP, origine HTTPS avec AC publiqueCONNECT puis TLSCONNECT example.com:443, page 200aucune
Proxy HTTP, origine HTTPS avec AC de labCONNECT puis TLSCONNECT journalisé, puis net::ERR_CERT_AUTHORITY_INVALIDpas d'API de fichier d'AC de contexte
SOCKS5, pas d'identifiantSOCKS CONNECTChromium 200, CONNECT du montageauth non impliquée
SOCKS5 plus identifiantrejeté au lancementBrowser does not support socks5 proxy authentication sur Chromium, Firefox, WebKitPlaywright 1.64.0 + moteurs
proxy.server http://127.0.0.1:1TCPnet::ERR_PROXY_CONNECTION_FAILEDaucune
bypass: "127.0.0.1"skiporigine 200, zéro événement proxyla liste est exacte, pas globale

Proxy refusé : Chromium s'est quand même lancé. page.goto a ensuite levé PlaywrightError avec net::ERR_PROXY_CONNECTION_FAILED. Le délai de navigation par défaut dans le laboratoire était de 8000 ms. Le cas refusé a échoué plus vite que ce délai. Gardez le nom de classe PlaywrightError et le code net::. Ne le renommez pas en délai générique.

Échec de certificat : gardez le TLS activé. Le chemin d'AC locale qui a réussi était Node https avec ca posé. Le chemin navigateur Playwright qui a réussi était https://example.com/ à travers CONNECT, qui a déjà une AC publique. Le chemin navigateur Playwright qui a échoué était l'origine du lab. ignoreHTTPSErrors reste false.

Comment vérifier une route, dans l'ordre :

  1. Confirmez que le port du proxy accepte une connexion. Un port refusé est ERR_PROXY_CONNECTION_FAILED, pas un mauvais mot de passe.
  2. Pour les proxies HTTP, regardez 407 contre 200. Identifiants manquants et faux étaient 407 dans ce Chromium.
  3. Pour les cibles HTTPS, cherchez CONNECT dans le journal du proxy, puis regardez le TLS. CONNECT plus ERR_CERT_AUTHORITY_INVALID veut dire que le tunnel a marché et que le certificat n'a pas marché.
  4. Pour SOCKS5 avec un identifiant, attendez-vous à une erreur de lancement en 1.64.0 plutôt qu'à une erreur de page.
  5. Seulement ensuite, regardez le corps de l'origine.

Le laboratoire public ci-dessous est le montage visible du lecteur. Il possède l'origine, le proxy CONNECT et le processus Chromium, et il les ferme dans finally, y compris sur les échecs de constructeur et de démarrage. Enregistrez-le sous public_lab.mjs.

npm install playwright@1.64.0
npx playwright install chromium
node public_lab.mjs
#!/usr/bin/env node
/**
 * Self-contained loopback lab for Playwright 1.64.0 (Node).
 * Dummy credentials only. Local origin and CONNECT proxy. Does not call a paid provider.
 *
 *   npm install playwright@1.64.0
 *   npx playwright install chromium
 *   node public_lab.mjs
 *
 * TLS stays verified. This script never sets ignoreHTTPSErrors.
 */
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";

const USER = "lab-user";
const PASSWORD = "p@ss:/#%";
const REDACT = "***";
const TIMEOUT_MS = 8000;
const SHUTDOWN_MS = 4000;
const STRIP = [
  "HTTP_PROXY",
  "HTTPS_PROXY",
  "ALL_PROXY",
  "NO_PROXY",
  "http_proxy",
  "https_proxy",
  "all_proxy",
  "no_proxy",
];

function redact(s) {
  return String(s)
    .split(PASSWORD)
    .join(REDACT)
    .split(Buffer.from(`${USER}:${PASSWORD}`).toString("base64"))
    .join(REDACT);
}

function isolateEnv() {
  for (const k of STRIP) delete process.env[k];
}

function basic() {
  return "Basic " + Buffer.from(`${USER}:${PASSWORD}`, "utf8").toString("base64");
}

function listen(server, sockets) {
  server._nativeClose = server.close.bind(server);
  server.on("connection", (s) => {
    sockets.push(s);
    s.on("error", () => {});
  });
  return new Promise((resolve, reject) => {
    server.once("error", reject);
    server.listen(0, "127.0.0.1", () => resolve(server.address().port));
  });
}

function closeServer(server) {
  const native = server._nativeClose || server.close.bind(server);
  const attempt = (fn) =>
    new Promise((resolve, reject) => {
      let settled = false;
      const done = (err) => {
        if (settled) return;
        settled = true;
        clearTimeout(t);
        if (err) reject(err);
        else resolve();
      };
      const t = setTimeout(() => done(new Error("server-close-timeout")), SHUTDOWN_MS);
      try {
        fn((err) => done(err || null));
      } catch (e) {
        done(e);
      }
    });
  return (async () => {
    try {
      if (typeof server.closeAllConnections === "function") server.closeAllConnections();
    } catch {}
    let lastErr = null;
    try {
      await attempt((cb) => server.close(cb));
    } catch (e) {
      lastErr = e;
      try {
        if (typeof server.closeAllConnections === "function") server.closeAllConnections();
      } catch {}
      try {
        await attempt((cb) => native(cb));
        lastErr = lastErr;
      } catch (e2) {
        lastErr = e2;
      }
    }
    if (server.listening) throw lastErr || new Error("server-still-listening");
    if (lastErr) throw lastErr;
  })();
}

async function guarded(name, fn, errors) {
  try {
    await Promise.race([
      fn(),
      new Promise((_, reject) => setTimeout(() => reject(new Error(`${name}-shutdown-bound`)), SHUTDOWN_MS)),
    ]);
  } catch (e) {
    errors.push(`${name}: ${e && e.message ? e.message : e}`);
  }
}

function makeCerts(tmp) {
  const caKey = path.join(tmp, "ca.key");
  const caPem = path.join(tmp, "ca.pem");
  const originKey = path.join(tmp, "origin.key");
  const originCsr = path.join(tmp, "origin.csr");
  const originPem = path.join(tmp, "origin.pem");
  const ext = path.join(tmp, "ext.cnf");
  fs.writeFileSync(ext, "subjectAltName=IP:127.0.0.1,DNS:localhost\nbasicConstraints=CA:FALSE\n");
  const run = (args) => {
    const r = spawnSync("openssl", args, { encoding: "utf8" });
    if (r.status !== 0) throw new Error(r.stderr || r.stdout);
  };
  run(["req", "-x509", "-newkey", "rsa:2048", "-keyout", caKey, "-out", caPem, "-days", "1", "-nodes", "-subj", "/CN=pw-public-lab-ca"]);
  run(["req", "-newkey", "rsa:2048", "-keyout", originKey, "-out", originCsr, "-nodes", "-subj", "/CN=127.0.0.1"]);
  run(["x509", "-req", "-in", originCsr, "-CA", caPem, "-CAkey", caKey, "-CAcreateserial", "-out", originPem, "-days", "1", "-extfile", ext]);
  return {
    caPem: fs.readFileSync(caPem, "utf8"),
    originKey: fs.readFileSync(originKey),
    originPem: fs.readFileSync(originPem),
  };
}

function startHttpOrigin(sockets) {
  const server = http.createServer((req, res) => {
    res.setHeader("Content-Type", "application/json");
    res.end(JSON.stringify({ ok: true, path: req.url, via: "http-origin" }));
  });
  return listen(server, sockets).then((port) => ({ server, port }));
}

function startHttpsOrigin(certs, sockets) {
  const server = https.createServer(
    { key: certs.originKey, cert: certs.originPem, minVersion: "TLSv1.2" },
    (req, res) => {
      res.setHeader("Content-Type", "application/json");
      res.end(JSON.stringify({ ok: true, path: req.url, via: "https-origin" }));
    },
  );
  return listen(server, sockets).then((port) => ({ server, port }));
}

function startConnectProxy(sockets) {
  const events = [];
  const server = net.createServer((socket) => {
    let buf = Buffer.alloc(0);
    const onData = (chunk) => {
      buf = Buffer.concat([buf, chunk]);
      const text = buf.toString("latin1");
      const split = text.indexOf("\r\n\r\n");
      if (split < 0) return;
      socket.removeListener("data", onData);
      const head = text.slice(0, split);
      const lines = head.split("\r\n");
      const [method, target] = lines[0].split(" ");
      const headers = {};
      for (const line of lines.slice(1)) {
        const c = line.indexOf(":");
        if (c >= 0) headers[line.slice(0, c).trim().toLowerCase()] = line.slice(c + 1).trim();
      }
      const rec = { method, target, auth: headers["proxy-authorization"] === basic() };
      events.push(rec);
      if (!rec.auth) {
        socket.end("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"lab\"\r\nContent-Length: 0\r\n\r\n");
        return;
      }
      if (method === "CONNECT") {
        const [host, portStr] = target.split(":");
        const dest = net.connect(Number(portStr), host, () => {
          socket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
          socket.pipe(dest);
          dest.pipe(socket);
        });
        sockets.push(dest);
        dest.on("error", () => socket.destroy());
        return;
      }
      let url;
      try {
        url = new URL(target);
      } catch {
        socket.end("HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n");
        return;
      }
      const req = http.request(
        {
          hostname: url.hostname,
          port: url.port || 80,
          path: url.pathname + url.search,
          method,
          headers: { host: url.host },
        },
        (res) => {
          socket.write(`HTTP/1.1 ${res.statusCode} ${res.statusMessage}\r\n`);
          for (const [k, v] of Object.entries(res.headers)) socket.write(`${k}: ${v}\r\n`);
          socket.write("\r\n");
          res.pipe(socket);
        },
      );
      req.on("error", () => socket.destroy());
      req.end();
    };
    socket.on("data", onData);
    socket.on("error", () => {});
  });
  return listen(server, sockets).then((port) => ({ server, port, events }));
}

function nodeHttpsWithCa(port, caPem, timeoutMs = TIMEOUT_MS) {
  return new Promise((resolve, reject) => {
    let settled = false;
    let deadline;
    const finish = (fn, arg) => {
      if (settled) return;
      settled = true;
      clearTimeout(deadline);
      fn(arg);
    };
    const failTimeout = () => {
      const err = new Error("node-https-timeout");
      err.name = "NodeHttpsTimeoutError";
      try {
        req.destroy(err);
      } catch {}
      finish(reject, err);
    };
    const req = https.request(
      {
        hostname: "localhost",
        port,
        path: "/",
        method: "GET",
        ca: caPem,
        rejectUnauthorized: true,
        timeout: timeoutMs,
      },
      (res) => {
        const chunks = [];
        res.on("data", (c) => chunks.push(c));
        res.on("end", () => finish(resolve, { status: res.statusCode, body: Buffer.concat(chunks).toString("utf8") }));
        res.on("error", (e) => finish(reject, e));
      },
    );
    req.on("timeout", failTimeout);
    req.on("error", (e) => finish(reject, e));
    deadline = setTimeout(failTimeout, timeoutMs);
    req.end();
  });
}

async function main() {
  isolateEnv();
  const owned = [];
  const sockets = [];
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pw-public-lab-"));
  let browser;
  const result = { ok: false, ignoreHTTPSErrors: false, cases: [] };
  const wipeTmp = () => {
    if (!tmp || !fs.existsSync(tmp)) return;
    for (const f of fs.readdirSync(tmp)) {
      if (f.endsWith(".key") || f.endsWith(".pem") || f.endsWith(".csr") || f.endsWith(".srl") || f.endsWith(".cnf")) {
        fs.rmSync(path.join(tmp, f), { force: true });
      }
    }
    fs.rmSync(tmp, { recursive: true, force: true });
  };
  try {
    const certs = makeCerts(tmp);
    const httpOrigin = await startHttpOrigin(sockets);
    owned.push(httpOrigin.server);
    const httpsOrigin = await startHttpsOrigin(certs, sockets);
    owned.push(httpsOrigin.server);
    const proxy = await startConnectProxy(sockets);
    owned.push(proxy.server);

    browser = await chromium.launch({
      proxy: {
        server: `http://127.0.0.1:${proxy.port}`,
        username: USER,
        password: PASSWORD,
      },
      timeout: 30000,
    });
    const context = await browser.newContext();
    const page = await context.newPage();
    page.setDefaultTimeout(TIMEOUT_MS);

    const httpRes = await page.goto(`http://127.0.0.1:${httpOrigin.port}/`);
    const httpBody = await page.textContent("body");
    result.cases.push({
      name: "browser-http-via-proxy",
      status: httpRes.status(),
      body: httpBody,
      proxyEvents: proxy.events.filter((e) => e.method === "GET").map((e) => ({ method: e.method, target: e.target })),
    });

    let tlsErr = null;
    try {
      await page.goto(`https://127.0.0.1:${httpsOrigin.port}/`);
    } catch (e) {
      tlsErr = { name: e.name, message: redact(e.message) };
    }
    result.cases.push({
      name: "browser-https-untrusted-ca",
      error: tlsErr,
      connectEvents: proxy.events.filter((e) => e.method === "CONNECT"),
    });

    const trusted = await nodeHttpsWithCa(httpsOrigin.port, certs.caPem);
    result.cases.push({
      name: "node-https-with-lab-ca",
      status: trusted.status,
      body: trusted.body,
    });

    await context.close();
    result.ok =
      result.cases[0].status === 200 &&
      tlsErr != null &&
      trusted.status === 200 &&
      result.cases[1].connectEvents.length >= 1;
    result.httpOrigin = httpOrigin.port;
    result.httpsOrigin = httpsOrigin.port;
    result.proxyPort = proxy.port;
  } finally {
    const errors = [];
    try {
      await guarded(
        "browser",
        async () => {
          if (browser) await browser.close();
        },
        errors,
      );
      await guarded(
        "sockets",
        async () => {
          for (const s of sockets.splice(0)) {
            try {
              s.destroy();
            } catch {}
          }
        },
        errors,
      );
      await guarded(
        "servers",
        async () => {
          const leftover = [];
          for (const s of owned) {
            try {
              await closeServer(s);
            } catch (e) {
              errors.push(`server: ${e && e.message ? e.message : e}`);
            }
            if (s.listening) leftover.push(s.address() && s.address().port);
          }
          if (leftover.length) throw new Error(`servers-still-listening:${leftover.join(",")}`);
        },
        errors,
      );
    } finally {
      await guarded("temp-keys", async () => wipeTmp(), errors);
    }
    if (errors.length) result.cleanupErrors = errors;
    result.serversLeftover = owned.filter((s) => s.listening).map((s) => s.address() && s.address().port);
  }
  console.log(JSON.stringify(result, null, 2));
  if (!result.ok || (result.serversLeftover && result.serversLeftover.length) || (result.cleanupErrors && result.cleanupErrors.some((e) => String(e).includes("still-listening")))) {
    process.exitCode = 1;
  }
}

export { nodeHttpsWithCa, TIMEOUT_MS, makeCerts, SHUTDOWN_MS, closeServer };

const isMain =
  process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
if (isMain) {
  main().catch((e) => {
    console.error(redact(e && e.stack ? e.stack : e));
    process.exit(1);
  });
}

Le 14 octobre 2026, ce script a renvoyé ok: true, HTTP 200 à travers le proxy, CONNECT vers le port HTTPS local, net::ERR_CERT_AUTHORITY_INVALID dans Chromium, et HTTP 200 depuis Node avec l'AC du lab. Identifiants factices seulement. openssl doit être dans le PATH.

Si vous avez besoin d'un fournisseur une fois que le client marche, les fiches du catalogue de Bright Data, Oxylabs, SOAX et Decodo (Smartproxy) ont renvoyé HTTP 200 le 14 octobre 2026 et mentionnent HTTP et SOCKS5. Ces pages n'étaient pas la sortie du laboratoire. L'adéquation au catalogue est le texte de protocole publié, pas un login Playwright à travers ce fournisseur.

Bright Data — proxies résidentiels et datacenter
Bright Data — proxies résidentiels et datacenter4.41442

Bright Data est un grand réseau de proxies pour la collecte de données : IP résidentielles, mobiles, ISP et datacenter dans environ 195 pays. Le trafic résidentiel peut se payer à l'usage sans abonnement, et les adresses datacenter et ISP se vendent par packs dès 10 IP. L'accès résidentiel et mobile exige une vérification d'identité.

10 pays
5,00 $US
par 1 GB
Oxylabs — proxies résidentiels et datacenter
Oxylabs — proxies résidentiels et datacenter4.0771

Oxylabs vend des proxies résidentiels, mobiles, ISP et datacenter sous un seul abonnement mensuel dès $30. Paiements acheteur : cartes, virement, AliPay, PayPal.

10 pays
0,45 $US
par 1 GB
SOAX — proxies résidentiels et mobiles
SOAX — proxies résidentiels et mobiles3.8218

SOAX vend des proxies résidentiels et mobiles rotatifs sur un pool de crédits partagé, avec datacenter et ISP dans les mêmes forfaits. Builder commence à $200/mois + TVA (200 crédits) ; le trafic Tier 1 est à $3/GB sur Builder.

85 pays
3,00 $US
par 1 GB
Decodo (Smartproxy) — proxies résidentiels et datacenter
Decodo (Smartproxy) — proxies résidentiels et datacenter4.22118

Decodo (anciennement Smartproxy) vend des proxies résidentiels et datacenter. Résidentiel : $4/GB sans forfait mensuel, ou packs mensuels dès $3.75/GB pour 3 GB. Paiements : cartes, PayPal, Alipay, Google/Apple Pay, crypto (avec limites).

83 pays
0,38 $US
par 1 GB

Pour un pré-contrôle d'hôte, de port et de protocole avant de brancher Playwright, utilisez le vérificateur de proxies. Pour un passage plus profond « ce proxy fuit-il », voir Comment vérifier qu'un proxy fonctionne.

Le proxy doit-il être sur le navigateur ou sur un contexte ?

Un proxy au niveau navigateur couvre chaque contexte de ce navigateur. Un proxy au niveau contexte s'applique à un pot et peut remplacer le défaut du navigateur. Le laboratoire a vu un proxy de contexte envoyer le trafic vers un second relais tandis que le proxy de lancement restait silencieux.

Playwright envoie-t-il le HTTPS à travers un proxy HTTP ?

Oui. L'URL du proxy reste http://host:port. Chromium émet ensuite CONNECT vers host:443. Le laboratoire a journalisé CONNECT example.com:443 et reçu HTTP 200 avec la vérification TLS activée.

Comment passer l'authentification du proxy ?

Posez username et password sur l'objet proxy pour les proxies HTTP. Une authentification Basic manquante ou fausse a renvoyé HTTP 407 dans Chromium 156.0.8078.4. Les httpCredentials d'origine ne sont pas des identifiants proxy.

Chromium, Firefox et WebKit peuvent-ils utiliser SOCKS5 avec un identifiant ?

Playwright 1.64.0 a rejeté l'authentification SOCKS5 au lancement pour chromium, firefox et webkit avec Browser does not support socks5 proxy authentication. socks5:// non authentifié a marché sur Chromium dans le laboratoire.

Faut-il poser ignoreHTTPSErrors pour faire passer une AC de laboratoire ?

Non. Le TLS local non fiable a échoué avec net::ERR_CERT_AUTHORITY_INVALID après CONNECT. Node https avec l'AC du lab a vérifié. ignoreHTTPSErrors reste false dans ce guide.

bypass veut-il dire que le proxy n'est jamais utilisé ?

Non. bypass est une liste d'hôtes. 127.0.0.1 listé dans bypass a sauté le proxy. La même origine a encore utilisé le proxy quand bypass n'était que example.com.

Un vérificateur ou une page Mon IP prouve-t-il la route Playwright ?

Non. Le vérificateur teste si un point d'accès répond. Une page Mon IP de navigateur est la sortie de ce navigateur. La preuve Playwright est le journal CONNECT ou GET du proxy plus le résultat de page du processus Playwright.