import fs from 'node:fs'; import path from 'node:path'; import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; import { expect, type Page, test } from '@playwright/test'; import { FieldType } from '@prisma/client'; import { addEnvelopeItemPdf, clickAddMyselfButton, clickEnvelopeEditorStep, getEnvelopeEditorSettingsTrigger, getEnvelopeItemDropzoneInput, getEnvelopeItemReplaceButtons, openDocumentEnvelopeEditor, openEmbeddedEnvelopeEditor, openTemplateEnvelopeEditor, persistEmbeddedEnvelope, setRecipientEmail, setRecipientName, type TEnvelopeEditorSurface, } from '../fixtures/envelope-editor'; import { expectToastTextToBeVisible } from '../fixtures/generic'; import { getKonvaElementCountForPage } from '../fixtures/konva'; test.use({ storageState: { cookies: [], origins: [], }, }); type TestFilePayload = { name: string; mimeType: string; buffer: Buffer; }; const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); const multiPagePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/field-font-alignment.pdf')); const createPdfPayload = (name: string, buffer: Buffer = examplePdfBuffer): TestFilePayload => ({ name, mimeType: 'application/pdf', buffer, }); // --- Shared helpers --- 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'); } }; const replaceEnvelopeItemPdf = async ( root: Page, index: number, file: TestFilePayload, options?: { isEmbedded?: boolean }, ) => { const replaceButton = getEnvelopeItemReplaceButtons(root).nth(index); await expect(replaceButton).toBeVisible(); // Listen for the file chooser event before clicking so the native dialog // is intercepted and never actually shown to the user. const [fileChooser] = await Promise.all([root.waitForEvent('filechooser'), replaceButton.click()]); await fileChooser.setFiles(file); // The button stays in the DOM but becomes disabled while the replace // mutation is in flight, then re-enables once the mutation completes. // Wait for both transitions to guarantee the replace has fully finished. // // For embedded surfaces the replacement is purely local state with no // network round-trip, so it can complete before Playwright observes the // disabled state. Skip the disabled assertion for embedded surfaces. if (!options?.isEmbedded) { await expect(replaceButton).toBeDisabled({ timeout: 15000 }); } await expect(replaceButton).toBeEnabled({ timeout: 15000 }); }; const assertPdfPageCount = async (root: Page, expectedCount: number) => { await expect(root.locator('[data-pdf-content]').first()).toHaveAttribute('data-page-count', String(expectedCount), { timeout: 15000, }); }; const placeFieldOnPdf = async (root: Page, fieldName: 'Signature' | 'Text', position: { x: number; y: number }) => { await root.getByRole('button', { name: fieldName, exact: true }).click(); const canvas = root.locator('.konva-container canvas').first(); await expect(canvas).toBeVisible(); await canvas.click({ position }); }; const scrollToPage = async (root: Page, pageNumber: number) => { const pageImage = root.locator(`img[data-page-number="${pageNumber}"]`); await pageImage.scrollIntoViewIfNeeded(); await expect(pageImage).toBeVisible({ timeout: 10000 }); // Wait for the Konva stage to initialize after virtualized rendering. await root.waitForTimeout(1000); }; const placeFieldOnPage = async ( root: Page, pageNumber: number, fieldName: 'Signature' | 'Text', position: { x: number; y: number }, ) => { await root.getByRole('button', { name: fieldName, exact: true }).click(); if (pageNumber > 1) { await scrollToPage(root, pageNumber); } // Find the canvas corresponding to this page number via Konva stages. // Since the virtualized list may have multiple canvases, we target the one // that belongs to the correct page by using the evaluate approach. const canvas = root.locator('.konva-container canvas'); if (pageNumber === 1) { await expect(canvas.first()).toBeVisible(); await canvas.first().click({ position }); } else { // For multi-page, find the canvas at the page position. // The canvases are rendered in page order within the viewport. // After scrolling to page N, it should be visible. We use nth based on // the page index among currently rendered canvases. // A more reliable approach: click on the page's img and offset into canvas. const pageImg = root.locator(`img[data-page-number="${pageNumber}"]`); const pageBox = await pageImg.boundingBox(); if (!pageBox) { throw new Error(`Could not find bounding box for page ${pageNumber}`); } // Click at the desired position relative to the page image. // The Konva canvas overlays the image, so clicking at the same coordinates works. await root.mouse.click(pageBox.x + position.x, pageBox.y + position.y); } }; // --- Test 1: Basic replace flow --- type BasicReplaceFlowResult = { externalId: string; originalDocumentDataId: string | null; }; const runBasicReplaceFlow = async (surface: TEnvelopeEditorSurface): Promise => { const { root, isEmbedded } = surface; const externalId = `e2e-replace-${nanoid()}`; // For embedded create, upload a PDF first. if (isEmbedded && !surface.envelopeId) { await addEnvelopeItemPdf(root, 'replace-test-original.pdf'); } await updateExternalId(surface, externalId); // Record the original documentDataId so we can assert it changed after replace. // For embedded flows the externalId only lives in client state until persist, // so query by envelope ID when available, and skip entirely for embedded create. let originalDocumentDataId: string | null = null; if (!isEmbedded) { const envelope = await prisma.envelope.findFirstOrThrow({ where: { externalId, userId: surface.userId, teamId: surface.teamId, type: surface.envelopeType, }, include: { envelopeItems: { orderBy: { order: 'asc' } }, }, }); originalDocumentDataId = envelope.envelopeItems[0].documentDataId; } else if (surface.envelopeId) { const envelope = await prisma.envelope.findFirstOrThrow({ where: { id: surface.envelopeId, }, include: { envelopeItems: { orderBy: { order: 'asc' } }, }, }); originalDocumentDataId = envelope.envelopeItems[0].documentDataId; } // Navigate to addFields step to verify initial page count. await clickEnvelopeEditorStep(root, 'addFields'); await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 }); await assertPdfPageCount(root, 1); // Navigate back to upload step. await clickEnvelopeEditorStep(root, 'upload'); await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible(); // Replace the PDF. await replaceEnvelopeItemPdf(root, 0, createPdfPayload('replace-test-new.pdf'), { isEmbedded }); // Navigate to addFields step to verify the PDF loaded correctly. await clickEnvelopeEditorStep(root, 'addFields'); await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 }); await assertPdfPageCount(root, 1); // Navigate back to upload step. await clickEnvelopeEditorStep(root, 'upload'); await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible(); return { externalId, originalDocumentDataId }; }; const assertBasicReplaceInDatabase = async ({ surface, externalId, originalDocumentDataId, }: { surface: TEnvelopeEditorSurface; externalId: string; originalDocumentDataId: string | null; }) => { const envelope = await prisma.envelope.findFirstOrThrow({ where: { externalId, userId: surface.userId, teamId: surface.teamId, type: surface.envelopeType, }, include: { envelopeItems: { orderBy: { order: 'asc' } }, }, orderBy: { createdAt: 'desc' }, }); expect(envelope.envelopeItems).toHaveLength(1); const item = envelope.envelopeItems[0]; // The documentDataId should have changed after replace. if (originalDocumentDataId) { expect(item.documentDataId).not.toBe(originalDocumentDataId); } // Verify documentDataId is a valid non-empty string. expect(item.documentDataId).toBeTruthy(); expect(typeof item.documentDataId).toBe('string'); // Title and order should be unchanged. expect(item.order).toBeGreaterThanOrEqual(1); }; // --- Test 2: Field cleanup replace flow --- const TEST_FIELD_VALUES = { embeddedRecipient: { email: 'embedded-replace-recipient@documenso.com', name: 'Embedded Replace Recipient', }, }; type FieldCleanupFlowResult = { externalId: string; recipientEmail: string; }; const setupRecipient = async (surface: TEnvelopeEditorSurface): Promise => { if (surface.isEmbedded) { await setRecipientEmail(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.email); await setRecipientName(surface.root, 0, TEST_FIELD_VALUES.embeddedRecipient.name); return TEST_FIELD_VALUES.embeddedRecipient.email; } await clickAddMyselfButton(surface.root); return surface.userEmail; }; const uploadMultiPagePdf = async (root: Page) => { await getEnvelopeItemDropzoneInput(root).setInputFiles({ name: 'multi-page.pdf', mimeType: 'application/pdf', buffer: multiPagePdfBuffer, }); }; const runFieldCleanupReplaceFlow = async (surface: TEnvelopeEditorSurface): Promise => { const { root, isEmbedded } = surface; const externalId = `e2e-replace-fields-${nanoid()}`; // Step 1: Get a 3-page PDF loaded. if (isEmbedded && !surface.envelopeId) { // Embedded create: upload the multi-page PDF directly. await uploadMultiPagePdf(root); } else { // All other surfaces: replace the existing 1-page PDF with the 3-page one. await replaceEnvelopeItemPdf(root, 0, createPdfPayload('multi-page.pdf', multiPagePdfBuffer), { isEmbedded, }); } await updateExternalId(surface, externalId); // Step 2: Add a recipient. const recipientEmail = await setupRecipient(surface); // Step 3: Navigate to addFields step and verify 3-page PDF is loaded. await clickEnvelopeEditorStep(root, 'addFields'); await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 }); await assertPdfPageCount(root, 3); // Step 4: Place a Signature field on page 1. await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 }); let fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCountPage1).toBe(1); // Step 5: Scroll to page 2 and place a Text field. await placeFieldOnPage(root, 2, 'Text', { x: 120, y: 140 }); const fieldCountPage2 = await getKonvaElementCountForPage(root, 2, '.field-group'); expect(fieldCountPage2).toBe(1); // Verify file selector shows "2 Fields". await expect(root.getByText('2 Fields')).toBeVisible(); // Step 6: Navigate back to upload step. await clickEnvelopeEditorStep(root, 'upload'); await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible(); // Step 7: Replace with 1-page PDF. await replaceEnvelopeItemPdf(root, 0, createPdfPayload('single-page.pdf'), { isEmbedded }); // Step 8: Navigate to addFields step to verify. await clickEnvelopeEditorStep(root, 'addFields'); await root.locator('[data-pdf-content]').first().waitFor({ state: 'visible', timeout: 15000 }); // PDF should now be 1 page. await assertPdfPageCount(root, 1); // Page 1 field should survive. fieldCountPage1 = await getKonvaElementCountForPage(root, 1, '.field-group'); expect(fieldCountPage1).toBe(1); // File selector should show "1 Field". await expect(root.getByText('1 Field')).toBeVisible(); return { externalId, recipientEmail }; }; const assertFieldCleanupInDatabase = async ({ surface, externalId, recipientEmail, }: { surface: TEnvelopeEditorSurface; externalId: string; recipientEmail: string; }) => { const envelope = await prisma.envelope.findFirstOrThrow({ where: { externalId, userId: surface.userId, teamId: surface.teamId, type: surface.envelopeType, }, include: { fields: true, recipients: true, envelopeItems: { orderBy: { order: 'asc' } }, }, orderBy: { createdAt: 'desc' }, }); const recipient = envelope.recipients.find((r) => r.email === recipientEmail); expect(recipient).toBeDefined(); // Only the page-1 field should remain. expect(envelope.fields).toHaveLength(1); expect(envelope.fields[0].page).toBe(1); expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE); expect(envelope.fields[0].recipientId).toBe(recipient?.id); // The envelope item should have a 1-page PDF (documentDataId should exist). expect(envelope.envelopeItems).toHaveLength(1); expect(envelope.envelopeItems[0].documentDataId).toBeTruthy(); }; // --- Test describe blocks --- test.describe('document editor', () => { test('replace PDF on an envelope item', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runBasicReplaceFlow(surface); await assertBasicReplaceInDatabase({ surface, ...result, }); }); test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runFieldCleanupReplaceFlow(surface); await assertFieldCleanupInDatabase({ surface, ...result, }); }); }); test.describe('template editor', () => { test('replace PDF on an envelope item', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runBasicReplaceFlow(surface); await assertBasicReplaceInDatabase({ surface, ...result, }); }); test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runFieldCleanupReplaceFlow(surface); await assertFieldCleanupInDatabase({ surface, ...result, }); }); }); test.describe('embedded create', () => { test('replace PDF on an envelope item', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', tokenNamePrefix: 'e2e-embed-replace', }); const result = await runBasicReplaceFlow(surface); await persistEmbeddedEnvelope(surface); await assertBasicReplaceInDatabase({ surface, ...result, }); }); test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', tokenNamePrefix: 'e2e-embed-replace-fields', }); const result = await runFieldCleanupReplaceFlow(surface); await persistEmbeddedEnvelope(surface); await assertFieldCleanupInDatabase({ surface, ...result, }); }); }); test.describe('embedded edit', () => { test('replace PDF on an envelope item', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', mode: 'edit', tokenNamePrefix: 'e2e-embed-replace', }); const result = await runBasicReplaceFlow(surface); await persistEmbeddedEnvelope(surface); await assertBasicReplaceInDatabase({ surface, ...result, }); }); test('replace PDF deletes fields on out-of-bounds pages', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', mode: 'edit', tokenNamePrefix: 'e2e-embed-replace-fields', }); const result = await runFieldCleanupReplaceFlow(surface); await persistEmbeddedEnvelope(surface); await assertFieldCleanupInDatabase({ surface, ...result, }); }); });