| name | envelope-editor-v2-e2e |
| description | 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. |
Envelope Editor V2 E2E Tests
Overview
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:
- Document (
documents/<id>) - Native document editor
- Template (
templates/<id>) - Native template editor
- Embedded Create (
/embed/v2/authoring/envelope/create) - Embedded editor creating a new envelope
- Embedded Edit (
/embed/v2/authoring/envelope/edit/<id>) - Embedded editor updating an existing envelope
Project Structure
packages/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
Core Abstraction: TEnvelopeEditorSurface
Every 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;
isEmbedded: boolean;
envelopeId?: string;
envelopeType: 'DOCUMENT' | 'TEMPLATE';
userId: number;
userEmail: string;
userName: string;
teamId: number;
};
Surface Openers (from fixtures/envelope-editor.ts)
const surface = await openDocumentEnvelopeEditor(page);
const surface = await openTemplateEnvelopeEditor(page);
const surface = await openEmbeddedEnvelopeEditor(page, {
envelopeType: 'DOCUMENT' | 'TEMPLATE',
mode?: 'create' | 'edit',
tokenNamePrefix?: string,
externalId?: string,
features?: EmbeddedEditorConfig,
});
Test Architecture Pattern
Every test file follows this structure, with four test.describe blocks grouping tests by editor surface:
1. Imports
import { type Page, expect, test } from '@playwright/test';
import { SomePrismaEnum } from '@prisma/client';
import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
openTemplateEnvelopeEditor,
persistEmbeddedEnvelope,
} from '../fixtures/envelope-editor';
import { expectToastTextToBeVisible } from '../fixtures/generic';
2. Type definitions and constants
type FlowResult = {
externalId: string;
};
const TEST_VALUES = {
};
3. Local helper functions
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');
}
};
4. The flow function
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()}`;
if (surface.isEmbedded && !surface.envelopeId) {
await addEnvelopeItemPdf(surface.root, 'embedded-feature.pdf');
}
await updateExternalId(surface, externalId);
if (surface.isEmbedded) {
await setRecipientEmail(surface.root, 0, 'embedded@example.com');
} else {
await clickAddMyselfButton(surface.root);
}
await clickEnvelopeEditorStep(surface.root, 'addFields');
await clickEnvelopeEditorStep(surface.root, 'upload');
return { externalId };
};
5. Database assertion function
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: {
documentMeta: true,
recipients: true,
fields: true,
envelopeAttachments: true,
},
orderBy: { createdAt: 'desc' },
});
expect(envelope.someField).toBe(expectedValue);
};
6. The four test.describe blocks
Tests 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,
});
});
});
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,
});
});
});
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);
await persistEmbeddedEnvelope(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
});
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);
await persistEmbeddedEnvelope(surface);
await assertFeaturePersistedInDatabase({
surface,
...result,
});
});
});
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.
Key Differences Between Surfaces
| 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' |
Available Fixture Helpers
From fixtures/envelope-editor.ts
Locator helpers (return Playwright Locators):
getEnvelopeEditorSettingsTrigger(root) - Settings gear button
getEnvelopeItemTitleInputs(root) - Title inputs for envelope items
getEnvelopeItemDragHandles(root) - Drag handles for reordering items
getEnvelopeItemRemoveButtons(root) - Remove buttons for items
getEnvelopeItemDropzoneInput(root) - File input for PDF upload
getRecipientEmailInputs(root) - Email inputs for recipients
getRecipientNameInputs(root) - Name inputs for recipients
getRecipientRows(root) - Full recipient row fieldsets
getRecipientRemoveButtons(root) - Remove buttons for recipients
getSigningOrderInputs(root) - Signing order number inputs
Action helpers:
addEnvelopeItemPdf(root, fileName?) - Upload a PDF to the dropzone
clickEnvelopeEditorStep(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 email
setRecipientName(root, index, name) - Fill recipient name
setRecipientRole(root, index, roleLabel) - Set role via combobox
assertRecipientRole(root, index, roleLabel) - Assert role value
toggleSigningOrder(root, enabled) - Toggle signing order switch
toggleAllowDictateSigners(root, enabled) - Toggle dictate signers switch
setSigningOrderValue(root, index, value) - Set signing order number
persistEmbeddedEnvelope(surface) - Click Create/Update button for embedded flows
From fixtures/generic.ts
expectTextToBeVisible(page, text) - Assert text visible on page
expectTextToNotBeVisible(page, text) - Assert text not visible
expectToastTextToBeVisible(page, text) - Assert toast message visible
External ID Pattern
Every 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.
Running Tests
npm run test:dev -w @documenso/app-tests -- --grep "Envelope Editor V2"
npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-recipients.spec.ts
npm run test-ui:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/
npm run test:dev -w @documenso/app-tests -- --grep "documents/<id>: add myself"
Checklist When Writing a New Test
- Create the spec file in
packages/app-tests/e2e/envelope-editor-v2/
- Import
TEnvelopeEditorSurface and the three opener functions
- Import
persistEmbeddedEnvelope if you need DB assertions for embedded flows
- Define a
FlowResult type for data passed between flow and assertion
- Define
TEST_VALUES constants for test data
- Write
updateExternalId helper (or reuse the pattern)
- Write the
runXxxFlow function handling embedded vs native differences
- Write the
assertXxxPersistedInDatabase function using Prisma
- Create four
test.describe blocks: 'document editor', 'template editor', 'embedded create', 'embedded edit'
- Place tests inside the appropriate describe block for each surface
- For embedded create tests, add a PDF via
addEnvelopeItemPdf before the flow
- For embedded tests, call
persistEmbeddedEnvelope(surface) before DB assertions
- Use
surface.isEmbedded to branch on behavioral differences (toasts, "Add Myself", etc.)
Common Pitfalls
- Missing
persistEmbeddedEnvelope: Embedded flows don't autosave. You MUST call this before any DB assertions.
- PDF required for embedded create: Embedded create starts with 0 items. Upload a PDF before navigating to fields.
- Toast assertions in embedded: Don't assert toasts for settings updates in embedded mode (they don't appear).
- Parallel test isolation: Always use a unique
externalId via nanoid() so parallel tests don't collide.
- Navigation verification: Navigate away from and back to the current step to verify UI state persistence (the editor may re-render).
- Delete confirmation: Native surfaces show a confirmation dialog for item deletion; embedded surfaces delete immediately.