mirror of
https://github.com/documenso/documenso.git
synced 2026-07-12 14:05:20 +10:00
517 lines
16 KiB
TypeScript
517 lines
16 KiB
TypeScript
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<BasicReplaceFlowResult> => {
|
|
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<string> => {
|
|
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<FieldCleanupFlowResult> => {
|
|
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,
|
|
});
|
|
});
|
|
});
|