import fs from 'node:fs'; import path from 'node:path'; import { nanoid } from '@documenso/lib/universal/id'; import { prisma } from '@documenso/prisma'; import { seedPendingDocument } from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; import { expect, type Page, test } from '@playwright/test'; import { apiSignin } from '../fixtures/authentication'; import { clickEnvelopeEditorStep, getEnvelopeEditorSettingsTrigger, getEnvelopeItemDragHandles, getEnvelopeItemDropzoneInput, getEnvelopeItemRemoveButtons, getEnvelopeItemTitleInputs, openDocumentEnvelopeEditor, openEmbeddedEnvelopeEditor, openTemplateEnvelopeEditor, persistEmbeddedEnvelope, type TEnvelopeEditorSurface, } from '../fixtures/envelope-editor'; import { expectToastTextToBeVisible } from '../fixtures/generic'; test.use({ storageState: { cookies: [], origins: [], }, }); type TestFilePayload = { name: string; mimeType: string; buffer: Buffer; }; const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); const createPdfPayload = (name: string): TestFilePayload => ({ name, mimeType: 'application/pdf', buffer: examplePdfBuffer, }); const getCurrentTitles = async (root: Page) => { const titleInputs = getEnvelopeItemTitleInputs(root); const count = await titleInputs.count(); return await Promise.all( Array.from({ length: count }, async (_, index) => await titleInputs.nth(index).inputValue()), ); }; 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 navigateToAddFieldsAndBack = async (root: Page) => { await clickEnvelopeEditorStep(root, 'addFields'); await expect(root.getByText('Selected Recipient')).toBeVisible(); await clickEnvelopeEditorStep(root, 'upload'); await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible(); }; const getEnvelopeItemsFromDatabase = async (surface: TEnvelopeEditorSurface, externalId: string) => { 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', }, }); return envelope.envelopeItems; }; type ItemFlowResult = { externalId: string; }; const uploadFiles = async (root: Page, files: TestFilePayload[]) => { const input = getEnvelopeItemDropzoneInput(root); await input.setInputFiles(files); }; const dragEnvelopeItemByHandle = async ({ root, sourceIndex, targetIndex, }: { root: Page; sourceIndex: number; targetIndex: number; }) => { const sourceHandle = getEnvelopeItemDragHandles(root).nth(sourceIndex); const targetHandle = getEnvelopeItemDragHandles(root).nth(targetIndex); await expect(sourceHandle).toBeVisible(); await expect(targetHandle).toBeVisible(); const sourceBox = await sourceHandle.boundingBox(); const targetBox = await targetHandle.boundingBox(); if (!sourceBox || !targetBox) { throw new Error('Could not resolve drag handle bounding boxes'); } const sourceX = sourceBox.x + sourceBox.width / 2; const sourceY = sourceBox.y + sourceBox.height / 2; const targetX = targetBox.x + targetBox.width / 2; const targetY = targetBox.y + targetBox.height / 2; await root.mouse.move(sourceX, sourceY); await root.mouse.down(); await root.mouse.move(targetX, targetY, { steps: 20 }); await root.mouse.up(); }; const runEnvelopeItemCrudFlow = async ({ surface, initialCount, filesToUpload, }: { surface: TEnvelopeEditorSurface; initialCount: number; filesToUpload: TestFilePayload[]; }): Promise => { const { root, isEmbedded } = surface; const externalId = `e2e-items-${nanoid()}`; await updateExternalId(surface, externalId); await expect(root.getByRole('heading', { name: 'Documents' })).toBeVisible(); await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(initialCount); // Upload files. await uploadFiles(root, filesToUpload); const expectedCountAfterUpload = initialCount + filesToUpload.length; await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload); if (!isEmbedded) { await navigateToAddFieldsAndBack(root); const itemsAfterUpload = await getEnvelopeItemsFromDatabase(surface, externalId); expect(itemsAfterUpload).toHaveLength(expectedCountAfterUpload); } // Rename items. await getEnvelopeItemTitleInputs(root).nth(0).fill('Envelope Item A'); await getEnvelopeItemTitleInputs(root).nth(1).fill('Envelope Item B'); await expect(getEnvelopeItemTitleInputs(root).nth(0)).toHaveValue('Envelope Item A'); await expect(getEnvelopeItemTitleInputs(root).nth(1)).toHaveValue('Envelope Item B'); if (!isEmbedded) { await navigateToAddFieldsAndBack(root); const itemsAfterRename = await getEnvelopeItemsFromDatabase(surface, externalId); expect(itemsAfterRename[0].title).toBe('Envelope Item A'); expect(itemsAfterRename[1].title).toBe('Envelope Item B'); } // Reorder items. await dragEnvelopeItemByHandle({ root, sourceIndex: 0, targetIndex: 1, }); await expect.poll(async () => await getCurrentTitles(root)).toEqual(['Envelope Item B', 'Envelope Item A']); if (!isEmbedded) { await navigateToAddFieldsAndBack(root); const itemsAfterReorder = await getEnvelopeItemsFromDatabase(surface, externalId); expect(itemsAfterReorder[0].title).toBe('Envelope Item B'); expect(itemsAfterReorder[1].title).toBe('Envelope Item A'); } // Remove first item. await getEnvelopeItemRemoveButtons(root).first().click(); if (!isEmbedded) { await root.getByRole('button', { name: 'Delete' }).click(); } await expect(getEnvelopeItemTitleInputs(root)).toHaveCount(expectedCountAfterUpload - 1); if (!isEmbedded) { await navigateToAddFieldsAndBack(root); const itemsAfterRemove = await getEnvelopeItemsFromDatabase(surface, externalId); expect(itemsAfterRemove).toHaveLength(expectedCountAfterUpload - 1); } return { externalId, }; }; test.describe('document editor', () => { test('add, remove, reorder and retitle items', async ({ page }) => { const surface = await openDocumentEnvelopeEditor(page); const result = await runEnvelopeItemCrudFlow({ surface, initialCount: 1, filesToUpload: [createPdfPayload('document-item-added.pdf')], }); const items = await getEnvelopeItemsFromDatabase(surface, result.externalId); expect(items).toHaveLength(1); expect(items[0].title).toBe('Envelope Item A'); expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items. }); }); test.describe('template editor', () => { test('add, remove, reorder and retitle items', async ({ page }) => { const surface = await openTemplateEnvelopeEditor(page); const result = await runEnvelopeItemCrudFlow({ surface, initialCount: 1, filesToUpload: [createPdfPayload('template-item-added.pdf')], }); const items = await getEnvelopeItemsFromDatabase(surface, result.externalId); expect(items).toHaveLength(1); expect(items[0].title).toBe('Envelope Item A'); expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items. }); }); test.describe('embedded create', () => { test('add, remove, reorder and retitle items', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'DOCUMENT', tokenNamePrefix: 'e2e-embed-items', }); const result = await runEnvelopeItemCrudFlow({ surface, initialCount: 0, filesToUpload: [ createPdfPayload('embedded-document-item-a.pdf'), createPdfPayload('embedded-document-item-b.pdf'), ], }); await persistEmbeddedEnvelope(surface); const items = await getEnvelopeItemsFromDatabase(surface, result.externalId); expect(items).toHaveLength(1); expect(items[0].title).toBe('Envelope Item A'); expect(items[0].order).toBe(1); // Expect order 1 because this is a one shot create via embedding. There are no incremental updates. }); }); test.describe('embedded edit', () => { test('add, remove, reorder and retitle items', async ({ page }) => { const surface = await openEmbeddedEnvelopeEditor(page, { envelopeType: 'TEMPLATE', mode: 'edit', tokenNamePrefix: 'e2e-embed-items', }); const result = await runEnvelopeItemCrudFlow({ surface, initialCount: 1, filesToUpload: [createPdfPayload('embedded-template-item-updated.pdf')], }); await persistEmbeddedEnvelope(surface); const items = await getEnvelopeItemsFromDatabase(surface, result.externalId); expect(items).toHaveLength(1); expect(items[0].title).toBe('Envelope Item A'); expect(items[0].order).toBe(2); // Expect order 2 because deleting items does not drop the order of sequential items. }); }); test.describe('pending envelope title editing', () => { test('edit envelope and item titles on a pending envelope', async ({ page }) => { const recipientEmail = `recipient-${nanoid()}@test.documenso.com`; const { user, team } = await seedUser(); const pendingDocument = await seedPendingDocument(user, team.id, [recipientEmail], { internalVersion: 2, }); await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents/${pendingDocument.id}/edit`, }); // Verify the envelope editor loaded with the upload step visible. await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); // Edit the envelope title in the header. const envelopeTitleInput = page.locator('[data-testid="envelope-title-input"]'); await expect(envelopeTitleInput).toBeEnabled(); await envelopeTitleInput.fill('Updated Pending Title'); // Edit the envelope item title. const itemTitleInput = getEnvelopeItemTitleInputs(page).first(); await expect(itemTitleInput).toBeEnabled(); await itemTitleInput.fill('Updated Item Title'); // Wait for debounced auto-save to persist by navigating away and back. await clickEnvelopeEditorStep(page, 'addFields'); await expect(page.getByText('Selected Recipient')).toBeVisible(); await clickEnvelopeEditorStep(page, 'upload'); await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); // Verify the titles persisted in the database. const updatedEnvelope = await prisma.envelope.findFirstOrThrow({ where: { id: pendingDocument.id }, include: { envelopeItems: true, auditLogs: { orderBy: { createdAt: 'desc' }, }, }, }); expect(updatedEnvelope.title).toBe('Updated Pending Title'); expect(updatedEnvelope.envelopeItems[0].title).toBe('Updated Item Title'); // Verify audit logs were created for both title changes. const titleAuditLog = updatedEnvelope.auditLogs.find((log) => log.type === 'DOCUMENT_TITLE_UPDATED'); expect(titleAuditLog).toBeDefined(); const itemAuditLog = updatedEnvelope.auditLogs.find((log) => log.type === 'ENVELOPE_ITEM_UPDATED'); expect(itemAuditLog).toBeDefined(); // Verify the upload dropzone is still disabled for pending envelopes. const dropzoneMessage = page.getByText('Cannot upload items after the document has been sent'); await expect(dropzoneMessage).toBeVisible(); // Drag handles are still rendered but dragging is disabled via isDragDisabled // when canItemsBeModified is false. Verify at least that the handle is present // but we cannot programmatically assert isDragDisabled from the DOM. await expect(getEnvelopeItemDragHandles(page)).toHaveCount(1); }); });