Writing and maintaining Playwright E2E tests for the Envelope Editor V2. Use when the user needs to create, modify, debug, or extend E2E tests in packages/app-tests/e2e/envelope-editor-v2/. Triggers include requests to "write an e2e test", "add a test for the envelope editor", "test envelope settings/recipients/fields/items/attachments", "fix a failing envelope test", or any task involving Playwright tests for the envelope editor feature.
The Envelope Editor V2 E2E test suite lives in packages/app-tests/e2e/envelope-editor-v2/. Each test file covers a distinct feature area of the envelope editor and follows a strict architectural pattern that tests the same flow across four surfaces:
documents/<id>) - Native document editortemplates/<id>) - Native template editor/embed/v2/authoring/envelope/create) - Embedded editor creating a new envelope/embed/v2/authoring/envelope/edit/<id>) - Embedded editor updating an existing envelopepackages/app-tests/
e2e/
envelope-editor-v2/
envelope-attachments.spec.ts # Attachment CRUD
envelope-fields.spec.ts # Field placement on PDF canvas
envelope-items.spec.ts # PDF document item CRUD
envelope-recipients.spec.ts # Recipient management
envelope-settings.spec.ts # Settings dialog
fixtures/
authentication.ts # apiSignin, apiSignout
documents.ts # Document tab helpers
envelope-editor.ts # Core fixture: surface openers + locator/action helpers
generic.ts # Toast assertions, text visibility
signature.ts # Signature pad helpers
playwright.config.ts # Test configuration
TEnvelopeEditorSurfaceEvery test revolves around the TEnvelopeEditorSurface type from fixtures/envelope-editor.ts. This is the central abstraction that normalizes differences between the four surfaces:
type TEnvelopeEditorSurface = {
root: Page; // The Playwright page
isEmbedded: boolean; // true for embed surfaces
envelopeId?: string; // Set for document/template/embed-edit, undefined for embed-create
envelopeType: 'DOCUMENT' | 'TEMPLATE';
userId: number; // Seeded user ID
userEmail: string; // Seeded user email
userName: string; // Seeded user name
teamId: number; // Seeded team ID
};
fixtures/envelope-editor.ts)// Native surfaces - seed user + document/template, sign in, navigate
const surface = await openDocumentEnvelopeEditor(page);
const surface = await openTemplateEnvelopeEditor(page);
// Embedded surfaces - seed user, create API token, get presign token, navigate
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT' | 'TEMPLATE',
mode?: 'create' | 'edit', // default: 'create'
tokenNamePrefix?: string, // for unique API token names
externalId?: string, // optional external ID in hash
features?: EmbeddedEditorConfig, // feature flags
});
Every test file follows this structure, with four test.describe blocks grouping tests by editor surface:
import { type Page, expect, test } from '@playwright/test';
// Prisma enums if needed for DB assertions
import { SomePrismaEnum } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface, // Import needed helpers from the fixture
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope, // ... other helpers
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
type FlowResult = {
externalId: string;
// ... other data needed for DB assertions
};
const TEST_VALUES = {
// Centralized test data constants
};
// Common: open settings and set external ID for DB lookup
const openSettingsDialog = async (root: Page) => {
await getEnvelopeEditorSettingsTrigger(root).click();
await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible();
};
const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => {
await openSettingsDialog(surface.root);
await surface.root.locator('input[name="externalId"]').fill(externalId);
await surface.root.getByRole('button', { name: 'Update' }).click();
if (!surface.isEmbedded) {
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
}
};
A single runXxxFlow function that works across ALL surfaces. It handles embedded vs non-embedded differences internally:
const runMyFeatureFlow = async (surface: TEnvelopeEditorSurface): Promise<FlowResult> => {
const externalId = `e2e-feature-${nanoid()}`;
// For embedded create, may need to add a PDF first
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'embedded-feature.pdf');
}
await updateExternalId(surface, externalId);
// Handle embedded vs native differences
if (surface.isEmbedded) {
// No "Add Myself" button in embedded mode
await setRecipientEmail(surface.root, 0, '[email protected]');
} else {
await clickAddMyselfButton(surface.root);
}
// ... perform feature-specific actions ...
// Navigate away and back to verify UI persistence
await clickEnvelopeEditorStep(surface.root, 'addFields');
await clickEnvelopeEditorStep(surface.root, 'upload');
// ... assert UI state after navigation ...
return { externalId /* ... */ };
};
Uses Prisma directly to verify data was persisted correctly:
const assertFeaturePersistedInDatabase = async ({
surface,
externalId,
// ... expected values
}: {
surface: TEnvelopeEditorSurface;
externalId: string;
// ...
}) => {
const envelope = await prisma.envelope.findFirstOrThrow({
where: {
externalId,
userId: surface.userId,
teamId: surface.teamId,
type: surface.envelopeType,
},
include: {
// Include related data as needed
documentMeta: true,
recipients: true,
fields: true,
envelopeAttachments: true,
},
orderBy: { createdAt: 'desc' },
});
// Assert expected values
expect(envelope.someField).toBe(expectedValue);
};
test.describe blocksTests are organized into four test.describe blocks, one per editor surface. Each describe block contains the tests relevant to that surface. This structure allows adding multiple tests per surface while keeping them grouped:
test.describe('document editor', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openDocumentEnvelopeEditor(page);
const result = await runMyFeatureFlow(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional document-editor-specific tests here...
});
test.describe('template editor', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openTemplateEnvelopeEditor(page);
const result = await runMyFeatureFlow(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional template-editor-specific tests here...
});
test.describe('embedded create', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT',
tokenNamePrefix: 'e2e-embed-feature',
});
const result = await runMyFeatureFlow(surface);
// IMPORTANT: Must persist before DB assertions for embedded
await persistEmbeddedEnvelope(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional embedded-create-specific tests here...
});
test.describe('embedded edit', () => {
test('description of what is tested', async ({ page }) => {
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'TEMPLATE',
mode: 'edit',
tokenNamePrefix: 'e2e-embed-feature',
});
const result = await runMyFeatureFlow(surface);
// IMPORTANT: Must persist before DB assertions for embedded
await persistEmbeddedEnvelope(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
// Additional embedded-edit-specific tests here...
});
When a test only applies to specific surfaces (e.g., a document-only action like "send document"), only include it in the relevant describe block(s). Not every describe block needs the same tests -- the structure groups tests by surface, not by requiring symmetry.
| Behavior | Document/Template | Embedded Create | Embedded Edit |
|---|---|---|---|
| User seeding | Seed + sign in | Seed + API token | Seed + API token + seed envelope |
| "Add Myself" button | Available | Not available | Not available |
| Toast on settings update | Yes ('Envelope updated') | No | No |
| PDF already attached | Yes (1 item) | No (0 items, must upload) | Yes (1 item) |
| Delete confirmation dialog | Yes ('Delete' button) | No (immediate) | No (immediate) |
| DB persistence timing | Immediate (autosaved) | After persistEmbeddedEnvelope() | After persistEmbeddedEnvelope() |
| Persist button label | N/A | 'Create Document' / 'Create Template' | 'Update Document' / 'Update Template' |
fixtures/envelope-editor.tsLocator helpers (return Playwright Locators):
getEnvelopeEditorSettingsTrigger(root) - Settings gear buttongetEnvelopeItemTitleInputs(root) - Title inputs for envelope itemsgetEnvelopeItemDragHandles(root) - Drag handles for reordering itemsgetEnvelopeItemRemoveButtons(root) - Remove buttons for itemsgetEnvelopeItemDropzoneInput(root) - File input for PDF uploadgetRecipientEmailInputs(root) - Email inputs for recipientsgetRecipientNameInputs(root) - Name inputs for recipientsgetRecipientRows(root) - Full recipient row fieldsetsgetRecipientRemoveButtons(root) - Remove buttons for recipientsgetSigningOrderInputs(root) - Signing order number inputsAction helpers:
addEnvelopeItemPdf(root, fileName?) - Upload a PDF to the dropzoneclickEnvelopeEditorStep(root, stepId) - Navigate to a step: 'upload', 'addFields', 'preview'clickAddMyselfButton(root) - Click "Add Myself" (native only)clickAddSignerButton(root) - Click "Add Signer"setRecipientEmail(root, index, email) - Fill recipient emailsetRecipientName(root, index, name) - Fill recipient namesetRecipientRole(root, index, roleLabel) - Set role via comboboxassertRecipientRole(root, index, roleLabel) - Assert role valuetoggleSigningOrder(root, enabled) - Toggle signing order switchtoggleAllowDictateSigners(root, enabled) - Toggle dictate signers switchsetSigningOrderValue(root, index, value) - Set signing order numberpersistEmbeddedEnvelope(surface) - Click Create/Update button for embedded flowsfixtures/generic.tsexpectTextToBeVisible(page, text) - Assert text visible on pageexpectTextToNotBeVisible(page, text) - Assert text not visibleexpectToastTextToBeVisible(page, text) - Assert toast message visibleEvery test uses an externalId (e.g., e2e-feature-${nanoid()}) set via the settings dialog. This unique ID is then used in Prisma queries to reliably locate the envelope in the database for assertions. This is critical because multiple tests run in parallel.
# Run all envelope editor tests
npm run test:dev -w @documenso/app-tests -- --grep "Envelope Editor V2"
# Run a specific test file
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-recipients.spec.ts
# Run with UI
npm run test-ui:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/
# Run specific test by name
npm run test:dev -w @documenso/app-tests -- --grep "documents/<id>: add myself"
packages/app-tests/e2e/envelope-editor-v2/TEnvelopeEditorSurface and the three opener functionspersistEmbeddedEnvelope if you need DB assertions for embedded flowsFlowResult type for data passed between flow and assertionTEST_VALUES constants for test dataupdateExternalId helper (or reuse the pattern)runXxxFlow function handling embedded vs native differencesassertXxxPersistedInDatabase function using Prismatest.describe blocks: 'document editor', 'template editor', 'embedded create', 'embedded edit'addEnvelopeItemPdf before the flowpersistEmbeddedEnvelope(surface) before DB assertionssurface.isEmbedded to branch on behavioral differences (toasts, "Add Myself", etc.)persistEmbeddedEnvelope: Embedded flows don't autosave. You MUST call this before any DB assertions.externalId via nanoid() so parallel tests don't collide.