Desglose completo archivo por archivo del formulario de perfil de arquitecto cloud. Todos los archivos necesarios para un formulario multi-paso funcional dentro de Next.js App Router con React 19 server actions, validación alineada con BDD, protección de subidas, UX de pendiente y hooks de prueba listos para Playwright.
Los archivos Gherkin son la fuente de verdad. Cada regla de validación, comportamiento de UI y mensaje de error se origina aquí. Los desarrolladores implementan código para satisfacer estos escenarios.
# features/profile-form/04-validation.featureFeature: Profile Form Validation Background: Given the architect is logged in And they navigate to "/profile/create" Scenario: Required fields show errors when empty Given the architect is on step 1 "Personal Info" And they have not filled in any fields When they click "Next" Then they should see "Full name is required" And they should see "Email is required" And the form should not advance to step 2 Scenario: Email format is validated Given the architect is on step 1 "Personal Info" When they type "not-an-email" in the "Email" field And they click "Next" Then they should see "Please enter a valid email address" Scenario: LinkedIn URL must point to linkedin.com Given the architect is on step 1 "Personal Info" When they type "https://twitter.com/someone" in the "LinkedIn URL" field And they click "Next" Then they should see "Must be a valid LinkedIn profile URL" Scenario: Years of experience must be between 0 and 50 Given the architect is on step 2 "Experience" When they type "-3" in the "Years of Experience" field And they click "Next" Then they should see "Must be between 0 and 50 years" Scenario: At least one cloud platform is required Given the architect is on step 4 "Skills" And no cloud platforms are checked When they click "Next" Then they should see "Select at least one cloud platform" Scenario: Job end date must be after start date Given the architect is on step 3 "Job History" When they set start date to "2025-06-01" And they set end date to "2024-01-01" And they click "Next" Then they should see "End date must be after start date" Scenario: Bio cannot exceed 1000 characters Given the architect is on step 2 "Experience" When they type 1001 characters in the "Bio" field Then they should see "Bio must be 1000 characters or fewer" Scenario: Profile photo must be an image under 5MB Given the architect is on step 5 "Uploads" When they select a 15MB PNG for "Profile Photo" Then they should see "File must be under 5MB" Scenario: Profile photo rejects non-image files Given the architect is on step 5 "Uploads" When they select a .exe file for "Profile Photo" Then they should see "Only JPEG, PNG, and WebP files are accepted"
Puntos clave del código:
Background se ejecuta antes de cada escenario - configura un usuario con sesión iniciada en la página de creación
Cada Scenario se mapea exactamente a un caso de prueba - el título describe el comportamiento esperado
Los pasos Given / When / Then se leen como lenguaje natural para que los no desarrolladores puedan revisar los criterios de aceptación
Las cadenas de mensajes de error (p. ej., "El nombre completo es obligatorio") deben coincidir exactamente con los mensajes del esquema zod
El escenario .refine() (fecha de fin posterior a la de inicio) muestra la validación entre campos en forma Gherkin
Los escenarios de subida de archivos prueban los límites de tamaño y el rechazo de tipo MIME como casos separados
La única fuente de verdad para toda la validación. Cada mensaje de error de zod coincide exactamente con un Then debería ver "..." de Gherkin. Importado por el cliente (zodResolver) y la server action (safeParse).
// lib/schemas/architect-profile.tsimport { z } from "zod";// ── Constantes compartidas ───────────────────────────────────const ACCEPTED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"];const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MBconst MAX_DIAGRAM_COUNT = 10;// ── Validador de archivos reutilizable ────────────────────────────const imageFileSchema = z .instanceof(File) .refine((file) => file.size <= MAX_FILE_SIZE, "El archivo debe pesar menos de 5 MB") .refine( (file) => ACCEPTED_IMAGE_TYPES.includes(file.type), "Solo se aceptan archivos JPEG, PNG y WebP" );// ── Paso 1: Información personal ──────────────────────────────export const personalInfoSchema = z.object({ fullName: z .string() .min(1, "El nombre completo es obligatorio") .min(2, "El nombre debe tener al menos 2 caracteres"), email: z .string() .min(1, "El correo electrónico es obligatorio") .email("Introduce una dirección de correo válida"), phone: z.string().optional(), linkedinUrl: z .string() .url("Debe ser una URL válida") .refine( (url) => url.includes("linkedin.com/"), "Debe ser una URL de perfil de LinkedIn válida" ) .or(z.literal("")),});// ── Paso 2: Experiencia ─────────────────────────────────export const experienceSchema = z.object({ yearsOfExperience: z .number({ invalid_type_error: "Debe ser un número" }) .int("Debe ser un número entero") .min(0, "Debe estar entre 0 y 50 años") .max(50, "Debe estar entre 0 y 50 años"), currentRole: z.string().min(1, "El rol actual es obligatorio"), certifications: z.array(z.string()), bio: z.string().max(1000, "La biografía debe tener 1000 caracteres o menos").optional(),});// ── Paso 3: Historial laboral ────────────────────────────────const jobEntrySchema = z .object({ company: z.string().min(1, "El nombre de la empresa es obligatorio"), role: z.string().min(1, "El rol es obligatorio"), startDate: z.string().min(1, "La fecha de inicio es obligatoria"), endDate: z.string().optional(), isCurrent: z.boolean().default(false), description: z.string().max(500).optional(), }) .refine( (data) => { if (data.isCurrent || !data.endDate) return true; return new Date(data.endDate) > new Date(data.startDate); }, { message: "La fecha de fin debe ser posterior a la de inicio", path: ["endDate"] } );export const jobHistorySchema = z.object({ jobs: z.array(jobEntrySchema).min(1, "Añade al menos un puesto"),});// ── Paso 4: Habilidades ─────────────────────────────────────export const skillsSchema = z.object({ cloudPlatforms: z .array(z.enum(["aws", "azure", "gcp"])) .min(1, "Selecciona al menos una plataforma cloud"), specialties: z.array(z.string()).min(1, "Selecciona al menos una especialidad"), awsProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(), azureProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(), gcpProficiency: z.enum(["beginner", "intermediate", "expert"]).optional(),});// ── Paso 5: Subidas ────────────────────────────────────export const uploadsSchema = z.object({ profilePhoto: imageFileSchema.optional(), architectureDiagrams: z .array(imageFileSchema) .max(MAX_DIAGRAM_COUNT, `Máximo ${MAX_DIAGRAM_COUNT} archivos permitidos`) .optional(), siteScreenshots: z .array(imageFileSchema) .max(MAX_DIAGRAM_COUNT, `Máximo ${MAX_DIAGRAM_COUNT} archivos permitidos`) .optional(),});// ── Esquemas por paso (usados por StepNavigation.trigger()) ──export const STEP_SCHEMAS = { 1: personalInfoSchema, 2: experienceSchema, 3: jobHistorySchema, 4: skillsSchema, 5: uploadsSchema,} as const;// ── Esquema combinado (usado por zodResolver + server action) ──export const architectProfileSchema = personalInfoSchema .merge(experienceSchema) .merge(jobHistorySchema) .merge(skillsSchema) .merge(uploadsSchema);// ── Tipos inferidos ─────────────────────────────────────export type ArchitectProfile = z.infer<typeof architectProfileSchema>;export type PersonalInfo = z.infer<typeof personalInfoSchema>;export type Experience = z.infer<typeof experienceSchema>;export type JobHistory = z.infer<typeof jobHistorySchema>;export type Skills = z.infer<typeof skillsSchema>;export type Uploads = z.infer<typeof uploadsSchema>;
Puntos clave del código:
imageFileSchema es un refinamiento zod reutilizable - valida tanto el tamaño (5MB) como el tipo MIME, compartido por todos los campos de subida
Cada paso tiene su propio esquema exportado (personalInfoSchema, experienceSchema, etc.) para que trigger() pueda validar un paso a la vez
.refine() en jobEntrySchema maneja la validación entre campos (fecha de fin > fecha de inicio) con un path personalizado que apunta al campo específico
.or(z.literal("")) en linkedinUrl permite dejar el campo vacío mientras valida el formato cuando se rellena
STEP_SCHEMAS mapea los números de paso a su esquema - usado por StepNavigation para validar solo los campos del paso actual
architectProfileSchema fusiona todos los esquemas de paso en uno - usado por zodResolver (cliente) y safeParse (servidor) para la validación del formulario completo
z.infer<typeof ...> genera tipos TypeScript a partir de cada esquema - única fuente de verdad tanto para validación como para tipos
El store de Zustand posee la máquina de estados de los pasos. Sabe qué paso está activo, cuáles están completados y si la navegación está permitida. Desacoplado de los datos del formulario (react-hook-form posee eso).
La server action recibe FormData, valida en el servidor con el mismo esquema zod, valida los archivos subidos de forma independiente, persiste los datos y redirige. La firma de dos argumentos (prevState, formData) es requerida por useActionState.
// lib/actions/create-profile.ts"use server";import { z } from "zod";import { redirect } from "next/navigation";import { architectProfileSchema } from "@/lib/schemas/architect-profile";// Esquema de archivos solo del servidor -- más estricto, comprueba el MIME realconst serverFileSchema = z .instanceof(File) .refine((file) => file.size <= 5 * 1024 * 1024, "El archivo debe pesar menos de 5 MB") .refine( (file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type), "Tipo de archivo no válido" );export type ProfileActionState = { success: boolean; message: string; fieldErrors: Record<string, string>;};const initialState: ProfileActionState = { success: false, message: "", fieldErrors: {},};export async function createProfile( prevState: ProfileActionState, formData: FormData): Promise<ProfileActionState> { try { // ── 1. Parsear campos de texto desde FormData ─────────── const rawData = { fullName: formData.get("fullName") as string, email: formData.get("email") as string, phone: formData.get("phone") as string, linkedinUrl: formData.get("linkedinUrl") as string, yearsOfExperience: Number(formData.get("yearsOfExperience")), currentRole: formData.get("currentRole") as string, certifications: formData.getAll("certifications") as string[], bio: formData.get("bio") as string, jobs: JSON.parse(formData.get("jobs") as string), cloudPlatforms: formData.getAll("cloudPlatforms") as string[], specialties: formData.getAll("specialties") as string[], awsProficiency: formData.get("awsProficiency") as string, azureProficiency: formData.get("azureProficiency") as string, gcpProficiency: formData.get("gcpProficiency") as string, }; // ── 2. Validar campos de texto (mismo esquema que el cliente) ── const textSchema = architectProfileSchema.omit({ profilePhoto: true, architectureDiagrams: true, siteScreenshots: true, }); const textResult = textSchema.safeParse(rawData); if (!textResult.success) { const fieldErrors: Record<string, string> = {}; for (const issue of textResult.error.issues) { const path = issue.path.join("."); fieldErrors[path] = issue.message; } return { success: false, message: "", fieldErrors }; } // ── 3. Validar archivos en el servidor (validación dual) ── const profilePhoto = formData.get("profilePhoto") as File | null; if (profilePhoto && profilePhoto.size > 0) { const fileResult = serverFileSchema.safeParse(profilePhoto); if (!fileResult.success) { return { success: false, message: "", fieldErrors: { profilePhoto: fileResult.error.issues[0].message }, }; } } const diagrams = formData.getAll("architectureDiagrams") as File[]; for (const diagram of diagrams) { if (diagram.size > 0) { const fileResult = serverFileSchema.safeParse(diagram); if (!fileResult.success) { return { success: false, message: "", fieldErrors: { architectureDiagrams: fileResult.error.issues[0].message, }, }; } } } const screenshots = formData.getAll("siteScreenshots") as File[]; for (const screenshot of screenshots) { if (screenshot.size > 0) { const fileResult = serverFileSchema.safeParse(screenshot); if (!fileResult.success) { return { success: false, message: "", fieldErrors: { siteScreenshots: fileResult.error.issues[0].message, }, }; } } } // ── 4. Comprobar correo duplicado ───────────────── const existingProfile = await findProfileByEmail(textResult.data.email); if (existingProfile) { return { success: false, message: "", fieldErrors: { email: "Este correo electrónico ya está registrado" }, }; } // ── 5. Subir archivos al almacenamiento ─────────────────── const photoUrl = profilePhoto?.size ? await uploadToStorage(profilePhoto, "profiles") : null; const diagramUrls = await Promise.all( diagrams .filter((f) => f.size > 0) .map((f) => uploadToStorage(f, "diagrams")) ); const screenshotUrls = await Promise.all( screenshots .filter((f) => f.size > 0) .map((f) => uploadToStorage(f, "screenshots")) ); // ── 6. Crear registro de perfil ───────────────────── const profile = await createProfileRecord({ ...textResult.data, photoUrl, diagramUrls, screenshotUrls, }); redirect(`/profile/${profile.id}`); } catch (error) { // redirect() lanza internamente -- relanzarlo if (error instanceof Error && error.message === "NEXT_REDIRECT") { throw error; } console.error("Profile creation failed:", error); return { success: false, message: "Algo salió mal. Inténtalo de nuevo.", fieldErrors: {}, }; }}// Sustituye con tu capa real de BD/almacenamientoasync function findProfileByEmail(email: string) { return null;}async function uploadToStorage(file: File, folder: string): Promise<string> { return `https://storage.example.com/${folder}/${file.name}`;}async function createProfileRecord(data: Record<string, unknown>) { return { id: "new-profile-id" };}
Puntos clave del código:
"use server" marca esto como server action - se ejecuta en el servidor, nunca se envía al bundle del cliente
serverFileSchema duplica la validación de archivos en el servidor - nunca confíes solo en comprobaciones del cliente (los usuarios pueden omitir el navegador)
La firma de dos argumentos (prevState, formData) es requerida por useActionState - prevState transporta el valor de retorno anterior
El tipo de retorno ProfileActionState tiene fieldErrors: Record<string, string> - el paso de envío los mapea de vuelta a react-hook-form y navega al paso correcto
.omit({ profilePhoto: true, ... }) elimina los campos de archivo del esquema de texto - los archivos se validan por separado porque safeParse no puede manejar objetos File de FormData de la misma forma
formData.getAll("certifications") recupera múltiples valores para el mismo nombre de campo - usado para campos array (certificaciones, plataformas, especialidades)
redirect() lanza internamente en Next.js - el bloque catch debe relanzar NEXT_REDIRECT o la redirección falla en silencio
Los pasos 1-6 son comentarios numerados - la acción sigue un pipeline estricto: parsear → validar texto → validar archivos → comprobar duplicados → subir → crear registro → redirigir
Crea la única instancia de useForm compartida entre todos los pasos. El zodResolver conecta la validación zod con react-hook-form. mode: "onBlur" da retroalimentación en tiempo real cuando el usuario sale de un campo con Tab.
useForm<ArchitectProfile>() crea una única instancia de formulario tipada al esquema completo - compartida entre todos los pasos vía contexto
zodResolver(architectProfileSchema) conecta la validación zod con react-hook-form - los errores de validación rellenan automáticamente formState.errors
mode: "onBlur" valida los campos cuando el usuario sale con Tab - da retroalimentación en tiempo real sin interrumpir la escritura
FormProvider envuelve todos los hijos - cualquier componente anidado puede llamar a useFormContext() para acceder al estado del formulario sin prop drilling
defaultValues inicializa el formulario con valores vacíos/cero - evita advertencias de inputs no controlados a controlados
Enruta al componente de paso correcto según el estado de zustand. Envuelve el paso activo en ayudantes de accesibilidad (anunciador para lector de pantalla y auto-foco).
// components/profile-form/profile-form-wizard.tsx"use client";import { useProfileFormStore } from "@/stores/profile-form-store";import { StepIndicator } from "./step-indicator";import { StepNavigation } from "./step-navigation";import { StepAnnouncer } from "./step-announcer";import { AutoFocusStep } from "./auto-focus-step";import { PersonalInfoStep } from "./personal-info-step";import { ExperienceStep } from "./experience-step";import { JobHistoryStep } from "./job-history-step";import { SkillsStep } from "./skills-step";import { UploadsStep } from "./uploads-step";import { SubmitStep } from "./submit-step";const STEP_COMPONENTS: Record<number, React.ComponentType> = { 1: PersonalInfoStep, 2: ExperienceStep, 3: JobHistoryStep, 4: SkillsStep, 5: UploadsStep, 6: SubmitStep,};export function ProfileFormWizard() { const { currentStep } = useProfileFormStore(); const StepComponent = STEP_COMPONENTS[currentStep]; return ( <div className="space-y-8" data-testid="profile-form-wizard"> <StepIndicator /> <StepAnnouncer /> <AutoFocusStep> <StepComponent /> </AutoFocusStep> <StepNavigation /> </div> );}
Puntos clave del código:
STEP_COMPONENTS es una búsqueda Record<number, React.ComponentType> - mapea el número de paso al componente a renderizar
useProfileFormStore() lee currentStep de zustand - el asistente se re-renderiza cuando cambia el paso
StepIndicator + StepAnnouncer + AutoFocusStep + StepNavigation envuelven el paso activo - separación de responsabilidades entre UI de progreso, accesibilidad y navegación
data-testid="profile-form-wizard" proporciona un hook de Playwright para el contenedor del asistente
La barra de progreso. Muestra marcas de verificación para los pasos completados, resalta el paso actual y deshabilita los pasos futuros. Cada botón de paso tiene etiquetas ARIA para lectores de pantalla.
Botones Atrás/Siguiente/Enviar. "Siguiente" valida los campos del paso actual vía trigger() antes de avanzar. El mapeo paso-a-campo garantiza que solo se comprueben los campos del paso activo.
Wrapper de campo reutilizable que conecta aria-required, aria-invalid, aria-describedby y mensajes de error con role="alert". Usado por cada componente de paso.
useFormContext<ArchitectProfile>() accede al formulario compartido - no necesita props, el provider lo maneja
register(name) conecta el input con react-hook-form - maneja onChange, onBlur, ref y value
aria-required={required} indica a los lectores de pantalla que el campo es obligatorio
aria-invalid={!!error} pasa a true cuando falla la validación - los lectores de pantalla anuncian "entrada no válida"
aria-describedby={error ? errorId : undefined} vincula el input con su mensaje de error - los lectores de pantalla leen el error cuando el campo tiene foco
role="alert" en el <p> de error lo convierte en región viva - los lectores de pantalla anuncian el error inmediatamente al aparecer
data-testid={field-${name}} proporciona selectores consistentes de Playwright en todos los campos
Input numérico para años, input de texto para rol, grupo de checkboxes para certificaciones y un textarea con contador de caracteres para la biografía.
togglePlatform / toggleSpecialty gestionan manualmente el estado del array vía setValue con { shouldValidate: true } - los checkboxes no usan register() porque mapean a arrays, no a valores individuales
watch("cloudPlatforms") renderiza dinámicamente los grupos radio de competencia - solo muestra AWS/Azure/GCP cuando la plataforma está seleccionada
`${platform}Proficiency` as keyof ArchitectProfile calcula el nombre del campo dinámicamente - p. ej., seleccionar "aws" renderiza el grupo radio awsProficiency
RadioGroup con onValueChange sincroniza el nivel seleccionado de vuelta al formulario vía setValue
PLATFORMS usa as const - TypeScript estrecha el value a la unión literal "aws" | "azure" | "gcp" que coincide con el enum zod
Arrastrar y soltar con miniaturas de vista previa, validación de tipo/tamaño en el cliente y un botón de eliminar por archivo. Usa useController para sincronizar archivos con el estado de react-hook-form.
useController({ control, name }) sincroniza el estado de archivos con react-hook-form - a diferencia de register(), funciona con inputs no nativos (zonas de arrastrar/soltar)
validateFile ejecuta comprobaciones en el cliente (tipo MIME, tamaño, conteo máximo) antes de añadir archivos - retroalimentación rápida sin ida y vuelta al servidor
URL.createObjectURL(file) crea miniaturas de vista previa - cada URL se rastrea en el estado previews para la limpieza
URL.revokeObjectURL(p.url) en removeFile evita fugas de memoria - cada URL creada debe revocarse cuando ya no se necesita
El estado isDragging alterna el estilo de borde/fondo al arrastrar - señal visual de que la zona de soltar está activa
isMaxReached deshabilita la zona de soltar y el input de archivo - evita superar el límite de maxFiles
role="button" + tabIndex={0} + onKeyDown hace la zona de soltar accesible por teclado - Enter/Espacio dispara el selector de archivos
El <input type="file"> oculto con className="sr-only" es el input real - la zona visible delega los clics vía inputRef
Modo simple vs. múltiple: field.onChange(validFiles[0]) reemplaza en simple, [...files, ...validFiles] añade en múltiple
Conecta los valores de react-hook-form con la server action vía FormData. Usa useActionState para el estado pendiente/error. Mapea los errores de campo del servidor de vuelta al paso correcto.
useActionState(createProfile, initialState) devuelve [state, formAction, isPending] - conecta la server action con la UI pendiente de React
FIELD_TO_STEP mapea nombres de campo a números de paso - cuando el servidor devuelve un error de campo, la UI navega al usuario al paso correcto
El primer useEffect itera state.fieldErrors y llama a setError() para cada uno - mapea los errores de validación del servidor de vuelta a react-hook-form para mostrarlos inline
goToStep(targetStep) navega automáticamente al paso que contiene el primer error - el usuario no tiene que buscar manualmente qué paso falló
handleSubmit construye manualmente FormData desde getValues() - conecta el estado de react-hook-form con la entrada FormData esperada por la server action
formData.append() (no set) se usa para campos array - certificaciones, plataformas, especialidades y subidas múltiples de archivos
JSON.stringify(values.jobs) serializa el array de puestos - los objetos anidados complejos no pueden enviarse como entradas individuales de FormData
isPending deshabilita el botón de envío y muestra un spinner Loader2 - evita el doble envío
El componente ReviewCard renderiza una tarjeta de resumen por paso con un botón "Editar" - permite al usuario volver a cualquier paso antes de enviar
querySelector con un selector complejo encuentra el primer input enfocable, no oculto y no deshabilitado - funciona para inputs de texto, selects y textareas
useEffect depende de currentStep - se dispara cada vez que cambia el paso, moviendo el foco al primer campo del nuevo paso
containerRef envuelve el contenido del paso - acota la consulta solo al DOM del paso actual, no a toda la página
Pruebas de extremo a extremo que se mapean directamente a los escenarios Gherkin. Usan los atributos data-testid añadidos a cada componente.
// e2e/profile-form.spec.tsimport { test, expect, type Page } from "@playwright/test";import path from "node:path";const BASE_URL = "http://localhost:3000/profile/create";// ── Ayudantes ────────────────────────────────────────────async function fillStep1(page: Page) { await page.getByTestId("field-fullName").fill("Jane Doe"); await page.getByTestId("field-email").fill("jane@example.com"); await page.getByTestId("field-phone").fill("+1-555-0100");}async function fillStep2(page: Page) { await page.getByTestId("field-yearsOfExperience").fill("8"); await page.getByTestId("field-currentRole").clear(); await page.getByTestId("field-currentRole").fill("Principal Architect"); await page.getByTestId("cert-aws-solutions-architect-professional").click();}async function fillStep3(page: Page) { await page.getByTestId("job-0-company").fill("Acme Corp"); await page.getByTestId("job-0-role").fill("Cloud Architect"); await page.getByTestId("job-0-start").fill("2020-01-01"); await page.getByTestId("job-0-current").click();}async function fillStep4(page: Page) { await page.getByTestId("platform-aws").click(); await page.getByTestId("specialty-serverless").click();}async function clickNext(page: Page) { await page.getByTestId("btn-next").click();}// ── Pruebas mapeadas a features Gherkin ───────────────────test.describe("Feature: Multi-Step Navigation", () => { test.beforeEach(async ({ page }) => { await page.goto(BASE_URL); }); test("el estado inicial muestra el paso 1", async ({ page }) => { await expect(page.getByTestId("step-personal-info")).toBeVisible(); await expect(page.getByTestId("step-1")).toHaveAttribute("data-status", "current"); await expect(page.getByTestId("btn-back")).not.toBeVisible(); }); test("completar el paso 1 desbloquea el paso 2", async ({ page }) => { await fillStep1(page); await clickNext(page); await expect(page.getByTestId("step-experience")).toBeVisible(); await expect(page.getByTestId("step-1")).toHaveAttribute("data-status", "completed"); await expect(page.getByTestId("btn-back")).toBeVisible(); }); test("navegar hacia atrás preserva los datos", async ({ page }) => { await fillStep1(page); await clickNext(page); await page.getByTestId("btn-back").click(); await expect(page.getByTestId("field-fullName")).toHaveValue("Jane Doe"); });});test.describe("Feature: Validation", () => { test.beforeEach(async ({ page }) => { await page.goto(BASE_URL); }); test("los campos obligatorios muestran errores cuando están vacíos", async ({ page }) => { await clickNext(page); await expect(page.getByText("El nombre completo es obligatorio")).toBeVisible(); await expect(page.getByText("El correo electrónico es obligatorio")).toBeVisible(); await expect(page.getByTestId("step-personal-info")).toBeVisible(); }); test("el formato del correo electrónico se valida", async ({ page }) => { await page.getByTestId("field-fullName").fill("Jane Doe"); await page.getByTestId("field-email").fill("not-an-email"); await clickNext(page); await expect(page.getByText("Introduce una dirección de correo válida")).toBeVisible(); }); test("la fecha de fin debe ser posterior a la de inicio", async ({ page }) => { await fillStep1(page); await clickNext(page); await fillStep2(page); await clickNext(page); await page.getByTestId("job-0-company").fill("Acme"); await page.getByTestId("job-0-role").fill("Architect"); await page.getByTestId("job-0-start").fill("2025-06-01"); await page.getByTestId("job-0-end").fill("2024-01-01"); await clickNext(page); await expect(page.getByText("La fecha de fin debe ser posterior a la de inicio")).toBeVisible(); });});test.describe("Feature: File Uploads", () => { test.beforeEach(async ({ page }) => { await page.goto(BASE_URL); // Navegar al paso 5 await fillStep1(page); await clickNext(page); await fillStep2(page); await clickNext(page); await fillStep3(page); await clickNext(page); await fillStep4(page); await clickNext(page); }); test("la subida muestra vista previa con nombre y tamaño", async ({ page }) => { const filePath = path.resolve("e2e/fixtures/sample.jpg"); const input = page.getByTestId("upload-profilePhoto").locator("input[type=file]"); await input.setInputFiles(filePath); await expect(page.getByText("sample.jpg")).toBeVisible(); }); test("rechaza archivos de tamaño excesivo", async ({ page }) => { // Crear un buffer de 6 MB para la prueba const input = page.getByTestId("upload-profilePhoto").locator("input[type=file]"); await input.setInputFiles({ name: "huge.jpg", mimeType: "image/jpeg", buffer: Buffer.alloc(6 * 1024 * 1024), }); await expect(page.getByTestId("profilePhoto-error")).toHaveText("El archivo debe pesar menos de 5 MB"); });});test.describe("Feature: Submission UX", () => { test("el botón de envío muestra el estado de carga", async ({ page }) => { await page.goto(BASE_URL); await fillStep1(page); await clickNext(page); await fillStep2(page); await clickNext(page); await fillStep3(page); await clickNext(page); await fillStep4(page); await clickNext(page); // Omitir subidas await clickNext(page); // En el paso de revisión await expect(page.getByTestId("step-review")).toBeVisible(); await page.getByTestId("btn-submit-profile").click(); await expect(page.getByText("Enviando...")).toBeVisible(); });});test.describe("Feature: Accessibility", () => { test("los campos obligatorios tienen aria-required", async ({ page }) => { await page.goto(BASE_URL); await expect(page.getByTestId("field-fullName")).toHaveAttribute("aria-required", "true"); await expect(page.getByTestId("field-email")).toHaveAttribute("aria-required", "true"); }); test("los campos no válidos tienen aria-invalid tras fallar la validación", async ({ page }) => { await page.goto(BASE_URL); await clickNext(page); await expect(page.getByTestId("field-fullName")).toHaveAttribute("aria-invalid", "true"); }); test("el indicador de paso tiene aria-current en el paso activo", async ({ page }) => { await page.goto(BASE_URL); await expect(page.getByTestId("step-1")).toHaveAttribute("aria-current", "step"); });});
Puntos clave del código:
Las funciones ayudantes (fillStep1, fillStep2, etc.) reflejan los pasos Given de Gherkin - configuración reutilizable para cada prueba
Los grupos test.describe se mapean a nombres Feature de Gherkin - "Multi-Step Navigation", "Validation", "File Uploads", etc.
test.beforeEach navega a la página - coincide con el bloque Background de Gherkin
Los selectores getByTestId() apuntan a atributos data-testid - desacoplados de clases CSS o contenido de texto
toHaveAttribute("data-status", "completed") afirma el estado del indicador de paso - prueba el mismo atributo que el CSS usa para estilos
toHaveValue("Jane Doe") tras navegar hacia atrás verifica la persistencia de datos - coincide con el escenario Gherkin "navegar hacia atrás preserva los datos"
setInputFiles() con un Buffer.alloc(6 * 1024 * 1024) crea un archivo sintético de tamaño excesivo - no se necesita un archivo real para la prueba de rechazo
fillStep1 → clickNext → fillStep2 → clickNext → ... encadena en la prueba de subida la navegación al paso 5 - cada paso debe pasar la validación antes de avanzar
Las aserciones toBeVisible() en mensajes de error coinciden con Gherkin Then debería ver "..." - la prueba verifica la cadena de error exacta visible para el usuario
Las pruebas de accesibilidad verifican aria-required, aria-invalid y aria-current - garantiza que los contratos ARIA documentados en la especificación Gherkin estén implementados
data-testid faltante -- Las pruebas de Playwright dependen de atributos data-testid. Cada elemento interactivo necesita uno. Añádelos cuando creas un componente, no después.
El archivo de esquema importa módulos del servidor -- lib/schemas/architect-profile.ts se comparte entre cliente y servidor. No debe importar nada de archivos "use server", next/headers ni clientes de base de datos.
Deriva de índice en useFieldArray -- cuando eliminas una entrada de puesto, todos los índices posteriores se desplazan. Usa field.id (de useFieldArray) como key de React, no el índice del array.
Vistas previas de archivos sin limpiar -- cada URL.createObjectURL debe tener un revokeObjectURL cuando se elimina el archivo. El componente FileUpload lo maneja, pero si construyes UI de subida personalizada, rastrea tus URLs.
redirect() en server actions -- redirect() de Next.js lanza un error interno. Tu try/catch en la server action debe relanzarlo, o la redirección falla en silencio.
Subida de archivos en Playwright -- usa page.locator('input[type=file]').setInputFiles() con una ruta de archivo real o un Buffer. La zona visual de soltar no es un input de archivo real, así que apunta al <input> oculto dentro.