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. + )}

- + {isAdmin(user) && ( + + )} )} 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));