import fs from 'node:fs'; import path from 'node:path'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; import { DEFAULT_EMBEDDED_EDITOR_CONFIG } from '@documenso/lib/types/envelope-editor'; import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; import { seedUser } from '@documenso/prisma/seed/users'; import type { Page } from '@playwright/test'; import { expect } from '@playwright/test'; import { apiSignin } from './authentication'; const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf')); export type TEnvelopeEditorSurface = { root: Page; isEmbedded: boolean; envelopeId?: string; envelopeType: TEnvelopeEditorType; userId: number; userEmail: string; userName: string; teamId: number; }; export type TEnvelopeEditorType = 'DOCUMENT' | 'TEMPLATE'; type TEmbeddedHashCommonOptions = { externalId?: string; features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG; css?: string; cssVars?: Record; darkModeDisabled?: boolean; }; const encodeEmbeddedOptions = (options: Record) => { const encodedPayload = encodeURIComponent(JSON.stringify(options)); if (typeof btoa === 'function') { return btoa(encodedPayload); } return Buffer.from(encodedPayload, 'utf8').toString('base64'); }; export const createEmbeddedEnvelopeCreateHash = ({ envelopeType, externalId, folderId, features = DEFAULT_EMBEDDED_EDITOR_CONFIG, css, cssVars, darkModeDisabled, }: { envelopeType: TEnvelopeEditorType; folderId?: string } & TEmbeddedHashCommonOptions) => { return encodeEmbeddedOptions({ externalId, type: envelopeType, folderId, features, css, cssVars, darkModeDisabled, }); }; export const createEmbeddedEnvelopeEditHash = ({ externalId, features = DEFAULT_EMBEDDED_EDITOR_CONFIG, css, cssVars, darkModeDisabled, }: TEmbeddedHashCommonOptions) => { return encodeEmbeddedOptions({ externalId, features, css, cssVars, darkModeDisabled, }); }; export const openDocumentEnvelopeEditor = async (page: Page): Promise => { const { user, team } = await seedUser(); const document = await seedBlankDocument(user, team.id, { internalVersion: 2, }); await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/documents/${document.id}/edit?step=uploadAndRecipients`, }); return { root: page, isEmbedded: false, envelopeId: document.id, envelopeType: 'DOCUMENT', userId: user.id, userEmail: user.email, userName: user.name ?? '', teamId: team.id, }; }; export const openTemplateEnvelopeEditor = async (page: Page): Promise => { const { user, team } = await seedUser(); const template = await seedBlankTemplate(user, team.id, { createTemplateOptions: { title: `E2E Template ${Date.now()}`, userId: user.id, teamId: team.id, internalVersion: 2, }, }); await apiSignin({ page, email: user.email, redirectPath: `/t/${team.url}/templates/${template.id}/edit?step=uploadAndRecipients`, }); return { root: page, isEmbedded: false, envelopeId: template.id, envelopeType: 'TEMPLATE', userId: user.id, userEmail: user.email, userName: user.name ?? '', teamId: team.id, }; }; type OpenEmbeddedEnvelopeEditorOptions = { envelopeType: TEnvelopeEditorType; mode?: 'create' | 'edit'; tokenNamePrefix?: string; externalId?: string; folderId?: string; features?: typeof DEFAULT_EMBEDDED_EDITOR_CONFIG; css?: string; cssVars?: Record; darkModeDisabled?: boolean; }; export const openEmbeddedEnvelopeEditor = async ( page: Page, { envelopeType, mode = 'create', tokenNamePrefix = 'e2e-embed', externalId, folderId, features, css, cssVars, darkModeDisabled, }: OpenEmbeddedEnvelopeEditorOptions, ): Promise => { const { user, team } = await seedUser(); const envelopeToEdit = mode === 'edit' ? envelopeType === 'DOCUMENT' ? await seedBlankDocument(user, team.id, { internalVersion: 2, }) : await seedBlankTemplate(user, team.id, { createTemplateOptions: { title: `E2E Template ${Date.now()}`, userId: user.id, teamId: team.id, internalVersion: 2, }, }) : null; const { token } = await createApiToken({ userId: user.id, teamId: team.id, tokenName: `${tokenNamePrefix}-${envelopeType.toLowerCase()}`, expiresIn: null, }); const embeddedToken = await resolveEmbeddingToken( page, token, envelopeToEdit ? `envelopeId:${envelopeToEdit.id}` : undefined, ); if (envelopeToEdit) { const hash = createEmbeddedEnvelopeEditHash({ externalId, features: features ?? DEFAULT_EMBEDDED_EDITOR_CONFIG, css, cssVars, darkModeDisabled, }); await page.goto( `/embed/v2/authoring/envelope/edit/${envelopeToEdit.id}?token=${encodeURIComponent(embeddedToken)}#${hash}`, ); } else { const hash = createEmbeddedEnvelopeCreateHash({ envelopeType, externalId, folderId, features, css, cssVars, darkModeDisabled, }); await page.goto(`/embed/v2/authoring/envelope/create?token=${encodeURIComponent(embeddedToken)}#${hash}`); } await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible(); return { root: page, isEmbedded: true, envelopeId: envelopeToEdit?.id, envelopeType, userId: user.id, userEmail: user.email, userName: user.name ?? '', teamId: team.id, }; }; export const getEnvelopeEditorSettingsTrigger = (root: Page) => root.locator('button[title="Settings"]'); export const getEnvelopeItemTitleInputs = (root: Page) => root.locator('[data-testid^="envelope-item-title-input-"]'); export const getEnvelopeItemDragHandles = (root: Page) => root.locator('[data-testid^="envelope-item-drag-handle-"]'); export const getEnvelopeItemRemoveButtons = (root: Page) => root.locator('[data-testid^="envelope-item-remove-button-"]'); export const getEnvelopeItemReplaceButtons = (root: Page) => root.locator('[data-testid^="envelope-item-replace-button-"]'); export const getEnvelopeItemDropzoneInput = (root: Page) => root.locator('[data-testid="envelope-item-dropzone"] input[type="file"]'); export const addEnvelopeItemPdf = async (root: Page, fileName = 'embedded-envelope-item.pdf') => { await getEnvelopeItemDropzoneInput(root).setInputFiles({ name: fileName, mimeType: 'application/pdf', buffer: examplePdfBuffer, }); }; export const getRecipientEmailInputs = (root: Page) => root.locator('[data-testid="signer-email-input"]'); export const getRecipientNameInputs = (root: Page) => root.locator('input[placeholder^="Recipient "]'); export const getRecipientRows = (root: Page) => root.locator('[data-testid="signer-email-input"]').locator('xpath=ancestor::fieldset[1]'); export const getRecipientRemoveButtons = (root: Page) => root.locator('[data-testid="remove-signer-button"]'); export const getSigningOrderInputs = (root: Page) => root.locator('[data-testid="signing-order-input"]'); export const clickEnvelopeEditorStep = async (root: Page, stepId: 'upload' | 'addFields' | 'preview') => { await root.waitForTimeout(200); await root.locator(`[data-testid="envelope-editor-step-${stepId}"]`).first().click(); }; export const clickAddMyselfButton = async (root: Page) => { await root.getByRole('button', { name: 'Add Myself' }).click(); }; export const clickAddSignerButton = async (root: Page) => { await root.getByRole('button', { name: 'Add Signer' }).click(); }; export const setRecipientEmail = async (root: Page, index: number, email: string) => { await getRecipientEmailInputs(root).nth(index).fill(email); }; export const setRecipientName = async (root: Page, index: number, name: string) => { await getRecipientNameInputs(root).nth(index).fill(name); }; export const setRecipientRole = async ( root: Page, index: number, roleLabel: 'Needs to sign' | 'Needs to approve' | 'Needs to view' | 'Receives copy' | 'Can prepare', ) => { const row = getRecipientRows(root).nth(index); await row.locator('button[role="combobox"]').first().click(); await root.getByRole('option', { name: roleLabel }).click(); }; export const assertRecipientRole = async ( root: Page, index: number, roleLabel: 'Needs to sign' | 'Needs to approve' | 'Needs to view' | 'Receives copy' | 'Can prepare', ) => { const row = getRecipientRows(root).nth(index); const roleValueByLabel: Record = { 'Needs to sign': 'SIGNER', 'Needs to approve': 'APPROVER', 'Needs to view': 'VIEWER', 'Receives copy': 'CC', 'Can prepare': 'ASSISTANT', }; await expect(row.locator('button[role="combobox"]').first()).toHaveAttribute('title', roleValueByLabel[roleLabel]); }; export const toggleSigningOrder = async (root: Page, enabled: boolean) => { const checkbox = root.locator('#signingOrder'); const currentState = await checkbox.getAttribute('aria-checked'); const isEnabled = currentState === 'true'; if (isEnabled !== enabled) { await checkbox.click(); } }; export const toggleAllowDictateSigners = async (root: Page, enabled: boolean) => { const checkbox = root.locator('#allowDictateNextSigner'); const currentState = await checkbox.getAttribute('aria-checked'); const isEnabled = currentState === 'true'; if (isEnabled !== enabled) { await checkbox.click(); } }; export const setSigningOrderValue = async (root: Page, index: number, value: number) => { const input = getSigningOrderInputs(root).nth(index); await input.fill(value.toString()); await input.blur(); }; export const persistEmbeddedEnvelope = async (surface: TEnvelopeEditorSurface) => { if (!surface.isEmbedded) { return; } const isUpdateFlow = (await surface.root.getByRole('button', { name: 'Update Document' }).count()) > 0 || (await surface.root.getByRole('button', { name: 'Update Template' }).count()) > 0; const actionButtonName = isUpdateFlow ? surface.envelopeType === 'DOCUMENT' ? 'Update Document' : 'Update Template' : surface.envelopeType === 'DOCUMENT' ? 'Create Document' : 'Create Template'; await surface.root.getByRole('button', { name: actionButtonName }).click(); const completionHeading = isUpdateFlow ? surface.envelopeType === 'DOCUMENT' ? 'Document Updated' : 'Template Updated' : surface.envelopeType === 'DOCUMENT' ? 'Document Created' : 'Template Created'; await expect(surface.root.getByRole('heading', { name: completionHeading })).toBeVisible(); }; const resolveEmbeddingToken = async (page: Page, inputToken: string, scope?: string): Promise => { if (!inputToken.startsWith('api_')) { return inputToken; } const response = await page .context() .request.post(`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2/embedding/create-presign-token`, { headers: { Authorization: `Bearer ${inputToken}`, 'Content-Type': 'application/json', }, data: scope ? { scope } : {}, }); if (!response.ok()) { const text = await response.text(); throw new Error(`Failed to exchange API token (${response.status()}): ${text}`); } const data: unknown = await response.json(); if (typeof data !== 'object' || data === null || !('token' in data)) { throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`); } const token = data.token; if (typeof token !== 'string' || token.length === 0) { throw new Error(`Unexpected response shape: ${JSON.stringify(data)}`); } return token; };