chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
@@ -0,0 +1,882 @@
/**
* API V2-based seed fixtures for E2E tests.
*
* These fixtures create documents, templates, envelopes, recipients, fields,
* and folders through the API V2 endpoints instead of direct Prisma calls.
* This ensures all creation-time side effects (PDF normalization, field meta
* defaults, etc.) are exercised the same way a real user would trigger them.
*
* Usage:
* import { apiSeedDraftDocument, apiSeedPendingDocument, ... } from '../fixtures/api-seeds';
*
* test('my test', async ({ request }) => {
* const { envelope, token, user, team } = await apiSeedDraftDocument(request, {
* title: 'My Document',
* recipients: [{ email: 'signer@example.com', name: 'Signer', role: 'SIGNER' }],
* });
* });
*/
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 { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope';
import { seedUser } from '@documenso/prisma/seed/users';
import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type {
TDistributeEnvelopeRequest,
TDistributeEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsResponse } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import { type APIRequestContext, expect } from '@playwright/test';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../assets/example.pdf'));
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ApiRecipient = {
email: string;
name?: string;
role?: 'SIGNER' | 'APPROVER' | 'VIEWER' | 'CC' | 'ASSISTANT';
signingOrder?: number;
accessAuth?: string[];
actionAuth?: string[];
};
export type ApiField = {
recipientId: number;
envelopeItemId?: string;
type: string;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
placeholder?: string;
matchAll?: boolean;
};
export type ApiSeedContext = {
user: Awaited<ReturnType<typeof seedUser>>['user'];
team: Awaited<ReturnType<typeof seedUser>>['team'];
token: string;
};
export type ApiSeedEnvelopeOptions = {
title?: string;
type?: 'DOCUMENT' | 'TEMPLATE';
externalId?: string;
visibility?: string;
globalAccessAuth?: string[];
globalActionAuth?: string[];
folderId?: string;
pdfFile?: { name: string; data: Buffer };
meta?: TCreateEnvelopePayload['meta'];
recipients?: Array<
ApiRecipient & {
fields?: Array<{
type: string;
identifier?: string | number;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
}>;
}
>;
};
// ---------------------------------------------------------------------------
// Core API helpers (low-level)
// ---------------------------------------------------------------------------
/**
* Create a fresh user + team + API token for test isolation.
* Every high-level seed function calls this internally, but you can also
* call it directly if you need the context for multiple operations.
*/
export const apiCreateTestContext = async (tokenName = 'e2e-seed'): Promise<ApiSeedContext> => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName,
expiresIn: null,
});
return { user, team, token };
};
const authHeader = (token: string) => ({
Authorization: `Bearer ${token}`,
});
/**
* Create an envelope via API V2 with a PDF file attached.
*
* This is the lowest-level envelope creation function. It creates the
* envelope with optional inline recipients and fields in a single call.
*/
export const apiCreateEnvelope = async (
request: APIRequestContext,
token: string,
options: ApiSeedEnvelopeOptions = {},
): Promise<TCreateEnvelopeResponse> => {
const {
title = '[TEST] API Seeded Envelope',
type = 'DOCUMENT',
externalId,
visibility,
globalAccessAuth,
globalActionAuth,
folderId,
pdfFile,
meta,
recipients,
} = options;
// Build payload as a plain object. The API receives this as a JSON string
// inside multipart form data, so strict TypeScript union narrowing is not
// required - the server validates with Zod at runtime.
const payload: Record<string, unknown> = {
title,
type,
};
if (externalId !== undefined) {
payload.externalId = externalId;
}
if (visibility !== undefined) {
payload.visibility = visibility;
}
if (globalAccessAuth !== undefined) {
payload.globalAccessAuth = globalAccessAuth;
}
if (globalActionAuth !== undefined) {
payload.globalActionAuth = globalActionAuth;
}
if (folderId !== undefined) {
payload.folderId = folderId;
}
if (meta !== undefined) {
payload.meta = meta;
}
if (recipients !== undefined) {
payload.recipients = recipients.map((r) => {
const recipientPayload: Record<string, unknown> = {
email: r.email,
name: r.name ?? r.email,
role: r.role ?? 'SIGNER',
};
if (r.signingOrder !== undefined) {
recipientPayload.signingOrder = r.signingOrder;
}
if (r.accessAuth !== undefined) {
recipientPayload.accessAuth = r.accessAuth;
}
if (r.actionAuth !== undefined) {
recipientPayload.actionAuth = r.actionAuth;
}
if (r.fields !== undefined) {
recipientPayload.fields = r.fields.map((f) => {
const fieldPayload: Record<string, unknown> = {
type: f.type,
page: f.page ?? 1,
positionX: f.positionX ?? 10,
positionY: f.positionY ?? 10,
width: f.width ?? 15,
height: f.height ?? 5,
};
if (f.identifier !== undefined) {
fieldPayload.identifier = f.identifier;
}
if (f.fieldMeta !== undefined) {
fieldPayload.fieldMeta = f.fieldMeta;
}
return fieldPayload;
});
}
return recipientPayload;
});
}
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const pdf = pdfFile ?? { name: 'example.pdf', data: examplePdfBuffer };
formData.append('files', new File([pdf.data], pdf.name, { type: 'application/pdf' }));
const res = await request.post(`${API_BASE_URL}/envelope/create`, {
headers: authHeader(token),
multipart: formData,
});
expect(res.ok(), `envelope/create failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TCreateEnvelopeResponse;
};
/**
* Get full envelope data via API V2.
*/
export const apiGetEnvelope = async (
request: APIRequestContext,
token: string,
envelopeId: string,
): Promise<TGetEnvelopeResponse> => {
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
headers: authHeader(token),
});
expect(res.ok(), `envelope/get failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TGetEnvelopeResponse;
};
/**
* Add recipients to an existing envelope via API V2.
*/
export const apiCreateRecipients = async (
request: APIRequestContext,
token: string,
envelopeId: string,
recipients: ApiRecipient[],
): Promise<TCreateEnvelopeRecipientsResponse> => {
const data = {
envelopeId,
data: recipients.map((r) => {
const recipientPayload: Record<string, unknown> = {
email: r.email,
name: r.name ?? r.email,
role: r.role ?? 'SIGNER',
};
if (r.signingOrder !== undefined) {
recipientPayload.signingOrder = r.signingOrder;
}
if (r.accessAuth !== undefined) {
recipientPayload.accessAuth = r.accessAuth;
}
if (r.actionAuth !== undefined) {
recipientPayload.actionAuth = r.actionAuth;
}
return recipientPayload;
}),
};
const res = await request.post(`${API_BASE_URL}/envelope/recipient/create-many`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `recipient/create-many failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TCreateEnvelopeRecipientsResponse;
};
/**
* Add fields to an existing envelope via API V2.
*
* If `recipientId` is not set on fields, the first recipient is used.
* If `envelopeItemId` is not set, the first envelope item is used.
*/
export const apiCreateFields = async (
request: APIRequestContext,
token: string,
envelopeId: string,
fields: ApiField[],
): Promise<void> => {
// Build as plain object - the deeply discriminated union types for fields
// (type + fieldMeta combinations) are validated by Zod on the server.
const data = {
envelopeId,
data: fields.map((f) => {
const fieldPayload: Record<string, unknown> = {
recipientId: f.recipientId,
type: f.type,
};
if (f.envelopeItemId !== undefined) {
fieldPayload.envelopeItemId = f.envelopeItemId;
}
if (f.fieldMeta !== undefined) {
fieldPayload.fieldMeta = f.fieldMeta;
}
if (f.placeholder) {
fieldPayload.placeholder = f.placeholder;
if (f.width !== undefined) {
fieldPayload.width = f.width;
}
if (f.height !== undefined) {
fieldPayload.height = f.height;
}
if (f.matchAll !== undefined) {
fieldPayload.matchAll = f.matchAll;
}
} else {
fieldPayload.page = f.page ?? 1;
fieldPayload.positionX = f.positionX ?? 10;
fieldPayload.positionY = f.positionY ?? 10;
fieldPayload.width = f.width ?? 15;
fieldPayload.height = f.height ?? 5;
}
return fieldPayload;
}),
};
const res = await request.post(`${API_BASE_URL}/envelope/field/create-many`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `field/create-many failed: ${await res.text()}`).toBeTruthy();
};
/**
* Distribute (send) an envelope via API V2.
* Returns the distribute response which includes signing URLs for recipients.
*/
export const apiDistributeEnvelope = async (
request: APIRequestContext,
token: string,
envelopeId: string,
meta?: TDistributeEnvelopeRequest['meta'],
): Promise<TDistributeEnvelopeResponse> => {
const data: TDistributeEnvelopeRequest = {
envelopeId,
...(meta !== undefined && { meta }),
};
const res = await request.post(`${API_BASE_URL}/envelope/distribute`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data,
});
expect(res.ok(), `envelope/distribute failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as TDistributeEnvelopeResponse;
};
/**
* Create a folder via API V2.
*/
export const apiCreateFolder = async (
request: APIRequestContext,
token: string,
options: {
name?: string;
parentId?: string;
type?: 'DOCUMENT' | 'TEMPLATE';
} = {},
): Promise<{ id: string; name: string }> => {
const { name = 'Test Folder', parentId, type } = options;
const res = await request.post(`${API_BASE_URL}/folder/create`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data: {
name,
...(parentId !== undefined && { parentId }),
...(type !== undefined && { type }),
},
});
expect(res.ok(), `folder/create failed: ${await res.text()}`).toBeTruthy();
return (await res.json()) as { id: string; name: string };
};
/**
* Create a direct template link via API V2.
*/
export const apiCreateDirectTemplateLink = async (
request: APIRequestContext,
token: string,
templateId: number,
directRecipientId?: number,
): Promise<{ id: number; token: string; enabled: boolean; directTemplateRecipientId: number }> => {
const res = await request.post(`${API_BASE_URL}/template/direct/create`, {
headers: { ...authHeader(token), 'Content-Type': 'application/json' },
data: {
templateId,
...(directRecipientId !== undefined && { directRecipientId }),
},
});
expect(res.ok(), `template/direct/create failed: ${await res.text()}`).toBeTruthy();
return await res.json();
};
// ---------------------------------------------------------------------------
// High-level seed functions (composites)
// ---------------------------------------------------------------------------
export type ApiSeedResult = {
/** The created envelope/document/template. */
envelope: TGetEnvelopeResponse;
/** API token for further API calls. */
token: string;
/** The seeded user. */
user: ApiSeedContext['user'];
/** The seeded team. */
team: ApiSeedContext['team'];
};
export type ApiSeedDocumentOptions = {
/** Document title. Default: '[TEST] API Document - Draft' */
title?: string;
/** Recipients to add to the document. */
recipients?: ApiRecipient[];
/** Fields to add per recipient. If provided, must match recipients order. */
fieldsPerRecipient?: Array<
Array<{
type: string;
page?: number;
positionX?: number;
positionY?: number;
width?: number;
height?: number;
fieldMeta?: Record<string, unknown>;
}>
>;
/** External ID for the envelope. */
externalId?: string;
/** Document visibility setting. */
visibility?: string;
/** Global access auth requirements. */
globalAccessAuth?: string[];
/** Global action auth requirements. */
globalActionAuth?: string[];
/** Folder ID to place the document in. */
folderId?: string;
/** Document meta settings. */
meta?: TCreateEnvelopePayload['meta'];
/** Custom PDF file. Default: example.pdf */
pdfFile?: { name: string; data: Buffer };
/** Reuse an existing test context instead of creating a new one. */
context?: ApiSeedContext;
};
/**
* Create a draft document via API V2.
*
* Creates a user, team, API token, and a DRAFT document. Optionally adds
* recipients and fields.
*
* @example
* ```ts
* const { envelope, token, user, team } = await apiSeedDraftDocument(request, {
* title: 'My Document',
* recipients: [{ email: 'signer@test.com', name: 'Test Signer' }],
* });
* ```
*/
export const apiSeedDraftDocument = async (
request: APIRequestContext,
options: ApiSeedDocumentOptions = {},
): Promise<ApiSeedResult> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-draft-doc'));
// Create the envelope with inline recipients if provided
const createOptions: ApiSeedEnvelopeOptions = {
title: options.title ?? '[TEST] API Document - Draft',
type: 'DOCUMENT',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
};
// If we have recipients but no per-recipient fields, use inline creation
if (options.recipients && !options.fieldsPerRecipient) {
createOptions.recipients = options.recipients.map((r) => ({
...r,
role: r.role ?? 'SIGNER',
}));
}
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, createOptions);
// If we need per-recipient fields, add recipients and fields separately
if (options.recipients && options.fieldsPerRecipient) {
const recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, options.recipients);
// Get envelope to resolve envelope item IDs
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
// Create fields for each recipient
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
const recipientId = recipientsRes.data[index].id;
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
}
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return { envelope, token: ctx.token, user: ctx.user, team: ctx.team };
};
export type ApiSeedPendingDocumentOptions = ApiSeedDocumentOptions & {
/** Distribution meta (subject, message, etc.). */
distributeMeta?: TDistributeEnvelopeRequest['meta'];
};
/**
* Seed a pending (distributed) document via API V2.
*
* Creates the document, adds recipients with SIGNATURE fields, then
* distributes (sends) it. The response includes signing URLs for each
* recipient.
*
* Every SIGNER recipient must have at least one SIGNATURE field for
* distribution to succeed. If you don't provide `fieldsPerRecipient`,
* a default SIGNATURE field is added for each SIGNER/APPROVER recipient.
*
* @example
* ```ts
* const { envelope, distributeResult, token } = await apiSeedPendingDocument(request, {
* recipients: [
* { email: 'signer@test.com', name: 'Signer' },
* { email: 'viewer@test.com', name: 'Viewer', role: 'VIEWER' },
* ],
* });
*
* // Access signing URL:
* const signingUrl = distributeResult.recipients[0].signingUrl;
* ```
*/
export const apiSeedPendingDocument = async (
request: APIRequestContext,
options: ApiSeedPendingDocumentOptions = {},
): Promise<
ApiSeedResult & {
distributeResult: TDistributeEnvelopeResponse;
}
> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-pending-doc'));
const recipients = options.recipients ?? [
{
email: `signer-${Date.now()}@test.documenso.com`,
name: 'Test Signer',
role: 'SIGNER' as const,
},
];
// Create the base envelope
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, {
title: options.title ?? '[TEST] API Document - Pending',
type: 'DOCUMENT',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
});
// Add recipients
const recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, recipients);
// Get envelope for item IDs
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
// Add fields
if (options.fieldsPerRecipient) {
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId: recipientsRes.data[index].id,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
} else {
// Auto-add a SIGNATURE field for each SIGNER/APPROVER recipient
const signerFields: ApiField[] = [];
for (const [index, r] of recipientsRes.data.entries()) {
const role = recipients[index].role ?? 'SIGNER';
if (role === 'SIGNER' || role === 'APPROVER') {
signerFields.push({
recipientId: r.id,
envelopeItemId: firstItemId,
type: 'SIGNATURE',
page: 1,
positionX: 10,
positionY: 10 + index * 10,
width: 15,
height: 5,
});
}
}
if (signerFields.length > 0) {
await apiCreateFields(request, ctx.token, envelopeId, signerFields);
}
}
// Distribute
const distributeResult = await apiDistributeEnvelope(request, ctx.token, envelopeId, options.distributeMeta);
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return {
envelope,
distributeResult,
token: ctx.token,
user: ctx.user,
team: ctx.team,
};
};
export type ApiSeedTemplateOptions = Omit<ApiSeedDocumentOptions, 'folderId'> & {
/** Folder ID to place the template in. */
folderId?: string;
};
/**
* Seed a template via API V2.
*
* Creates a TEMPLATE envelope with optional recipients and fields.
*
* @example
* ```ts
* const { envelope, token } = await apiSeedTemplate(request, {
* title: 'My Template',
* recipients: [{ email: 'recipient@test.com', name: 'Signer', role: 'SIGNER' }],
* });
* ```
*/
export const apiSeedTemplate = async (
request: APIRequestContext,
options: ApiSeedTemplateOptions = {},
): Promise<ApiSeedResult> => {
const ctx = options.context ?? (await apiCreateTestContext('e2e-template'));
const createOptions: ApiSeedEnvelopeOptions = {
title: options.title ?? '[TEST] API Template',
type: 'TEMPLATE',
externalId: options.externalId,
visibility: options.visibility,
globalAccessAuth: options.globalAccessAuth,
globalActionAuth: options.globalActionAuth,
folderId: options.folderId,
meta: options.meta,
pdfFile: options.pdfFile,
};
if (options.recipients && !options.fieldsPerRecipient) {
createOptions.recipients = options.recipients.map((r) => ({
...r,
role: r.role ?? 'SIGNER',
}));
}
const { id: envelopeId } = await apiCreateEnvelope(request, ctx.token, createOptions);
if (options.recipients && options.fieldsPerRecipient) {
const recipientsRes = await apiCreateRecipients(request, ctx.token, envelopeId, options.recipients);
const envelopeData = await apiGetEnvelope(request, ctx.token, envelopeId);
const firstItemId = envelopeData.envelopeItems[0]?.id;
for (const [index, recipientFields] of options.fieldsPerRecipient.entries()) {
if (recipientFields.length === 0) {
continue;
}
await apiCreateFields(
request,
ctx.token,
envelopeId,
recipientFields.map((f) => ({
...f,
recipientId: recipientsRes.data[index].id,
envelopeItemId: firstItemId,
type: f.type,
})),
);
}
}
const envelope = await apiGetEnvelope(request, ctx.token, envelopeId);
return { envelope, token: ctx.token, user: ctx.user, team: ctx.team };
};
/**
* Seed a template with a direct link via API V2.
*
* Creates a template with a recipient, then creates a direct link for it.
*
* @example
* ```ts
* const { envelope, directLink, token } = await apiSeedDirectTemplate(request, {
* title: 'Direct Template',
* });
*
* // Use directLink.token for the signing URL
* ```
*/
export const apiSeedDirectTemplate = async (
request: APIRequestContext,
options: ApiSeedTemplateOptions & {
/** Custom recipient for the direct link. Default: a SIGNER placeholder. */
directRecipient?: ApiRecipient;
} = {},
): Promise<
ApiSeedResult & {
directLink: { id: number; token: string; enabled: boolean; directTemplateRecipientId: number };
}
> => {
const recipients = options.recipients ?? [
options.directRecipient ?? {
email: 'direct-template-recipient@documenso.com',
name: 'Direct Template Recipient',
role: 'SIGNER' as const,
},
];
const templateResult = await apiSeedTemplate(request, {
...options,
recipients,
});
// Find the recipient ID for the direct link
const directRecipientEmail = options.directRecipient?.email ?? recipients[0].email;
const directRecipient = templateResult.envelope.recipients.find((r) => r.email === directRecipientEmail);
if (!directRecipient) {
throw new Error(`Direct template recipient not found: ${directRecipientEmail}`);
}
const numericTemplateId = mapSecondaryIdToTemplateId(templateResult.envelope.secondaryId);
const directLink = await apiCreateDirectTemplateLink(
request,
templateResult.token,
numericTemplateId,
directRecipient.id,
);
// Re-fetch envelope to include directLink data
const envelope = await apiGetEnvelope(request, templateResult.token, templateResult.envelope.id);
return {
...templateResult,
envelope,
directLink,
};
};
/**
* Seed multiple draft documents in parallel for a single user context.
*
* Useful for tests that need multiple documents (e.g., bulk actions, find/filter tests).
*
* @example
* ```ts
* const { documents, token, user, team } = await apiSeedMultipleDraftDocuments(request, [
* { title: 'Doc A' },
* { title: 'Doc B' },
* { title: 'Doc C' },
* ]);
* ```
*/
export const apiSeedMultipleDraftDocuments = async (
request: APIRequestContext,
documents: ApiSeedDocumentOptions[],
context?: ApiSeedContext,
): Promise<{
documents: TGetEnvelopeResponse[];
token: string;
user: ApiSeedContext['user'];
team: ApiSeedContext['team'];
}> => {
const ctx = context ?? (await apiCreateTestContext('e2e-multi-doc'));
const results = await Promise.all(
documents.map(async (docOptions) => apiSeedDraftDocument(request, { ...docOptions, context: ctx })),
);
return {
documents: results.map((r) => r.envelope),
token: ctx.token,
user: ctx.user,
team: ctx.team,
};
};
@@ -1,6 +1,5 @@
import type { Page } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { Page } from '@playwright/test';
type LoginOptions = {
page: Page;
@@ -0,0 +1,407 @@
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<string, string>;
darkModeDisabled?: boolean;
};
const encodeEmbeddedOptions = (options: Record<string, unknown>) => {
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<TEnvelopeEditorSurface> => {
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<TEnvelopeEditorSurface> => {
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<string, string>;
darkModeDisabled?: boolean;
};
export const openEmbeddedEnvelopeEditor = async (
page: Page,
{
envelopeType,
mode = 'create',
tokenNamePrefix = 'e2e-embed',
externalId,
folderId,
features,
css,
cssVars,
darkModeDisabled,
}: OpenEmbeddedEnvelopeEditorOptions,
): Promise<TEnvelopeEditorSurface> => {
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<typeof roleLabel, string> = {
'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<string> => {
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;
};
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Locator } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
import { expect, type Page } from '@playwright/test';
export const expectTextToBeVisible = async (page: Page, text: string) => {
await expect(page.getByText(text).first()).toBeVisible();
+18
View File
@@ -0,0 +1,18 @@
import type { Page } from '@playwright/test';
import type Konva from 'konva';
export const getKonvaElementCountForPage = async (page: Page, pageNumber: number, elementSelector: string) => {
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
return await page.evaluate(
({ pageNumber, elementSelector }) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
const pageOne = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
return pageOne?.find(elementSelector).length || 0;
},
{ pageNumber, elementSelector },
);
};