diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
index 3535194c55..045fde0407 100644
--- a/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
+++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
@@ -349,11 +349,16 @@ export const EnvelopeEditorRecipientForm = () => {
removeSigner(formStateIndex);
const updatedSigners = form.getValues('signers').filter((s) => s.formId !== signer.formId);
+ const normalizedSigners = normalizeSigningOrders(updatedSigners);
- form.setValue('signers', normalizeSigningOrders(updatedSigners), {
+ form.setValue('signers', normalizedSigners, {
shouldValidate: true,
shouldDirty: true,
});
+
+ // Enqueue the removal immediately so rapid step navigation cannot flush
+ // before the form-change effect has observed the updated recipients.
+ setRecipientsDebounced(normalizedSigners);
}
};
@@ -515,8 +520,7 @@ export const EnvelopeEditorRecipientForm = () => {
const updatedSigner = updatedSigners.find((s) => s.formId === signer.formId);
const isLastSlot =
- updatedSigner?.signingOrder ===
- Math.max(...updatedSigners.map((s) => s.signingOrder ?? 0));
+ updatedSigner?.signingOrder === Math.max(...updatedSigners.map((s) => s.signingOrder ?? 0));
if (signer.role === RecipientRole.ASSISTANT && isLastSlot) {
toast({
diff --git a/apps/remix/app/root.tsx b/apps/remix/app/root.tsx
index 3322d757f2..9f12490a1a 100644
--- a/apps/remix/app/root.tsx
+++ b/apps/remix/app/root.tsx
@@ -101,7 +101,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
const [theme] = useTheme();
return (
-
+
diff --git a/apps/remix/app/routes/_authenticated+/dashboard.tsx b/apps/remix/app/routes/_authenticated+/dashboard.tsx
index 80fd6acb6c..068191f56b 100644
--- a/apps/remix/app/routes/_authenticated+/dashboard.tsx
+++ b/apps/remix/app/routes/_authenticated+/dashboard.tsx
@@ -10,6 +10,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
+import { isAdmin } from '@documenso/lib/utils/is-admin';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
@@ -67,15 +68,21 @@ export default function DashboardPage() {
No organisations found
- Create an organisation to get started.
+ {isAdmin(user) ? (
+ Create an organisation to get started.
+ ) : (
+ Contact your administrator to request organisation access.
+ )}
-
-
- Create organisation
-
-
+ {isAdmin(user) && (
+
+
+ Create organisation
+
+
+ )}
)}
diff --git a/docker/development/compose.yml b/docker/development/compose.yml
index 2b7626e5c8..57870394e9 100644
--- a/docker/development/compose.yml
+++ b/docker/development/compose.yml
@@ -7,7 +7,7 @@ services:
volumes:
- documenso_database:/var/lib/postgresql/data
healthcheck:
- test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}']
+ test: ['CMD-SHELL', 'pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}']
interval: 10s
timeout: 5s
retries: 5
diff --git a/outputs/019fbb1d-dbe9-7422-849d-ca0e82986978/documenso-feature-audit.xlsx b/outputs/019fbb1d-dbe9-7422-849d-ca0e82986978/documenso-feature-audit.xlsx
new file mode 100644
index 0000000000..9d97efbd0c
Binary files /dev/null and b/outputs/019fbb1d-dbe9-7422-849d-ca0e82986978/documenso-feature-audit.xlsx differ
diff --git a/packages/app-tests/e2e/api/v1/document-sending.spec.ts b/packages/app-tests/e2e/api/v1/document-sending.spec.ts
index ae1a28564b..149424e3de 100644
--- a/packages/app-tests/e2e/api/v1/document-sending.spec.ts
+++ b/packages/app-tests/e2e/api/v1/document-sending.spec.ts
@@ -193,7 +193,7 @@ test.describe('Document API', () => {
expect(response.status()).toBe(400);
});
- test('sendDocument: should fail when signer has only non-signature fields', async ({
+ test('sendDocument: should succeed when signer has only non-signature fields', async ({
request,
}) => {
const { user, team } = await seedUser();
@@ -251,8 +251,8 @@ test.describe('Document API', () => {
},
);
- expect(response.ok()).toBeFalsy();
- expect(response.status()).toBe(400);
+ expect(response.ok()).toBeTruthy();
+ expect(response.status()).toBe(200);
});
test('sendDocument: should succeed when signer has signature field', async ({ request }) => {
diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
index b152a389e4..8f6d440ab9 100644
--- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
+++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts
@@ -466,14 +466,18 @@ test('[DOCUMENT_FLOW]: should be able to create, send with redirect url, sign a
const { status } = await getDocumentByToken(token);
expect(status).toBe(DocumentStatus.PENDING);
- await page.getByRole('button', { name: 'Approve' }).click();
- await expect(
- page
- .getByRole('dialog')
- .getByText('You are about to complete approving the following document')
- .first(),
- ).toBeVisible();
- await page.getByRole('button', { name: 'Approve' }).click();
+ const approvalDialog = page.getByRole('dialog');
+
+ await expect(async () => {
+ await page.getByRole('button', { name: 'Approve' }).first().click();
+ await expect(
+ approvalDialog
+ .getByText('You are about to complete approving the following document')
+ .first(),
+ ).toBeVisible({ timeout: 5000 });
+ }).toPass({ timeout: 30_000 });
+
+ await approvalDialog.getByRole('button', { name: 'Approve' }).click();
await page.waitForURL('https://documenso.com');
diff --git a/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
index a87280779d..c84993bb0d 100644
--- a/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
+++ b/packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
@@ -63,6 +63,7 @@ test('[BULK_ACTIONS]: header checkbox selects all documents on page', async ({ p
redirectPath: `/t/${sender.team.url}/documents`,
});
+ await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${documents.length} selected`)).toBeVisible();
@@ -77,6 +78,7 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
redirectPath: `/t/${sender.team.url}/documents`,
});
+ await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
@@ -102,9 +104,10 @@ test('[BULK_ACTIONS]: can move multiple documents to a folder', async ({ page })
await expect(page.getByText('Move Documents to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
+ const moveToast = expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.getByRole('button', { name: 'Move' }).click();
- await expectToastTextToBeVisible(page, 'Selected items have been moved.');
+ await moveToast;
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
@@ -162,9 +165,10 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: folder.name }).click();
+ const moveToast = expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.getByRole('button', { name: 'Move' }).click();
- await expectToastTextToBeVisible(page, 'Selected items have been moved.');
+ await moveToast;
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
@@ -250,9 +254,10 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
+ const moveToast = expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.getByRole('button', { name: 'Move' }).click();
- await expectToastTextToBeVisible(page, 'Selected items have been moved.');
+ await moveToast;
await page.goto(`/t/${sender.team.url}/documents`);
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).toBeVisible();
diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts
index 42bc69b137..0e6a8c8f31 100644
--- a/packages/app-tests/e2e/documents/find-documents.spec.ts
+++ b/packages/app-tests/e2e/documents/find-documents.spec.ts
@@ -1167,8 +1167,9 @@ test.describe('Find Documents UI - Sender Filter', () => {
include: { members: { include: { user: true }, orderBy: { id: 'asc' } } },
});
- const member1 = org.members[1].user;
- const member2 = org.members[2].user;
+ const [member1, member2] = org.members
+ .filter((member) => member.userId !== owner.id)
+ .map((member) => member.user);
const { user: outsideUser } = await seedUser();
@@ -1207,11 +1208,12 @@ test.describe('Find Documents UI - Sender Filter', () => {
// Filter by member1
await page.locator('button').filter({ hasText: 'Sender: All' }).click();
- await page.getByRole('option', { name: member1.name ?? '' }).click();
- await page.waitForURL(/senderIds/);
+ await page.getByRole('option', { name: member1.name ?? '', exact: true }).click();
+ await expect(page).toHaveURL(new RegExp(`senderIds=${member1.id}`));
+ await expect(page.locator('button').filter({ hasText: member1.name ?? '' })).toBeVisible();
// Should only show member1's doc
- await checkDocumentTabCount(page, 'All', 1);
+ await expect(page.getByTestId('data-table-count')).toContainText('Showing 1');
await expect(page.getByRole('link', { name: 'Member1 Sent Doc' })).toBeVisible();
});
});
diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts
index 4eba467285..6708634d5a 100644
--- a/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts
@@ -120,12 +120,10 @@ const runAddAndPersistSignatureTextFields = async (
await expect(surface.root.locator('.konva-container canvas').first()).toBeVisible();
await placeFieldOnPdf(surface.root, 'Signature', { x: 120, y: 140 });
- let fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
- expect(fieldCount).toBe(1);
+ await expectFieldCount(surface.root, 1);
await placeFieldOnPdf(surface.root, 'Text', { x: 220, y: 240 });
- fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
- expect(fieldCount).toBe(2);
+ await expectFieldCount(surface.root, 2);
await clickEnvelopeEditorStep(surface.root, 'upload');
await expect(surface.root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
@@ -133,8 +131,7 @@ const runAddAndPersistSignatureTextFields = async (
await clickEnvelopeEditorStep(surface.root, 'addFields');
await surface.root.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
await expect(surface.root.getByText('Selected Recipient')).toBeVisible();
- fieldCount = await getKonvaElementCountForPage(surface.root, 1, '.field-group');
- expect(fieldCount).toBe(2);
+ await expectFieldCount(surface.root, 2);
return {
externalId,
@@ -214,6 +211,12 @@ const MULTI_RECIPIENT_VALUES = {
},
};
+const expectFieldCount = async (root: Page, expectedCount: number) => {
+ await expect
+ .poll(async () => getKonvaElementCountForPage(root, 1, '.field-group'))
+ .toBe(expectedCount);
+};
+
const runMultiRecipientFieldFlow = async (
surface: TEnvelopeEditorSurface,
): Promise => {
@@ -247,25 +250,21 @@ const runMultiRecipientFieldFlow = async (
await expect(root.getByText('Selected Recipient')).toBeVisible();
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
- let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(0);
+ await expectFieldCount(root, 0);
// Place Signature for recipient #1 (auto-selected).
await placeFieldOnPdf(root, 'Signature', { x: 120, y: 140 });
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(1);
+ await expectFieldCount(root, 1);
// Switch recipient and place text field for recipient #2.
await selectRecipientInFieldsStep(root, MULTI_RECIPIENT_VALUES.secondSigner.email);
await placeFieldOnPdf(root, 'Text', { x: 220, y: 240 });
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(2);
+ await expectFieldCount(root, 2);
// Navigate away and back to ensure fields are persisted in the UI.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(2);
+ await expectFieldCount(root, 2);
// Phase 2: cascade deletion — go back to recipients and remove the second one.
await clickEnvelopeEditorStep(root, 'upload');
@@ -276,8 +275,7 @@ const runMultiRecipientFieldFlow = async (
// Go back to fields and verify cascade removal.
await clickEnvelopeEditorStep(root, 'addFields');
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(1);
+ await expectFieldCount(root, 1);
return {
externalId,
@@ -436,8 +434,7 @@ const runAllFieldTypesFlow = async (
await root.locator('[data-testid="field-form-defaultValue"]').click();
await root.getByRole('option', { name: 'Red' }).click();
- let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(10);
+ await expectFieldCount(root, 10);
// Wait briefly for auto-save to fire on the last configured field.
await root.waitForTimeout(500);
@@ -445,8 +442,7 @@ const runAllFieldTypesFlow = async (
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(10);
+ await expectFieldCount(root, 10);
return { externalId };
};
@@ -580,8 +576,7 @@ const runDuplicateDeleteFieldFlow = async (
// Place a Signature field.
await placeFieldOnPdf(root, 'Signature', { x: 150, y: 150 });
- let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(1);
+ await expectFieldCount(root, 1);
// Select the field on canvas to show the action toolbar.
await selectFieldOnCanvas(root, { x: 150, y: 150 });
@@ -589,27 +584,23 @@ const runDuplicateDeleteFieldFlow = async (
// Duplicate the field.
await root.locator('button[title="Duplicate"]').click();
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(2);
+ await expectFieldCount(root, 2);
// Navigate away and back to persist changes.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(2);
+ await expectFieldCount(root, 2);
// Select a field and delete it via the Remove button.
await selectFieldOnCanvas(root, { x: 150, y: 150 });
await expect(root.locator('button[title="Remove"]')).toBeVisible();
await root.locator('button[title="Remove"]').click();
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(1);
+ await expectFieldCount(root, 1);
// Navigate away and back to verify persistence.
await clickEnvelopeEditorStep(root, 'upload');
await clickEnvelopeEditorStep(root, 'addFields');
- fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
- expect(fieldCount).toBe(1);
+ await expectFieldCount(root, 1);
return { externalId };
};
diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts
index 7fc722c411..7e890b46fc 100644
--- a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts
@@ -7,14 +7,13 @@ import { prisma } from '@documenso/prisma';
import {
type TEnvelopeEditorSurface,
addEnvelopeItemPdf,
- assertRecipientRole,
clickAddMyselfButton,
clickAddSignerButton,
clickEnvelopeEditorStep,
getEnvelopeEditorSettingsTrigger,
getRecipientEmailInputs,
- getRecipientNameInputs,
getRecipientRemoveButtons,
+ getRecipientRows,
getSigningOrderInputs,
openDocumentEnvelopeEditor,
openEmbeddedEnvelopeEditor,
@@ -31,7 +30,7 @@ import { expectToastTextToBeVisible } from '../fixtures/generic';
type RecipientFlowResult = {
externalId: string;
- expectedRecipientsBySigningOrder: Array<{
+ expectedRecipients: Array<{
email: string;
name: string;
role: RecipientRole;
@@ -78,6 +77,20 @@ const navigateToAddFieldsAndBack = async (root: Page) => {
await expect(root.getByRole('heading', { name: 'Recipients' })).toBeVisible();
};
+const getRecipientRowByEmail = async (root: Page, email: string) => {
+ const recipientEmailInputs = await getRecipientEmailInputs(root).all();
+ const recipientEmails = await Promise.all(
+ recipientEmailInputs.map(async (input) => input.inputValue()),
+ );
+ const recipientIndex = recipientEmails.indexOf(email);
+
+ if (recipientIndex === -1) {
+ throw new Error(`Could not find recipient row for ${email}`);
+ }
+
+ return getRecipientRows(root).nth(recipientIndex);
+};
+
const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise => {
const externalId = `e2e-recipients-${nanoid()}`;
@@ -128,18 +141,28 @@ const runRecipientFlow = async (surface: TEnvelopeEditorSurface): Promise {
const envelope = await prisma.envelope.findFirstOrThrow({
@@ -191,27 +214,23 @@ const assertRecipientsPersistedInDatabase = async ({
},
include: {
documentMeta: true,
- recipients: {
- // `id` breaks ties between recipients that share a signing order slot.
- orderBy: [{ signingOrder: 'asc' }, { id: 'asc' }],
- },
+ recipients: true,
},
orderBy: {
createdAt: 'desc',
},
});
- expect(envelope.recipients).toHaveLength(expectedRecipientsBySigningOrder.length);
+ expect(envelope.recipients).toHaveLength(expectedRecipients.length);
expect(envelope.documentMeta.signingOrder).toBe(DocumentSigningOrder.SEQUENTIAL);
expect(envelope.documentMeta.allowDictateNextSigner).toBe(true);
- expectedRecipientsBySigningOrder.forEach((expectedRecipient, index) => {
- const recipient = envelope.recipients[index];
+ expectedRecipients.forEach((expectedRecipient) => {
+ const recipient = envelope.recipients.find(
+ (candidate) => candidate.email === expectedRecipient.email,
+ );
- expect(recipient.email).toBe(expectedRecipient.email);
- expect(recipient.name).toBe(expectedRecipient.name);
- expect(recipient.role).toBe(expectedRecipient.role);
- expect(recipient.signingOrder).toBe(expectedRecipient.signingOrder);
+ expect(recipient).toMatchObject(expectedRecipient);
});
expect(envelope.recipients.some((recipient) => recipient.email === removedRecipientEmail)).toBe(
diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-save-as-template.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-save-as-template.spec.ts
index 30ba7e4734..5144989c36 100644
--- a/packages/app-tests/e2e/envelope-editor-v2/envelope-save-as-template.spec.ts
+++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-save-as-template.spec.ts
@@ -21,7 +21,7 @@ import {
getRecipientEmailInputs,
openDocumentEnvelopeEditor,
} from '../fixtures/envelope-editor';
-import { expectToastTextToBeVisible } from '../fixtures/generic';
+import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const V2_API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
@@ -354,7 +354,7 @@ test.describe('documents table', () => {
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
// Click the actions dropdown for the document row.
- await page.getByTestId('document-table-action-btn').first().click();
+ await openDropdownMenu(page, page.getByTestId('document-table-action-btn').first());
// Click "Save as Template" in the dropdown.
await page.getByRole('menuitem', { name: 'Save as Template' }).click();
@@ -452,10 +452,7 @@ test.describe('document index page', () => {
test.describe('legacy ID correctness', () => {
test('save as template uses template counter, not document counter', async ({ page }) => {
// Record the current counter values before the operation.
- const [documentCounterBefore, templateCounterBefore] = await Promise.all([
- prisma.counter.findUnique({ where: { id: 'document' } }),
- prisma.counter.findUnique({ where: { id: 'template' } }),
- ]);
+ const templateCounterBefore = await prisma.counter.findUnique({ where: { id: 'template' } });
const surface = await openDocumentEnvelopeEditor(page);
@@ -469,16 +466,14 @@ test.describe('legacy ID correctness', () => {
await expect(page).toHaveURL(/\/templates\/.*\/edit/);
// Record the counter values after the operation.
- const [documentCounterAfter, templateCounterAfter] = await Promise.all([
- prisma.counter.findUnique({ where: { id: 'document' } }),
- prisma.counter.findUnique({ where: { id: 'template' } }),
- ]);
+ const templateCounterAfter = await prisma.counter.findUnique({ where: { id: 'template' } });
// The template counter MUST have incremented (at least once - could be more due to
// the seedBlankDocument call in openDocumentEnvelopeEditor seeding other templates).
expect(templateCounterAfter!.value).toBeGreaterThan(templateCounterBefore!.value);
- // Verify the created template's secondaryId matches the template counter.
+ // Verify the created template used a value from the template counter. Other
+ // parallel tests can increment the same counter before this assertion runs.
const createdTemplate = await prisma.envelope.findFirst({
where: {
userId: surface.userId,
@@ -489,7 +484,11 @@ test.describe('legacy ID correctness', () => {
});
expect(createdTemplate).not.toBeNull();
- expect(createdTemplate!.secondaryId).toBe(`template_${templateCounterAfter!.value}`);
expect(createdTemplate!.secondaryId).not.toMatch(/^document_/);
+
+ const createdTemplateCounter = Number(createdTemplate!.secondaryId.replace('template_', ''));
+
+ expect(createdTemplateCounter).toBeGreaterThan(templateCounterBefore!.value);
+ expect(createdTemplateCounter).toBeLessThanOrEqual(templateCounterAfter!.value);
});
});
diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts
index 42245c3e03..ca4867876e 100644
--- a/packages/app-tests/e2e/fixtures/authentication.ts
+++ b/packages/app-tests/e2e/fixtures/authentication.ts
@@ -45,18 +45,31 @@ export const apiSignout = async ({ page }: { page: Page }) => {
const getCsrfToken = async (page: Page) => {
const { request } = page.context();
+ let lastError: unknown = new Error('Invalid session');
- const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/csrf`, {
- method: 'get',
- });
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
+ try {
+ const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/csrf`, {
+ method: 'get',
+ });
+
+ const { csrfToken } = await response.json();
+
+ if (csrfToken) {
+ return csrfToken;
+ }
- const { csrfToken } = await response.json();
+ lastError = new Error('Invalid session');
+ } catch (error) {
+ lastError = error;
+ }
- if (!csrfToken) {
- throw new Error('Invalid session');
+ if (attempt < 3) {
+ await page.waitForTimeout(attempt * 250);
+ }
}
- return csrfToken;
+ throw lastError;
};
export const checkSessionValid = async (page: Page): Promise => {
diff --git a/packages/app-tests/e2e/fixtures/generic.ts b/packages/app-tests/e2e/fixtures/generic.ts
index f0a2fc5e02..912499c3b8 100644
--- a/packages/app-tests/e2e/fixtures/generic.ts
+++ b/packages/app-tests/e2e/fixtures/generic.ts
@@ -10,7 +10,7 @@ export const expectTextToNotBeVisible = async (page: Page, text: string) => {
};
export const expectToastTextToBeVisible = async (page: Page, text: string) => {
- await expect(page.locator('[role="status"]').getByText(text)).toBeVisible();
+ await expect(page.locator('[role="status"]').getByText(text)).toBeVisible({ timeout: 10_000 });
};
export const openDropdownMenu = async (page: Page, dropdownButton: Locator) => {
diff --git a/packages/app-tests/e2e/organisations/manage-organisation.spec.ts b/packages/app-tests/e2e/organisations/manage-organisation.spec.ts
index 85cb71764a..aef910692a 100644
--- a/packages/app-tests/e2e/organisations/manage-organisation.spec.ts
+++ b/packages/app-tests/e2e/organisations/manage-organisation.spec.ts
@@ -14,6 +14,7 @@ import {
test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
const { user, organisation } = await seedUser({
+ isAdmin: true,
isPersonalOrganisation: false,
});
diff --git a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts
index c9b7febf90..9688cf2c27 100644
--- a/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts
+++ b/packages/app-tests/e2e/organisations/organisation-team-preferences.spec.ts
@@ -30,7 +30,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
await page.getByRole('option', { name: 'Australia/Perth' }).click();
// Set default date
- await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm AM/PM' }).click();
+ await page.getByTestId('document-date-format-trigger').click();
await page.getByRole('option', { name: 'DD/MM/YYYY', exact: true }).click();
await page.getByTestId('signature-types-trigger').click();
diff --git a/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts b/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts
index 3fb9a77a2c..e5e71fd130 100644
--- a/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts
+++ b/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts
@@ -300,7 +300,12 @@ test.describe('PDF Viewer Rendering', () => {
await page.goto(`/embed/v1/multisign?token=${recipientsV2[0].token}`);
await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 });
- await page.getByRole('button', { name: /View/i }).first().click();
+
+ await expect(async () => {
+ await page.getByRole('button', { name: /View/i }).first().click();
+ await expect(page.getByText('Sign Documents')).not.toBeVisible({ timeout: 5000 });
+ }).toPass({ timeout: 30_000 });
+
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
// Todo: Multisign does not support multiple envelope items.
diff --git a/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts b/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts
index d184895ce0..c1b6ffeec9 100644
--- a/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts
+++ b/packages/app-tests/e2e/public-profiles/public-profiles.spec.ts
@@ -78,10 +78,16 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
await expect(page.locator('body')).toContainText('public-direct-template-title');
await expect(page.locator('body')).toContainText('public-direct-template-description');
- await page.getByRole('link', { name: 'Sign' }).click();
+ await page.getByRole('link', { name: 'Sign', exact: true }).click();
await page.getByRole('button', { name: 'Continue' }).click();
- await page.getByRole('button', { name: 'Complete' }).click();
- await page.getByRole('button', { name: 'Sign' }).click();
+
+ const completeButton = page.getByRole('button', { name: 'Complete' });
+ await expect(completeButton).toBeVisible({ timeout: 30_000 });
+ await completeButton.click();
+
+ const signButton = page.getByRole('button', { name: 'Sign' });
+ await expect(signButton).toBeVisible({ timeout: 30_000 });
+ await signButton.click();
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
await expect(page.getByRole('heading')).toContainText('Document Signed');
diff --git a/packages/app-tests/e2e/scenarios/field-transparency-and-positioning.spec.ts b/packages/app-tests/e2e/scenarios/field-transparency-and-positioning.spec.ts
index 7030d9408e..45c6411a22 100644
--- a/packages/app-tests/e2e/scenarios/field-transparency-and-positioning.spec.ts
+++ b/packages/app-tests/e2e/scenarios/field-transparency-and-positioning.spec.ts
@@ -3,193 +3,139 @@
import { expect, test } from '@playwright/test';
import { FieldType } from '@prisma/client';
-import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
-import { prisma } from '@documenso/prisma';
-import { EnvelopeStatus } from '@documenso/prisma/client';
+import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
-import { apiSignin } from '../fixtures/authentication';
+import {
+ clickAddMyselfButton,
+ clickEnvelopeEditorStep,
+ openDocumentEnvelopeEditor,
+} from '../fixtures/envelope-editor';
+
+const seedSigningEnvelope = async (fields: FieldType[]) => {
+ const { user, team } = await seedUser();
+ const { recipients } = await seedPendingDocumentWithFullFields({
+ owner: user,
+ recipients: [`field-positioning-${user.id}@example.com`],
+ fields,
+ teamId: team.id,
+ });
-const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
+ const recipient = recipients[0];
-test.describe('Field Transparency', () => {
- test('checkbox fields in signing view have transparent background', async ({ page }) => {
- const user = await seedUser();
+ if (!recipient) {
+ throw new Error('Expected the field-positioning envelope to have a recipient');
+ }
- const envelope = await prisma.envelope.findFirst({
- where: {
- userId: user.id,
- status: EnvelopeStatus.DRAFT,
- },
- include: {
- recipients: true,
- fields: true,
- },
- });
+ return recipient;
+};
- test.skip(!envelope, 'No draft envelope found for seeded user');
+const openCheckboxEditor = async (page: Parameters[0]) => {
+ const surface = await openDocumentEnvelopeEditor(page);
- const checkboxField = envelope!.fields.find((f) => f.type === FieldType.CHECKBOX);
- test.skip(!checkboxField, 'No checkbox field found on envelope');
+ await clickAddMyselfButton(surface.root);
+ await clickEnvelopeEditorStep(surface.root, 'addFields');
+ await surface.root.getByRole('button', { name: 'Checkbox', exact: true }).click();
- const recipient = envelope!.recipients[0];
- test.skip(!recipient, 'No recipient found on envelope');
+ const canvas = surface.root.locator('.konva-container canvas').first();
+ await expect(canvas).toBeVisible();
+ await canvas.click({ position: { x: 150, y: 150 } });
- await page.goto(`${WEBAPP_BASE_URL}/sign/${recipient.token}`);
- await page.waitForSelector('[data-field-type="CHECKBOX"]', { timeout: 10000 });
+ return surface.root;
+};
- const fieldEl = page.locator('[data-field-type="CHECKBOX"]').first();
- const bgColor = await fieldEl.evaluate((el) => {
- return window.getComputedStyle(el).backgroundColor;
- });
+const assertFieldHasNoOffsets = (fieldMeta: unknown) => {
+ expect(fieldMeta).toEqual(
+ expect.objectContaining({
+ values: expect.arrayContaining([
+ expect.not.objectContaining({
+ offsetX: expect.anything(),
+ offsetY: expect.anything(),
+ }),
+ ]),
+ }),
+ );
+};
- expect(bgColor).toBe('rgba(0, 0, 0, 0)');
- });
+test.describe('Field Transparency', () => {
+ test('checkbox fields in signing view have transparent background', async ({ page }) => {
+ const recipient = await seedSigningEnvelope([FieldType.CHECKBOX]);
- test('signature fields still have opaque background', async ({ page }) => {
- const user = await seedUser();
+ await page.goto(`/sign/${recipient.token}`);
- const envelope = await prisma.envelope.findFirst({
- where: {
- userId: user.id,
- status: EnvelopeStatus.DRAFT,
- },
- include: {
- recipients: true,
- fields: true,
- },
- });
+ const checkboxField = page.locator('[data-field-type="CHECKBOX"]').first();
+ await expect(checkboxField).toBeVisible();
+
+ const backgroundColor = await checkboxField.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ );
- test.skip(!envelope, 'No draft envelope found');
+ expect(backgroundColor).toBe('rgba(0, 0, 0, 0)');
+ });
- const sigField = envelope!.fields.find((f) => f.type === FieldType.SIGNATURE);
- test.skip(!sigField, 'No signature field found');
+ test('signature fields still have opaque background', async ({ page }) => {
+ const recipient = await seedSigningEnvelope([FieldType.SIGNATURE]);
- const recipient = envelope!.recipients[0];
- test.skip(!recipient, 'No recipient');
+ await page.goto(`/sign/${recipient.token}`);
- await page.goto(`${WEBAPP_BASE_URL}/sign/${recipient.token}`);
- await page.waitForSelector('[data-field-type="SIGNATURE"]', { timeout: 10000 });
+ const signatureField = page.locator('[data-field-type="SIGNATURE"]').first();
+ await expect(signatureField).toBeVisible();
- const fieldEl = page.locator('[data-field-type="SIGNATURE"]').first();
- const bgColor = await fieldEl.evaluate((el) => {
- return window.getComputedStyle(el).backgroundColor;
- });
+ const backgroundColor = await signatureField.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ );
- expect(bgColor).not.toBe('rgba(0, 0, 0, 0)');
+ expect(backgroundColor).not.toBe('rgba(0, 0, 0, 0)');
});
});
test.describe('Checkbox Editor Offset Inputs', () => {
test('offset inputs appear in checkbox editor form', async ({ page }) => {
- await apiSignin({
- page,
- email: 'playwright@psd401.net',
- password: 'TestDev2026!',
- });
-
- await page.goto(`${WEBAPP_BASE_URL}/documents`);
-
- const firstTemplate = page.locator('table tbody tr').first();
- await firstTemplate.click();
- await page.waitForTimeout(1000);
-
- const checkboxField = page.locator('[data-field-type="CHECKBOX"]').first();
- if (await checkboxField.isVisible()) {
- await checkboxField.click();
- await page.waitForTimeout(500);
-
- const offsetXInput = page.locator('[data-testid="field-form-values-0-offsetX"]');
- const offsetYInput = page.locator('[data-testid="field-form-values-0-offsetY"]');
+ const root = await openCheckboxEditor(page);
- await expect(offsetXInput).toBeVisible();
- await expect(offsetYInput).toBeVisible();
- }
+ await expect(root.getByTestId('field-form-values-0-offsetX')).toBeVisible();
+ await expect(root.getByTestId('field-form-values-0-offsetY')).toBeVisible();
});
test('editing offset switches direction to custom', async ({ page }) => {
- await apiSignin({
- page,
- email: 'playwright@psd401.net',
- password: 'TestDev2026!',
- });
-
- await page.goto(`${WEBAPP_BASE_URL}/documents`);
+ const root = await openCheckboxEditor(page);
- const firstTemplate = page.locator('table tbody tr').first();
- await firstTemplate.click();
- await page.waitForTimeout(1000);
+ await root.getByTestId('field-form-values-0-offsetX').fill('10');
- const checkboxField = page.locator('[data-field-type="CHECKBOX"]').first();
- if (await checkboxField.isVisible()) {
- await checkboxField.click();
- await page.waitForTimeout(500);
-
- const offsetXInput = page.locator('[data-testid="field-form-values-0-offsetX"]');
- if (await offsetXInput.isVisible()) {
- await offsetXInput.fill('10');
- await page.waitForTimeout(300);
-
- const directionSelect = page.locator('[data-testid="field-form-direction"]');
- const directionValue = await directionSelect.textContent();
- expect(directionValue).toContain('Custom');
- }
- }
+ await expect(root.getByTestId('field-form-direction')).toContainText('Custom');
});
});
test.describe('Schema Backward Compatibility', () => {
test('existing checkbox fields without offsets render normally', async ({ page }) => {
- const field = await prisma.field.findFirst({
- where: {
- type: FieldType.CHECKBOX,
- fieldMeta: {
- path: ['type'],
- equals: 'checkbox',
- },
- },
- include: {
- Recipient: true,
- },
- });
-
- test.skip(!field || !field.Recipient, 'No checkbox field with recipient found');
-
- await page.goto(`${WEBAPP_BASE_URL}/sign/${field!.Recipient!.token}`);
- await page.waitForSelector(`#field-${field!.id}`, { timeout: 10000 });
-
- const fieldEl = page.locator(`#field-${field!.id}`);
- await expect(fieldEl).toBeVisible();
-
- const checkboxes = fieldEl.locator('input[type="checkbox"], [role="checkbox"]');
- const count = await checkboxes.count();
- expect(count).toBeGreaterThan(0);
+ const recipient = await seedSigningEnvelope([FieldType.CHECKBOX]);
+ const checkboxField = recipient.fields.find((field) => field.type === FieldType.CHECKBOX);
+
+ if (!checkboxField) {
+ throw new Error('Expected the field-positioning envelope to have a checkbox field');
+ }
+
+ assertFieldHasNoOffsets(checkboxField.fieldMeta);
+ await page.goto(`/sign/${recipient.token}`);
+
+ const field = page.locator(`#field-${checkboxField.id}`);
+ await expect(field).toBeVisible();
+ await expect(field.locator('input[type="checkbox"], [role="checkbox"]')).not.toHaveCount(0);
});
test('existing radio fields without offsets render normally', async ({ page }) => {
- const field = await prisma.field.findFirst({
- where: {
- type: FieldType.RADIO,
- fieldMeta: {
- path: ['type'],
- equals: 'radio',
- },
- },
- include: {
- Recipient: true,
- },
- });
-
- test.skip(!field || !field.Recipient, 'No radio field with recipient found');
-
- await page.goto(`${WEBAPP_BASE_URL}/sign/${field!.Recipient!.token}`);
- await page.waitForSelector(`#field-${field!.id}`, { timeout: 10000 });
-
- const fieldEl = page.locator(`#field-${field!.id}`);
- await expect(fieldEl).toBeVisible();
-
- const radios = fieldEl.locator('input[type="radio"], [role="radio"]');
- const count = await radios.count();
- expect(count).toBeGreaterThan(0);
+ const recipient = await seedSigningEnvelope([FieldType.RADIO]);
+ const radioField = recipient.fields.find((field) => field.type === FieldType.RADIO);
+
+ if (!radioField) {
+ throw new Error('Expected the field-positioning envelope to have a radio field');
+ }
+
+ assertFieldHasNoOffsets(radioField.fieldMeta);
+ await page.goto(`/sign/${recipient.token}`);
+
+ const field = page.locator(`#field-${radioField.id}`);
+ await expect(field).toBeVisible();
+ await expect(field.locator('input[type="radio"], [role="radio"]')).not.toHaveCount(0);
});
});
diff --git a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts
index 30e41724db..5d24fa3b7b 100644
--- a/packages/app-tests/e2e/teams/team-signature-settings.spec.ts
+++ b/packages/app-tests/e2e/teams/team-signature-settings.spec.ts
@@ -13,6 +13,21 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
+const openSignatureDialog = async (page: Parameters[0]['page']) => {
+ const signatureDialog = page.getByRole('dialog');
+
+ await expect(async () => {
+ if (await signatureDialog.isVisible()) {
+ return;
+ }
+
+ const signatureButton = page.getByTestId('signature-pad-dialog-button');
+ await expect(signatureButton).toBeVisible({ timeout: 5000 });
+ await signatureButton.click();
+ await expect(signatureDialog).toBeVisible({ timeout: 5000 });
+ }).toPass({ timeout: 30_000 });
+};
+
test('[TEAMS]: check that default team signature settings are all enabled', async ({ page }) => {
const { user, team } = await seedUser();
@@ -37,7 +52,7 @@ test('[TEAMS]: check that default team signature settings are all enabled', asyn
// Go to document and check that the signatured tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
- await page.getByTestId('signature-pad-dialog-button').click();
+ await openSignatureDialog(page);
// Check the tab values
await expect(page.getByRole('tab', { name: 'Type' })).toBeVisible();
@@ -93,10 +108,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
// Go to document and check that the signature tabs are correct.
await page.goto(`/sign/${document.recipients[0].token}`);
- await page.getByTestId('signature-pad-dialog-button').click();
-
- // Wait for signature dialog to fully load
- await page.waitForSelector('[role="dialog"]');
+ await openSignatureDialog(page);
// Check the tab values
for (const tab of allTabs) {
diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts
index 8a8b88ad59..c5ac04bf11 100644
--- a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts
+++ b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts
@@ -151,7 +151,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
- await page.getByText('Text').nth(1).click();
+ await page.locator('[data-field-type="TEXT"]:not([data-disabled])').click();
await page.getByRole('button', { name: 'Remove' }).click();
await triggerAutosave(page);
@@ -220,7 +220,7 @@ test.describe('AutoSave Fields Step', () => {
await page.getByRole('combobox').first().click();
await page.getByRole('option', { name: 'Recipient 1 (recipient1@documenso.com)' }).click();
- await page.getByText('Signature').nth(1).click();
+ await page.locator('[data-field-type="SIGNATURE"]:not([data-disabled])').click();
await page.getByRole('button', { name: 'Duplicate', exact: true }).click();
await triggerAutosave(page);
diff --git a/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts
index 88dbb31890..6d9e39f8bf 100644
--- a/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts
+++ b/packages/app-tests/e2e/templates/bulk-template-actions.spec.ts
@@ -65,13 +65,14 @@ test('[BULK_ACTIONS]: header checkbox selects all templates on page', async ({ p
redirectPath: `/t/${sender.team.url}/templates`,
});
+ await expect(page.getByRole('link', { name: templates[0].title })).toBeVisible();
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(`${templates.length} selected`)).toBeVisible();
});
test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
- const { sender } = await seedBulkActionsTestRequirements();
+ const { sender, templates } = await seedBulkActionsTestRequirements();
await apiSignin({
page,
@@ -79,6 +80,7 @@ test('[BULK_ACTIONS]: can clear selection with X button', async ({ page }) => {
redirectPath: `/t/${sender.team.url}/templates`,
});
+ await expect(page.getByRole('link', { name: templates[0].title })).toBeVisible();
await page.locator('thead').getByRole('checkbox').click();
await expect(page.getByText(/\d+ selected/)).toBeVisible();
@@ -104,9 +106,10 @@ test('[BULK_ACTIONS]: can move multiple templates to a folder', async ({ page })
await expect(page.getByText('Move Templates to Folder')).toBeVisible();
await page.getByRole('button', { name: folder.name }).click();
+ const moveToast = expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.getByRole('button', { name: 'Move' }).click();
- await expectToastTextToBeVisible(page, 'Selected items have been moved.');
+ await moveToast;
await page.goto(`/t/${sender.team.url}/templates/f/${folder.id}`);
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
@@ -164,9 +167,10 @@ test('[BULK_ACTIONS]: selection clears after successful move', async ({ page })
await page.getByRole('button', { name: 'Move to Folder' }).click();
await page.getByRole('button', { name: folder.name }).click();
+ const moveToast = expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.getByRole('button', { name: 'Move' }).click();
- await expectToastTextToBeVisible(page, 'Selected items have been moved.');
+ await moveToast;
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
});
@@ -253,9 +257,10 @@ test('[BULK_ACTIONS]: can move templates from folder to home (root)', async ({ p
await page.getByRole('button', { name: 'Home (No Folder)' }).click();
+ const moveToast = expectToastTextToBeVisible(page, 'Selected items have been moved.');
await page.getByRole('button', { name: 'Move' }).click();
- await expectToastTextToBeVisible(page, 'Selected items have been moved.');
+ await moveToast;
await page.goto(`/t/${sender.team.url}/templates`);
await expect(page.getByRole('link', { name: 'Bulk Test Template 1' })).toBeVisible();
diff --git a/packages/app-tests/e2e/templates/direct-templates.spec.ts b/packages/app-tests/e2e/templates/direct-templates.spec.ts
index dbe165cdd2..621865cef7 100644
--- a/packages/app-tests/e2e/templates/direct-templates.spec.ts
+++ b/packages/app-tests/e2e/templates/direct-templates.spec.ts
@@ -158,8 +158,10 @@ test('[DIRECT_TEMPLATES]: V1 direct template link auth access', async ({ page })
await expect(page.getByLabel('Email')).toBeDisabled();
await page.getByRole('button', { name: 'Continue' }).click();
+ await expect(page.getByRole('button', { name: 'Complete' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Complete' }).click();
+ await expect(page.getByRole('button', { name: 'Sign' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
@@ -200,6 +202,7 @@ test('[DIRECT_TEMPLATES]: V2 direct template link auth access', async ({ page })
await page.getByRole('button', { name: 'Complete' }).click();
await expect(page.getByLabel('Your Email')).not.toBeVisible();
+ await expect(page.getByRole('button', { name: 'Sign' })).toBeVisible({ timeout: 30_000 });
await page.getByRole('button', { name: 'Sign' }).click();
await page.waitForURL(/\/sign/);
await expect(page.getByRole('heading', { name: 'Document Signed' })).toBeVisible();
@@ -222,7 +225,7 @@ test('[DIRECT_TEMPLATES]: use direct template link with 1 recipient', async ({ p
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(100);
- await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
+ await page.getByRole('textbox', { name: /^Email/ }).fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
@@ -281,7 +284,7 @@ test('[DIRECT_TEMPLATES]: V1 use direct template link with 2 recipients with nex
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await page.waitForTimeout(100);
- await page.getByPlaceholder('recipient@documenso.com').fill(seedTestEmail());
+ await page.getByRole('textbox', { name: /^Email/ }).fill(seedTestEmail());
await page.getByRole('button', { name: 'Continue' }).click();
await page.getByRole('button', { name: 'Complete' }).click();
diff --git a/packages/app-tests/e2e/user/auth-flow.spec.ts b/packages/app-tests/e2e/user/auth-flow.spec.ts
index 8eda007b3c..799ceee454 100644
--- a/packages/app-tests/e2e/user/auth-flow.spec.ts
+++ b/packages/app-tests/e2e/user/auth-flow.spec.ts
@@ -1,71 +1,53 @@
-import { type Page, expect, test } from '@playwright/test';
+import { expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
-import {
- extractUserVerificationToken,
- seedTestEmail,
- seedUser,
-} from '@documenso/prisma/seed/users';
+import { seedUser } from '@documenso/prisma/seed/users';
-import { signSignaturePad } from '../fixtures/signature';
+import { apiSignin } from '../fixtures/authentication';
test.use({ storageState: { cookies: [], origins: [] } });
-test('[USER] can sign up with email and password', async ({ page }: { page: Page }) => {
- const username = 'Test User';
- const email = seedTestEmail();
- const password = 'Password123#';
-
+test('[USER] password sign-up redirects to the PSD sign-in page', async ({ page }) => {
await page.goto('/signup');
- await page.getByLabel('Name').fill(username);
- await page.getByLabel('Email').fill(email);
- await page.getByLabel('Password', { exact: true }).fill(password);
-
- await signSignaturePad(page);
- await page.getByRole('button', { name: 'Create account', exact: true }).click();
+ await expect(page).toHaveURL('/signin');
+ await expect(page.getByRole('heading', { name: 'PSD Document Signing' })).toBeVisible();
+ await expect(page.getByRole('textbox', { name: 'Email' })).toHaveCount(0);
+ await expect(page.getByLabel('Password', { exact: true })).toHaveCount(0);
+});
- await page.waitForURL('/unverified-account');
+test('[USER] sign-in directs users to PSD Google SSO and support', async ({ page }) => {
+ await page.goto('/signin');
- // Wait to ensure token is created in the database
- await page.waitForTimeout(2000);
+ await expect(
+ page.getByText('Sign in with your Peninsula School District Google account.'),
+ ).toBeVisible();
+ await expect(page.getByText('Google sign-in is not configured')).toBeVisible();
+ await expect(page.getByRole('link', { name: 'PSD401 IT Support' })).toHaveAttribute(
+ 'href',
+ 'https://support.psd401.net',
+ );
+ await expect(page.getByRole('textbox', { name: 'Email' })).toHaveCount(0);
+ await expect(page.getByLabel('Password', { exact: true })).toHaveCount(0);
+});
- const { token } = await extractUserVerificationToken(email);
+test('[USER] teamless non-admin receives an actionable dashboard empty state', async ({ page }) => {
+ const { user, organisation } = await seedUser();
- const team = await prisma.team.findFirstOrThrow({
+ await prisma.organisation.delete({
where: {
- organisation: {
- members: {
- some: {
- user: {
- email,
- },
- },
- },
- },
+ id: organisation.id,
},
});
- await page.goto(`/verify-email/${token}`);
-
- await expect(page.getByRole('heading')).toContainText('Email Confirmed!');
-
- // We now automatically redirect to the home page
- await page.getByRole('link', { name: 'Continue' }).click();
-
- // Expect to be redirected to their only team.
- await page.waitForURL(`/t/${team.url}/documents`);
- await expect(page).toHaveURL(`/t/${team.url}/documents`);
-});
-
-test('[USER] can sign in using email and password', async ({ page }: { page: Page }) => {
- const { user, team } = await seedUser();
-
- await page.goto('/signin');
- await page.getByLabel('Email').fill(user.email);
- await page.getByLabel('Password', { exact: true }).fill('password');
- await page.getByRole('button', { name: 'Sign In' }).click();
+ await apiSignin({
+ page,
+ email: user.email,
+ });
- await page.waitForURL(`/t/${team.url}/documents`);
- await expect(page).toHaveURL(`/t/${team.url}/documents`);
+ await expect(page.getByText('No organisations found')).toBeVisible();
+ await expect(
+ page.getByText('Contact your administrator to request organisation access.'),
+ ).toBeVisible();
+ await expect(page.getByRole('link', { name: 'Create organisation' })).toHaveCount(0);
});
diff --git a/packages/app-tests/e2e/user/password.spec.ts b/packages/app-tests/e2e/user/password.spec.ts
index 3047aebab5..3d0467dbed 100644
--- a/packages/app-tests/e2e/user/password.spec.ts
+++ b/packages/app-tests/e2e/user/password.spec.ts
@@ -15,9 +15,8 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
password: oldPassword,
});
- await page.goto('http://localhost:3000/signin');
- await page.getByRole('link', { name: 'Forgot your password?' }).click();
- await expect(page).toHaveURL('http://localhost:3000/forgot-password');
+ await page.goto('/forgot-password');
+ await expect(page).toHaveURL('/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
@@ -38,7 +37,7 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
},
});
- await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
+ await page.goto(`/reset-password/${foundToken.token}`);
// Assert that password cannot be same as old password.
await page.getByLabel('Password', { exact: true }).fill(oldPassword);
@@ -134,9 +133,8 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page:
await page.context().clearCookies();
- await page.goto('http://localhost:3000/signin');
- await page.getByRole('link', { name: 'Forgot your password?' }).click();
- await expect(page).toHaveURL('http://localhost:3000/forgot-password');
+ await page.goto('/forgot-password');
+ await expect(page).toHaveURL('/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent', {
@@ -147,7 +145,7 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page:
where: { userId: user.id },
});
- await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
+ await page.goto(`/reset-password/${foundToken.token}`);
await page.getByLabel('Password', { exact: true }).fill(newPassword);
await page.getByLabel('Repeat Password').fill(newPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
@@ -155,8 +153,8 @@ test('[USER] password reset invalidates all sessions', async ({ page }: { page:
await page.context().addCookies(initialCookies);
- await page.goto('http://localhost:3000/settings/profile');
- await expect(page).toHaveURL('http://localhost:3000/signin');
+ await page.goto('/settings/profile');
+ await expect(page).toHaveURL('/signin');
expect(await checkSessionValid(page)).toBe(false);
@@ -204,7 +202,7 @@ test('[USER] password update invalidates other sessions but keeps current', asyn
expect(await checkSessionValid(page)).toBe(true);
- await page.goto('http://localhost:3000/settings/security');
+ await page.goto('/settings/security');
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
@@ -215,13 +213,13 @@ test('[USER] password update invalidates other sessions but keeps current', asyn
await page.context().clearCookies();
await page.context().addCookies(initialCookies);
- await page.goto('http://localhost:3000/settings/profile');
- await expect(page).toHaveURL('http://localhost:3000/signin');
+ await page.goto('/settings/profile');
+ await expect(page).toHaveURL('/signin');
expect(await checkSessionValid(page)).toBe(false);
await page.context().clearCookies();
await page.context().addCookies(finalCookies);
- await page.goto('http://localhost:3000/settings/security');
- await expect(page).toHaveURL('http://localhost:3000/settings/security');
+ await page.goto('/settings/security');
+ await expect(page).toHaveURL('/settings/security');
expect(await checkSessionValid(page)).toBe(true);
});
diff --git a/packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts b/packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts
index def95e7cc6..05ccf3afae 100644
--- a/packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts
+++ b/packages/app-tests/e2e/webhooks/webhooks-crud.spec.ts
@@ -62,8 +62,7 @@ test('[WEBHOOKS]: create webhook', async ({ page }) => {
await page.waitForTimeout(200); // Wait for dropdown to open
await page.getByText('document.created').click();
- // Click outside the triggers field to close the dropdown
- await page.getByText('The URL for Documenso to send webhook events to.').click();
+ await page.getByLabel('Webhook URL*').click();
// Fill in the form
await page.getByLabel('Secret').fill('secret');
@@ -240,8 +239,7 @@ test('[WEBHOOKS]: update webhook', async ({ page }) => {
await page.getByText('document.completed').click();
await page.waitForTimeout(200);
- // Click outside to close the dropdown
- await page.getByText('The URL for Documenso to send webhook events to.').click();
+ await page.getByLabel('Webhook URL*').click();
// Submit the form
await page.getByRole('button', { name: 'Update' }).click();
diff --git a/packages/app-tests/playwright.config.ts b/packages/app-tests/playwright.config.ts
index eba5f33f1d..00667cfff4 100644
--- a/packages/app-tests/playwright.config.ts
+++ b/packages/app-tests/playwright.config.ts
@@ -12,8 +12,8 @@ function calculateWorkers() {
// 1 worker per 2 cores, minimum 1
const workers = Math.max(Math.floor(usable / 2), 1);
- // Max 6 workers
- return Math.min(workers, 6);
+ // Keep the local Remix server responsive during the full browser suite.
+ return Math.min(workers, 3);
}
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
@@ -30,7 +30,7 @@ ENV_FILES.forEach((file) => {
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
- workers: 10, // See Projects where 10 is utilized for API tests. We're not running 10 workers for UI tests.
+ workers: calculateWorkers(),
maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
@@ -41,7 +41,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
- baseURL: 'http://localhost:3000',
+ baseURL: process.env.NEXT_PUBLIC_WEBAPP_URL || 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'retain-on-failure',
diff --git a/packages/app-tests/visual-regression/alignment-pdf-0.png b/packages/app-tests/visual-regression/alignment-pdf-0.png
index 27d098da29..f31352ed2a 100644
Binary files a/packages/app-tests/visual-regression/alignment-pdf-0.png and b/packages/app-tests/visual-regression/alignment-pdf-0.png differ
diff --git a/packages/app-tests/visual-regression/alignment-pdf-1.png b/packages/app-tests/visual-regression/alignment-pdf-1.png
index 88912e4b97..cc0176ded8 100644
Binary files a/packages/app-tests/visual-regression/alignment-pdf-1.png and b/packages/app-tests/visual-regression/alignment-pdf-1.png differ
diff --git a/packages/app-tests/visual-regression/alignment-pdf-2.png b/packages/app-tests/visual-regression/alignment-pdf-2.png
index af4de39e73..79b37493f7 100644
Binary files a/packages/app-tests/visual-regression/alignment-pdf-2.png and b/packages/app-tests/visual-regression/alignment-pdf-2.png differ
diff --git a/packages/app-tests/visual-regression/field-meta-pdf-1.png b/packages/app-tests/visual-regression/field-meta-pdf-1.png
index 6af19cb293..2e3cc00b9d 100644
Binary files a/packages/app-tests/visual-regression/field-meta-pdf-1.png and b/packages/app-tests/visual-regression/field-meta-pdf-1.png differ
diff --git a/packages/app-tests/visual-regression/field-meta-pdf-2.png b/packages/app-tests/visual-regression/field-meta-pdf-2.png
index 665dfad5b9..42d908a764 100644
Binary files a/packages/app-tests/visual-regression/field-meta-pdf-2.png and b/packages/app-tests/visual-regression/field-meta-pdf-2.png differ
diff --git a/packages/app-tests/visual-regression/field-meta-pdf-3.png b/packages/app-tests/visual-regression/field-meta-pdf-3.png
index 548c107ce6..166a36b3aa 100644
Binary files a/packages/app-tests/visual-regression/field-meta-pdf-3.png and b/packages/app-tests/visual-regression/field-meta-pdf-3.png differ
diff --git a/packages/app-tests/visual-regression/field-meta-pdf-4.png b/packages/app-tests/visual-regression/field-meta-pdf-4.png
index 2164c634eb..bdf7adc45a 100644
Binary files a/packages/app-tests/visual-regression/field-meta-pdf-4.png and b/packages/app-tests/visual-regression/field-meta-pdf-4.png differ
diff --git a/packages/app-tests/visual-regression/field-meta-pdf-5.png b/packages/app-tests/visual-regression/field-meta-pdf-5.png
index 82a05ed46e..574cf66857 100644
Binary files a/packages/app-tests/visual-regression/field-meta-pdf-5.png and b/packages/app-tests/visual-regression/field-meta-pdf-5.png differ
diff --git a/packages/app-tests/visual-regression/field-meta-pdf-6.png b/packages/app-tests/visual-regression/field-meta-pdf-6.png
index 729cbed94a..ae9b2cac00 100644
Binary files a/packages/app-tests/visual-regression/field-meta-pdf-6.png and b/packages/app-tests/visual-regression/field-meta-pdf-6.png differ
diff --git a/packages/lib/client-only/providers/envelope-editor-provider.tsx b/packages/lib/client-only/providers/envelope-editor-provider.tsx
index d024a82943..22b57972a4 100644
--- a/packages/lib/client-only/providers/envelope-editor-provider.tsx
+++ b/packages/lib/client-only/providers/envelope-editor-provider.tsx
@@ -126,11 +126,10 @@ export const EnvelopeEditorProvider = ({
}, []);
const setEnvelope: typeof _setEnvelope = (action) => {
- _setEnvelope((prev) => {
- const next = typeof action === 'function' ? action(prev) : action;
- envelopeRef.current = next;
- return next;
- });
+ const next = typeof action === 'function' ? action(envelopeRef.current) : action;
+
+ envelopeRef.current = next;
+ _setEnvelope(next);
};
const isEmbedded = editorConfig.embedded !== undefined;
@@ -452,7 +451,12 @@ export const EnvelopeEditorProvider = ({
};
const flushAutosave = async (): Promise => {
- await Promise.all([flushSetFields(), flushSetRecipients(), flushUpdateEnvelope()]);
+ // Recipient updates can remove fields as part of their cascade. Flush field
+ // changes first so a slower field save cannot restore fields belonging to a
+ // recipient that has just been removed.
+ await flushSetFields();
+ await flushSetRecipients();
+ await flushUpdateEnvelope();
// Flush all registered external flushes (e.g., upload page's debounced item updates).
const externalFlushes = Array.from(externalFlushCallbacksRef.current.values());
diff --git a/packages/lib/jobs/client/bullmq.ts b/packages/lib/jobs/client/bullmq.ts
index 031d0f5f07..0c80d14965 100644
--- a/packages/lib/jobs/client/bullmq.ts
+++ b/packages/lib/jobs/client/bullmq.ts
@@ -1,7 +1,3 @@
-import { createBullBoard } from '@bull-board/api';
-import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
-import { HonoAdapter } from '@bull-board/hono';
-import { serveStatic } from '@hono/node-server/serve-static';
import { sha256 } from '@noble/hashes/sha2';
import { BackgroundJobStatus, Prisma } from '@prisma/client';
import { Queue, Worker } from 'bullmq';
@@ -199,12 +195,20 @@ export class BullMQJobProvider extends BaseJobProvider {
private createBoardApp(): Hono {
const _require = createRequire(import.meta.url);
+ const bullBoardApi: typeof import('@bull-board/api') = _require('@bull-board/api');
+ const bullBoardBullMqAdapter: typeof import('@bull-board/api/bullMQAdapter') = _require(
+ '@bull-board/api/bullMQAdapter',
+ );
+ const bullBoardHono: typeof import('@bull-board/hono') = _require('@bull-board/hono');
+ const honoServeStatic: typeof import('@hono/node-server/serve-static') = _require(
+ '@hono/node-server/serve-static',
+ );
const uiPackagePath = path.dirname(_require.resolve('@bull-board/ui/package.json'));
- const serverAdapter = new HonoAdapter(serveStatic);
+ const serverAdapter = new bullBoardHono.HonoAdapter(honoServeStatic.serveStatic);
- createBullBoard({
- queues: [new BullMQAdapter(this._queue)],
+ bullBoardApi.createBullBoard({
+ queues: [new bullBoardBullMqAdapter.BullMQAdapter(this._queue)],
serverAdapter,
options: { uiBasePath: uiPackagePath },
});
diff --git a/packages/lib/jobs/client/client.ts b/packages/lib/jobs/client/client.ts
index 67120ad619..dae9cfdac1 100644
--- a/packages/lib/jobs/client/client.ts
+++ b/packages/lib/jobs/client/client.ts
@@ -1,32 +1,29 @@
-import { match } from 'ts-pattern';
+import type { Context as HonoContext } from 'hono';
import { env } from '../../utils/env';
import type { JobDefinition, TriggerJobOptions } from './_internal/job';
import type { BaseJobProvider as JobClientProvider } from './base';
-import { BullMQJobProvider } from './bullmq';
-import { InngestJobProvider } from './inngest';
import { LocalJobProvider } from './local';
export class JobClient = []> {
- private _provider: JobClientProvider;
+ private _provider: Promise;
public constructor(definitions: T) {
- this._provider = match(env('NEXT_PRIVATE_JOBS_PROVIDER'))
- .with('inngest', () => InngestJobProvider.getInstance())
- .with('bullmq', () => BullMQJobProvider.getInstance())
- .otherwise(() => LocalJobProvider.getInstance());
-
- definitions.forEach((definition) => {
- this._provider.defineJob(definition);
- });
+ this._provider = this.initializeProvider(definitions);
}
public async triggerJob(options: TriggerJobOptions) {
- return this._provider.triggerJob(options);
+ const provider = await this._provider;
+
+ return provider.triggerJob(options);
}
public getApiHandler() {
- return this._provider.getApiHandler();
+ return async (context: HonoContext) => {
+ const provider = await this._provider;
+
+ return provider.getApiHandler()(context);
+ };
}
/**
@@ -37,6 +34,35 @@ export class JobClient = []> {
* (e.g. Inngest).
*/
public startCron() {
- this._provider.startCron();
+ void this._provider
+ .then((provider) => {
+ provider.startCron();
+ })
+ .catch((error) => {
+ console.error('[JOBS]: Failed to start cron scheduler', error);
+ });
+ }
+
+ private async initializeProvider(definitions: T): Promise {
+ const providerName = env('NEXT_PRIVATE_JOBS_PROVIDER');
+ let provider: JobClientProvider;
+
+ if (providerName === 'inngest') {
+ const { InngestJobProvider } = await import('./inngest');
+
+ provider = InngestJobProvider.getInstance();
+ } else if (providerName === 'bullmq') {
+ const { BullMQJobProvider } = await import('./bullmq');
+
+ provider = BullMQJobProvider.getInstance();
+ } else {
+ provider = LocalJobProvider.getInstance();
+ }
+
+ definitions.forEach((definition) => {
+ provider.defineJob(definition);
+ });
+
+ return provider;
}
}
diff --git a/packages/prisma/seed-database.ts b/packages/prisma/seed-database.ts
index c2bdde60e4..5bbae7fce3 100644
--- a/packages/prisma/seed-database.ts
+++ b/packages/prisma/seed-database.ts
@@ -2,7 +2,10 @@ import fs from 'node:fs';
import path from 'node:path';
const seedDatabase = async () => {
- const files = fs.readdirSync(path.join(__dirname, './seed'));
+ const files = fs
+ .readdirSync(path.join(__dirname, './seed'))
+ .filter((file) => file.endsWith('-seed.ts'))
+ .sort();
for (const file of files) {
const stat = fs.statSync(path.join(__dirname, './seed', file));