Correr Playwright en CodeBuild suena simple: instalas Playwright en el buildspec, ejecutas tests, reportas. Pero cuando tu suite crece a 300+ tests E2E, la cosa cambia. Los cold starts de instalar browsers cada build consumen minutos preciosos. Las imágenes default de CodeBuild tienen versiones que no siempre sincronizan con tu versión local. Y el paralelismo real requiere orquestación.
Este artículo es la configuración completa que llevo usando un año. Un contenedor Docker custom con Playwright preinstalado, CodeBuild ejecutando con paralelismo batch, y artefactos que sirven para debuggear fallos sin adivinar. La suite de 300 tests corre en 8 minutos en vez de los 40 que tomaba antes.
El problema con la imagen default de CodeBuild
La imagen aws/codebuild/standard:7.0 tiene Node pero no Playwright. Cada build hace:
# buildspec.yml (forma ingenua)
phases:
install:
runtime-versions:
nodejs: 20
commands:
- npm ci
- npx playwright install --with-deps chromium firefox webkit
build:
commands:
- npx playwright test
El npx playwright install --with-deps baja Chromium (170MB), Firefox (90MB), WebKit (80MB), más dependencias del sistema. En mi proyecto eso tomaba 3-5 minutos cada build. Con 30 builds al día, son ~2 horas diarias de CPU gastadas reinstalando browsers que no cambiaron.
La solución obvia: imagen Docker custom con todo preinstalado.
La imagen custom
# docker/playwright-codebuild/Dockerfile
FROM mcr.microsoft.com/playwright:v1.49.0-jammy
USER root
# Herramientas que CodeBuild espera
RUN apt-get update && apt-get install -y \
git \
curl \
jq \
unzip \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# AWS CLI v2
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
&& unzip awscliv2.zip \
&& ./aws/install \
&& rm -rf awscliv2.zip aws/
# Node cache config para builds más rápidos
ENV NPM_CONFIG_CACHE=/tmp/.npm
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
WORKDIR /workspace
# Verificación que los browsers están listos
RUN npx playwright --version && \
ls -la /ms-playwright
La clave: uso la imagen oficial de Microsoft (mcr.microsoft.com/playwright) con los browsers preinstalados en /ms-playwright. Agrego AWS CLI, git, y herramientas que CodeBuild usa internamente.
Pushear a ECR
# scripts/push-image.sh
#!/bin/bash
set -e
REGION=${AWS_REGION:-us-east-1}
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REPO_NAME=playwright-codebuild
TAG=${1:-v1.49.0}
# Crear repo si no existe
aws ecr describe-repositories --repository-names $REPO_NAME --region $REGION \
|| aws ecr create-repository --repository-name $REPO_NAME --region $REGION
# Login
aws ecr get-login-password --region $REGION \
| docker login --username AWS --password-stdin \
$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com
# Build y push
docker build -t $REPO_NAME:$TAG ./docker/playwright-codebuild/
docker tag $REPO_NAME:$TAG \
$ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$TAG
docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$TAG
echo "Image pushed: $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$TAG"
Corro este script cada vez que actualizo Playwright. El resto del tiempo, CodeBuild usa la imagen cacheada.
El CDK para el proyecto CodeBuild
// cdk/lib/e2e-pipeline-stack.ts
import { Stack, StackProps, Duration } from "aws-cdk-lib";
import {
Project,
Source,
BuildEnvironment,
LinuxBuildImage,
ComputeType,
BuildSpec,
Cache,
LocalCacheMode,
} from "aws-cdk-lib/aws-codebuild";
import { Bucket } from "aws-cdk-lib/aws-s3";
import { Repository } from "aws-cdk-lib/aws-ecr";
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";
export class E2EPipelineStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const artifactsBucket = new Bucket(this, "E2EArtifacts", {
lifecycleRules: [{ expiration: Duration.days(30) }],
});
const ecrRepo = Repository.fromRepositoryName(
this,
"PlaywrightImage",
"playwright-codebuild"
);
const project = new Project(this, "E2EProject", {
projectName: "e2e-tests",
source: Source.gitHub({
owner: "mi-org",
repo: "mi-app",
webhook: true,
webhookFilters: [],
}),
environment: {
buildImage: LinuxBuildImage.fromEcrRepository(ecrRepo, "v1.49.0"),
computeType: ComputeType.LARGE,
privileged: false,
},
cache: Cache.local(LocalCacheMode.CUSTOM, LocalCacheMode.SOURCE),
buildSpec: BuildSpec.fromSourceFilename("buildspec-e2e.yml"),
timeout: Duration.minutes(30),
concurrentBuildLimit: 5,
});
// Permisos para subir artefactos
artifactsBucket.grantReadWrite(project);
// Permisos para leer secrets de los tests (URLs, credenciales de test user)
project.addToRolePolicy(
new PolicyStatement({
actions: ["secretsmanager:GetSecretValue"],
resources: [
`arn:aws:secretsmanager:${this.region}:${this.account}:secret:e2e/*`,
],
})
);
}
}
Tres cosas importantes en esta configuración. ComputeType.LARGE da 8 vCPUs y 15GB RAM, suficiente para correr 4-6 workers de Playwright en paralelo. concurrentBuildLimit: 5 evita que 10 PRs al mismo tiempo saturen tu cuenta. Y Cache.local con CUSTOM permite cachear node_modules entre builds del mismo branch.
El buildspec completo
# buildspec-e2e.yml
version: 0.2
env:
variables:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
secrets-manager:
TEST_USER_EMAIL: e2e/test-users:email
TEST_USER_PASSWORD: e2e/test-users:password
phases:
install:
commands:
- echo "Playwright version check:"
- npx playwright --version
- echo "Installing project dependencies..."
- npm ci --prefer-offline --no-audit
pre_build:
commands:
- echo "Shard $SHARD_INDEX of $SHARD_TOTAL"
- mkdir -p test-results/shard-$SHARD_INDEX
build:
commands:
- echo "Running tests for shard $SHARD_INDEX/$SHARD_TOTAL"
- |
BASE_URL=$E2E_BASE_URL \
CI=true \
npx playwright test \
--shard=$SHARD_INDEX/$SHARD_TOTAL \
--reporter=list,html,json \
--output=test-results/shard-$SHARD_INDEX \
|| echo "Tests failed, collecting artifacts"
post_build:
commands:
- echo "Collecting artifacts..."
- ls -la test-results/shard-$SHARD_INDEX
- |
if [ -d "playwright-report" ]; then
aws s3 sync playwright-report \
s3://$ARTIFACTS_BUCKET/$CODEBUILD_BUILD_ID/shard-$SHARD_INDEX/ \
--quiet
echo "Report: https://$ARTIFACTS_BUCKET.s3.amazonaws.com/$CODEBUILD_BUILD_ID/shard-$SHARD_INDEX/index.html"
fi
- |
if [ -d "test-results/shard-$SHARD_INDEX" ]; then
aws s3 sync test-results/shard-$SHARD_INDEX \
s3://$ARTIFACTS_BUCKET/$CODEBUILD_BUILD_ID/shard-$SHARD_INDEX-traces/ \
--quiet
fi
artifacts:
files:
- 'test-results/**/*'
- 'playwright-report/**/*'
base-directory: .
cache:
paths:
- 'node_modules/**/*'
- '/tmp/.npm/**/*'
Tres detalles importantes.
Primero, uso secrets-manager de CodeBuild para inyectar credenciales de test. Los secrets van a variables de entorno sin que queden expuestos en logs.
Segundo, --shard=$SHARD_INDEX/$SHARD_TOTAL es nativo de Playwright. Si le paso --shard=2/4, solo corre un cuarto de los tests (los que le tocan al shard 2). Combinando esto con múltiples CodeBuild en paralelo, reparto la suite automáticamente.
Tercero, subo artefactos a S3 en post_build. El reporte HTML de Playwright queda accesible via URL pública, con traces (screenshots, videos, action logs) para cada fallo.
Orquestación con batch builds
CodeBuild soporta "batch builds" que lanzan múltiples builds en paralelo con config distinta. Esto es la clave para paralelismo real.
# buildspec-e2e-batch.yml
version: 0.2
batch:
fast-fail: false
build-list:
- identifier: shard_1
env:
variables:
SHARD_INDEX: "1"
SHARD_TOTAL: "4"
- identifier: shard_2
env:
variables:
SHARD_INDEX: "2"
SHARD_TOTAL: "4"
- identifier: shard_3
env:
variables:
SHARD_INDEX: "3"
SHARD_TOTAL: "4"
- identifier: shard_4
env:
variables:
SHARD_INDEX: "4"
SHARD_TOTAL: "4"
# Cada build corre el buildspec-e2e.yml con su SHARD_INDEX
Al trigger del webhook, CodeBuild lanza 4 builds en paralelo, cada uno corriendo 1/4 de la suite. Si cada shard toma 8 minutos, la suite total termina en 8 minutos (no 32).
Para habilitar batch builds en CDK:
// dentro del Project
batchBuildConfig: {
serviceRole: project.role!,
combineArtifacts: true,
restrictions: {
maximumBuildsAllowed: 5,
computeTypesAllowed: [ComputeType.LARGE, ComputeType.MEDIUM],
},
},
Playwright config para shards
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: [
["list"],
["html", { open: "never" }],
["json", { outputFile: "test-results/results.json" }],
],
use: {
baseURL: process.env.E2E_BASE_URL,
trace: "retain-on-failure",
screenshot: "only-on-failure",
video: "retain-on-failure",
viewport: { width: 1280, height: 720 },
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
],
outputDir: "test-results",
});
trace: "retain-on-failure" guarda el trace solo cuando un test falla. El trace incluye screenshots de cada acción, network logs, y timeline. Puedes reproducir exactamente qué pasó descargando el .zip y abriéndolo con npx playwright show-trace trace.zip.
Merge de reportes
Con 4 shards generando reportes separados, quiero un reporte consolidado al final. Para eso agrego un build final que corre después de los shards:
# buildspec-e2e-merge.yml
version: 0.2
batch:
build-list:
- identifier: shard_1
# ... como antes
- identifier: shard_2
# ...
- identifier: shard_3
# ...
- identifier: shard_4
# ...
- identifier: merge_reports
depend-on:
- shard_1
- shard_2
- shard_3
- shard_4
buildspec: buildspec-merge.yml
env:
compute-type: BUILD_GENERAL1_SMALL
El buildspec-merge descarga todos los reportes de S3 y los mergea:
# buildspec-merge.yml
version: 0.2
phases:
install:
commands:
- npm install -g playwright-merge-html-reports
build:
commands:
- mkdir -p merged-report
- aws s3 sync s3://$ARTIFACTS_BUCKET/$CODEBUILD_INITIATOR/ ./all-shards/
- playwright-merge-html-reports all-shards/ -o merged-report/
- aws s3 sync merged-report/ \
s3://$ARTIFACTS_BUCKET/$CODEBUILD_INITIATOR/merged/ --quiet
- echo "Merged report: https://$ARTIFACTS_BUCKET.s3.amazonaws.com/$CODEBUILD_INITIATOR/merged/index.html"
Notificaciones a Slack al fallar
El buildspec también dispara notificaciones cuando hay fallos. Uso EventBridge + SNS + un Lambda de formateo:
// cdk/lib/notifications-stack.ts
import { Rule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
const notifyFn = new NodejsFunction(this, "NotifyFailure", {
entry: "lambdas/notify-failure.ts",
environment: {
SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK!,
},
});
new Rule(this, "E2EFailureRule", {
eventPattern: {
source: ["aws.codebuild"],
detailType: ["CodeBuild Build State Change"],
detail: {
"project-name": ["e2e-tests"],
"build-status": ["FAILED"],
},
},
targets: [new LambdaFunction(notifyFn)],
});
El Lambda formatea el evento y postea en Slack con link al reporte:
// lambdas/notify-failure.ts
export const handler = async (event: any) => {
const { "build-id": buildId, "project-name": project } = event.detail;
const shortId = buildId.split("/").pop()?.split(":").pop();
const reportUrl = `https://${process.env.BUCKET}.s3.amazonaws.com/${buildId}/merged/index.html`;
await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
blocks: [
{
type: "section",
text: {
type: "mrkdwn",
text: `:x: E2E tests failed on *${project}*\nBuild: \`${shortId}\`\n<${reportUrl}|Ver reporte completo>`,
},
},
],
}),
});
};
Números reales del setup
Proyecto con 340 tests Playwright en Chromium + Firefox + WebKit. Antes del setup custom:
| Fase | Tiempo antes | Tiempo después |
|---|---|---|
| Install Playwright browsers | 3.5 min | 0 (preinstalados) |
npm ci |
2 min | 25s (con cache local) |
| Run tests serial | 34 min | - |
| Run tests shard 1/4 paralelo | - | 8 min |
| Total wall-clock | 39.5 min | 8.5 min |
Reducción de 80% en tiempo, con la suite completa corriendo en cada PR.
Lo que aprendí afinando esto
1. Las imágenes de Microsoft se actualizan cada release de Playwright.
Un día actualicé Playwright en el proyecto y los tests empezaron a fallar en CI pero no localmente. La imagen de CodeBuild usaba Playwright 1.45 pero el package.json ya estaba en 1.47. Cree un pre-commit que valida que los dos matchean, o el build falla rápido con mensaje claro.
2. El cache local de CodeBuild es frágil.
Cache.local(LocalCacheMode.CUSTOM) funciona pero no siempre. Si el build corre en una instancia nueva del pool de CodeBuild, el cache está vacío. Para garantías, usa S3 cache, aunque es más lento. Para pragmatismo, acepto que a veces el cache no está y tomo el hit.
3. Los secrets del Secrets Manager suben el costo de builds.
Cada build con secrets mana hace API calls. Para 30 builds/día con 5 secrets cada uno, son 4,500 calls/mes extras. Si tienes muchos secrets, usa Parameter Store (gratis hasta cierto límite) o inyecta vía variables de entorno del proyecto CodeBuild para secretos no críticos.
4. El timeout de CodeBuild cuenta el pull de imagen.
Si tu imagen pesa 2GB, el primer pull en una instancia nueva toma 2-3 minutos que cuentan contra tu timeout de 30 minutos. Tuve builds que timeouteaban no porque los tests tardaran, sino porque la imagen tardó mucho. Solución: mantener la imagen chica (la mía pesa 1.1GB) y habilitar image pull cache en la config del proyecto.
5. fail-fast apagado cambia la estrategia de debugging.
Con fast-fail: false en batch builds, todos los shards terminan aunque uno falle. Esto te da visibilidad completa del estado de la suite, pero también significa que tardas más en detectar "está roto". Para main branch uso fail-fast: true (rápido), para PRs fail-fast: false (completo).
6. Videos y traces ocupan espacio.
340 tests con video en fallos generan gigas cuando hay mala semana. Configuré lifecycle en el bucket de artefactos para borrar a los 30 días. También limito video: "retain-on-failure" en vez de on para grabar solo cuando hay algo que mirar.
Cuándo NO usar este setup
Si tu suite tiene menos de 20 tests, la infra es sobrecosto. Ejecutar en GitHub Actions con el action oficial de Playwright es más simple.
Si tu equipo no tiene experiencia con Docker y CI, mantener imágenes custom agrega fricción. Acepta imágenes default de CodeBuild con un poco más de tiempo de build al principio, y migra custom cuando duela lo suficiente.
Si tus tests dependen de servicios muy específicos (drivers para Selenium Grid, browsers legacy), considera AWS Device Farm o un servicio dedicado como BrowserStack. Playwright en CodeBuild cubre el 90% pero no el 100% de casos.
En el próximo artículo: bundle splitting guiado por métricas reales de CloudWatch RUM. Cómo dejar de adivinar qué chunks crear y usar datos de usuarios reales para decidir la arquitectura de tus bundles.
Top comments (0)